package qq_obv11 // 本文件承载 qq-obv11 的**连接态三态**:一次 Connect 返回之后,该适配器到底处于 // 「真连上 / 正在重试 / 就绪等接入」中的哪一种,以及没连上时的**脱敏原因**。 // // 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连 // 失败是**故意非致命**的(见 ws_client.go 与 reconnect.go:NapCat 等 OneBot 实现可能 // 比 Cyrene 后启动)。于是管理面板拿到的 `connected=false` 既可能是「正在后台重试」, // 也可能是「监听已就绪等对端接入」,而它与「配置错、起不来」在布尔上无从区分;面板只能 // 编一个说法,编出来的正是 2026-10-07 那个「点了连接就弹已连接」的谎。三态把这三件事 // 分开,并且**由适配器自己回答**——只有它知道自己的 transport × mode 意味着什么。 // // 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端 // transport × mode);三态取值与判定顺序定义在 internal/channel/adapter 的 // ConnectStateOf(跨平台共用一份口径,别的适配器接上时不会各自走样)。立场与 // design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致:一个布尔说不了三件事。 import ( "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // maskedCredential 是脱敏后的占位符(与全链路可视化的脱敏标记同形,便于一眼认出 // 「这里原本是凭据」而不是「这里本来就长这样」)。 const maskedCredential = "[redacted]" // ConnectOutcome 报告一次 Connect 之后该适配器处于哪种就绪状态。 // // state 是闭集取值(adapter.ConnectStateConnected / ConnectStatePending / // ConnectStateReady),空串表示「无意见」(尚未启动,无从判断);reason 是未连上时的 // **脱敏**原因,绝不含 access_token。 // // 它是可选的适配器能力:网关经类型断言读取(internal/services/gateway 的 // connectOutcomeAware),核心 PlatformAdapter 契约刻意不扩展。 func (a *Adapter) ConnectOutcome() (state, reason string) { a.mu.RLock() defer a.mu.RUnlock() return adapter.ConnectStateOf(adapter.ConnectSignals{ Started: a.started, Connected: a.connected, // 只有 ws+client 有一条后台重试循环(readLoop 复用 connectClient),因此 // 「未连上 ⇒ 连接中」这条推论只在它上面成立;HTTP 没有会话可重连。 Retrying: a.cfg.Mode == modeClient && a.cfg.Transport != transportHTTP, // 监听已绑定 ⇒ 就绪等对端接入(ws+server;http+server 也有监听器,但它由 // Connect 直接置 connected=true,走不到这里)。 Listening: a.ln != nil, Reason: a.lastConnectErr, }) } // connectFailureReason 把一次连接失败整理成适配器可以如实回答的脱敏原因。 func (a *Adapter) connectFailureReason(err error) string { if err == nil { return "" } return sanitizeConnectReason(err.Error(), a.cfg.AccessToken) } // sanitizeConnectReason 把一次连接失败的原因整理成可以对外说的短句。三层防线缺一不可: // // 1. trace.RedactText 做形状级兜底与截断(与全链路可视化同一道红线):一个不设上限的 // 错误串不该被原样端给面板,而凭据形状(api_key/bearer/JWT/≥6 字符的 token 赋值) // 在这一层就被抹掉 —— 包括 token 已被换掉、我们并不知道明文的那种情况。 // 2. 配置里的 access_token 明文整体替换 —— ws_client.go 的 appendQueryToken 会把 token // 拼进拨号 URL,任何把该 URL 写进错误串的实现都会把它带出来;而**短 token** 逃得过 // 第 1 层的形状匹配,只有「已知明文」这一层能兜住。 // 3. 剥掉 URL 里 access_token 查询参数的值(含内嵌在引号/反括号里的那些)。 // // 顺序是刻意的:先形状级、再逐字剥。反过来(先剥参数再交给形状级)会让第 1 层的贪婪 // 匹配把我们已经写好的 `access_token=` 参数名一起吃掉,留下更难读懂的一句话。 // // 空原因返回空串 —— 「没有原因」与「原因被脱敏成空」是两件事,绝不互相冒充。 func sanitizeConnectReason(raw, token string) string { if raw == "" { return "" } raw = trace.RedactText(raw) if token != "" { raw = strings.ReplaceAll(raw, token, maskedCredential) } return maskAccessTokenQuery(raw) } // maskAccessTokenQuery 把 access_token 查询参数的值替换成占位符。大小写不敏感,循环 // 处理多次出现(同一错误串里可能带多条 URL)。 // // 每轮把已处理的前缀搬进 builder、只对**剩余部分**再查:参数名会被原样保留,因此在同一 // 段文本上原地重查会永远命中同一个位置(占位符不改变它的存在),那是死循环而不是脱敏。 func maskAccessTokenQuery(raw string) string { const key = "access_token=" var b strings.Builder rest := raw for { i := strings.Index(strings.ToLower(rest), key) if i < 0 { b.WriteString(rest) return b.String() } start := i + len(key) end := start for end < len(rest) && !isQueryValueDelimiter(rest[end]) { end++ } b.WriteString(rest[:start]) b.WriteString(maskedCredential) rest = rest[end:] } } // isQueryValueDelimiter 报告该字节是否终止一个查询参数值:`&`/`#` 是 URL 分隔符, // 空白与引号、反括号则是内嵌在错误串里的 URL 的常见收尾。 func isQueryValueDelimiter(c byte) bool { switch c { case '&', '#', ' ', '\t', '\n', '\r', '"', '\'', ')', '}', ']': return true default: return false } }