state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不 写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。 ⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
212 lines
9.2 KiB
Go
212 lines
9.2 KiB
Go
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 })
|
||
}
|
||
|
||
// 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
|
||
}
|