package gateway // 管理渠道列表里的**每账号**连接状态(AdminChannelAccount.Connected)。 // // 为什么要有它:平台级 `connected` 只反映该平台第一条已注册适配器(ChannelSummary // 由聚合行上的那个适配器决定),多账号平台上它无法回答「我点连接的那条现在活着吗」。 // 面板因此只能盲目渲染每行的连接/断开按钮 —— 主人有两条 qq-obv11 时,「断开」到底 // 断的是哪一条、断完有没有生效,看板上都无从判断。这里按账号把它补上。 import ( capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // liveAccountConnections 收集每个平台各账号的实时连接状态,键与 liveBotIdentity // 相同(裸适配器名 ⇒ 空账号 id)。注册表里**没有**该账号的适配器时它直接缺席, // 而不是报 false:缺席 = 状态未知(面板画「状态未知」),false = 确知未连接。 func (s *Server) liveAccountConnections() map[string]map[string]bool { out := make(map[string]map[string]bool) if s.caps == nil { return out } for _, cap := range s.caps.List(capreg.KindChannel) { ch, ok := cap.Value.(sdk.Channel) if !ok { continue } platform, acct := splitAdapterName(ch.Name()) if out[platform] == nil { out[platform] = make(map[string]bool) } out[platform][acct] = ch.IsConnected() } return out } // enrichAccountsWithConnection 把实时连接状态合并进平台中匹配的账号(按账号 id // 配对,与适配器注册名一致)。没有实时适配器的账号保持 Connected == nil(未知), // 因此面板能在「没连上」与「读不到」之间如实区分。入参切片不被修改。 func enrichAccountsWithConnection(accts []AdminChannelAccount, by map[string]bool) []AdminChannelAccount { return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.Connected = &v }) } // enrichAccountsWithAccountOnline 把**确知的**账号在线状态合并进平台中匹配的账号 // (按账号 id 配对)。不在 by 里的账号保持 AccountOnline == nil ⇒ 字段缺席 ⇒ 未知, // 因此面板能在「确知离线」与「没探到」之间如实区分。入参切片不被修改。 func enrichAccountsWithAccountOnline(accts []AdminChannelAccount, by map[string]bool) []AdminChannelAccount { return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v }) } // liveAccountConnectOutcomes 收集每个平台各账号的**连接态三态**(适配器自报的 // pending / ready / reason),键与 liveAccountConnections 相同(裸适配器名 ⇒ 空账号 id)。 // // 只有确实自报了三态的账号才进 map:三态投影为零值(适配器没这条状态面,或它此刻自认 // 「真连上 / 无意见」)就缺席 —— 缺席 = 面板退回只看 connected 布尔,绝不凭空编一个 // 状态。补一个 false 顶上去,与本次要根除的「编一个状态」同属一类。 func (s *Server) liveAccountConnectOutcomes() map[string]map[string]channelConnectOutcome { out := make(map[string]map[string]channelConnectOutcome) if s.caps == nil { return out } for _, cap := range s.caps.List(capreg.KindChannel) { ch, ok := cap.Value.(sdk.Channel) if !ok { continue } _, outcome := channelConnectStateOf(ch) if outcome == (channelConnectOutcome{}) { continue } platform, acct := splitAdapterName(ch.Name()) if out[platform] == nil { out[platform] = make(map[string]channelConnectOutcome) } out[platform][acct] = outcome } return out } // enrichAccountsWithConnectOutcome 把**连接态三态**合并进平台中匹配的账号(按账号 id // 配对)。不在 by 里的账号保持 Pending/Ready/Reason 全空 ⇒ 整键缺席 ⇒ 面板退回只看 // connected。入参切片不被修改。 func enrichAccountsWithConnectOutcome( accts []AdminChannelAccount, by map[string]channelConnectOutcome, ) []AdminChannelAccount { if len(by) == 0 { return accts } out := make([]AdminChannelAccount, 0, len(accts)) for _, a := range accts { item := a if o, ok := by[item.ID]; ok { item.Pending = o.Pending item.Ready = o.Ready item.Reason = o.Reason } out = append(out, item) } return out } // enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的 // 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 —— // 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。 func enrichAccountBool( accts []AdminChannelAccount, by map[string]bool, set func(a *AdminChannelAccount, v bool), ) []AdminChannelAccount { if len(by) == 0 { return accts } out := make([]AdminChannelAccount, 0, len(accts)) for _, a := range accts { item := a if v, ok := by[item.ID]; ok { set(&item, v) } out = append(out, item) } return out } // accountStatusAware 是渠道适配器暴露「账号在平台上是否在线」这一**独立状态面**的 // 可选能力。*qq_obv11.Adapter 实现了它(AccountStatus):OneBot 实现(NapCat 等)的 // 进程与正向 WS 可以一直连着,而它背后的 QQ 账号已经掉线 —— 此时「我们连着」 // (connected)为真,消息却既收不到也发不出(2026-10-05 事故)。 // // 该能力返回**两个**布尔而不是一个,是因为 `known` 必须能传到线上:调用方要把 // 「尚未探到」处理成**字段缺席**(未知),绝不能用 false 冒充「确知离线」。 // // 核心的 SDK Channel 契约被刻意不扩展,因此没有这条状态面的平台(telegram、webhook……) // 不受任何改动,也不会凭空多出一个字段。 type accountStatusAware interface { // AccountStatus 报告账号在线状态;known=false 表示尚未探到结果 // (探活未接线 / 刚启动 / 一直失败)。 AccountStatus() (online bool, known bool) } // asAccountStatusAware 报告已注册的渠道值是否暴露账号在线状态能力。与 asRescanAware // 同一套路(见 admin_channels_rescan.go):注册时会把适配器包进 cmd/cyrene 的 // namedAdapter —— 一个内嵌 adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的 // 方法,因此具体适配器多出的能力只能通过拆开被内嵌字段才能抵达。 func asAccountStatusAware(v any) (accountStatusAware, bool) { if as, ok := v.(accountStatusAware); ok { return as, true } inner, ok := unwrapNamedAdapter(v) if !ok { return nil, false } as, ok := inner.(accountStatusAware) return as, ok } // liveAccountOnline 收集每个平台各账号**在平台侧**的在线状态,键与 // liveAccountConnections 相同(裸平台名 ⇒ 空账号 id)。 // // 与 liveAccountConnections 的关键差别:**只收「确知」的结果**。适配器没有这条独立 // 状态面,或实现了但 known=false(刚启动 / 还没探到 / 一直失败)时该账号**不进 map** —— // 缺席 = 状态未知。补一个 false 顶上去,等于把「没探到」谎报成「账号离线」,与本次 // 事故(面板一片绿 ↔ 账号已掉线)同属一类:画出来的与事实无关。 func (s *Server) liveAccountOnline() map[string]map[string]bool { out := make(map[string]map[string]bool) if s.caps == nil { return out } for _, cap := range s.caps.List(capreg.KindChannel) { ch, ok := cap.Value.(sdk.Channel) if !ok { continue } as, ok := asAccountStatusAware(cap.Value) if !ok { continue } online, known := as.AccountStatus() if !known { continue } platform, acct := splitAdapterName(ch.Name()) if out[platform] == nil { out[platform] = make(map[string]bool) } out[platform][acct] = online } return out } // sessionStatusAware 是渠道适配器暴露「**会话凭据三态**」(有效 / 被拒 / 未知)的 // 可选能力。*weixin_ilink.Adapter 实现了它(SessionStatus)。 // // 为什么需要它:rescanAware(admin_channels_rescan.go)只回答「要不要重新扫码」,于是 // 「凭据确实被 iLink 接受过」与「有凭据、但从没被接受过」在面板上长得一模一样 —— 而 // 后者只是**还没被否定**。三态把这两者分开,并把如实的说明(detail)一起交给面板, // 使面板不必自己编造措辞(编造正是「未知」被写成「过期」的来源)。 // // 与 accountStatusAware 同一契约风格:核心 SDK 契约被刻意不扩展,因此没有这条状态面 // 的适配器不受任何改动,也不会凭空多出一个字段。 type sessionStatusAware interface { // SessionStatus 报告会话凭据状态。state 是闭集取值(当前为 // "active"/"rejected"/"unknown",定义在 plugins/channels/weixin_ilink 的 // SessionState* 常量),detail 是如实的中文说明(绝不含凭据)。 SessionStatus() (state string, detail string) } // asSessionStatusAware 报告已注册的渠道值是否暴露会话三态能力。与 asAccountStatusAware // 同一套路:注册时会把适配器包进 cmd/cyrene 的 namedAdapter —— 一个内嵌 // adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的方法,因此具体适配器多出的 // 能力只能通过拆开被内嵌字段才能抵达。 func asSessionStatusAware(v any) (sessionStatusAware, bool) { if ss, ok := v.(sessionStatusAware); ok { return ss, true } inner, ok := unwrapNamedAdapter(v) if !ok { return nil, false } ss, ok := inner.(sessionStatusAware) return ss, ok } // sessionStatus 是一个账号的会话凭据三态快照:State ∈ {"active","rejected","unknown"} // (定义在 plugins/channels/weixin_ilink 的 SessionState* 常量),Detail 是如实的说明。 type sessionStatus struct { State string Detail string } // liveAccountAuthStates 收集每个平台各账号的**会话凭据三态**,键与 // liveAccountConnections 相同(裸平台名 ⇒ 空账号 id)。 // // 与 liveAccountOnline 的关键差别:三态里的「未知」是一个**值**("unknown"),不是 // 缺席 —— 它必须带着「为什么还未知」一起上屏,否则面板只能写一句它自己编的话(而 // 「未知」被编成「过期」正是本切片要根除的那句话)。缺席只留给「该账号没有这条状态面」 // (适配器没实现 sessionStatusAware),两者不可互相冒充。 func (s *Server) liveAccountAuthStates() map[string]map[string]sessionStatus { out := make(map[string]map[string]sessionStatus) if s.caps == nil { return out } for _, cap := range s.caps.List(capreg.KindChannel) { ch, ok := cap.Value.(sdk.Channel) if !ok { continue } ss, ok := asSessionStatusAware(cap.Value) if !ok { continue } state, detail := ss.SessionStatus() if state == "" { continue } platform, acct := splitAdapterName(ch.Name()) if out[platform] == nil { out[platform] = make(map[string]sessionStatus) } out[platform][acct] = sessionStatus{State: state, Detail: detail} } return out }