package weixin_ilink // 本文件承载适配器的「会话被拒绝」状态机(design/08 §4 的 weixin-ilink 条目只写到 // 「扫码登录」,未记 `-14` 口径;其语义与对照依据见 // DevDocs/reference/weixin_ClawBot_Bridge/对照清单.md §2.2「`errcode=-14` = 会话/token // 被拒」与 §3 的「-14 处理」对照行)。 // // iLink 在拒绝或作废会话凭据时返回 errcode=-14。bot token 是长期凭据——一个月前 // 扫码拿到的 token 依然可用——所以 -14 不是基于时间的过期:它表示 iLink 已不再接受 // 这个会话(已解绑、在别处重新登录、或协议层拒绝)。值得记下的是,日志里从未真正 // 出现过 -14;早先把 -14 读作「基于时间的过期」只是推断,并非观测。 // // 无论如何,它都不是读循环能靠重试消解的瞬时故障:同一份凭据会一直被拒,直到主人 // 重新扫码。因此适配器保留一个**显式**的 rejected 标志,而不是静默丢弃连接,使得: // // - 读循环不会退出后让适配器悄悄变哑,却仍上报「connected」(即「凭据被拒 → // 静默变哑」缺陷); // - Connect/HealthCheck/发送路径上报 ErrSessionRejected——「需要重新扫码」—— // 而不是假 OK 或笼统的「未连接」; // - 管理面板可以读 RescanRequired() 并显示 需要重新扫码(见 // internal/services/gateway/admin.go)。 // // 生命周期(一次置位,拿到可用凭据后清除): // // SET markRejected getupdates、sendmessage 或 getuploadurl // 返回 errcode=-14。 // CLEAR clearRescanLocked 扫码登录交付了新的 bot_token(completeLogin),或 // rejectedLocked 发现 bot_token 已不同于 iLink 拒绝的 // 那一个——配置变更 / 热重载提供了新 token。 // 记录在「被拒绝」状态上的重扫原因。它们简短、人类可读,且不携带任何凭据: // bot token 本身从不离开 cfg。 const ( rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)" rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(本次会话已被 iLink 拒绝)" rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(本次会话已被 iLink 拒绝)" ) // markRejected 记录 iLink 拒绝了当前会话凭据(errcode=-14)。它清掉 connected // 标志,使 IsConnected 永不为一已被拒绝的凭据继续宣称会话存活;并以 ERROR 级别 // 恰好记录一次该状态转移(失败的轮询循环或反复的发送不得刷屏日志)。只记录非敏感的 // bot_id 与原因。 func (a *Adapter) markRejected(reason string) { a.mu.Lock() first := !a.rejected a.rejected = true a.rejectedToken = a.cfg.BotToken a.rescanDetail = reason a.connected = false botID := a.cfg.BotID a.mu.Unlock() if first { a.log.Error("ilink session rejected(errcode=-14):本次会话已被 iLink 拒绝,需要重新扫码绑定(请用「扫码登录」)", "bot_id", botID, "errcode", errcodeSessionRejected, "reason", reason) } } // clearRescanLocked 清除被拒绝状态。调用方须持有 a.mu。 func (a *Adapter) clearRescanLocked() { a.rejected = false a.rejectedToken = "" a.rescanDetail = "" } // rejectedLocked 报告 iLink 是否拒绝了该会话。当已配置的 bot_token 不再是 iLink // 拒绝的那一个时,它会先清除该状态,于是新 token(扫码登录、运维改配置、热重载) // 无需额外簿记即可恢复会话。调用方须持有 a.mu。 func (a *Adapter) rejectedLocked() bool { if a.rejected && a.rejectedToken != a.cfg.BotToken { a.clearRescanLocked() } return a.rejected } // rejectedState 在一次加锁内同时返回「被拒绝」标志与重扫原因。会话未被拒绝时 // reason 为 ""。 func (a *Adapter) rejectedState() (bool, string) { a.mu.Lock() defer a.mu.Unlock() if !a.rejectedLocked() { return false, "" } return true, a.rescanDetail } // RescanRequired 实现管理面板用类型断言读取的可选能力 // (internal/services/gateway/admin.go):它报告该适配器是否需要重新扫码登录而非 // 简单重连,并给出人类可读的原因(绝不含密钥)。健康的适配器返回 false 与空原因。 func (a *Adapter) RescanRequired() (bool, string) { return a.rejectedState() }