Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -315,6 +346,11 @@ async function runDaemon(): Promise<void> {
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);
Expand Down
38 changes: 38 additions & 0 deletions src/wechat/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
onSessionExpired: () => void;
/** Fired once when the login state appears to have silently lapsed. */
onSessionLikelyExpired?: () => void;
}

export function createMonitor(api: WeChatApi, callbacks: MonitorCallbacks) {
Expand All @@ -22,6 +39,8 @@ export function createMonitor(api: WeChatApi, callbacks: MonitorCallbacks) {

async function run(): Promise<void> {
let consecutiveFailures = 0;
let expiredShapeStreak = 0;
let expiryAlerted = false;

while (!controller.signal.aborted) {
try {
Expand All @@ -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);
Expand Down