package weixin_ilink // This file holds the adapter's session-rejection state machine (design/08 §4-§5). // // iLink answers errcode=-14 when it rejects or invalidates the session credential. // The bot token is a LONG-LIVED credential — a token obtained by scanning a month // ago still works — so -14 is NOT a time-based expiry: it means iLink no longer // accepts this session (unbound, re-logged-in elsewhere, a protocol-level refusal). // It is worth recording that no log has ever actually shown -14; the earlier // reading of it as a time-based expiry was an inference, not an observation. // // Either way it is not a transient failure the read loop can retry away: the same // credential keeps being refused until the owner scans a fresh QR code. The adapter // therefore keeps an EXPLICIT rejected flag instead of silently dropping the // connection, so: // // - the read loop cannot exit and leave the adapter quietly mute while still // reporting "connected" (the "credential rejected → silently mute" defect); // - Connect/HealthCheck/send surface ErrSessionRejected — "re-scan required" — // rather than a fake OK or a generic "not connected"; // - the admin panel can read RescanRequired() and show 需要重新扫码 (see // internal/services/gateway/admin.go). // // Lifecycle (set once, cleared on a usable credential): // // SET markRejected iLink returned errcode=-14 on getupdates, // sendmessage or getuploadurl. // CLEAR clearRescanLocked a scan-code login delivered a new bot_token // (completeLogin), or // rejectedLocked saw a bot_token different from the one // iLink rejected — a config change / hot reload that // supplied a fresh token. // Rescan reasons recorded on the rejected state. They are short, human-readable // and carry no credential: the bot token itself never leaves cfg. const ( rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)" rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(本次会话已被 iLink 拒绝)" rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(本次会话已被 iLink 拒绝)" ) // markRejected records that iLink rejected the current session credential // (errcode=-14). It drops the connected flag so IsConnected can never keep claiming // a live session for a rejected credential, and logs the transition at ERROR level // exactly once (a failing poll loop or a repeated send must not flood the log). Only // the non-secret bot_id and the reason are logged. 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 drops the rejected state. Callers must hold a.mu. func (a *Adapter) clearRescanLocked() { a.rejected = false a.rejectedToken = "" a.rescanDetail = "" } // rejectedLocked reports whether iLink rejected the session. It first clears the // state when the configured bot_token is no longer the one iLink rejected, so a // fresh token (scan login, operator config change, hot reload) restores the session // without extra bookkeeping. Callers must hold a.mu. func (a *Adapter) rejectedLocked() bool { if a.rejected && a.rejectedToken != a.cfg.BotToken { a.clearRescanLocked() } return a.rejected } // rejectedState reports the rejected flag together with the rescan reason in one // lock acquisition. reason is "" when the session has not been rejected. func (a *Adapter) rejectedState() (bool, string) { a.mu.Lock() defer a.mu.Unlock() if !a.rejectedLocked() { return false, "" } return true, a.rescanDetail } // RescanRequired implements the optional capability the admin panel reads by type // assertion (internal/services/gateway/admin.go): it reports whether this adapter // needs a new scan-code login instead of a plain reconnect, plus the // human-readable reason (never a secret). A healthy adapter reports false with an // empty reason. func (a *Adapter) RescanRequired() (bool, string) { return a.rejectedState() }