根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。 改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。 线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。 凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。 验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
264 lines
11 KiB
Go
264 lines
11 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 })
|
||
}
|
||
|
||
// 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
|
||
}
|