package weixin_ilink // This file holds the adapter's session-expiry state machine (design/08 §4-§5). // // iLink answers errcode=-14 when the bot token is no longer valid. That is not a // transient failure the read loop can retry away: the token is dead until the // owner scans a fresh QR code. The adapter therefore keeps an EXPLICIT expired // flag instead of silently dropping the connection, so: // // - the read loop cannot exit and leave the adapter quietly mute while still // reporting "connected" (the "过期后静默变哑" defect); // - Connect/HealthCheck/send surface ErrSessionExpired — "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 markExpired iLink returned errcode=-14 on getupdates, // sendmessage or getuploadurl. // CLEAR clearRescanLocked a scan-code login delivered a new bot_token // (completeLogin), or // expiredLocked 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 expired state. They are short, human-readable // and carry no credential: the bot token itself never leaves cfg. const ( rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(bot token 已过期)" rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(bot token 已过期)" rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(bot token 已过期)" ) // markExpired records that iLink rejected the current bot token (errcode=-14). // It drops the connected flag so IsConnected can never keep claiming a live // session for a dead token, 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) markExpired(reason string) { a.mu.Lock() first := !a.expired a.expired = true a.expiredToken = a.cfg.BotToken a.rescanDetail = reason a.connected = false botID := a.cfg.BotID a.mu.Unlock() if first { a.log.Error("ilink session expired: 需要重新扫码登录(请用「扫码登录」重新绑定)", "bot_id", botID, "errcode", errSessionExpired, "reason", reason) } } // clearRescanLocked drops the expired state. Callers must hold a.mu. func (a *Adapter) clearRescanLocked() { a.expired = false a.expiredToken = "" a.rescanDetail = "" } // expiredLocked reports whether the session is expired. 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) expiredLocked() bool { if a.expired && a.expiredToken != a.cfg.BotToken { a.clearRescanLocked() } return a.expired } // expiredState reports the expired flag together with the rescan reason in one // lock acquisition. reason is "" when the session is not expired. func (a *Adapter) expiredState() (bool, string) { a.mu.Lock() defer a.mu.Unlock() if !a.expiredLocked() { 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.expiredState() }