Files
AskaEth b4d55f412e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
根因: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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00

264 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}