From 05823b6f66c8b28a15914c92a9c8c93882503e69 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Fri, 1 May 2026 12:05:58 +0800 Subject: [PATCH 1/4] feat(hub): add WeCom bot push notification channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a WeCom (企业微信) notification channel parallel to the existing Telegram / ServerChan / WebPush channels, using the official @wecom/aibot-node-sdk for the WebSocket long-connection transport. Pushes every existing NotificationHub event (permission request, ready, task failure, session completion) as a template_card, and handles the button_interaction callbacks to approve/deny permission requests, replacing the original card via aibot_respond_update_msg with the original req_id threaded through by the SDK. User binding: a WeCom user sends ":" in a single chat with the bot; the handler validates the namespace against a conservative whitelist, rejects silent rebinds to a different namespace, and persists the mapping to store.users. Configuration: WECOM_BOT_ID / WECOM_BOT_SECRET / WECOM_NOTIFICATION env vars mirror the existing TELEGRAM_* shape. Channel is enabled iff both credentials are present and the toggle is on. Notable wire-format fix: the SDK's TypeScript declarations put event_key / task_id flat on the template_card_event, but the live wire nests them under event.template_card_event.*. callbacks.ts reads from the nested path first with a flat fallback. Notable server-side requirement: WeCom rejects update reply cards without a card_action with errcode 42045. buildSystemReplyCard always attaches one pointing at the session URL (or publicUrl). Notable post-kick behaviour: when the server pushes disconnected_event, the SDK marks the client as manually closed (no auto-reconnect); WecomBot re-arms connect() after a 30 s cooldown. Includes an E2E smoke harness (hub/scripts/e2e-wecom.ts) that walks every notification type plus the binding + click flow against the real WeCom endpoint, gated on WECOM_BOT_ID / WECOM_BOT_SECRET. --- bun.lock | 9 +- hub/README.md | 26 ++ hub/package.json | 4 +- hub/scripts/e2e-wecom.ts | 368 +++++++++++++++++++++ hub/src/config/serverSettings.ts | 54 +++ hub/src/config/settings.ts | 3 + hub/src/configuration.ts | 22 ++ hub/src/index.ts | 28 ++ hub/src/notifications/notificationTypes.ts | 10 + hub/src/serverchan/channel.ts | 5 +- hub/src/telegram/bot.ts | 4 +- hub/src/wecom/bot.test.ts | 295 +++++++++++++++++ hub/src/wecom/bot.ts | 296 +++++++++++++++++ hub/src/wecom/callbacks.test.ts | 187 +++++++++++ hub/src/wecom/callbacks.ts | 119 +++++++ hub/src/wecom/renderer.test.ts | 81 +++++ hub/src/wecom/renderer.ts | 33 ++ hub/src/wecom/sessionView.test.ts | 107 ++++++ hub/src/wecom/sessionView.ts | 125 +++++++ hub/src/wecom/types.ts | 26 ++ 20 files changed, 1795 insertions(+), 7 deletions(-) create mode 100644 hub/scripts/e2e-wecom.ts create mode 100644 hub/src/wecom/bot.test.ts create mode 100644 hub/src/wecom/bot.ts create mode 100644 hub/src/wecom/callbacks.test.ts create mode 100644 hub/src/wecom/callbacks.ts create mode 100644 hub/src/wecom/renderer.test.ts create mode 100644 hub/src/wecom/renderer.ts create mode 100644 hub/src/wecom/sessionView.test.ts create mode 100644 hub/src/wecom/sessionView.ts create mode 100644 hub/src/wecom/types.ts diff --git a/bun.lock b/bun.lock index 67700f6e8f..f509ff2da5 100644 --- a/bun.lock +++ b/bun.lock @@ -64,6 +64,7 @@ "dependencies": { "@hapi/protocol": "workspace:*", "@socket.io/bun-engine": "^0.1.0", + "@wecom/aibot-node-sdk": "^1.0.6", "grammy": "^1.38.4", "hono": "^4.11.2", "jose": "^6.1.3", @@ -1272,6 +1273,8 @@ "@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="], + "@wecom/aibot-node-sdk": ["@wecom/aibot-node-sdk@1.0.6", "", { "dependencies": { "axios": "^1.6.7", "eventemitter3": "^5.0.1", "ws": "^8.16.0" } }, "sha512-WZJN3Q+s+94Qjc0VW8d5W1cVkA3emYxiqf+mNRO9UEHoF40puHvizreNMtudjFhm7mmkYiK5ue/QzNiCk+xwLA=="], + "@xterm/addon-canvas": ["@xterm/addon-canvas@0.7.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw=="], "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], @@ -1726,7 +1729,7 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], @@ -3402,6 +3405,8 @@ "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@wecom/aibot-node-sdk/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3504,6 +3509,8 @@ "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + "recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "redent/indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "rehype-katex/katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], diff --git a/hub/README.md b/hub/README.md index faed729730..0a0cc4f831 100644 --- a/hub/README.md +++ b/hub/README.md @@ -29,6 +29,32 @@ See `src/configuration.ts` for all options. - `ELEVENLABS_API_KEY` - ElevenLabs API key for voice assistant. - `ELEVENLABS_AGENT_ID` - Custom ElevenLabs agent ID (auto-created if not set). +### Optional (WeCom) + +- `WECOM_BOT_ID` - BotID for a WeCom smart robot (long-connection mode). +- `WECOM_BOT_SECRET` - Secret for the same robot (long-connection mode). +- `WECOM_NOTIFICATION` - Enable/disable WeCom notifications (default: true). + +Bind your WeCom user to a namespace by sending `:` as a +text message to the bot in a single chat. The bot replies with a confirmation. Once +bound, permission requests arrive as interactive template cards with Allow / Deny +buttons; ready, task-failure, and session-completion events arrive as text-notice +cards with a link to the session. + +#### E2E smoke test + +`hub/scripts/e2e-wecom.ts` runs the real `WecomBot` against the live endpoint +and walks through binding, permission click, ready, task failure, and session +completion. Use this to verify a WeCom bot is wired correctly end-to-end: + +```bash +WECOM_BOT_ID=… WECOM_BOT_SECRET=… bun run --cwd hub e2e:wecom +``` + +The script generates a per-run binding token and prints instructions at each +step. Interactive steps (binding, button click) time out after +`E2E_TIMEOUT_MS` (default 90 s). + ### Optional - `HAPI_LISTEN_HOST` - HTTP bind address (default: 127.0.0.1). diff --git a/hub/package.json b/hub/package.json index aa7c78fa1f..d81f9ef8f1 100644 --- a/hub/package.json +++ b/hub/package.json @@ -12,11 +12,13 @@ "test": "bun test", "typecheck": "tsc --noEmit", "build": "bun build src/index.ts --outdir dist --target bun", - "generate:embedded-web-assets": "bun run scripts/generate-embedded-web-assets.ts" + "generate:embedded-web-assets": "bun run scripts/generate-embedded-web-assets.ts", + "e2e:wecom": "bun run scripts/e2e-wecom.ts" }, "dependencies": { "@hapi/protocol": "workspace:*", "@socket.io/bun-engine": "^0.1.0", + "@wecom/aibot-node-sdk": "^1.0.6", "grammy": "^1.38.4", "hono": "^4.11.2", "jose": "^6.1.3", diff --git a/hub/scripts/e2e-wecom.ts b/hub/scripts/e2e-wecom.ts new file mode 100644 index 0000000000..51a67b38d9 --- /dev/null +++ b/hub/scripts/e2e-wecom.ts @@ -0,0 +1,368 @@ +#!/usr/bin/env bun +/** + * End-to-end smoke harness for the WeCom bot push channel. + * + * Connects to the real WeCom long-connection endpoint + * (`wss://openws.work.weixin.qq.com`) and walks through every notification + * type plus the interactive binding + approve/deny flow. Nothing is mocked: + * the real `WecomBot` wrapper and the official `@wecom/aibot-node-sdk` + * `WSClient` run against the real service; only `Store` and `SyncEngine` + * are in-memory stand-ins so the harness can boot without the rest of the + * hub. + * + * What it verifies: + * 1. Subscribe succeeds against the real endpoint. + * 2. Binding: user sends `:` in single chat → + * `onTextMessage` validates, persists into the in-memory store, replies + * with a markdown confirmation. + * 3. Permission request push → `button_interaction` card arrives; user + * taps Allow or Deny → `template_card_event` callback dispatches to + * `approvePermission` / `denyPermission` → update card replaces the + * original using the callback `req_id`. + * 4. Ready push: `Ready for input` text_notice card. + * 5. Task-failure push: `Task failed` text_notice card (completed status + * is filtered out — the harness sends both and expects only one card). + * 6. Session-completion push: `Session completed` text_notice card. + * + * Required env vars: + * WECOM_BOT_ID BotID from the WeCom admin console (long-connection mode) + * WECOM_BOT_SECRET Secret for the same bot + * + * Optional env vars: + * E2E_CLI_API_TOKEN Binding token to use (default: random per run) + * E2E_NAMESPACE Namespace to bind into (default: "e2e") + * E2E_TIMEOUT_MS Per-step interactive timeout in ms (default: 90000) + * E2E_VERBOSE Set to "1" / "true" to enable debug-level frame logs + * (every received WS frame is dumped). Useful when + * clicks aren't propagating. + * + * Usage: + * WECOM_BOT_ID=… WECOM_BOT_SECRET=… bun run hub/scripts/e2e-wecom.ts + * + * The script prints each step with instructions; interactive steps wait up + * to E2E_TIMEOUT_MS for the user to tap in WeCom before failing that step. + * Non-interactive pushes are visual-only — the script confirms the frame + * was sent on the wire, but the user needs to glance at WeCom to confirm + * the card rendered correctly. + */ + +import { randomBytes } from 'node:crypto' +import type { SessionEndReason } from '@hapi/protocol' +import type { Session, SyncEngine } from '../src/sync/syncEngine' +import type { Store } from '../src/store' +import type { StoredUser } from '../src/store/types' +import { WecomBot } from '../src/wecom/bot' +import { WSClient } from '@wecom/aibot-node-sdk' + +const WECOM_BOT_ID = requireEnv('WECOM_BOT_ID') +const WECOM_BOT_SECRET = requireEnv('WECOM_BOT_SECRET') +const CLI_API_TOKEN = + process.env.E2E_CLI_API_TOKEN ?? `e2e-${randomBytes(6).toString('hex')}` +const NAMESPACE = process.env.E2E_NAMESPACE ?? 'e2e' +const TIMEOUT_MS = Number(process.env.E2E_TIMEOUT_MS ?? 90_000) + +const SESSION_ID = `sess-e2e-${randomBytes(4).toString('hex')}` +const REQUEST_ID = `req-e2e-${randomBytes(4).toString('hex')}` + +type ClickDecision = 'approved' | 'denied' +type BindingEvent = { userid: string; namespace: string } + +async function main(): Promise { + header('HAPI WeCom E2E') + info(`BotID : ${mask(WECOM_BOT_ID)}`) + info(`Secret : ${mask(WECOM_BOT_SECRET)}`) + info(`Binding token : ${CLI_API_TOKEN}`) + info(`Namespace : ${NAMESPACE}`) + info(`Interactive timeout: ${TIMEOUT_MS} ms`) + info(`Synthetic session : ${SESSION_ID} (request ${REQUEST_ID})`) + + // --- Fakes: Store, SyncEngine, Session -------------------------------- + + const userMap = new Map() + let bindingResolver: ((evt: BindingEvent) => void) | null = null + + const store: Store = { + users: { + getUser(platform: string, platformUserId: string) { + return userMap.get(userKey(platform, platformUserId)) ?? null + }, + getUsersByPlatform(platform: string) { + return [...userMap.values()].filter((u) => u.platform === platform) + }, + getUsersByPlatformAndNamespace(platform: string, namespace: string) { + return [...userMap.values()].filter( + (u) => u.platform === platform && u.namespace === namespace + ) + }, + addUser(platform: string, platformUserId: string, namespace: string) { + const key = userKey(platform, platformUserId) + const existing = userMap.get(key) + if (existing) return existing + const row: StoredUser = { + id: userMap.size + 1, + platform, + platformUserId, + namespace, + createdAt: Date.now() + } + userMap.set(key, row) + bindingResolver?.({ userid: platformUserId, namespace }) + return row + }, + removeUser(platform: string, platformUserId: string) { + return userMap.delete(userKey(platform, platformUserId)) + } + } + } as unknown as Store + + let clickResolver: ((decision: ClickDecision) => void) | null = null + + const session: Session = { + id: SESSION_ID, + namespace: NAMESPACE, + seq: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + active: true, + activeAt: Date.now(), + metadata: { path: '/tmp/e2e', host: 'e2e-host', name: 'E2E session' }, + metadataVersion: 0, + agentState: { + requests: { + [REQUEST_ID]: { + tool: 'Bash', + arguments: { command: 'ls -la /tmp' } + } + } + }, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null + } as Session + + const syncEngine: SyncEngine = { + getSessionsByNamespace(namespace: string) { + return namespace === NAMESPACE ? [session] : [] + }, + async approvePermission(_sid: string, rid: string) { + info(`[syncEngine] approvePermission(${_sid}, ${rid})`) + deleteRequest(session, rid) + clickResolver?.('approved') + }, + async denyPermission(_sid: string, rid: string) { + info(`[syncEngine] denyPermission(${_sid}, ${rid})`) + deleteRequest(session, rid) + clickResolver?.('denied') + } + } as unknown as SyncEngine + + // --- Wire up the bot with an observable logger so we can detect ready --- + + const verbose = process.env.E2E_VERBOSE === '1' || process.env.E2E_VERBOSE === 'true' + + let ready = false + const client = new WSClient({ + botId: WECOM_BOT_ID, + secret: WECOM_BOT_SECRET, + logger: { + debug: (msg: string, ...args: unknown[]) => { + if (verbose) console.log(`[client debug] ${msg}`, ...args) + }, + info: (msg: string, ...args: unknown[]) => console.log(`[client] ${msg}`, ...args), + warn: (msg: string, ...args: unknown[]) => console.warn(`[client] ${msg}`, ...args), + error: (msg: string, ...args: unknown[]) => console.error(`[client] ${msg}`, ...args) + } + }) + client.once('authenticated', () => { ready = true }) + + const bot = new WecomBot({ + botId: WECOM_BOT_ID, + secret: WECOM_BOT_SECRET, + cliApiToken: CLI_API_TOKEN, + publicUrl: 'https://hapi.example.com', + store, + syncEngine, + client, + logger: { + debug: verbose + ? (msg: string, ...args: unknown[]) => console.log(`[bot debug] ${msg}`, ...args) + : undefined, + info: (msg: string, ...args: unknown[]) => console.log(`[bot] ${msg}`, ...args), + warn: (msg: string, ...args: unknown[]) => console.warn(`[bot] ${msg}`, ...args), + error: (msg: string, ...args: unknown[]) => console.error(`[bot] ${msg}`, ...args) + } + }) + + const cleanup = () => { + try { bot.stop() } catch { /* ignore */ } + } + process.on('SIGINT', () => { cleanup(); process.exit(130) }) + process.on('SIGTERM', () => { cleanup(); process.exit(143) }) + + try { + // --- Step 1: connect + subscribe ------------------------------------ + header('Step 1/5 — Connecting') + bot.start() + await waitUntil(() => ready, 30_000, 'subscribe success') + ok('Subscribed to wss://openws.work.weixin.qq.com') + + // --- Step 2: binding ----------------------------------------------- + header('Step 2/5 — Binding') + instruct( + 'In WeCom, send this EXACT text to the bot (single chat):', + ` ${CLI_API_TOKEN}:${NAMESPACE}`, + `(waiting up to ${Math.round(TIMEOUT_MS / 1000)}s)` + ) + const bindingPromise = new Promise((resolve) => { + bindingResolver = resolve + }) + const binding = await race(bindingPromise, TIMEOUT_MS, 'binding message') + ok(`Binding received: userid=${binding.userid}, namespace=${binding.namespace}`) + if (binding.namespace !== NAMESPACE) { + throw new Error( + `Binding namespace mismatch: expected ${NAMESPACE}, got ${binding.namespace}` + ) + } + // Give the bind confirmation a moment to reach the user's WeCom. + await sleep(500) + ok('Bind confirmation card should now be visible in WeCom') + + // --- Step 3: permission request + click ----------------------------- + header('Step 3/5 — Permission request (interactive)') + info(`Pushing button_interaction card for ${SESSION_ID}/${REQUEST_ID}…`) + await bot.sendPermissionRequest(session) + ok('Permission card sent') + instruct( + 'In WeCom, tap Allow or Deny on the "Permission Request" card.', + `(waiting up to ${Math.round(TIMEOUT_MS / 1000)}s)` + ) + const clickPromise = new Promise((resolve) => { + clickResolver = resolve + }) + const decision = await race(clickPromise, TIMEOUT_MS, 'button click') + ok(`Click received: ${decision}`) + // Wait for the update card to leave the wire. + await sleep(800) + ok('Update card should now have replaced the original card') + + // --- Step 4: ready -------------------------------------------------- + header('Step 4/5 — Ready notification') + await bot.sendReady(session) + ok('Ready card sent (title "Ready for input")') + + // --- Step 5a: task completion (filtered) + task failure ------------- + header('Step 5/5 — Task notifications') + await bot.sendTaskNotification(session, { + status: 'completed', + summary: 'This should NOT appear in WeCom (filter is enabled)' + }) + ok('Completed-status task suppressed (no frame sent)') + await bot.sendTaskNotification(session, { + status: 'failed', + summary: 'E2E synthetic failure' + }) + ok('Task-failure card sent (title "Task failed")') + + // --- Step 5b: session completion ------------------------------------ + await bot.sendSessionCompletion( + session, + 'completed' satisfies SessionEndReason + ) + ok('Session-completion card sent (title "Session completed")') + + // Let the last frames flush before we close. + await sleep(800) + + header('All steps completed ✓') + info('Visually confirm in WeCom:') + info(' - binding confirmation markdown') + info(' - permission card with Allow/Deny, replaced by "Permission approved."/"denied."') + info(' - "Ready for input" card') + info(' - "Task failed" card') + info(' - "Session completed" card') + info(' - NO "Task completed" card was sent for the completed status') + } finally { + cleanup() + } +} + +// ---- helpers ------------------------------------------------------------ + +function deleteRequest(session: Session, rid: string): void { + const requests = session.agentState?.requests as Record | null | undefined + if (requests) delete requests[rid] +} + +function userKey(platform: string, platformUserId: string): string { + return `${platform}:${platformUserId}` +} + +function requireEnv(name: string): string { + const v = process.env[name] + if (!v) { + console.error(`Missing required env var: ${name}`) + process.exit(2) + } + return v +} + +function mask(secret: string): string { + if (secret.length <= 6) return '***' + return `${secret.slice(0, 3)}…${secret.slice(-3)}` +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function waitUntil( + predicate: () => boolean, + timeoutMs: number, + description: string +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${description} after ${timeoutMs} ms`) + } + await sleep(100) + } +} + +function race(p: Promise, timeoutMs: number, description: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timed out waiting for ${description} after ${timeoutMs} ms`)) + }, timeoutMs) + p.then( + (v) => { clearTimeout(timer); resolve(v) }, + (e) => { clearTimeout(timer); reject(e) } + ) + }) +} + +function header(text: string): void { + console.log(`\n=== ${text} ===`) +} + +function info(...lines: string[]): void { + for (const line of lines) console.log(line) +} + +function instruct(...lines: string[]): void { + console.log('>>>') + for (const line of lines) console.log(`>>> ${line}`) + console.log('>>>') +} + +function ok(text: string): void { + console.log(`[ok] ${text}`) +} + +main().catch((err) => { + console.error(`\n[FAIL] ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) +}) diff --git a/hub/src/config/serverSettings.ts b/hub/src/config/serverSettings.ts index fd894c500c..0ea3f9e600 100644 --- a/hub/src/config/serverSettings.ts +++ b/hub/src/config/serverSettings.ts @@ -15,6 +15,9 @@ export interface ServerSettings { telegramNotification: boolean serverChanSendKey: string | null serverChanNotification: boolean + wecomBotId: string | null + wecomBotSecret: string | null + wecomNotification: boolean listenHost: string listenPort: number publicUrl: string @@ -28,6 +31,9 @@ export interface ServerSettingsResult { telegramNotification: 'env' | 'file' | 'default' serverChanSendKey: 'env' | 'file' | 'default' serverChanNotification: 'env' | 'file' | 'default' + wecomBotId: 'env' | 'file' | 'default' + wecomBotSecret: 'env' | 'file' | 'default' + wecomNotification: 'env' | 'file' | 'default' listenHost: 'env' | 'file' | 'default' listenPort: 'env' | 'file' | 'default' publicUrl: 'env' | 'file' | 'default' @@ -93,6 +99,9 @@ export async function loadServerSettings(dataDir: string): Promise file > null + let wecomBotId: string | null = null + if (process.env.WECOM_BOT_ID) { + wecomBotId = process.env.WECOM_BOT_ID + sources.wecomBotId = 'env' + if (settings.wecomBotId === undefined) { + settings.wecomBotId = wecomBotId + needsSave = true + } + } else if (settings.wecomBotId !== undefined) { + wecomBotId = settings.wecomBotId + sources.wecomBotId = 'file' + } + + // wecomBotSecret: env > file > null + let wecomBotSecret: string | null = null + if (process.env.WECOM_BOT_SECRET) { + wecomBotSecret = process.env.WECOM_BOT_SECRET + sources.wecomBotSecret = 'env' + if (settings.wecomBotSecret === undefined) { + settings.wecomBotSecret = wecomBotSecret + needsSave = true + } + } else if (settings.wecomBotSecret !== undefined) { + wecomBotSecret = settings.wecomBotSecret + sources.wecomBotSecret = 'file' + } + + // wecomNotification: env > file > true + let wecomNotification = true + if (process.env.WECOM_NOTIFICATION !== undefined) { + wecomNotification = process.env.WECOM_NOTIFICATION === 'true' + sources.wecomNotification = 'env' + if (settings.wecomNotification === undefined) { + settings.wecomNotification = wecomNotification + needsSave = true + } + } else if (settings.wecomNotification !== undefined) { + wecomNotification = settings.wecomNotification + sources.wecomNotification = 'file' + } + // listenHost: env > file (new or old name) > default let listenHost = '127.0.0.1' if (process.env.HAPI_LISTEN_HOST) { @@ -248,6 +299,9 @@ export async function loadServerSettings(dataDir: string): Promise | null = null let sseManager: SSEManager | null = null let visibilityTracker: VisibilityTracker | null = null let notificationHub: NotificationHub | null = null +let wecomBot: WecomBot | null = null let tunnelManager: TunnelManager | null = null async function main() { @@ -158,6 +160,14 @@ async function main() { } else { console.log('[Hub] ServerChan: disabled (no SERVERCHAN_SENDKEY)') } + if (config.wecomEnabled) { + const source = formatSource(config.sources.wecomBotId) + const notificationSource = formatSource(config.sources.wecomNotification) + console.log(`[Hub] WeCom: enabled (${source})`) + console.log(`[Hub] WeCom notifications: ${config.wecomNotification ? 'enabled' : 'disabled'} (${notificationSource})`) + } else { + console.log('[Hub] WeCom: disabled (no WECOM_BOT_ID/WECOM_BOT_SECRET)') + } // Display tunnel status if (relayFlag.enabled) { @@ -217,6 +227,19 @@ async function main() { } } + // Initialize WeCom bot (optional) + if (config.wecomEnabled && config.wecomBotId && config.wecomBotSecret && config.wecomNotification) { + wecomBot = new WecomBot({ + botId: config.wecomBotId, + secret: config.wecomBotSecret, + cliApiToken: config.cliApiToken, + publicUrl: config.publicUrl, + store, + syncEngine + }) + notificationChannels.push(wecomBot) + } + notificationHub = new NotificationHub(syncEngine, notificationChannels) // Start HTTP service first (before tunnel, so tunnel has something to forward to) @@ -238,6 +261,10 @@ async function main() { await happyBot.start() } + if (wecomBot) { + wecomBot.start() + } + console.log('') console.log('[Web] Hub listening on :' + config.listenPort) console.log('[Web] Local: http://localhost:' + config.listenPort) @@ -310,6 +337,7 @@ async function main() { console.log('\nShutting down...') await tunnelManager?.stop() await happyBot?.stop() + wecomBot?.stop() notificationHub?.stop() syncEngine?.stop() sseManager?.stop() diff --git a/hub/src/notifications/notificationTypes.ts b/hub/src/notifications/notificationTypes.ts index 07e2b642c9..8ff535ffe2 100644 --- a/hub/src/notifications/notificationTypes.ts +++ b/hub/src/notifications/notificationTypes.ts @@ -17,3 +17,13 @@ export type NotificationHubOptions = { readyCooldownMs?: number permissionDebounceMs?: number } + +/** + * Task-status classifier shared by notification channels. Accepts the raw + * status string and returns true when the task is considered a failure. + * The comparison is case-insensitive and trims whitespace. + */ +export function isFailureStatus(status: string | undefined): boolean { + const s = status?.trim().toLowerCase() + return s === 'failed' || s === 'error' || s === 'killed' || s === 'aborted' +} diff --git a/hub/src/serverchan/channel.ts b/hub/src/serverchan/channel.ts index 7187169550..906d690033 100644 --- a/hub/src/serverchan/channel.ts +++ b/hub/src/serverchan/channel.ts @@ -1,6 +1,7 @@ import type { Session } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import { isFailureStatus } from '../notifications/notificationTypes' import { getAgentName, getSessionName } from '../notifications/sessionInfo' function buildSessionUrl(baseUrl: string, sessionId: string): string { @@ -50,9 +51,7 @@ export class ServerChanChannel implements NotificationChannel { const agentName = getAgentName(session) const name = getSessionName(session) - const status = notification.status?.trim().toLowerCase() - const isFailure = status === 'failed' || status === 'error' || status === 'killed' || status === 'aborted' - if (!isFailure) { + if (!isFailureStatus(notification.status)) { return } const url = buildSessionUrl(this.publicUrl, session.id) diff --git a/hub/src/telegram/bot.ts b/hub/src/telegram/bot.ts index 637975a966..a8a46eb347 100644 --- a/hub/src/telegram/bot.ts +++ b/hub/src/telegram/bot.ts @@ -11,6 +11,7 @@ import { handleCallback, CallbackContext } from './callbacks' import { formatSessionNotification, createNotificationKeyboard } from './sessionView' import { getAgentName } from '../notifications/sessionInfo' import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' +import { isFailureStatus } from '../notifications/notificationTypes' import type { Store } from '../store' export interface BotContext extends Context { @@ -248,8 +249,7 @@ export class HappyBot implements NotificationChannel { } const agentName = getAgentName(session) - const status = notification.status?.trim().toLowerCase() - const prefix = status === 'failed' || status === 'error' || status === 'killed' || status === 'aborted' + const prefix = isFailureStatus(notification.status) ? 'Task failed' : 'Task completed' const url = buildMiniAppDeepLink(this.publicUrl, `session_${session.id}`) diff --git a/hub/src/wecom/bot.test.ts b/hub/src/wecom/bot.test.ts new file mode 100644 index 0000000000..76c0375397 --- /dev/null +++ b/hub/src/wecom/bot.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it, mock } from 'bun:test' +import type { Session, SyncEngine } from '../sync/syncEngine' +import type { Store } from '../store' +import type { SendMsgBody, TemplateCard, WsFrame } from './types' +import { WecomBot, type WecomBotClient } from './bot' + +type FakeListener = (...args: unknown[]) => void + +class FakeClient { + private listeners = new Map() + started = false + stopped = false + + sendMessage = mock(async (_chatid: string, _body: SendMsgBody): Promise => ({ + headers: { req_id: 'ack' }, + errcode: 0 + })) + + updateTemplateCard = mock( + async ( + _frame: { headers: { req_id: string } }, + _card: TemplateCard, + _userids?: string[] + ): Promise => ({ headers: { req_id: 'ack' }, errcode: 0 }) + ) + + connect() { + this.started = true + } + + disconnect() { + this.stopped = true + } + + on(event: string, handler: FakeListener): this { + const list = this.listeners.get(event) ?? [] + list.push(handler) + this.listeners.set(event, list) + return this + } + + emit(event: string, ...args: unknown[]) { + for (const l of this.listeners.get(event) ?? []) l(...args) + } +} + +function session(overrides: Partial = {}): Session { + return { + id: 'abcdef0123456789', + namespace: 'default', + seq: 0, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { path: '/tmp/proj', host: 'mac' }, + metadataVersion: 0, + agentState: { + requests: { + 'req98765432abc': { tool: 'Bash', arguments: { command: 'ls' } } + } + }, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + ...overrides + } as Session +} + +function makeBot(bound: Array<{ platformUserId: string; namespace: string }> = [ + { platformUserId: 'wecom-user-1', namespace: 'default' } +]) { + const addUser = mock((_platform: string, _uid: string, ns: string) => ({ + id: 1, platform: 'wecom', platformUserId: 'wecom-user-1', namespace: ns, createdAt: 0 + })) + const store = { + users: { + getUsersByPlatformAndNamespace: (_p: string, ns: string) => + bound.filter((u) => u.namespace === ns).map((u) => ({ + id: 1, platform: 'wecom', createdAt: 0, ...u + })), + getUser: (_p: string, uid: string) => { + const hit = bound.find((u) => u.platformUserId === uid) + return hit + ? { id: 1, platform: 'wecom', platformUserId: uid, namespace: hit.namespace, createdAt: 0 } + : null + }, + addUser + } + } as unknown as Store + + const syncEngine = { + getSessionsByNamespace: (_ns: string) => [session()], + approvePermission: mock(async () => {}), + denyPermission: mock(async () => {}) + } as unknown as SyncEngine + + const client = new FakeClient() + const bot = new WecomBot({ + botId: 'BOT', + secret: 'SECRET', + cliApiToken: 'TOKEN', + publicUrl: 'https://hapi.example.com', + store, + syncEngine, + client: client as unknown as WecomBotClient + }) + return { bot, client, store, syncEngine, addUser } +} + +function tick(): Promise { + return new Promise((r) => setTimeout(r, 0)) +} + +describe('WecomBot.start / stop', () => { + it('starts and stops the underlying client', () => { + const { bot, client } = makeBot() + bot.start() + expect(client.started).toBe(true) + bot.stop() + expect(client.stopped).toBe(true) + }) +}) + +describe('WecomBot.sendPermissionRequest', () => { + it('sends a button_interaction card to every bound userid with Allow/Deny keys', async () => { + const { bot, client } = makeBot([ + { platformUserId: 'u1', namespace: 'default' }, + { platformUserId: 'u2', namespace: 'default' } + ]) + await bot.sendPermissionRequest(session()) + + expect(client.sendMessage.mock.calls).toHaveLength(2) + for (const [_chatid, body] of client.sendMessage.mock.calls) { + const b = body as Extract + expect(b.msgtype).toBe('template_card') + expect(b.template_card.card_type).toBe('button_interaction') + expect(b.template_card.button_list?.[0].key).toBe('ap:abcdef01:req98765') + expect(b.template_card.button_list?.[1].key).toBe('dn:abcdef01:req98765') + } + expect(client.sendMessage.mock.calls[0][0]).toBe('u1') + expect(client.sendMessage.mock.calls[1][0]).toBe('u2') + }) + + it('no-ops when the session has no bound WeCom users', async () => { + const { bot, client } = makeBot([]) + await bot.sendPermissionRequest(session()) + expect(client.sendMessage.mock.calls).toHaveLength(0) + }) + + it('no-ops when the session is inactive', async () => { + const { bot, client } = makeBot() + await bot.sendPermissionRequest(session({ active: false })) + expect(client.sendMessage.mock.calls).toHaveLength(0) + }) +}) + +describe('WecomBot.sendReady', () => { + it('sends a text_notice card to each bound user', async () => { + const { bot, client } = makeBot() + await bot.sendReady(session()) + expect(client.sendMessage.mock.calls).toHaveLength(1) + const body = client.sendMessage.mock.calls[0][1] as Extract + expect(body.template_card.main_title?.title).toBe('Ready for input') + }) +}) + +describe('WecomBot.sendTaskNotification', () => { + it('sends task notifications only for failure statuses', async () => { + const { bot, client } = makeBot() + await bot.sendTaskNotification(session(), { status: 'completed', summary: 's' }) + expect(client.sendMessage.mock.calls).toHaveLength(0) + await bot.sendTaskNotification(session(), { status: 'failed', summary: 's' }) + expect(client.sendMessage.mock.calls).toHaveLength(1) + const body = client.sendMessage.mock.calls[0][1] as Extract + expect(body.template_card.main_title?.title).toBe('Task failed') + }) +}) + +function textFrame(userid: string, content: string): WsFrame { + return { + cmd: 'aibot_msg_callback', + headers: { req_id: 'r1' }, + body: { + msgid: 'm', aibotid: 'b', chattype: 'single', + from: { userid }, + msgtype: 'text', + text: { content } + } + } as unknown as WsFrame +} + +describe('WecomBot binding', () => { + it('binds a user when they send ":"', async () => { + const { client, addUser } = makeBot([]) + client.emit('message.text', textFrame('u-new', 'TOKEN:myns')) + await tick() + expect(addUser).toHaveBeenCalledWith('wecom', 'u-new', 'myns') + expect(client.sendMessage.mock.calls).toHaveLength(1) + const [chatid, body] = client.sendMessage.mock.calls[0] as [string, SendMsgBody] + expect(chatid).toBe('u-new') + const b = body as Extract + expect(b.msgtype).toBe('markdown') + expect(b.markdown.content).toMatch(/namespace \*\*myns\*\*\.$/) + }) + + it('ignores non-matching text content', async () => { + const { client, addUser } = makeBot([]) + client.emit('message.text', textFrame('u-new', 'hello')) + await tick() + expect(addUser).not.toHaveBeenCalled() + expect(client.sendMessage.mock.calls).toHaveLength(0) + }) + + it('rejects invalid namespace characters with a usage reply', async () => { + const { client, addUser } = makeBot([]) + client.emit('message.text', textFrame('u-new', 'TOKEN:**bad ns**\n')) + await tick() + expect(addUser).not.toHaveBeenCalled() + expect(client.sendMessage.mock.calls).toHaveLength(1) + const body = client.sendMessage.mock.calls[0][1] as Extract + expect(body.markdown.content).toContain('Invalid namespace') + }) + + it('refuses to silently rebind an already-bound userid to a different namespace', async () => { + const { client, addUser } = makeBot([ + { platformUserId: 'u-existing', namespace: 'nsA' } + ]) + client.emit('message.text', textFrame('u-existing', 'TOKEN:nsB')) + await tick() + expect(addUser).not.toHaveBeenCalled() + const body = client.sendMessage.mock.calls[0][1] as Extract + expect(body.markdown.content).toContain('Already bound to a different namespace') + }) + + it('confirms idempotent rebind to the same namespace without writing', async () => { + const { client, addUser } = makeBot([ + { platformUserId: 'u-existing', namespace: 'nsA' } + ]) + client.emit('message.text', textFrame('u-existing', 'TOKEN:nsA')) + await tick() + expect(addUser).not.toHaveBeenCalled() + const body = client.sendMessage.mock.calls[0][1] as Extract + expect(body.markdown.content).toContain('Already bound to namespace **nsA**') + }) +}) + +function clickFrame(eventKey: string, userid: string, reqId: string): WsFrame { + return { + cmd: 'aibot_event_callback', + headers: { req_id: reqId }, + body: { + msgid: 'm', aibotid: 'b', + from: { userid }, + msgtype: 'event', + event: { + eventtype: 'template_card_event', + template_card_event: { event_key: eventKey, task_id: 't' } + } + } + } as unknown as WsFrame +} + +describe('WecomBot onEvent (template card click)', () => { + it('dispatches approve and passes the original frame to updateTemplateCard', async () => { + const { client, syncEngine } = makeBot() + client.emit('event.template_card_event', clickFrame('ap:abcdef01:req98765', 'wecom-user-1', 'cb-42')) + await tick() + + expect((syncEngine.approvePermission as unknown as { mock: { calls: unknown[][] } }).mock.calls).toHaveLength(1) + expect(client.updateTemplateCard.mock.calls).toHaveLength(1) + const [frame, card] = client.updateTemplateCard.mock.calls[0] as [ + WsFrame, + TemplateCard + ] + // The original callback frame is threaded through so the SDK reuses its req_id. + expect(frame.headers.req_id).toBe('cb-42') + expect(card.task_id).toBe('t') + expect(card.main_title?.title).toBe('Permission approved.') + }) + + it('denies and passes the original frame (with its req_id) to updateTemplateCard', async () => { + const { client, syncEngine } = makeBot() + client.emit('event.template_card_event', clickFrame('dn:abcdef01:req98765', 'wecom-user-1', 'cb-43')) + await tick() + + expect((syncEngine.denyPermission as unknown as { mock: { calls: unknown[][] } }).mock.calls).toHaveLength(1) + const [frame, card] = client.updateTemplateCard.mock.calls[0] as [WsFrame, TemplateCard] + expect(frame.headers.req_id).toBe('cb-43') + expect(card.main_title?.title).toBe('Permission denied.') + }) +}) diff --git a/hub/src/wecom/bot.ts b/hub/src/wecom/bot.ts new file mode 100644 index 0000000000..2f6885d4e4 --- /dev/null +++ b/hub/src/wecom/bot.ts @@ -0,0 +1,296 @@ +import type { SessionEndReason } from '@hapi/protocol' +import type { Session, SyncEngine } from '../sync/syncEngine' +import type { Store } from '../store' +import type { + NotificationChannel, + TaskNotification +} from '../notifications/notificationTypes' +import { isFailureStatus } from '../notifications/notificationTypes' +import { WSClient, type WsFrame, type WSClientOptions } from '@wecom/aibot-node-sdk' +import type { EventMessageWith, SendMsgBody, TemplateCard, TemplateCardEventData } from './types' +import { handleTemplateCardEvent, type CallbackCtx } from './callbacks' +import { + buildPermissionCard, + buildReadyCard, + buildSessionCompletionCard, + buildTaskCard +} from './sessionView' + +/** 30 s cooldown before we try to reconnect after a server-initiated kick. */ +const SERVER_KICK_RECONNECT_DELAY_MS = 30_000 + +/** + * Shape of the WeCom SDK client that WecomBot needs. Kept narrow on purpose + * so tests can supply a fake (an EventEmitter-backed stub) without having to + * construct a real {@link WSClient}. + */ +export interface WecomBotClient { + connect(): unknown + disconnect(): void + on(event: 'message.text', handler: (frame: WsFrame) => void): unknown + on(event: 'event.template_card_event', handler: (frame: WsFrame) => void): unknown + on(event: 'event.disconnected_event', handler: (frame: WsFrame) => void): unknown + on(event: 'disconnected', handler: (reason: string) => void): unknown + on(event: 'error', handler: (err: Error) => void): unknown + sendMessage(chatid: string, body: SendMsgBody): Promise + updateTemplateCard( + frame: { headers: { req_id: string } }, + templateCard: TemplateCard, + userids?: string[] + ): Promise +} + +export interface WecomBotConfig { + botId: string + secret: string + cliApiToken: string + publicUrl: string + store: Store + syncEngine: SyncEngine + /** Pre-constructed client; if omitted, a real WSClient from the SDK is used. */ + client?: WecomBotClient + /** Additional SDK options forwarded when no {@link client} is provided. */ + clientOptions?: Partial> + /** Optional logger; falls back to console. Supports optional debug level. */ + logger?: { + debug?: (msg: string, ...args: unknown[]) => void + info?: (msg: string, ...args: unknown[]) => void + warn?: (msg: string, ...args: unknown[]) => void + error?: (msg: string, ...args: unknown[]) => void + } +} + +export class WecomBot implements NotificationChannel { + private readonly store: Store + private readonly syncEngine: SyncEngine + private readonly cliApiToken: string + private readonly publicUrl: string + private readonly client: WecomBotClient + private readonly logger: NonNullable + private stopped = false + private reconnectTimer: ReturnType | null = null + + constructor(config: WecomBotConfig) { + this.store = config.store + this.syncEngine = config.syncEngine + this.cliApiToken = config.cliApiToken + this.publicUrl = config.publicUrl + this.logger = config.logger ?? {} + this.client = config.client ?? new WSClient({ + botId: config.botId, + secret: config.secret, + logger: this.adaptLogger(), + ...config.clientOptions + }) + + this.client.on('message.text', (frame) => this.onTextMessage(frame)) + this.client.on('event.template_card_event', (frame) => this.onEvent(frame)) + this.client.on('event.disconnected_event', () => this.scheduleReconnectAfterKick()) + this.client.on('error', (err) => { + (this.logger.error ?? console.error)('[WecomBot] client error:', err) + }) + } + + start(): void { + this.stopped = false + this.client.connect() + } + + stop(): void { + this.stopped = true + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.client.disconnect() + } + + // --- NotificationChannel --- + + async sendPermissionRequest(session: Session): Promise { + if (!session.active) return + const card = buildPermissionCard(session, this.publicUrl) + if (!card) return + await this.broadcast(session.namespace, { + msgtype: 'template_card', + template_card: card + }) + } + + async sendReady(session: Session): Promise { + if (!session.active) return + const card = buildReadyCard(session, this.publicUrl) + await this.broadcast(session.namespace, { + msgtype: 'template_card', + template_card: card + }) + } + + async sendTaskNotification(session: Session, notification: TaskNotification): Promise { + if (!session.active) return + if (!isFailureStatus(notification.status)) return + const card = buildTaskCard(session, notification, this.publicUrl) + await this.broadcast(session.namespace, { + msgtype: 'template_card', + template_card: card + }) + } + + async sendSessionCompletion(session: Session, _reason: SessionEndReason): Promise { + const card = buildSessionCompletionCard(session, this.publicUrl) + await this.broadcast(session.namespace, { + msgtype: 'template_card', + template_card: card + }) + } + + // --- Helpers --- + + private async broadcast(namespace: string, body: SendMsgBody): Promise { + const chatids = this.bindingsFor(namespace) + for (const chatid of chatids) { + try { + await this.client.sendMessage(chatid, body) + } catch (err) { + // One bad chatid shouldn't abort the rest of the fan-out. + (this.logger.warn ?? console.warn)( + `[WecomBot] sendMessage to ${chatid} failed:`, err + ) + } + } + } + + // --- Incoming frames --- + + private onTextMessage(frame: WsFrame): void { + const body = frame.body as { text?: { content?: string }; from?: { userid?: string } } | undefined + const content = body?.text?.content?.trim() + const userid = body?.from?.userid + if (!content || !userid) return + + const prefix = `${this.cliApiToken}:` + if (!content.startsWith(prefix)) return + const namespace = content.slice(prefix.length).trim() + if (!namespace) return + + // Whitelist: letters, digits, dash, underscore, up to 64 chars. + // Rejects markdown metacharacters that would break the confirmation + // card and keeps the `users.namespace` column to a known charset. + if (!/^[A-Za-z0-9_-]{1,64}$/.test(namespace)) { + void this.sendBindReply(userid, + 'Invalid namespace. Allowed: letters, digits, `-`, `_`, max 64 chars.') + return + } + + const existing = this.store.users.getUser('wecom', userid) + if (existing) { + if (existing.namespace === namespace) { + void this.sendBindReply(userid, + `Already bound to namespace **${namespace}**.`) + } else { + // Refuse to silently no-op: surface the conflict. + void this.sendBindReply(userid, + `Already bound to a different namespace. Unbind first before rebinding.`) + } + return + } + + try { + this.store.users.addUser('wecom', userid, namespace) + } catch (err) { + (this.logger.error ?? console.error)('[WecomBot] failed to persist binding:', err) + return + } + void this.sendBindReply(userid, + `Bound WeCom user **${userid}** to namespace **${namespace}**.`) + } + + private async sendBindReply(chatid: string, content: string): Promise { + try { + await this.client.sendMessage(chatid, { + msgtype: 'markdown', + markdown: { content } + }) + } catch (err) { + (this.logger.warn ?? console.warn)( + `[WecomBot] bind reply to ${chatid} failed:`, err + ) + } + } + + private onEvent(frame: WsFrame): void { + const typedFrame = frame as WsFrame> + const event = typedFrame.body?.event + if (!event || event.eventtype !== 'template_card_event') return + + const details = + (event as { template_card_event?: { event_key?: string; task_id?: string } }) + .template_card_event ?? {} + const eventKey = details.event_key ?? event.event_key + const taskId = details.task_id ?? event.task_id + this.logger.debug?.( + `[WecomBot] onEvent event_key=${eventKey ?? '(none)'} task_id=${taskId ?? '(none)'}` + ) + + const ctx: CallbackCtx = { + syncEngine: this.syncEngine, + store: this.store, + publicUrl: this.publicUrl, + sendUpdate: (payload) => { + this.logger.debug?.( + `[WecomBot] update_template_card req_id=${payload.frame.headers.req_id} task_id=${payload.card.task_id ?? '(none)'}` + ) + // The SDK threads frame.headers.req_id onto the outgoing frame + // and enforces the 5-second reply window. + void this.client.updateTemplateCard(payload.frame, payload.card, payload.userids) + .catch((err) => { + (this.logger.error ?? console.error)( + '[WecomBot] updateTemplateCard failed:', err + ) + }) + } + } + void handleTemplateCardEvent(typedFrame, ctx).catch((err) => { + (this.logger.error ?? console.error)('[WecomBot] handleTemplateCardEvent failed:', err) + }) + } + + private scheduleReconnectAfterKick(): void { + // The SDK treats disconnected_event as a manual close and will not + // auto-reconnect. Re-arm the connection after a cooldown so a second + // connection (e.g., another dev starting the hub) only briefly takes + // us offline instead of requiring a manual restart. + if (this.stopped) return + if (this.reconnectTimer) return + (this.logger.warn ?? console.warn)( + `[WecomBot] server kicked this connection; reconnecting in ${SERVER_KICK_RECONNECT_DELAY_MS}ms` + ) + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + if (this.stopped) return + try { + this.client.connect() + } catch (err) { + (this.logger.error ?? console.error)( + '[WecomBot] reconnect after kick failed:', err + ) + } + }, SERVER_KICK_RECONNECT_DELAY_MS) + } + + private bindingsFor(namespace: string): string[] { + return this.store.users + .getUsersByPlatformAndNamespace('wecom', namespace) + .map((u) => u.platformUserId) + } + + private adaptLogger() { + const l = this.logger + return { + debug: (msg: string, ...args: unknown[]) => l.debug?.(msg, ...args), + info: (msg: string, ...args: unknown[]) => (l.info ?? console.log)(msg, ...args), + warn: (msg: string, ...args: unknown[]) => (l.warn ?? console.warn)(msg, ...args), + error: (msg: string, ...args: unknown[]) => (l.error ?? console.error)(msg, ...args) + } + } +} diff --git a/hub/src/wecom/callbacks.test.ts b/hub/src/wecom/callbacks.test.ts new file mode 100644 index 0000000000..df491c6758 --- /dev/null +++ b/hub/src/wecom/callbacks.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, mock } from 'bun:test' +import type { Session, SyncEngine } from '../sync/syncEngine' +import type { Store } from '../store' +import type { EventMessageWith, TemplateCard, TemplateCardEventData, WsFrame } from './types' +import { handleTemplateCardEvent } from './callbacks' + +function makeSession(overrides: Partial = {}): Session { + return { + id: 'abcdef0123456789', + namespace: 'default', + seq: 0, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: null, + metadataVersion: 0, + agentState: { + requests: { 'req98765432abc': { tool: 'Bash', arguments: { command: 'ls' } } } + }, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + ...overrides + } as Session +} + +function makeFrame(event_key: string, userid = 'u-1'): WsFrame> { + return { + cmd: 'aibot_event_callback', + headers: { req_id: 'callback-req-1' }, + body: { + msgid: 'm1', + aibotid: 'bot', + from: { userid }, + msgtype: 'event', + event: { + eventtype: 'template_card_event', + template_card_event: { event_key, task_id: 't' } + } + } + } as unknown as WsFrame> +} + +function makeFlatFrame(event_key: string, userid = 'u-1'): WsFrame> { + return { + cmd: 'aibot_event_callback', + headers: { req_id: 'callback-req-1' }, + body: { + msgid: 'm1', + aibotid: 'bot', + from: { userid }, + msgtype: 'event', + event: { eventtype: 'template_card_event', event_key, task_id: 't' } + } + } as unknown as WsFrame> +} + +function makeCtx(opts: { + session?: Session | null + userNamespace?: string | null + approve?: () => Promise + deny?: () => Promise +} = {}) { + const sendUpdate = mock((_payload: { + frame: WsFrame> + card: TemplateCard + userids?: string[] + }) => {}) + const approve = opts.approve ?? (async () => {}) + const deny = opts.deny ?? (async () => {}) + + const syncEngine = { + getSessionsByNamespace: () => (opts.session ? [opts.session] : []), + approvePermission: approve, + denyPermission: deny + } as unknown as SyncEngine + + const store = { + users: { + getUser: (_platform: string, _uid: string) => + opts.userNamespace ? { platform: 'wecom', platformUserId: 'u-1', namespace: opts.userNamespace } : null + } + } as unknown as Store + + return { + syncEngine, + store, + publicUrl: 'https://hapi.example.com', + sendUpdate + } +} + +describe('handleTemplateCardEvent', () => { + it('approves and sends an "approved" update card threading the callback frame', async () => { + const approve = mock(async () => {}) + const ctx = makeCtx({ session: makeSession(), userNamespace: 'default', approve }) + const frame = makeFrame('ap:abcdef01:req98765') + + await handleTemplateCardEvent(frame, ctx) + + expect(approve).toHaveBeenCalledWith('abcdef0123456789', 'req98765432abc') + expect(ctx.sendUpdate).toHaveBeenCalledTimes(1) + const [arg] = ctx.sendUpdate.mock.calls[0] + // The ORIGINAL callback frame is threaded through so the SDK can reuse + // its req_id when posting the update card (within the 5s window). + expect(arg.frame).toBe(frame) + expect(arg.frame.headers.req_id).toBe('callback-req-1') + expect(arg.card.main_title?.title).toBe('Permission approved.') + }) + + it('denies and sends a "denied" update card', async () => { + const deny = mock(async () => {}) + const ctx = makeCtx({ session: makeSession(), userNamespace: 'default', deny }) + const frame = makeFrame('dn:abcdef01:req98765') + + await handleTemplateCardEvent(frame, ctx) + + expect(deny).toHaveBeenCalledWith('abcdef0123456789', 'req98765432abc') + const [arg] = ctx.sendUpdate.mock.calls[0] + expect(arg.card.main_title?.title).toBe('Permission denied.') + }) + + it('replies with "Not bound" when the userid has no binding', async () => { + const ctx = makeCtx({ userNamespace: null }) + await handleTemplateCardEvent(makeFrame('ap:abcdef01:req98765'), ctx) + const [arg] = ctx.sendUpdate.mock.calls[0] + expect(arg.card.main_title?.title).toBe('Not bound') + }) + + it('replies with "Session inactive" when the session is inactive', async () => { + const ctx = makeCtx({ + session: makeSession({ active: false }), + userNamespace: 'default' + }) + await handleTemplateCardEvent(makeFrame('ap:abcdef01:req98765'), ctx) + const [arg] = ctx.sendUpdate.mock.calls[0] + expect(arg.card.main_title?.title).toBe('Session inactive') + }) + + it('replies with "Already processed" when the request is gone', async () => { + const ctx = makeCtx({ + session: makeSession({ agentState: { requests: {} } }), + userNamespace: 'default' + }) + await handleTemplateCardEvent(makeFrame('ap:abcdef01:req98765'), ctx) + const [arg] = ctx.sendUpdate.mock.calls[0] + expect(arg.card.main_title?.title).toBe('Already processed') + }) + + it('ignores unknown actions', async () => { + const ctx = makeCtx({ session: makeSession(), userNamespace: 'default' }) + await handleTemplateCardEvent(makeFrame('xx:abcdef01:req98765'), ctx) + expect(ctx.sendUpdate).not.toHaveBeenCalled() + }) + + it('replies with "Already processed" when the event_key has no request prefix', async () => { + const ctx = makeCtx({ session: makeSession(), userNamespace: 'default' }) + await handleTemplateCardEvent(makeFrame('ap:abcdef01'), ctx) + const [arg] = ctx.sendUpdate.mock.calls[0] + expect(arg.card.main_title?.title).toBe('Already processed') + }) + + it('accepts legacy flat event payloads (event_key/task_id on event root)', async () => { + const approve = mock(async () => {}) + const ctx = makeCtx({ session: makeSession(), userNamespace: 'default', approve }) + const frame = makeFlatFrame('ap:abcdef01:req98765') + + await handleTemplateCardEvent(frame, ctx) + + expect(approve).toHaveBeenCalledWith('abcdef0123456789', 'req98765432abc') + const [arg] = ctx.sendUpdate.mock.calls[0] + expect(arg.card.task_id).toBe('t') + expect(arg.card.main_title?.title).toBe('Permission approved.') + }) + + it('always includes card_action on update cards to satisfy WeCom errcode 42045', async () => { + const ctx = makeCtx({ session: makeSession(), userNamespace: 'default' }) + await handleTemplateCardEvent(makeFrame('ap:abcdef01:req98765'), ctx) + const [arg] = ctx.sendUpdate.mock.calls[0] + expect(arg.card.card_action?.type).toBe(1) + expect(arg.card.card_action?.url).toMatch(/^https:\/\/hapi\.example\.com/) + }) +}) diff --git a/hub/src/wecom/callbacks.ts b/hub/src/wecom/callbacks.ts new file mode 100644 index 0000000000..d0de8fea43 --- /dev/null +++ b/hub/src/wecom/callbacks.ts @@ -0,0 +1,119 @@ +import type { Session, SyncEngine } from '../sync/syncEngine' +import type { Store } from '../store' +import { ACTION_APPROVE, ACTION_DENY, parseCallbackData, findSessionByPrefix } from './renderer' +import { buildSystemReplyCard, sessionUrl } from './sessionView' +import type { + EventMessageWith, + TemplateCard, + TemplateCardEventData, + WsFrame +} from './types' + +export interface CallbackCtx { + syncEngine: SyncEngine + store: Store + publicUrl: string + /** + * Send an update_template_card reply for the given callback frame. + * + * The implementation must thread {@link frame}`.headers.req_id` onto the + * outgoing frame — WeCom requires update replies to reuse the callback's + * req_id (and to fire within 5 seconds). The SDK's `updateTemplateCard` + * handles this transparently; the bot-side implementation delegates to it. + */ + sendUpdate: (payload: { + frame: WsFrame> + card: TemplateCard + userids?: string[] + }) => void +} + +function findRequestByPrefix(session: Session, prefix: string): string | null { + if (!prefix) return null + const requests = session.agentState?.requests + if (!requests) return null + for (const id of Object.keys(requests)) { + if (id.startsWith(prefix)) return id + } + return null +} + +function reply( + ctx: CallbackCtx, + frame: WsFrame>, + title: string, + taskId?: string, + sessionId?: string +): void { + const url = sessionId ? sessionUrl(ctx.publicUrl, sessionId) : ctx.publicUrl + const card = buildSystemReplyCard(title, url) + // WeCom requires the update-card's template_card.task_id to match the + // original card's task_id; otherwise the server silently discards the + // response and the original card never gets replaced in the client. + if (taskId) card.task_id = taskId + ctx.sendUpdate({ frame, card }) +} + +export async function handleTemplateCardEvent( + frame: WsFrame>, + ctx: CallbackCtx +): Promise { + const event = frame.body?.event + if (!event || event.eventtype !== 'template_card_event') return + if (!frame.headers?.req_id) return + + // WeCom's live wire format nests click details under `event.template_card_event`. + // Fall back to flat fields on the event itself for older payloads (and for + // the shape the SDK's own .d.ts declares). + const details = + (event as { template_card_event?: { event_key?: string; task_id?: string } }) + .template_card_event ?? {} + const rawKey = details.event_key ?? event.event_key ?? '' + const taskId = details.task_id ?? event.task_id + + const userid = frame.body?.from?.userid + const parsed = parseCallbackData(rawKey) + if (parsed.action !== ACTION_APPROVE && parsed.action !== ACTION_DENY) { + return + } + + if (!userid) { + reply(ctx, frame, 'Not bound', taskId) + return + } + + const user = ctx.store.users.getUser('wecom', userid) + if (!user) { + reply(ctx, frame, 'Not bound', taskId) + return + } + + const sessions = ctx.syncEngine.getSessionsByNamespace(user.namespace) + const session = findSessionByPrefix(sessions, parsed.sessionPrefix) + if (!session) { + reply(ctx, frame, 'Session not found', taskId) + return + } + if (!session.active) { + reply(ctx, frame, 'Session inactive', taskId, session.id) + return + } + const requestId = findRequestByPrefix(session, parsed.extra ?? '') + if (!requestId) { + reply(ctx, frame, 'Already processed', taskId, session.id) + return + } + + try { + if (parsed.action === ACTION_APPROVE) { + await ctx.syncEngine.approvePermission(session.id, requestId) + reply(ctx, frame, 'Permission approved.', taskId, session.id) + } else { + await ctx.syncEngine.denyPermission(session.id, requestId) + reply(ctx, frame, 'Permission denied.', taskId, session.id) + } + } catch (err) { + console.error('[WecomBot] callback failed:', err) + reply(ctx, frame, 'An error occurred', taskId, session.id) + } +} diff --git a/hub/src/wecom/renderer.test.ts b/hub/src/wecom/renderer.test.ts new file mode 100644 index 0000000000..790cdb0a32 --- /dev/null +++ b/hub/src/wecom/renderer.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'bun:test' +import { createCallbackData, parseCallbackData, findSessionByPrefix } from './renderer' +import type { Session } from '../sync/syncEngine' + +function session(id: string, overrides: Partial = {}): Session { + return { + id, + namespace: 'default', + seq: 0, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: null, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + ...overrides + } +} + +describe('createCallbackData / parseCallbackData', () => { + it('encodes action + session prefix + extra using 8-char session prefix', () => { + const data = createCallbackData('ap', 'abcdef0123456789', 'req98765432') + expect(data).toBe('ap:abcdef01:req98765432') + }) + + it('round-trips via parseCallbackData', () => { + const data = createCallbackData('dn', 'sessionidabc', 'requestidxyz') + expect(parseCallbackData(data)).toEqual({ + action: 'dn', + sessionPrefix: 'sessioni', + extra: 'requestidxyz' + }) + }) + + it('omits extra segment when not provided', () => { + const data = createCallbackData('ap', 'sessid12xyz') + expect(data).toBe('ap:sessid12') + expect(parseCallbackData(data)).toEqual({ + action: 'ap', + sessionPrefix: 'sessid12', + extra: undefined + }) + }) +}) + +describe('findSessionByPrefix', () => { + it('returns the first session whose id starts with the prefix', () => { + const a = session('abcd1234-aaaa') + const b = session('abcd5678-bbbb') + expect(findSessionByPrefix([a, b], 'abcd5678')).toBe(b) + }) + + it('returns undefined when no session matches', () => { + expect(findSessionByPrefix([session('zzzz')], 'aaaa')).toBeUndefined() + }) +}) + +describe('parseCallbackData — extra with colons', () => { + it('preserves colons inside the extra segment', () => { + const data = createCallbackData('ap', 'sid12345', 'a:b:c') + expect(data).toBe('ap:sid12345:a:b:c') + expect(parseCallbackData(data)).toEqual({ + action: 'ap', + sessionPrefix: 'sid12345', + extra: 'a:b:c' + }) + }) +}) + +describe('findSessionByPrefix — empty prefix guard', () => { + it('returns undefined when the prefix is empty', () => { + expect(findSessionByPrefix([session('abc')], '')).toBeUndefined() + }) +}) diff --git a/hub/src/wecom/renderer.ts b/hub/src/wecom/renderer.ts new file mode 100644 index 0000000000..3e5623eeeb --- /dev/null +++ b/hub/src/wecom/renderer.ts @@ -0,0 +1,33 @@ +import type { Session } from '../sync/syncEngine' + +const SESSION_PREFIX_LEN = 8 + +export const ACTION_APPROVE = 'ap' +export const ACTION_DENY = 'dn' + +export function createCallbackData(action: string, sessionId: string, extra?: string): string { + const sessionPrefix = sessionId.slice(0, SESSION_PREFIX_LEN) + let data = `${action}:${sessionPrefix}` + if (extra) { + data += `:${extra}` + } + return data +} + +export function parseCallbackData(data: string): { + action: string + sessionPrefix: string + extra?: string +} { + const parts = data.split(':') + return { + action: parts[0] ?? '', + sessionPrefix: parts[1] ?? '', + extra: parts.length > 2 ? parts.slice(2).join(':') : undefined + } +} + +export function findSessionByPrefix(sessions: Session[], prefix: string): Session | undefined { + if (!prefix) return undefined + return sessions.find((session) => session.id.startsWith(prefix)) +} diff --git a/hub/src/wecom/sessionView.test.ts b/hub/src/wecom/sessionView.test.ts new file mode 100644 index 0000000000..ad2c843cef --- /dev/null +++ b/hub/src/wecom/sessionView.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'bun:test' +import type { Session } from '../sync/syncEngine' +import { + buildPermissionCard, + buildReadyCard, + buildTaskCard, + buildSessionCompletionCard, + buildSystemReplyCard +} from './sessionView' + +function session(overrides: Partial = {}): Session { + return { + id: 'abcdef0123456789', + namespace: 'default', + seq: 0, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { path: '/tmp/proj', host: 'mac' }, + metadataVersion: 0, + agentState: { + requests: { + 'req98765432abcdef': { + tool: 'Bash', + arguments: { command: 'ls -la' } + } + } + }, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + ...overrides + } as Session +} + +describe('buildPermissionCard', () => { + it('returns a button_interaction card with Allow / Deny keyed on session+request prefixes', () => { + const card = buildPermissionCard(session(), 'https://hapi.example.com') + if (!card) throw new Error('expected a permission card') + expect(card.card_type).toBe('button_interaction') + expect(card.main_title?.title).toBe('Permission Request') + expect(card.button_list).toHaveLength(2) + expect(card.button_list![0]).toEqual({ + text: 'Allow', + style: 1, + key: 'ap:abcdef01:req98765' + }) + expect(card.button_list![1]).toEqual({ + text: 'Deny', + style: 2, + key: 'dn:abcdef01:req98765' + }) + expect(card.task_id).toMatch(/^hapi-abcdef01-req98765-\d+$/) + }) + + it('returns null when there are no pending requests', () => { + const card = buildPermissionCard(session({ agentState: null }), 'https://hapi.example.com') + expect(card).toBeNull() + }) +}) + +describe('buildReadyCard', () => { + it('returns a text_notice card with a session URL action', () => { + const card = buildReadyCard(session(), 'https://hapi.example.com') + expect(card.card_type).toBe('text_notice') + expect(card.main_title?.title).toBe('Ready for input') + expect(card.card_action).toEqual({ + type: 1, + url: 'https://hapi.example.com/sessions/abcdef0123456789' + }) + }) +}) + +describe('buildTaskCard', () => { + it('marks failed tasks with a failure title', () => { + const card = buildTaskCard(session(), { status: 'failed', summary: 'Boom' }, 'https://hapi.example.com') + expect(card.main_title?.title).toBe('Task failed') + }) + + it('marks completed tasks with a success title', () => { + const card = buildTaskCard(session(), { status: 'completed', summary: 'Done' }, 'https://hapi.example.com') + expect(card.main_title?.title).toBe('Task completed') + }) +}) + +describe('buildSessionCompletionCard', () => { + it('returns a text_notice card', () => { + const card = buildSessionCompletionCard(session(), 'https://hapi.example.com') + expect(card.card_type).toBe('text_notice') + expect(card.main_title?.title).toBe('Session completed') + }) +}) + +describe('buildSystemReplyCard', () => { + it('builds a notice card with the given title and a card_action', () => { + const card = buildSystemReplyCard('Permission approved.', 'https://hapi.example.com/sessions/abc') + expect(card.card_type).toBe('text_notice') + expect(card.main_title?.title).toBe('Permission approved.') + expect(card.button_list).toBeUndefined() + // WeCom rejects template cards without card_action with errcode 42045. + expect(card.card_action).toEqual({ type: 1, url: 'https://hapi.example.com/sessions/abc' }) + }) +}) diff --git a/hub/src/wecom/sessionView.ts b/hub/src/wecom/sessionView.ts new file mode 100644 index 0000000000..e815338521 --- /dev/null +++ b/hub/src/wecom/sessionView.ts @@ -0,0 +1,125 @@ +import type { Session } from '../sync/syncEngine' +import type { TaskNotification } from '../notifications/notificationTypes' +import { isFailureStatus } from '../notifications/notificationTypes' +import { getAgentName, getSessionName } from '../notifications/sessionInfo' +import { ACTION_APPROVE, ACTION_DENY, createCallbackData } from './renderer' +import type { TemplateCard } from './types' + +const MAX_ARGS_LEN = 200 + +function sessionUrl(publicUrl: string, sessionId: string): string { + try { + return new URL(`/sessions/${sessionId}`, publicUrl).toString() + } catch { + const normalized = publicUrl.replace(/\/+$/, '') + return `${normalized}/sessions/${sessionId}` + } +} + +export { sessionUrl } + +function truncate(value: string, max: number): string { + if (value.length <= max) return value + return value.slice(0, max - 3) + '...' +} + +function formatArgs(tool: string, args: unknown): string { + if (!args || typeof args !== 'object') return '' + const obj = args as Record + switch (tool) { + case 'Bash': + return typeof obj.command === 'string' ? `Command: ${truncate(obj.command, MAX_ARGS_LEN)}` : '' + case 'Edit': + case 'Write': + case 'Read': { + const file = obj.file_path ?? obj.path + return typeof file === 'string' ? `File: ${truncate(file, MAX_ARGS_LEN)}` : '' + } + default: + try { + return `Args: ${truncate(JSON.stringify(args), MAX_ARGS_LEN)}` + } catch { + return '' + } + } +} + +export function buildPermissionCard(session: Session, publicUrl: string): TemplateCard | null { + const requests = session.agentState?.requests + if (!requests) return null + const requestId = Object.keys(requests)[0] + if (!requestId) return null + const request = requests[requestId] + + const sidPrefix = session.id.slice(0, 8) + const reqPrefix = requestId.slice(0, 8) + const name = getSessionName(session) + const argsLine = formatArgs(request.tool, request.arguments) + + const card: TemplateCard = { + card_type: 'button_interaction', + main_title: { title: 'Permission Request', desc: name }, + sub_title_text: argsLine + ? `Tool: ${request.tool}\n${argsLine}` + : `Tool: ${request.tool}`, + button_list: [ + { text: 'Allow', style: 1, key: createCallbackData(ACTION_APPROVE, session.id, reqPrefix) }, + { text: 'Deny', style: 2, key: createCallbackData(ACTION_DENY, session.id, reqPrefix) } + ], + card_action: { type: 1, url: sessionUrl(publicUrl, session.id) }, + task_id: `hapi-${sidPrefix}-${reqPrefix}-${Date.now()}` + } + return card +} + +export function buildReadyCard(session: Session, publicUrl: string): TemplateCard { + const agent = getAgentName(session) + const name = getSessionName(session) + return { + card_type: 'text_notice', + main_title: { title: 'Ready for input', desc: `${agent} · ${name}` }, + sub_title_text: `${agent} is waiting for your command`, + card_action: { type: 1, url: sessionUrl(publicUrl, session.id) } + } +} + +export function buildTaskCard( + session: Session, + notification: TaskNotification, + publicUrl: string +): TemplateCard { + const agent = getAgentName(session) + const name = getSessionName(session) + const failed = isFailureStatus(notification.status) + return { + card_type: 'text_notice', + main_title: { + title: failed ? 'Task failed' : 'Task completed', + desc: `${agent} · ${name}` + }, + sub_title_text: truncate(notification.summary, 300), + card_action: { type: 1, url: sessionUrl(publicUrl, session.id) } + } +} + +export function buildSessionCompletionCard(session: Session, publicUrl: string): TemplateCard { + const agent = getAgentName(session) + const name = getSessionName(session) + return { + card_type: 'text_notice', + main_title: { title: 'Session completed', desc: `${agent} · ${name}` }, + card_action: { type: 1, url: sessionUrl(publicUrl, session.id) } + } +} + +export function buildSystemReplyCard(title: string, url: string, desc?: string): TemplateCard { + // WeCom rejects template cards without a valid card_action with errcode + // 42045 ("Template_Card.card_action missing or invalid"), including on + // update_template_card replies. Always attach one pointing to publicUrl + // (or a session URL) so the server accepts the update. + return { + card_type: 'text_notice', + main_title: { title, desc }, + card_action: { type: 1, url } + } +} diff --git a/hub/src/wecom/types.ts b/hub/src/wecom/types.ts new file mode 100644 index 0000000000..b1f4e63c17 --- /dev/null +++ b/hub/src/wecom/types.ts @@ -0,0 +1,26 @@ +/** + * WeCom aibot WS types. + * + * Re-exported from the official @wecom/aibot-node-sdk. The only reason this + * file still exists is that the SDK's TemplateCardEventData declares + * event_key and task_id as flat fields on the event object, but the live + * wire nests them under event.template_card_event.*. callbacks.ts reads + * the nested path first and falls back to the flat one; see the extractor + * there. + */ + +export type { + WsFrame, + TextMessage, + EventMessage, + EventMessageWith, + TemplateCardEventData, + TemplateCard, + TemplateCardButton, + TemplateCardMainTitle, + TemplateCardAction, + SendMsgBody, + SendMarkdownMsgBody, + SendTemplateCardMsgBody, + UpdateTemplateCardBody +} from '@wecom/aibot-node-sdk' From fcaa4f9dbaa4208e139bef696431d383c1997f1a Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Wed, 6 May 2026 11:04:58 +0800 Subject: [PATCH 2/4] fix(hub/wecom): accept any namespace parseAccessToken accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bind handler's `[A-Za-z0-9_-]{1,64}` regex blocks namespaces that the rest of HAPI happily accepts — parseAccessToken / /api/auth allow any non-empty trimmed string. A user with a namespace containing spaces or dots could authenticate elsewhere but not bind WeCom. Drop the whitelist and instead escape markdown metacharacters (`\`, `_`, `*`, `` ` ``) when interpolating namespace or userid into the bind-reply markdown so an arbitrary namespace can't break the rendered card. Storage uses the raw namespace; only the rendered reply is escaped. --- hub/src/wecom/bot.test.ts | 21 ++++++++++++++++----- hub/src/wecom/bot.ts | 22 ++++++++++++---------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/hub/src/wecom/bot.test.ts b/hub/src/wecom/bot.test.ts index 76c0375397..a4838edd8c 100644 --- a/hub/src/wecom/bot.test.ts +++ b/hub/src/wecom/bot.test.ts @@ -215,14 +215,25 @@ describe('WecomBot binding', () => { expect(client.sendMessage.mock.calls).toHaveLength(0) }) - it('rejects invalid namespace characters with a usage reply', async () => { + it('accepts namespaces with markdown metacharacters and escapes them in the reply', async () => { const { client, addUser } = makeBot([]) - client.emit('message.text', textFrame('u-new', 'TOKEN:**bad ns**\n')) + client.emit('message.text', textFrame('u-new', 'TOKEN:**bad ns**')) await tick() - expect(addUser).not.toHaveBeenCalled() - expect(client.sendMessage.mock.calls).toHaveLength(1) + // Match parseAccessToken / /api/auth: any non-empty trimmed namespace is valid. + expect(addUser).toHaveBeenCalledWith('wecom', 'u-new', '**bad ns**') + const body = client.sendMessage.mock.calls[0][1] as Extract + // Asterisks must be escaped so the rendered card doesn't mis-bold. + expect(body.markdown.content).toContain('\\*\\*bad ns\\*\\*') + expect(body.markdown.content).not.toMatch(/\*\*bad ns\*\*/) + }) + + it('accepts namespaces with spaces and dots that the rest of HAPI also accepts', async () => { + const { client, addUser } = makeBot([]) + client.emit('message.text', textFrame('u-new', 'TOKEN:team alpha.v2')) + await tick() + expect(addUser).toHaveBeenCalledWith('wecom', 'u-new', 'team alpha.v2') const body = client.sendMessage.mock.calls[0][1] as Extract - expect(body.markdown.content).toContain('Invalid namespace') + expect(body.markdown.content).toContain('team alpha.v2') }) it('refuses to silently rebind an already-bound userid to a different namespace', async () => { diff --git a/hub/src/wecom/bot.ts b/hub/src/wecom/bot.ts index 2f6885d4e4..0e5357be61 100644 --- a/hub/src/wecom/bot.ts +++ b/hub/src/wecom/bot.ts @@ -19,6 +19,10 @@ import { /** 30 s cooldown before we try to reconnect after a server-initiated kick. */ const SERVER_KICK_RECONNECT_DELAY_MS = 30_000 +function escapeMarkdown(value: string): string { + return value.replace(/([\\_*`])/g, '\\$1') +} + /** * Shape of the WeCom SDK client that WecomBot needs. Kept narrow on purpose * so tests can supply a fake (an EventEmitter-backed stub) without having to @@ -173,20 +177,18 @@ export class WecomBot implements NotificationChannel { const namespace = content.slice(prefix.length).trim() if (!namespace) return - // Whitelist: letters, digits, dash, underscore, up to 64 chars. - // Rejects markdown metacharacters that would break the confirmation - // card and keeps the `users.namespace` column to a known charset. - if (!/^[A-Za-z0-9_-]{1,64}$/.test(namespace)) { - void this.sendBindReply(userid, - 'Invalid namespace. Allowed: letters, digits, `-`, `_`, max 64 chars.') - return - } + // Accept any non-empty namespace, matching parseAccessToken() and the + // /api/auth flow. Escape markdown metacharacters when interpolating + // into the confirmation reply so a namespace with `*` / `_` / `` ` `` + // can't break the rendered card. + const safeNamespace = escapeMarkdown(namespace) + const safeUserid = escapeMarkdown(userid) const existing = this.store.users.getUser('wecom', userid) if (existing) { if (existing.namespace === namespace) { void this.sendBindReply(userid, - `Already bound to namespace **${namespace}**.`) + `Already bound to namespace **${safeNamespace}**.`) } else { // Refuse to silently no-op: surface the conflict. void this.sendBindReply(userid, @@ -202,7 +204,7 @@ export class WecomBot implements NotificationChannel { return } void this.sendBindReply(userid, - `Bound WeCom user **${userid}** to namespace **${namespace}**.`) + `Bound WeCom user **${safeUserid}** to namespace **${safeNamespace}**.`) } private async sendBindReply(chatid: string, content: string): Promise { From 8814172985581889998cd6ebcd88ccf7bd902762 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Wed, 6 May 2026 11:24:49 +0800 Subject: [PATCH 3/4] fix(hub/wecom): carry full session+request IDs in button keys Approve/Deny callback button keys carried only 8-char prefixes of session.id and request.id, and handleTemplateCardEvent resolved them via prefix search. If two active sessions or pending requests in the same namespace shared that prefix, a click could approve the wrong permission. WeCom button keys allow up to 1024 bytes (per the official SDK type), so the Telegram-style truncation was never needed here. createCallbackData now carries the full IDs and handleTemplateCardEvent resolves them by exact match (findSessionById + hashmap lookup on agentState.requests). The 8-char prefixes are kept only for task_id, which has WeCom's own 128-byte / [0-9A-Za-z_-@] constraints and where collisions are benign (an update card landing on the wrong card, not the wrong syncEngine call). Adds a regression test that two sessions sharing an 8-char prefix do not collide under the new exact-match lookup. --- hub/src/wecom/bot.test.ts | 8 ++-- hub/src/wecom/callbacks.test.ts | 18 ++++----- hub/src/wecom/callbacks.ts | 22 +++++------ hub/src/wecom/renderer.test.ts | 65 +++++++++++++++++++------------ hub/src/wecom/renderer.ts | 38 ++++++++++-------- hub/src/wecom/sessionView.test.ts | 10 +++-- hub/src/wecom/sessionView.ts | 15 ++++--- 7 files changed, 103 insertions(+), 73 deletions(-) diff --git a/hub/src/wecom/bot.test.ts b/hub/src/wecom/bot.test.ts index a4838edd8c..58f345f2dc 100644 --- a/hub/src/wecom/bot.test.ts +++ b/hub/src/wecom/bot.test.ts @@ -138,8 +138,8 @@ describe('WecomBot.sendPermissionRequest', () => { const b = body as Extract expect(b.msgtype).toBe('template_card') expect(b.template_card.card_type).toBe('button_interaction') - expect(b.template_card.button_list?.[0].key).toBe('ap:abcdef01:req98765') - expect(b.template_card.button_list?.[1].key).toBe('dn:abcdef01:req98765') + expect(b.template_card.button_list?.[0].key).toBe('ap:abcdef0123456789:req98765432abc') + expect(b.template_card.button_list?.[1].key).toBe('dn:abcdef0123456789:req98765432abc') } expect(client.sendMessage.mock.calls[0][0]).toBe('u1') expect(client.sendMessage.mock.calls[1][0]).toBe('u2') @@ -278,7 +278,7 @@ function clickFrame(eventKey: string, userid: string, reqId: string): WsFrame { describe('WecomBot onEvent (template card click)', () => { it('dispatches approve and passes the original frame to updateTemplateCard', async () => { const { client, syncEngine } = makeBot() - client.emit('event.template_card_event', clickFrame('ap:abcdef01:req98765', 'wecom-user-1', 'cb-42')) + client.emit('event.template_card_event', clickFrame('ap:abcdef0123456789:req98765432abc', 'wecom-user-1', 'cb-42')) await tick() expect((syncEngine.approvePermission as unknown as { mock: { calls: unknown[][] } }).mock.calls).toHaveLength(1) @@ -295,7 +295,7 @@ describe('WecomBot onEvent (template card click)', () => { it('denies and passes the original frame (with its req_id) to updateTemplateCard', async () => { const { client, syncEngine } = makeBot() - client.emit('event.template_card_event', clickFrame('dn:abcdef01:req98765', 'wecom-user-1', 'cb-43')) + client.emit('event.template_card_event', clickFrame('dn:abcdef0123456789:req98765432abc', 'wecom-user-1', 'cb-43')) await tick() expect((syncEngine.denyPermission as unknown as { mock: { calls: unknown[][] } }).mock.calls).toHaveLength(1) diff --git a/hub/src/wecom/callbacks.test.ts b/hub/src/wecom/callbacks.test.ts index df491c6758..cd7fe14a74 100644 --- a/hub/src/wecom/callbacks.test.ts +++ b/hub/src/wecom/callbacks.test.ts @@ -98,7 +98,7 @@ describe('handleTemplateCardEvent', () => { it('approves and sends an "approved" update card threading the callback frame', async () => { const approve = mock(async () => {}) const ctx = makeCtx({ session: makeSession(), userNamespace: 'default', approve }) - const frame = makeFrame('ap:abcdef01:req98765') + const frame = makeFrame('ap:abcdef0123456789:req98765432abc') await handleTemplateCardEvent(frame, ctx) @@ -115,7 +115,7 @@ describe('handleTemplateCardEvent', () => { it('denies and sends a "denied" update card', async () => { const deny = mock(async () => {}) const ctx = makeCtx({ session: makeSession(), userNamespace: 'default', deny }) - const frame = makeFrame('dn:abcdef01:req98765') + const frame = makeFrame('dn:abcdef0123456789:req98765432abc') await handleTemplateCardEvent(frame, ctx) @@ -126,7 +126,7 @@ describe('handleTemplateCardEvent', () => { it('replies with "Not bound" when the userid has no binding', async () => { const ctx = makeCtx({ userNamespace: null }) - await handleTemplateCardEvent(makeFrame('ap:abcdef01:req98765'), ctx) + await handleTemplateCardEvent(makeFrame('ap:abcdef0123456789:req98765432abc'), ctx) const [arg] = ctx.sendUpdate.mock.calls[0] expect(arg.card.main_title?.title).toBe('Not bound') }) @@ -136,7 +136,7 @@ describe('handleTemplateCardEvent', () => { session: makeSession({ active: false }), userNamespace: 'default' }) - await handleTemplateCardEvent(makeFrame('ap:abcdef01:req98765'), ctx) + await handleTemplateCardEvent(makeFrame('ap:abcdef0123456789:req98765432abc'), ctx) const [arg] = ctx.sendUpdate.mock.calls[0] expect(arg.card.main_title?.title).toBe('Session inactive') }) @@ -146,20 +146,20 @@ describe('handleTemplateCardEvent', () => { session: makeSession({ agentState: { requests: {} } }), userNamespace: 'default' }) - await handleTemplateCardEvent(makeFrame('ap:abcdef01:req98765'), ctx) + await handleTemplateCardEvent(makeFrame('ap:abcdef0123456789:req98765432abc'), ctx) const [arg] = ctx.sendUpdate.mock.calls[0] expect(arg.card.main_title?.title).toBe('Already processed') }) it('ignores unknown actions', async () => { const ctx = makeCtx({ session: makeSession(), userNamespace: 'default' }) - await handleTemplateCardEvent(makeFrame('xx:abcdef01:req98765'), ctx) + await handleTemplateCardEvent(makeFrame('xx:abcdef0123456789:req98765432abc'), ctx) expect(ctx.sendUpdate).not.toHaveBeenCalled() }) it('replies with "Already processed" when the event_key has no request prefix', async () => { const ctx = makeCtx({ session: makeSession(), userNamespace: 'default' }) - await handleTemplateCardEvent(makeFrame('ap:abcdef01'), ctx) + await handleTemplateCardEvent(makeFrame('ap:abcdef0123456789'), ctx) const [arg] = ctx.sendUpdate.mock.calls[0] expect(arg.card.main_title?.title).toBe('Already processed') }) @@ -167,7 +167,7 @@ describe('handleTemplateCardEvent', () => { it('accepts legacy flat event payloads (event_key/task_id on event root)', async () => { const approve = mock(async () => {}) const ctx = makeCtx({ session: makeSession(), userNamespace: 'default', approve }) - const frame = makeFlatFrame('ap:abcdef01:req98765') + const frame = makeFlatFrame('ap:abcdef0123456789:req98765432abc') await handleTemplateCardEvent(frame, ctx) @@ -179,7 +179,7 @@ describe('handleTemplateCardEvent', () => { it('always includes card_action on update cards to satisfy WeCom errcode 42045', async () => { const ctx = makeCtx({ session: makeSession(), userNamespace: 'default' }) - await handleTemplateCardEvent(makeFrame('ap:abcdef01:req98765'), ctx) + await handleTemplateCardEvent(makeFrame('ap:abcdef0123456789:req98765432abc'), ctx) const [arg] = ctx.sendUpdate.mock.calls[0] expect(arg.card.card_action?.type).toBe(1) expect(arg.card.card_action?.url).toMatch(/^https:\/\/hapi\.example\.com/) diff --git a/hub/src/wecom/callbacks.ts b/hub/src/wecom/callbacks.ts index d0de8fea43..071516f6b6 100644 --- a/hub/src/wecom/callbacks.ts +++ b/hub/src/wecom/callbacks.ts @@ -1,6 +1,6 @@ -import type { Session, SyncEngine } from '../sync/syncEngine' +import type { SyncEngine } from '../sync/syncEngine' import type { Store } from '../store' -import { ACTION_APPROVE, ACTION_DENY, parseCallbackData, findSessionByPrefix } from './renderer' +import { ACTION_APPROVE, ACTION_DENY, parseCallbackData, findSessionById } from './renderer' import { buildSystemReplyCard, sessionUrl } from './sessionView' import type { EventMessageWith, @@ -28,14 +28,12 @@ export interface CallbackCtx { }) => void } -function findRequestByPrefix(session: Session, prefix: string): string | null { - if (!prefix) return null - const requests = session.agentState?.requests - if (!requests) return null - for (const id of Object.keys(requests)) { - if (id.startsWith(prefix)) return id - } - return null +function findRequestById( + requests: Record | null | undefined, + id: string +): string | null { + if (!id || !requests) return null + return Object.prototype.hasOwnProperty.call(requests, id) ? id : null } function reply( @@ -89,7 +87,7 @@ export async function handleTemplateCardEvent( } const sessions = ctx.syncEngine.getSessionsByNamespace(user.namespace) - const session = findSessionByPrefix(sessions, parsed.sessionPrefix) + const session = findSessionById(sessions, parsed.sessionId) if (!session) { reply(ctx, frame, 'Session not found', taskId) return @@ -98,7 +96,7 @@ export async function handleTemplateCardEvent( reply(ctx, frame, 'Session inactive', taskId, session.id) return } - const requestId = findRequestByPrefix(session, parsed.extra ?? '') + const requestId = findRequestById(session.agentState?.requests, parsed.requestId ?? '') if (!requestId) { reply(ctx, frame, 'Already processed', taskId, session.id) return diff --git a/hub/src/wecom/renderer.test.ts b/hub/src/wecom/renderer.test.ts index 790cdb0a32..716dc5b039 100644 --- a/hub/src/wecom/renderer.test.ts +++ b/hub/src/wecom/renderer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { createCallbackData, parseCallbackData, findSessionByPrefix } from './renderer' +import { createCallbackData, parseCallbackData, findSessionById } from './renderer' import type { Session } from '../sync/syncEngine' function session(id: string, overrides: Partial = {}): Session { @@ -25,57 +25,74 @@ function session(id: string, overrides: Partial = {}): Session { } describe('createCallbackData / parseCallbackData', () => { - it('encodes action + session prefix + extra using 8-char session prefix', () => { + it('encodes action + full session id + full request id', () => { const data = createCallbackData('ap', 'abcdef0123456789', 'req98765432') - expect(data).toBe('ap:abcdef01:req98765432') + expect(data).toBe('ap:abcdef0123456789:req98765432') }) - it('round-trips via parseCallbackData', () => { + it('round-trips via parseCallbackData (full IDs, no truncation)', () => { const data = createCallbackData('dn', 'sessionidabc', 'requestidxyz') expect(parseCallbackData(data)).toEqual({ action: 'dn', - sessionPrefix: 'sessioni', - extra: 'requestidxyz' + sessionId: 'sessionidabc', + requestId: 'requestidxyz' }) }) - it('omits extra segment when not provided', () => { + it('omits the request id segment when not provided', () => { const data = createCallbackData('ap', 'sessid12xyz') - expect(data).toBe('ap:sessid12') + expect(data).toBe('ap:sessid12xyz') expect(parseCallbackData(data)).toEqual({ action: 'ap', - sessionPrefix: 'sessid12', - extra: undefined + sessionId: 'sessid12xyz', + requestId: undefined + }) + }) + + it('round-trips realistic UUID-shaped session IDs', () => { + const sessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const requestId = 'fedcba98-7654-3210-1111-222233334444' + const data = createCallbackData('ap', sessionId, requestId) + expect(parseCallbackData(data)).toEqual({ + action: 'ap', + sessionId, + requestId }) }) }) -describe('findSessionByPrefix', () => { - it('returns the first session whose id starts with the prefix', () => { +describe('findSessionById', () => { + it('returns the session whose id matches exactly', () => { const a = session('abcd1234-aaaa') const b = session('abcd5678-bbbb') - expect(findSessionByPrefix([a, b], 'abcd5678')).toBe(b) + expect(findSessionById([a, b], 'abcd5678-bbbb')).toBe(b) + }) + + it('returns undefined for a prefix-only match (would have been a bug before)', () => { + // Two sessions sharing the same 8-char prefix used to collide under + // the old findSessionByPrefix; with exact-match, only the full id wins. + const a = session('abcd1234-aaaa') + const b = session('abcd1234-bbbb') + expect(findSessionById([a, b], 'abcd1234')).toBeUndefined() }) it('returns undefined when no session matches', () => { - expect(findSessionByPrefix([session('zzzz')], 'aaaa')).toBeUndefined() + expect(findSessionById([session('zzzz')], 'aaaa')).toBeUndefined() + }) + + it('returns undefined when the id is empty', () => { + expect(findSessionById([session('abc')], '')).toBeUndefined() }) }) -describe('parseCallbackData — extra with colons', () => { - it('preserves colons inside the extra segment', () => { +describe('parseCallbackData — request id with colons', () => { + it('preserves colons inside the request id segment', () => { const data = createCallbackData('ap', 'sid12345', 'a:b:c') expect(data).toBe('ap:sid12345:a:b:c') expect(parseCallbackData(data)).toEqual({ action: 'ap', - sessionPrefix: 'sid12345', - extra: 'a:b:c' + sessionId: 'sid12345', + requestId: 'a:b:c' }) }) }) - -describe('findSessionByPrefix — empty prefix guard', () => { - it('returns undefined when the prefix is empty', () => { - expect(findSessionByPrefix([session('abc')], '')).toBeUndefined() - }) -}) diff --git a/hub/src/wecom/renderer.ts b/hub/src/wecom/renderer.ts index 3e5623eeeb..d4f39f008c 100644 --- a/hub/src/wecom/renderer.ts +++ b/hub/src/wecom/renderer.ts @@ -1,33 +1,39 @@ import type { Session } from '../sync/syncEngine' -const SESSION_PREFIX_LEN = 8 - export const ACTION_APPROVE = 'ap' export const ACTION_DENY = 'dn' -export function createCallbackData(action: string, sessionId: string, extra?: string): string { - const sessionPrefix = sessionId.slice(0, SESSION_PREFIX_LEN) - let data = `${action}:${sessionPrefix}` - if (extra) { - data += `:${extra}` - } - return data +/** + * Encode a callback button payload as `action:sessionId[:requestId]`. + * + * WeCom's button `key` field tolerates up to 1024 bytes (per the official + * SDK type), so unlike the Telegram channel we don't need to truncate the + * IDs. Carrying the full session and request IDs lets us resolve clicks via + * exact match in {@link findSessionById}, eliminating the prefix-collision + * window where two sessions or pending requests in the same namespace + * could share an 8-char prefix and a click could approve the wrong one. + * + * Assumes neither {@link sessionId} nor {@link requestId} contains a `:` + * (true for randomUUID-shaped IDs, which is what the hub uses today). + */ +export function createCallbackData(action: string, sessionId: string, requestId?: string): string { + return requestId ? `${action}:${sessionId}:${requestId}` : `${action}:${sessionId}` } export function parseCallbackData(data: string): { action: string - sessionPrefix: string - extra?: string + sessionId: string + requestId?: string } { const parts = data.split(':') return { action: parts[0] ?? '', - sessionPrefix: parts[1] ?? '', - extra: parts.length > 2 ? parts.slice(2).join(':') : undefined + sessionId: parts[1] ?? '', + requestId: parts.length > 2 ? parts.slice(2).join(':') : undefined } } -export function findSessionByPrefix(sessions: Session[], prefix: string): Session | undefined { - if (!prefix) return undefined - return sessions.find((session) => session.id.startsWith(prefix)) +export function findSessionById(sessions: Session[], id: string): Session | undefined { + if (!id) return undefined + return sessions.find((session) => session.id === id) } diff --git a/hub/src/wecom/sessionView.test.ts b/hub/src/wecom/sessionView.test.ts index ad2c843cef..c01c3d487c 100644 --- a/hub/src/wecom/sessionView.test.ts +++ b/hub/src/wecom/sessionView.test.ts @@ -38,22 +38,26 @@ function session(overrides: Partial = {}): Session { } describe('buildPermissionCard', () => { - it('returns a button_interaction card with Allow / Deny keyed on session+request prefixes', () => { + it('returns a button_interaction card with Allow / Deny carrying the full session and request IDs', () => { const card = buildPermissionCard(session(), 'https://hapi.example.com') if (!card) throw new Error('expected a permission card') expect(card.card_type).toBe('button_interaction') expect(card.main_title?.title).toBe('Permission Request') expect(card.button_list).toHaveLength(2) + // Full IDs in the button key — see renderer.ts: WeCom button keys + // tolerate up to 1024 bytes, so we don't truncate (which used to risk + // prefix collisions resolving to the wrong session/request). expect(card.button_list![0]).toEqual({ text: 'Allow', style: 1, - key: 'ap:abcdef01:req98765' + key: 'ap:abcdef0123456789:req98765432abcdef' }) expect(card.button_list![1]).toEqual({ text: 'Deny', style: 2, - key: 'dn:abcdef01:req98765' + key: 'dn:abcdef0123456789:req98765432abcdef' }) + // task_id stays prefixed since WeCom limits it to 128 bytes / [0-9A-Za-z_-@]. expect(card.task_id).toMatch(/^hapi-abcdef01-req98765-\d+$/) }) diff --git a/hub/src/wecom/sessionView.ts b/hub/src/wecom/sessionView.ts index e815338521..d344dcc604 100644 --- a/hub/src/wecom/sessionView.ts +++ b/hub/src/wecom/sessionView.ts @@ -51,8 +51,6 @@ export function buildPermissionCard(session: Session, publicUrl: string): Templa if (!requestId) return null const request = requests[requestId] - const sidPrefix = session.id.slice(0, 8) - const reqPrefix = requestId.slice(0, 8) const name = getSessionName(session) const argsLine = formatArgs(request.tool, request.arguments) @@ -62,12 +60,19 @@ export function buildPermissionCard(session: Session, publicUrl: string): Templa sub_title_text: argsLine ? `Tool: ${request.tool}\n${argsLine}` : `Tool: ${request.tool}`, + // Carry full IDs in the button key (WeCom allows up to 1024 bytes here) + // so handleTemplateCardEvent can resolve them by exact match instead of + // prefix — see renderer.ts for the rationale. button_list: [ - { text: 'Allow', style: 1, key: createCallbackData(ACTION_APPROVE, session.id, reqPrefix) }, - { text: 'Deny', style: 2, key: createCallbackData(ACTION_DENY, session.id, reqPrefix) } + { text: 'Allow', style: 1, key: createCallbackData(ACTION_APPROVE, session.id, requestId) }, + { text: 'Deny', style: 2, key: createCallbackData(ACTION_DENY, session.id, requestId) } ], card_action: { type: 1, url: sessionUrl(publicUrl, session.id) }, - task_id: `hapi-${sidPrefix}-${reqPrefix}-${Date.now()}` + // task_id only needs to uniquely identify the original card on the + // server so the update reply can target it. 8-char prefixes plus a + // millisecond timestamp keep us well under the 128-byte limit and + // within the [0-9A-Za-z_-@] charset WeCom requires for task_id. + task_id: `hapi-${session.id.slice(0, 8)}-${requestId.slice(0, 8)}-${Date.now()}` } return card } From 169c01f70ad886b7c1639d565b3cd40ad8d7a438 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Wed, 6 May 2026 16:25:50 +0800 Subject: [PATCH 4/4] fix(hub/wecom): use parseAccessToken for bind validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The naive prefix-strip accepted any text starting with `${cliApiToken}:` and stored the suffix as the namespace, so `TOKEN:foo:bar` was confirmed as namespace `foo:bar` — but parseAccessToken (used by the CLI and /api/auth) splits on the LAST colon and would parse the same input as baseToken=`TOKEN:foo`, namespace=`bar`, then reject it because the base token doesn't match. Net effect: a persisted WeCom binding for a namespace no client could ever authenticate into. Replace the manual parse with parseAccessToken + constantTimeEquals on the base token. Now WeCom accepts exactly what the rest of HAPI accepts: bare TOKEN binds to `default`, TOKEN: binds to , TOKEN:foo:bar is rejected. --- hub/src/wecom/bot.test.ts | 22 ++++++++++++++++++++++ hub/src/wecom/bot.ts | 23 ++++++++++++++--------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/hub/src/wecom/bot.test.ts b/hub/src/wecom/bot.test.ts index 58f345f2dc..99c2d8ba77 100644 --- a/hub/src/wecom/bot.test.ts +++ b/hub/src/wecom/bot.test.ts @@ -257,6 +257,28 @@ describe('WecomBot binding', () => { const body = client.sendMessage.mock.calls[0][1] as Extract expect(body.markdown.content).toContain('Already bound to namespace **nsA**') }) + + it('rejects "::" because parseAccessToken splits on the LAST colon', async () => { + // Old behaviour persisted namespace `foo:bar`, which no CLI/web client + // could ever authenticate into (parseAccessToken would parse the same + // string as baseToken=`TOKEN:foo`, namespace=`bar`). + const { client, addUser } = makeBot([]) + client.emit('message.text', textFrame('u-new', 'TOKEN:foo:bar')) + await tick() + expect(addUser).not.toHaveBeenCalled() + expect(client.sendMessage.mock.calls).toHaveLength(0) + }) + + it('binds to the default namespace when only the bare token is sent', async () => { + // parseAccessToken returns { baseToken, namespace: 'default' } when the + // input has no colon — match that behaviour rather than silently dropping. + const { client, addUser } = makeBot([]) + client.emit('message.text', textFrame('u-new', 'TOKEN')) + await tick() + expect(addUser).toHaveBeenCalledWith('wecom', 'u-new', 'default') + const body = client.sendMessage.mock.calls[0][1] as Extract + expect(body.markdown.content).toContain('namespace **default**') + }) }) function clickFrame(eventKey: string, userid: string, reqId: string): WsFrame { diff --git a/hub/src/wecom/bot.ts b/hub/src/wecom/bot.ts index 0e5357be61..a4774188a4 100644 --- a/hub/src/wecom/bot.ts +++ b/hub/src/wecom/bot.ts @@ -15,6 +15,8 @@ import { buildSessionCompletionCard, buildTaskCard } from './sessionView' +import { parseAccessToken } from '../utils/accessToken' +import { constantTimeEquals } from '../utils/crypto' /** 30 s cooldown before we try to reconnect after a server-initiated kick. */ const SERVER_KICK_RECONNECT_DELAY_MS = 30_000 @@ -168,19 +170,22 @@ export class WecomBot implements NotificationChannel { private onTextMessage(frame: WsFrame): void { const body = frame.body as { text?: { content?: string }; from?: { userid?: string } } | undefined - const content = body?.text?.content?.trim() + const content = body?.text?.content const userid = body?.from?.userid if (!content || !userid) return - const prefix = `${this.cliApiToken}:` - if (!content.startsWith(prefix)) return - const namespace = content.slice(prefix.length).trim() - if (!namespace) return + // Use parseAccessToken (the same parser /api/auth and the CLI use) so we + // can't persist a binding that no client could authenticate into. The + // naive prefix-strip would accept `TOKEN:foo:bar` as namespace `foo:bar` + // even though parseAccessToken splits on the LAST colon and would reject + // that token (baseToken becomes `TOKEN:foo`, which is not our token). + const parsed = parseAccessToken(content) + if (!parsed || !constantTimeEquals(parsed.baseToken, this.cliApiToken)) return + const namespace = parsed.namespace - // Accept any non-empty namespace, matching parseAccessToken() and the - // /api/auth flow. Escape markdown metacharacters when interpolating - // into the confirmation reply so a namespace with `*` / `_` / `` ` `` - // can't break the rendered card. + // Escape markdown metacharacters when interpolating into the + // confirmation reply so a namespace with `*` / `_` / `` ` `` can't + // break the rendered card. const safeNamespace = escapeMarkdown(namespace) const safeUserid = escapeMarkdown(userid)