From 3f6ef76182ef5e46b82c1f0e10cbf597553f5d8d Mon Sep 17 00:00:00 2001 From: Firegoiste Date: Thu, 23 Jul 2026 14:44:30 +0800 Subject: [PATCH] feat: detect silently-lapsed login state and alert the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ilink bot login state can expire with no error code (typically overnight): the token still authenticates and getUpdates keeps returning HTTP 200, but the server stops delivering messages. The existing onSessionExpired only fires on ret:-14, so this silent lapse went unnoticed — the daemon kept polling forever while the user's messages silently went nowhere. A/B comparing a valid vs. an expired token showed the only reliable difference: healthy idle : msgs === [] AND get_updates_buf is a non-empty string expired : msgs is ABSENT AND get_updates_buf is empty/missing monitor.ts now counts consecutive expired-shaped responses and, after 3 in a row (so a one-off blip never trips it; a healthy response re-arms it), fires a new onSessionLikelyExpired callback. main.ts surfaces it via an out-of-band desktop notification (WeChat itself is the dead channel), telling the user to re-scan. Quiet-but-healthy nights never false-positive because a healthy idle poll always carries a fresh cursor. --- src/main.ts | 36 ++++++++++++++++++++++++++++++++++++ src/wechat/monitor.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/main.ts b/src/main.ts index ac03bb8..3c75416 100644 --- a/src/main.ts +++ b/src/main.ts @@ -165,6 +165,37 @@ function openFile(filePath: string): void { } } +/** + * Pop an out-of-band desktop notification. Used when the login state lapses — + * WeChat itself is the dead channel, so we cannot reach the user through it. + * Best-effort: silently no-ops if the platform mechanism is unavailable. + */ +function notifyDesktop(title: string, message: string): void { + const platform = process.platform; + try { + if (platform === 'win32') { + const safe = (v: string) => v.replace(/'/g, "''"); + const ps = [ + `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null;`, + `$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);`, + `$x = $t.GetElementsByTagName('text');`, + `$x.Item(0).AppendChild($t.CreateTextNode('${safe(title)}')) > $null;`, + `$x.Item(1).AppendChild($t.CreateTextNode('${safe(message)}')) > $null;`, + `$n = [Windows.UI.Notifications.ToastNotification]::new($t);`, + `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('wechat-claude-code').Show($n);`, + ].join(' '); + spawnSync('powershell', ['-NoProfile', '-Command', ps], { stdio: 'ignore' }); + } else if (platform === 'darwin') { + const safe = (v: string) => v.replace(/"/g, '\\"'); + spawnSync('osascript', ['-e', `display notification "${safe(message)}" with title "${safe(title)}"`], { stdio: 'ignore' }); + } else { + spawnSync('notify-send', [title, message], { stdio: 'ignore' }); + } + } catch (err) { + logger.warn('Desktop notification failed', { error: err instanceof Error ? err.message : String(err) }); + } +} + // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- @@ -315,6 +346,11 @@ async function runDaemon(): Promise { logger.warn('Session expired, will keep retrying...'); console.error('⚠️ 微信会话已过期,请重新运行 setup 扫码绑定'); }, + onSessionLikelyExpired: () => { + logger.warn('Login state likely expired — notifying user out-of-band'); + console.error('⚠️ 微信登录态可能已失效,收不到新消息。请重新扫码:node dist/main.js setup 然后 npm run daemon -- restart'); + notifyDesktop('WeChat Claude Code', '微信登录态可能已过期,请重新扫码绑定。'); + }, }; const monitor = createMonitor(api, callbacks); diff --git a/src/wechat/monitor.ts b/src/wechat/monitor.ts index 4d4fd17..f98ccc8 100644 --- a/src/wechat/monitor.ts +++ b/src/wechat/monitor.ts @@ -9,9 +9,26 @@ const BACKOFF_THRESHOLD = 3; const BACKOFF_LONG_MS = 30_000; const BACKOFF_SHORT_MS = 3_000; +// Heuristic detection of a silently-lapsed login state. +// +// The ilink bot login state can expire without any error code (typically +// overnight): the token still authenticates and getUpdates keeps returning +// HTTP 200, but the server stops delivering messages. A/B comparing a valid +// vs. an expired token showed the only reliable difference in that state: +// healthy idle : msgs === [] AND get_updates_buf is a non-empty string +// expired : msgs is ABSENT AND get_updates_buf is empty/missing +// (ret is undefined in both cases, so ret cannot be used to tell them apart.) +// +// We require several consecutive expired-shaped responses before alerting so a +// one-off network blip never trips it. A genuinely idle-but-healthy session +// always carries a fresh buf, so quiet nights do not false-positive. +const EXPIRED_SHAPE_THRESHOLD = 3; + export interface MonitorCallbacks { onMessage: (msg: WeixinMessage) => Promise; onSessionExpired: () => void; + /** Fired once when the login state appears to have silently lapsed. */ + onSessionLikelyExpired?: () => void; } export function createMonitor(api: WeChatApi, callbacks: MonitorCallbacks) { @@ -22,6 +39,8 @@ export function createMonitor(api: WeChatApi, callbacks: MonitorCallbacks) { async function run(): Promise { let consecutiveFailures = 0; + let expiredShapeStreak = 0; + let expiryAlerted = false; while (!controller.signal.aborted) { try { @@ -42,6 +61,25 @@ export function createMonitor(api: WeChatApi, callbacks: MonitorCallbacks) { logger.warn('getUpdates returned error', { ret: resp.ret, retmsg: resp.retmsg }); } + // Heuristic login-expiry detection: an expired session returns + // neither a msgs field nor a fresh cursor. A healthy poll always + // carries one (idle -> msgs:[] + buf; active -> msgs:[...]). + const looksExpired = resp.msgs === undefined && !resp.get_updates_buf; + if (looksExpired) { + expiredShapeStreak++; + if (expiredShapeStreak >= EXPIRED_SHAPE_THRESHOLD && !expiryAlerted) { + expiryAlerted = true; + logger.warn('Login state appears to have silently expired', { consecutive: expiredShapeStreak }); + try { + callbacks.onSessionLikelyExpired?.(); + } catch (err) { + logger.warn('onSessionLikelyExpired handler threw', { error: err instanceof Error ? err.message : String(err) }); + } + } + } else { + expiredShapeStreak = 0; + expiryAlerted = false; + } // Save the new sync buffer regardless of ret if (resp.get_updates_buf) { saveSyncBuf(resp.get_updates_buf);