From 5ebb56fc5b6d26f0dcf6bbbb9acb2493cef5d687 Mon Sep 17 00:00:00 2001 From: zggtao Date: Wed, 29 Jul 2026 14:57:51 +0800 Subject: [PATCH] fix(weixin): harden session recovery and service observability --- README.md | 10 ++ config/examples/weixin.service.env.example | 3 +- package.json | 1 + scripts/service/_common.sh | 27 +++- scripts/service/install-launchd-user.sh | 26 +++- scripts/service/install-windows-task.ps1 | 18 ++- src/cli.ts | 45 +++++- src/i18n/index.ts | 2 + src/platforms/weixin/account_store.ts | 26 ++++ src/platforms/weixin/config.ts | 9 +- src/platforms/weixin/connection_status.ts | 54 +++++++ src/platforms/weixin/official/api.ts | 24 +++ src/platforms/weixin/official/login.ts | 3 +- src/platforms/weixin/official/transport.ts | 22 +++ src/platforms/weixin/official/types.ts | 12 ++ src/platforms/weixin/plugin.ts | 69 ++++++++- test/platforms/weixin/cli.test.ts | 8 + .../weixin/connection_status.test.ts | 29 ++++ test/platforms/weixin/official/login.test.ts | 1 + .../weixin/official/transport.test.ts | 25 ++++ test/platforms/weixin/plugin.test.ts | 139 ++++++++++++++++++ 21 files changed, 538 insertions(+), 15 deletions(-) create mode 100644 src/platforms/weixin/connection_status.ts create mode 100644 test/platforms/weixin/connection_status.test.ts diff --git a/README.md b/README.md index 6260c59..47577b9 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,14 @@ codex --version If `codex --version` still fails, fix that before attempting `weixin:login` or `weixin:serve`. +After login or any service restart, use the non-sensitive health check: + +```bash +npm run weixin:status +``` + +It reports the selected account, service lock/PID, and last recorded connection state without exposing tokens. If it reports `reauthorization_required`, run `npm run weixin:login` and confirm the new QR code. + ### Linux ```bash @@ -567,6 +575,8 @@ That file is the stable place to adjust: - optional OpenAI-compatible provider keys such as `DEEPSEEK_*`, `MINIMAX_*`, `QWEN_*`, `OPENROUTER_*`, or `CODEX_COMPAT_*` - `CODEXBRIDGE_DEBUG_WEIXIN` +When more than one Weixin account is saved, a successful `weixin:login` marks the new account as active. Leave `WEIXIN_ACCOUNT_ID` blank to use that active account, or set it explicitly only when intentionally overriding it. + ### Windows Scheduled Task Install and start a hidden per-user scheduled task: diff --git a/config/examples/weixin.service.env.example b/config/examples/weixin.service.env.example index 8e6aa32..525673d 100644 --- a/config/examples/weixin.service.env.example +++ b/config/examples/weixin.service.env.example @@ -4,7 +4,8 @@ # applies this service env file as an override layer. Provider API keys and # default models can therefore live in the repo-local `.env`. -# Explicit account selection. If omitted, the runtime auto-picks the only saved account. +# Explicit account selection. If omitted, the runtime uses the account selected by +# the most recent successful `weixin:login`, then falls back to the only saved account. WEIXIN_ACCOUNT_ID= # Policy diff --git a/package.json b/package.json index dc05252..d5aabca 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "codex-provider:test": "tsx --test packages/codex-provider-relay/test/*.test.ts", "codex-provider:typecheck": "tsc -p packages/codex-provider-relay/tsconfig.json --noEmit", "weixin:login": "tsx src/cli.ts weixin login", + "weixin:status": "tsx src/cli.ts weixin status", "weixin:clear-context": "tsx src/cli.ts weixin clear-context", "weixin:serve": "tsx src/cli.ts weixin serve", "codex:cleanup-internal-threads": "tsx src/cli.ts codex cleanup-internal-threads", diff --git a/scripts/service/_common.sh b/scripts/service/_common.sh index 4afe9ab..e61a3b9 100755 --- a/scripts/service/_common.sh +++ b/scripts/service/_common.sh @@ -80,6 +80,29 @@ require_unit_installed() { fi } +find_active_weixin_account_id() { + local accounts_dir="${STATE_DIR}/weixin/accounts" + local active_account_file="${accounts_dir}/active-account.json" + if [[ ! -f "${active_account_file}" ]]; then + return + fi + python3 - "${active_account_file}" "${accounts_dir}" <<'PY' +import json +import os +import sys + +active_file, accounts_dir = sys.argv[1:] +try: + with open(active_file, encoding="utf-8") as handle: + account_id = str(json.load(handle).get("account_id", "")).strip() +except (OSError, ValueError, TypeError): + account_id = "" + +if account_id and os.path.isfile(os.path.join(accounts_dir, f"{account_id}.json")): + print(account_id, end="") +PY +} + find_single_weixin_account_id() { local accounts_dir="${STATE_DIR}/weixin/accounts" local candidates=() @@ -91,7 +114,7 @@ find_single_weixin_account_id() { local basename basename="$(basename "${file}")" case "${basename}" in - *.context-tokens.json|*.sync.json) continue ;; + active-account.json|*.context-tokens.json|*.sync.json) continue ;; *.json) candidates+=("${basename%.json}") ;; esac done < <(find "${accounts_dir}" -maxdepth 1 -type f -name '*.json' -print0 | sort -z) @@ -112,7 +135,7 @@ ensure_service_env_file() { fi local account_id - account_id="$(find_single_weixin_account_id || true)" + account_id="$(find_active_weixin_account_id || find_single_weixin_account_id || true)" cat > "${SERVICE_ENV_FILE}" < "${ENV_FILE}" < = { 'cli.nativeApiServe.stopping': '正在停止 Codex Native API localhost 服务:{signal}', 'cli.usage.title': '用法:', 'cli.usage.login': ' npm run weixin:login -- [--base-url URL] [--state-dir DIR] [--bot-type N] [--timeout-sec N]', + 'cli.usage.status': ' npm run weixin:status -- [--state-dir DIR]', 'cli.usage.clearContext': ' npm run weixin:clear-context -- [--state-dir DIR] [--account-id ID]', 'cli.usage.serve': ' npm run weixin:serve -- [--state-dir DIR] [--cwd DIR]', 'cli.usage.cleanupInternalThreads': ' npm run codex:cleanup-internal-threads -- [--state-dir DIR] [--cwd DIR] [--limit N] [--dry-run|--apply]', @@ -2340,6 +2341,7 @@ const CATALOGS: Record = { 'cli.nativeApiServe.stopping': 'Stopping the Codex Native API localhost service: {signal}', 'cli.usage.title': 'Usage:', 'cli.usage.login': ' npm run weixin:login -- [--base-url URL] [--state-dir DIR] [--bot-type N] [--timeout-sec N]', + 'cli.usage.status': ' npm run weixin:status -- [--state-dir DIR]', 'cli.usage.clearContext': ' npm run weixin:clear-context -- [--state-dir DIR] [--account-id ID]', 'cli.usage.serve': ' npm run weixin:serve -- [--state-dir DIR] [--cwd DIR]', 'cli.usage.cleanupInternalThreads': ' npm run codex:cleanup-internal-threads -- [--state-dir DIR] [--cwd DIR] [--limit N] [--dry-run|--apply]', diff --git a/src/platforms/weixin/account_store.ts b/src/platforms/weixin/account_store.ts index 4786369..25502bf 100644 --- a/src/platforms/weixin/account_store.ts +++ b/src/platforms/weixin/account_store.ts @@ -15,6 +15,10 @@ export interface SavedWeixinAccount { } type ContextTokenMap = Record; +type ActiveAccountRecord = { + account_id: string; + selected_at: string; +}; export class WeixinAccountStore { constructor({ rootDir = defaultWeixinAccountsDir() } = {}) { this.rootDir = rootDir; @@ -27,6 +31,7 @@ export class WeixinAccountStore { const entries = fs.readdirSync(this.rootDir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .filter((entry) => entry.name !== 'active-account.json') .filter((entry) => !entry.name.endsWith('.context-tokens.json')) .filter((entry) => !entry.name.endsWith('.sync.json')) .map((entry) => entry.name.slice(0, -'.json'.length)) @@ -48,6 +53,23 @@ export class WeixinAccountStore { return this.readJson(this.accountFile(accountId)); } + setActiveAccount(accountId: string) { + const normalizedAccountId = String(accountId ?? '').trim(); + if (!normalizedAccountId) { + return; + } + this.writeJson(this.activeAccountFile(), { + account_id: normalizedAccountId, + selected_at: new Date().toISOString(), + } satisfies ActiveAccountRecord); + } + + getActiveAccount() { + const record = this.readJson(this.activeAccountFile()); + const accountId = typeof record?.account_id === 'string' ? record.account_id.trim() : ''; + return accountId || null; + } + getContextToken(accountId: string, peerId: string) { const tokens = this.readJson(this.contextTokensFile(accountId)) ?? {}; const token = tokens?.[peerId]; @@ -72,6 +94,10 @@ export class WeixinAccountStore { return path.join(this.rootDir, `${accountId}.json`); } + activeAccountFile() { + return path.join(this.rootDir, 'active-account.json'); + } + contextTokensFile(accountId: string) { return path.join(this.rootDir, `${accountId}.context-tokens.json`); } diff --git a/src/platforms/weixin/config.ts b/src/platforms/weixin/config.ts index 2042725..679ee84 100644 --- a/src/platforms/weixin/config.ts +++ b/src/platforms/weixin/config.ts @@ -42,9 +42,14 @@ export function loadWeixinConfig({ } = {}): WeixinConfig { let accountId = normalizeString(env.WEIXIN_ACCOUNT_ID); if (!accountId) { - const accountIds = accountStore.listAccounts(); - if (accountIds.length === 1) { + const activeAccountId = accountStore.getActiveAccount(); + if (activeAccountId && accountStore.loadAccount(activeAccountId)) { + accountId = activeAccountId; + } else { + const accountIds = accountStore.listAccounts(); + if (accountIds.length === 1) { [accountId] = accountIds; + } } } diff --git a/src/platforms/weixin/connection_status.ts b/src/platforms/weixin/connection_status.ts new file mode 100644 index 0000000..385eea5 --- /dev/null +++ b/src/platforms/weixin/connection_status.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type WeixinConnectionState = 'connected' | 'reauthorization_required' | 'stopped'; + +export interface WeixinConnectionStatus { + accountId: string | null; + state: WeixinConnectionState; + updatedAt: string; + errorCode: number | null; +} + +export function weixinConnectionStatusFile(stateDir: string) { + return path.join(stateDir, 'runtime', 'weixin-connection-status.json'); +} + +export function writeWeixinConnectionStatus({ + stateDir, + accountId, + state, + errorCode = null, +}: Omit & { stateDir: string }) { + const status: WeixinConnectionStatus = { + accountId: accountId ? String(accountId) : null, + state, + updatedAt: new Date().toISOString(), + errorCode, + }; + const filePath = weixinConnectionStatusFile(stateDir); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(status, null, 2)}\n`, 'utf8'); + } catch {} + return status; +} + +export function readWeixinConnectionStatus(stateDir: string): WeixinConnectionStatus | null { + const filePath = weixinConnectionStatusFile(stateDir); + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Partial; + const state = parsed.state; + if (state !== 'connected' && state !== 'reauthorization_required' && state !== 'stopped') { + return null; + } + return { + accountId: typeof parsed.accountId === 'string' && parsed.accountId ? parsed.accountId : null, + state, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '', + errorCode: typeof parsed.errorCode === 'number' ? parsed.errorCode : null, + }; + } catch { + return null; + } +} diff --git a/src/platforms/weixin/official/api.ts b/src/platforms/weixin/official/api.ts index d165fd3..635964d 100644 --- a/src/platforms/weixin/official/api.ts +++ b/src/platforms/weixin/official/api.ts @@ -9,6 +9,8 @@ import type { GetUploadUrlResp, GetUpdatesReq, GetUpdatesResp, + NotifyStartResp, + NotifyStopResp, SendMessageReq, SendMessageResp, SendTypingReq, @@ -139,6 +141,28 @@ export async function sendTyping( }); } +export async function notifyStart( + params: WeixinOfficialApiOptions, +): Promise { + return postJson({ + ...params, + endpoint: 'ilink/bot/msg/notifystart', + payload: {}, + timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS, + }); +} + +export async function notifyStop( + params: WeixinOfficialApiOptions, +): Promise { + return postJson({ + ...params, + endpoint: 'ilink/bot/msg/notifystop', + payload: {}, + timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS, + }); +} + export async function getConfig( params: GetConfigReq & WeixinOfficialApiOptions, ): Promise { diff --git a/src/platforms/weixin/official/login.ts b/src/platforms/weixin/official/login.ts index 2e423ea..c28a318 100644 --- a/src/platforms/weixin/official/login.ts +++ b/src/platforms/weixin/official/login.ts @@ -8,7 +8,7 @@ export const DEFAULT_ILINK_BOT_TYPE = '3'; export const FIXED_QR_BASE_URL = 'https://ilinkai.weixin.qq.com'; interface OfficialQrLoginOptions { - accountStore: Pick; + accountStore: Pick; accountsDir?: string | null; fetchImpl?: WeixinOfficialFetch; locale?: string | null; @@ -142,6 +142,7 @@ export async function officialQrLogin( baseUrl: credentials.base_url, userId: credentials.user_id, }); + accountStore.setActiveAccount(credentials.account_id); return credentials; } diff --git a/src/platforms/weixin/official/transport.ts b/src/platforms/weixin/official/transport.ts index 8df1294..79a841e 100644 --- a/src/platforms/weixin/official/transport.ts +++ b/src/platforms/weixin/official/transport.ts @@ -3,6 +3,8 @@ import { getConfig, getQrStatus, getUpdates, + notifyStart, + notifyStop, sendMessage, sendTyping, type WeixinOfficialFetch, @@ -12,6 +14,8 @@ import { sendWeixinMediaFile } from './send_media.js'; import type { GetConfigResp, GetUpdatesResp, + NotifyStartResp, + NotifyStopResp, SendMessageResp, SendTypingResp, WeixinQrCodeResponse, @@ -27,6 +31,8 @@ export interface WeixinOfficialTransport { fetch: WeixinOfficialFetch | undefined; locale: string | null; getUpdates(params?: { syncCursor?: string; timeoutMs?: number }): Promise; + notifyStart(): Promise; + notifyStop(): Promise; sendMessage(params: { toUserId: string; text: string; @@ -88,6 +94,22 @@ export function createWeixinOfficialTransport({ get_updates_buf: syncCursor, }); }, + async notifyStart() { + return notifyStart({ + baseUrl: normalizedBaseUrl, + token, + fetchImpl: effectiveFetchImpl, + locale, + }); + }, + async notifyStop() { + return notifyStop({ + baseUrl: normalizedBaseUrl, + token, + fetchImpl: effectiveFetchImpl, + locale, + }); + }, async sendMessage({ toUserId, text, contextToken = null, clientId, timeoutMs }) { return sendMessage({ baseUrl: normalizedBaseUrl, diff --git a/src/platforms/weixin/official/types.ts b/src/platforms/weixin/official/types.ts index 613846c..a90db5f 100644 --- a/src/platforms/weixin/official/types.ts +++ b/src/platforms/weixin/official/types.ts @@ -184,6 +184,18 @@ export interface SendTypingResp { errmsg?: string; } +export interface NotifyStartResp { + ret?: number; + errcode?: number; + errmsg?: string; +} + +export interface NotifyStopResp { + ret?: number; + errcode?: number; + errmsg?: string; +} + export interface GetConfigReq { ilink_user_id?: string; context_token?: string; diff --git a/src/platforms/weixin/plugin.ts b/src/platforms/weixin/plugin.ts index dbe7ed8..d48a462 100644 --- a/src/platforms/weixin/plugin.ts +++ b/src/platforms/weixin/plugin.ts @@ -25,6 +25,7 @@ import { SESSION_EXPIRED_ERRCODE, } from './official/session_guard.js'; import { WeixinConfigManager } from './official/config_cache.js'; +import { writeWeixinConnectionStatus, type WeixinConnectionState } from './connection_status.js'; import { downloadMediaFromItem } from './official/media/media_download.js'; import { getExtensionFromMime, getMimeFromFilename } from './official/media/mime.js'; import { buildTextMessageReq } from './official/send.js'; @@ -124,6 +125,7 @@ export class WeixinPlatformPlugin implements Pick; nextMessageSendAt: number; + reauthorizationRequired: boolean; async start() { if (this.running && this.client) { @@ -162,6 +165,7 @@ export class WeixinPlatformPlugin implements Pick 0 ? Math.ceil(remainingPauseMs / 60_000) : 0, @@ -777,7 +785,7 @@ export class WeixinPlatformPlugin implements Pick pauseSession(this.config.accountId), + onSessionExpired: () => this.markSessionExpired(), log: (message) => debugWeixin('config_cache', { message }), }); } @@ -788,6 +796,55 @@ export class WeixinPlatformPlugin implements Pick { assert.equal(parsed.cwd, '/tmp/project'); }); +test('parseWeixinStatusArgs reads an optional state directory', () => { + assert.deepEqual(parseWeixinStatusArgs(['--state-dir', '/tmp/codexbridge-state']), { + stateDir: '/tmp/codexbridge-state', + }); + assert.deepEqual(parseWeixinStatusArgs([]), { stateDir: null }); +}); + test('createWeixinServeCodexAuthManager stores account data under runtime/codex-login', () => { const manager = createWeixinServeCodexAuthManager('/tmp/codexbridge-state'); diff --git a/test/platforms/weixin/connection_status.test.ts b/test/platforms/weixin/connection_status.test.ts new file mode 100644 index 0000000..b1e2e47 --- /dev/null +++ b/test/platforms/weixin/connection_status.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + readWeixinConnectionStatus, + weixinConnectionStatusFile, + writeWeixinConnectionStatus, +} from '../../../src/platforms/weixin/connection_status.js'; + +test('Weixin connection status persists a non-sensitive reauthorization signal', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codexbridge-weixin-status-')); + + writeWeixinConnectionStatus({ + stateDir, + accountId: 'bot-account', + state: 'reauthorization_required', + errorCode: -14, + }); + + assert.deepEqual(readWeixinConnectionStatus(stateDir), { + accountId: 'bot-account', + state: 'reauthorization_required', + updatedAt: readWeixinConnectionStatus(stateDir)?.updatedAt, + errorCode: -14, + }); + assert.equal(fs.existsSync(weixinConnectionStatusFile(stateDir)), true); +}); diff --git a/test/platforms/weixin/official/login.test.ts b/test/platforms/weixin/official/login.test.ts index 6b18aec..e430fc9 100644 --- a/test/platforms/weixin/official/login.test.ts +++ b/test/platforms/weixin/official/login.test.ts @@ -55,5 +55,6 @@ test('officialQrLogin follows confirmed QR flow and persists credentials', async assert.equal(credentials?.account_id, 'bot-account'); assert.equal(accountStore.loadAccount('bot-account')?.token, 'bot-token'); + assert.equal(accountStore.getActiveAccount(), 'bot-account'); assert.equal(accountStore.getContextToken('bot-account', 'wxid_sender'), null); }); diff --git a/test/platforms/weixin/official/transport.test.ts b/test/platforms/weixin/official/transport.test.ts index 1d9376d..ec62c45 100644 --- a/test/platforms/weixin/official/transport.test.ts +++ b/test/platforms/weixin/official/transport.test.ts @@ -191,6 +191,31 @@ test('WeixinOfficialTransport.getUpdates posts iLink payload with authorization' assert.equal(body.base_info.channel_version, '2.2.0'); }); +test('WeixinOfficialTransport notifies iLink when the bot starts and stops', async () => { + const { fetchImpl, calls } = createFetchMock([ + { body: { ret: 0 } }, + { body: { ret: 0 } }, + ]); + const transport = createWeixinOfficialTransport({ + baseUrl: 'https://ilink.example.com/', + token: 'bot-token', + fetchImpl, + }); + + await transport.notifyStart(); + await transport.notifyStop(); + + assert.equal(calls[0].url, 'https://ilink.example.com/ilink/bot/msg/notifystart'); + assert.equal(calls[1].url, 'https://ilink.example.com/ilink/bot/msg/notifystop'); + for (const call of calls) { + assert.equal(call.init.method, 'POST'); + const headers = call.init.headers as Record; + assert.match(headers.Authorization, /^Bearer bot-token$/); + const body = JSON.parse(String(call.init.body)); + assert.deepEqual(body, { base_info: { channel_version: '2.2.0' } }); + } +}); + test('WeixinOfficialTransport.sendMessage and getConfig use Hermes-compatible payload fields', async () => { const { fetchImpl, calls } = createFetchMock([ { body: { ret: 0 } }, diff --git a/test/platforms/weixin/plugin.test.ts b/test/platforms/weixin/plugin.test.ts index 6eae757..7870dfd 100644 --- a/test/platforms/weixin/plugin.test.ts +++ b/test/platforms/weixin/plugin.test.ts @@ -50,6 +50,145 @@ test('loadWeixinConfig restores token and base URL from saved account state', () assert.deepEqual(config.allowFrom, ['wxid_a', 'wxid_b']); }); +test('loadWeixinConfig selects the active account when multiple Weixin accounts are saved', () => { + const rootDir = makeTempAccountsDir(); + const accountStore = new WeixinAccountStore({ rootDir }); + accountStore.saveAccount({ + accountId: 'old-account', + token: 'old-token', + baseUrl: 'https://old.example.com', + }); + accountStore.saveAccount({ + accountId: 'new-account', + token: 'new-token', + baseUrl: 'https://new.example.com', + }); + accountStore.setActiveAccount('new-account'); + + const config = loadWeixinConfig({ + env: {}, + accountStore, + stateDir: path.dirname(path.dirname(rootDir)), + }); + + assert.equal(config.accountId, 'new-account'); + assert.equal(config.token, 'new-token'); + assert.equal(config.baseUrl, 'https://new.example.com'); +}); + +test('WeixinPlatformPlugin notifies iLink during start and before transport cleanup', async () => { + const rootDir = makeTempAccountsDir(); + const originalFetch = globalThis.fetch; + const requests: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requests.push(String(input)); + return new Response(JSON.stringify({ ret: 0 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof globalThis.fetch; + + try { + const plugin = makePlugin({ + config: { + enabled: true, + accountId: 'bot-account', + token: 'token', + baseUrl: 'https://ilink.example.com', + cdnBaseUrl: 'https://novac2c.cdn.weixin.qq.com/c2c', + dmPolicy: 'open', + groupPolicy: 'disabled', + allowFrom: [], + groupAllowFrom: [], + stateDir: path.dirname(path.dirname(rootDir)), + accountsDir: rootDir, + maxMessageLength: 4000, + }, + }); + + await plugin.start(); + assert.ok(plugin.client); + await plugin.stop(); + + assert.deepEqual(requests, [ + 'https://ilink.example.com/ilink/bot/msg/notifystart', + 'https://ilink.example.com/ilink/bot/msg/notifystop', + ]); + assert.equal(plugin.client, null); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('WeixinPlatformPlugin keeps its lifecycle running when iLink rejects an online-state notification', async () => { + const rootDir = makeTempAccountsDir(); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(JSON.stringify({ + errcode: -14, + errmsg: 'session timeout', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as typeof globalThis.fetch; + + try { + const plugin = makePlugin({ + config: { + enabled: true, + accountId: 'bot-account', + token: 'token', + baseUrl: 'https://ilink.example.com', + cdnBaseUrl: 'https://novac2c.cdn.weixin.qq.com/c2c', + dmPolicy: 'open', + groupPolicy: 'disabled', + allowFrom: [], + groupAllowFrom: [], + stateDir: path.dirname(path.dirname(rootDir)), + accountsDir: rootDir, + maxMessageLength: 4000, + }, + }); + + await plugin.start(); + assert.equal(plugin.running, true); + await plugin.stop(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('WeixinPlatformPlugin exposes reauthorization-required status after session expiry', async () => { + const rootDir = makeTempAccountsDir(); + const plugin = makePlugin({ + config: { + enabled: true, + accountId: 'bot-account', + token: 'token', + baseUrl: 'https://ilink.example.com', + cdnBaseUrl: 'https://novac2c.cdn.weixin.qq.com/c2c', + dmPolicy: 'open', + groupPolicy: 'disabled', + allowFrom: [], + groupAllowFrom: [], + stateDir: path.dirname(path.dirname(rootDir)), + accountsDir: rootDir, + maxMessageLength: 4000, + }, + }); + plugin.running = true; + (plugin as any).client = { + async getUpdates() { + return { errcode: -14, errmsg: 'session timeout' }; + }, + }; + + await plugin.pollOnce(); + + const status = plugin.getStatus().data as Record; + assert.equal(status.connectionState, 'reauthorization_required'); + assert.equal(status.reauthorizationRequired, true); +}); + test('WeixinPlatformPlugin normalizes inbound DM text and persists context token', async () => { const rootDir = makeTempAccountsDir(); const accountStore = new WeixinAccountStore({ rootDir });