diff --git a/docs/users/features/channels/_meta.ts b/docs/users/features/channels/_meta.ts index 6f4e1c4bb60..333b19b71e0 100644 --- a/docs/users/features/channels/_meta.ts +++ b/docs/users/features/channels/_meta.ts @@ -3,6 +3,7 @@ export default { telegram: 'Telegram', weixin: 'WeChat', dingtalk: 'DingTalk', + wecom: 'WeCom', feishu: 'Feishu', qqbot: 'QQ Bot', plugins: 'Plugins', diff --git a/docs/users/features/channels/wecom.md b/docs/users/features/channels/wecom.md new file mode 100644 index 00000000000..2f0d87fcc85 --- /dev/null +++ b/docs/users/features/channels/wecom.md @@ -0,0 +1,111 @@ +# WeCom (Enterprise WeChat) + +This guide covers setting up Qwen Code with a WeCom intelligent robot (企业微信智能机器人). + +## Prerequisites + +- A WeCom organization account +- A WeCom intelligent robot created in API mode +- The robot's Bot ID and Secret + +## Creating the Robot + +1. Open the WeCom admin console and create an intelligent robot. +2. Choose API mode. +3. Copy the Bot ID and Secret. +4. Add the robot to the direct chats or groups where it should be available. + +The intelligent robot uses a WebSocket connection from Qwen Code to WeCom. You do not need a public callback URL, Token, EncodingAESKey, Corp ID, or Agent ID. + +## Configuration + +Add the channel to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-wecom": { + "type": "wecom", + "botId": "$WECOM_BOT_ID", + "secret": "$WECOM_SECRET", + "senderPolicy": "allowlist", + "allowedUsers": ["zhangsan"], + "sessionScope": "user", + "cwd": "/path/to/your/project", + "instructions": "You are a concise coding assistant responding via WeCom.", + "groupPolicy": "open", + "groups": { + "*": { "requireMention": true } + } + } + } +} +``` + +Set the credentials as environment variables: + +```bash +export WECOM_BOT_ID= +export WECOM_SECRET= +``` + +Or define them in the `env` section of `settings.json`: + +```json +{ + "env": { + "WECOM_BOT_ID": "your-bot-id", + "WECOM_SECRET": "your-secret" + } +} +``` + +## Running + +```bash +qwen channel start my-wecom +``` + +Open WeCom and send a message to the intelligent robot. + +## Access Control + +`senderPolicy` works the same way as other IM channels: + +- `allowlist`: only users in `allowedUsers` can use the bot. This is the recommended enterprise default. +- `pairing`: users must pair before using the bot. +- `open`: anyone who can message the robot can use it. + +For groups, set `groupPolicy` to `"allowlist"` or `"open"`. By default, group messages require a mention through `"requireMention": true`. + +When the WeCom SDK includes explicit mention metadata, Qwen Code uses it for this gate. If no mention metadata is present, the channel treats delivered group messages as unmentioned. Set `"requireMention": false` only if you want to rely on WeCom-side delivery scoping instead. + +## Images and Files + +Users can send text, voice messages with transcription, images, mixed text plus images, files, and videos. Images are passed to the agent as image attachments. Files and videos are downloaded to temporary local paths so the agent can read them with file tools. + +Assistant responses are sent as WeCom markdown. To send a local image generated by the agent, include one marker outside code blocks: + +```text +[IMAGE: /absolute/path/to/image.png] +``` + +For safety, local image paths must be inside the channel file directory under the system temporary directory, such as `/tmp/channel-files/...` on Linux. Generic file, video, and voice upload markers are ignored because model-produced file paths could otherwise upload arbitrary workspace files. + +## Troubleshooting + +### Bot does not connect + +- Verify the Bot ID and Secret. +- Make sure the robot is created in API mode. +- Check that the environment variables are available in the shell running `qwen channel start`. + +### Bot does not respond in groups + +- Check `groupPolicy`. +- Mention the bot unless the group config sets `"requireMention": false`. +- Confirm the robot has been added to the group. + +### Self-built application credentials do not work + +This channel is for WeCom intelligent robots. Self-built application callback credentials such as Corp ID, Agent ID, Token, and EncodingAESKey are not used by this channel. diff --git a/package-lock.json b/package-lock.json index 8d998ccc7d7..24902751b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "packages/channels/telegram", "packages/channels/weixin", "packages/channels/dingtalk", + "packages/channels/wecom", "packages/channels/feishu", "packages/channels/qqbot", "packages/channels/plugin-example", @@ -4045,6 +4046,10 @@ "resolved": "packages/channels/telegram", "link": true }, + "node_modules/@qwen-code/channel-wecom": { + "resolved": "packages/channels/wecom", + "link": true + }, "node_modules/@qwen-code/channel-weixin": { "resolved": "packages/channels/weixin", "link": true @@ -7206,6 +7211,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@wecom/aibot-node-sdk": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@wecom/aibot-node-sdk/-/aibot-node-sdk-1.0.7.tgz", + "integrity": "sha512-51w+sTqunry6GD3HFvmuh0gArMSJDFE418vyvR1wMJHj1N6DaFuGD3HuaY2fazZs3mb9FWeQFX3+vU5t0Qhwmw==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.7", + "eventemitter3": "^5.0.1", + "ws": "^8.16.0" + } + }, "node_modules/@xterm/headless": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", @@ -11653,7 +11669,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -24119,6 +24134,17 @@ "typescript": "^5.0.0" } }, + "packages/channels/wecom": { + "name": "@qwen-code/channel-wecom", + "version": "0.19.6", + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "@wecom/aibot-node-sdk": "^1.0.7" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, "packages/channels/weixin": { "name": "@qwen-code/channel-weixin", "version": "0.19.6", @@ -24157,6 +24183,7 @@ "@qwen-code/channel-feishu": "file:../channels/feishu", "@qwen-code/channel-qqbot": "file:../channels/qqbot", "@qwen-code/channel-telegram": "file:../channels/telegram", + "@qwen-code/channel-wecom": "file:../channels/wecom", "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/sdk": "file:../sdk-typescript", diff --git a/package.json b/package.json index afffcd2fa60..b456464e1f0 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "packages/channels/telegram", "packages/channels/weixin", "packages/channels/dingtalk", + "packages/channels/wecom", "packages/channels/feishu", "packages/channels/qqbot", "packages/channels/plugin-example", diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index 61ddbe8e32c..1f0c40803ad 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -28,6 +28,17 @@ class TestChannel extends ChannelBase { }> = []; promptEnds: Array<{ chatId: string; sessionId: string; messageId?: string }> = []; + promptBuffers: Array<{ + chatId: string; + sessionId: string; + messageId?: string; + bufferSize: number; + }> = []; + promptBufferDrops: Array<{ + chatId: string; + sessionId: string; + messageIds: string[]; + }> = []; responseChunks: Array<{ chatId: string; chunk: string; sessionId: string }> = []; /** When set, onPromptEnd throws AFTER recording — to exercise the finally guard. */ @@ -90,6 +101,32 @@ class TestChannel extends ChannelBase { } } + protected override onPromptBufferDropped( + chatId: string, + sessionId: string, + messageIds: string[], + ): void { + this.promptBufferDrops.push({ chatId, sessionId, messageIds }); + } + + protected override onPromptBuffered( + chatId: string, + sessionId: string, + messageId?: string, + ): void { + const buffers = ( + this as unknown as { + collectBuffers: Map; + } + ).collectBuffers; + this.promptBuffers.push({ + chatId, + sessionId, + messageId, + bufferSize: buffers.get(sessionId)?.length ?? 0, + }); + } + protected override onResponseChunk( chatId: string, chunk: string, @@ -108,6 +145,12 @@ class TestChannel extends ChannelBase { } } +class UnsafeProcessChannel extends TestChannel { + processWithoutPreflight(envelope: Envelope): Promise { + return this.processInbound(envelope); + } +} + function createBridge(): ChannelAgentBridge { const emitter = new EventEmitter(); let sessionCounter = 0; @@ -193,6 +236,31 @@ describe('ChannelBase', () => { expect(bridge.prompt).toHaveBeenCalled(); }); + it('rejects direct processing without preflight', async () => { + const ch = new UnsafeProcessChannel('test-chan', defaultConfig(), bridge); + + await expect(ch.processWithoutPreflight(envelope())).rejects.toThrow( + 'processInbound called without a successful preflightInbound check.', + ); + }); + + it('waits for thenable preflight results', async () => { + class ThenablePreflightChannel extends TestChannel { + protected override preflightInbound(): PromiseLike { + return { then: (resolve) => resolve(false) }; + } + } + const ch = new ThenablePreflightChannel( + 'test-chan', + defaultConfig(), + bridge, + ); + + await ch.handleInbound(envelope()); + + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + it('rejects sender with allowlist policy', async () => { const ch = createChannel({ senderPolicy: 'allowlist', @@ -5283,12 +5351,18 @@ describe('ChannelBase', () => { collectBuffers: Map; }; maps.collectBuffers.set(sessionId, [ - { text: 'buffered', envelope: envelope({ text: 'buffered' }) }, + { + text: 'buffered', + envelope: envelope({ text: 'buffered', messageId: 'm-buffered' }), + }, ]); await expect(ch.cancelPromptForTest(sessionId)).resolves.toBe(true); expect(maps.collectBuffers.has(sessionId)).toBe(false); + expect(ch.promptBufferDrops).toEqual([ + { chatId: 'chat1', sessionId, messageIds: ['m-buffered'] }, + ]); resolvePrompt('late'); await prompt; }); @@ -5825,6 +5899,35 @@ describe('ChannelBase', () => { expect(ch.sent[0]!.text).toContain('pairing code'); expect(bridge.prompt).not.toHaveBeenCalled(); }); + + it('treats pairing notification failures as preflight rejection', async () => { + class PairingFailureChannel extends TestChannel { + override async sendMessage(): Promise { + throw new Error('send failed'); + } + } + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const ch = new PairingFailureChannel( + 'test', + defaultConfig({ + senderPolicy: 'pairing', + allowedUsers: [], + }), + bridge, + ); + + await expect( + ch.handleInbound(envelope({ senderId: 'stranger' })), + ).resolves.toBeUndefined(); + + expect(bridge.prompt).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('pairing notification failed'), + ); + stderr.mockRestore(); + }); }); describe('setBridge', () => { @@ -5914,8 +6017,12 @@ describe('ChannelBase', () => { await new Promise((r) => setTimeout(r, 10)); // Send two more messages while first is busy — these should buffer - const p2 = ch.handleInbound(envelope({ text: 'second' })); - const p3 = ch.handleInbound(envelope({ text: 'third' })); + const p2 = ch.handleInbound( + envelope({ text: 'second', messageId: 'msg-2' }), + ); + const p3 = ch.handleInbound( + envelope({ text: 'third', messageId: 'msg-3' }), + ); // p2 and p3 should resolve immediately (buffered, not queued) await p2; @@ -5923,6 +6030,18 @@ describe('ChannelBase', () => { // First prompt is still running, bridge.prompt called only once expect(callCount).toBe(1); + expect(ch.promptBuffers).toEqual([ + expect.objectContaining({ + chatId: 'chat1', + messageId: 'msg-2', + bufferSize: 1, + }), + expect.objectContaining({ + chatId: 'chat1', + messageId: 'msg-3', + bufferSize: 2, + }), + ]); // Resolve the first prompt resolveFirst('first response'); @@ -5949,6 +6068,48 @@ describe('ChannelBase', () => { ); }); + it('collect: does not preflight the coalesced followup again', async () => { + class CountingPreflightChannel extends TestChannel { + preflightTexts: string[] = []; + + protected override preflightInbound( + message: Envelope, + ): boolean | Promise { + this.preflightTexts.push(message.text); + return super.preflightInbound(message); + } + } + + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((resolve) => { + resolveFirst = resolve; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('coalesced response'); + }); + + const ch = new CountingPreflightChannel( + 'test-chan', + defaultConfig({ dispatchMode: 'collect' }), + bridge, + ); + + const first = ch.handleInbound(envelope({ text: 'first' })); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await ch.handleInbound(envelope({ text: 'second' })); + await ch.handleInbound(envelope({ text: 'third' })); + + resolveFirst('first response'); + await first; + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(2)); + + expect(ch.preflightTexts).toEqual(['first', 'second', 'third']); + }); + it('collect: no followup if no messages buffered', async () => { const ch = createChannel({ dispatchMode: 'collect' }); await ch.handleInbound(envelope({ text: 'only message' })); @@ -7505,7 +7666,7 @@ describe('ChannelBase', () => { expect(ch.promptEnds).toHaveLength(0); }); - it('does not call hooks for buffered messages in collect mode', async () => { + it('does not call start or end hooks for buffered messages in collect mode', async () => { let resolveFirst!: (v: string) => void; const firstPrompt = new Promise((r) => { resolveFirst = r; @@ -7530,6 +7691,13 @@ describe('ChannelBase', () => { // Only one prompt start so far (for the first message) expect(ch.promptStarts).toHaveLength(1); expect(ch.promptStarts[0]!.messageId).toBe('msg-1'); + expect(ch.promptBuffers).toEqual([ + expect.objectContaining({ + chatId: 'chat1', + messageId: 'msg-2', + bufferSize: 1, + }), + ]); resolveFirst('done'); await p1; @@ -9221,5 +9389,67 @@ describe('ChannelBase', () => { expect.any(Object), ); }); + + it('does not preflight collected messages again after a loop prompt completes', async () => { + class CountingPreflightChannel extends TestChannel { + preflightTexts: string[] = []; + + protected override preflightInbound( + message: Envelope, + ): boolean | Promise { + this.preflightTexts.push(message.text); + return super.preflightInbound(message); + } + } + + let resolveLoop: (value: string) => void = () => {}; + (bridge.prompt as ReturnType) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoop = resolve; + }), + ) + .mockResolvedValueOnce('follow-up response'); + const ch = new CountingPreflightChannel( + 'test-chan', + defaultConfig({ dispatchMode: 'collect' }), + bridge, + ); + ch.proactiveSupported = true; + + const loopRun = ch.runLoopPrompt({ + id: 'job-1', + channelName: 'test-chan', + target: { + channelName: 'test-chan', + senderId: 'user1', + chatId: 'chat1', + isGroup: false, + }, + cwd: '/tmp', + cron: '0 9 * * *', + prompt: 'post summary', + label: 'daily summary', + recurring: true, + enabled: true, + createdBy: 'User 1', + createdAt: '2026-06-30T01:00:00.000Z', + consecutiveFailures: 0, + runCount: 0, + }); + await vi.waitFor(() => { + expect(bridge.prompt).toHaveBeenCalledTimes(1); + }); + + await ch.handleInbound(envelope({ text: 'while loop runs' })); + resolveLoop('loop response'); + await loopRun; + + await vi.waitFor(() => { + expect(bridge.prompt).toHaveBeenCalledTimes(2); + }); + expect(ch.preflightTexts).toEqual(['while loop runs']); + }); }); }); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index c5dfabf18ea..8fd103f4444 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -90,6 +90,7 @@ export interface ChannelLoopPromptOptions { /** Handler for a slash command. Return true if handled, false to forward to agent. */ type CommandHandler = (envelope: Envelope, args: string) => Promise; +type CollectBufferEntry = { text: string; envelope: Envelope }; type ActivePrompt = { cancelled: boolean; cancelPending?: boolean; @@ -188,10 +189,8 @@ export abstract class ChannelBase { /** Per-session active prompt tracking for dispatch modes. */ private activePrompts: Map = new Map(); /** Per-session message buffer for collect mode. */ - private collectBuffers: Map< - string, - Array<{ text: string; envelope: Envelope }> - > = new Map(); + private collectBuffers: Map = new Map(); + private readonly preflightedEnvelopes = new WeakSet(); private readonly bridgeToolCallListener = (event: ToolCallEvent): void => { this.dispatchToolCall(event); }; @@ -720,6 +719,11 @@ export abstract class ChannelBase { const lost = buffer.length; const coalesced = buffer.map((b) => b.text).join('\n\n'); const lastEnvelope = buffer[buffer.length - 1]!.envelope; + this.notifyPromptBufferDrained( + lastEnvelope.chatId, + sessionId, + buffer, + ); const syntheticEnvelope: Envelope = { ...lastEnvelope, text: coalesced, @@ -729,7 +733,8 @@ export abstract class ChannelBase { imageBase64: undefined, imageMimeType: undefined, }; - this.handleInbound(syntheticEnvelope).catch((err) => { + this.markPreflighted(syntheticEnvelope); + this.processInbound(syntheticEnvelope).catch((err) => { process.stderr.write( `[${this.name}] dropped ${lost} buffered message(s) after loop ${job.id} for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${ err instanceof Error ? err.message : String(err) @@ -864,12 +869,49 @@ export abstract class ChannelBase { } active.cancelled = true; this.stopActiveStreaming(active, sessionId, reason); - this.collectBuffers.delete(sessionId); + this.dropCollectBuffer(sessionId); this.emitTaskCancellation(active, sessionId, reason); return true; }); } + private dropCollectBuffer(sessionId: string): void { + const buffer = this.collectBuffers.get(sessionId); + if (!buffer) return; + this.collectBuffers.delete(sessionId); + const chatId = buffer[0]?.envelope.chatId ?? ''; + const messageIds = this.collectBufferMessageIds(buffer); + try { + this.onPromptBufferDropped(chatId, sessionId, messageIds); + } catch (err) { + process.stderr.write( + `[${this.name}] onPromptBufferDropped threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`, + ); + } + } + + private notifyPromptBufferDrained( + chatId: string, + sessionId: string, + buffer: CollectBufferEntry[], + ): void { + const messageIds = this.collectBufferMessageIds(buffer); + if (messageIds.length === 0) return; + try { + this.onPromptBufferDrained(chatId, sessionId, messageIds); + } catch (err) { + process.stderr.write( + `[${this.name}] onPromptBufferDrained threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`, + ); + } + } + + private collectBufferMessageIds(buffer: CollectBufferEntry[]): string[] { + return buffer + .map((entry) => entry.envelope.messageId) + .filter((id): id is string => typeof id === 'string' && id.length > 0); + } + private logCancelSessionFailure(sessionId: string, err: unknown): void { process.stderr.write( `[${sanitizeLogText(this.name, 64)}] cancelSession failed for session=${sanitizeLogText(sessionId, 64)}: ${this.lifecycleError(err)}\n`, @@ -925,6 +967,24 @@ export abstract class ChannelBase { _messageId?: string, ): void {} + protected onPromptBuffered( + _chatId: string, + _sessionId: string, + _messageId?: string, + ): void {} + + protected onPromptBufferDrained( + _chatId: string, + _sessionId: string, + _messageIds: string[], + ): void {} + + protected onPromptBufferDropped( + _chatId: string, + _sessionId: string, + _messageIds: string[], + ): void {} + /** * Called when a prompt finishes (response sent or cancelled). * Override to hide the working indicator. @@ -1049,7 +1109,7 @@ export abstract class ChannelBase { // purging, so a running prompt can't deliver a stale response into — // or resurrect via collect-drain — the just-cleared session. const active = this.activePrompts.get(id); - this.collectBuffers.delete(id); + this.dropCollectBuffer(id); if (active) { // Bounded cancel + wind-down wait; purge regardless of the result. const settled = await this.cancelAndAwaitActive(active, id); @@ -2146,23 +2206,60 @@ export abstract class ChannelBase { return `${GROUP_HISTORY_CONTEXT_MARKER}\n${formatted.join('\n')}\n\n${CURRENT_MESSAGE_MARKER}\n${promptText}`; } - async handleInbound(envelope: Envelope): Promise { - // 1. Group gate: policy + allowlist + mention gating + protected preflightInbound(envelope: Envelope): boolean | Promise { const groupResult = this.groupGate.check(envelope); if (!groupResult.allowed) { if (groupResult.reason === 'mention_required') { this.recordPendingGroupHistory(envelope); } - return; // silently drop — no pairing, no reply + return false; } - // 2. Sender gate: allowlist / pairing / open const result = this.gate.check(envelope.senderId, envelope.senderName); if (!result.allowed) { if (result.pairingCode !== undefined) { - await this.onPairingRequired(envelope.chatId, result.pairingCode); + return this.onPairingRequired(envelope.chatId, result.pairingCode) + .then(() => false) + .catch((err: unknown) => { + process.stderr.write( + `[Channel:${this.name}] pairing notification failed: ${sanitizeLogText( + err instanceof Error ? err.message : String(err), + 200, + )}\n`, + ); + return false; + }); } - return; + return false; + } + + this.markPreflighted(envelope); + return true; + } + + async handleInbound(envelope: Envelope): Promise { + const preflight = this.preflightInbound(envelope); + if (!(isPromiseLike(preflight) ? await preflight : preflight)) return; + + await this.processInbound(envelope); + } + + protected markPreflighted(envelope: Envelope): void { + this.preflightedEnvelopes.add(envelope); + } + + /** + * Process an inbound message after preflight gates have passed. + * + * This method does not run group gating, sender allowlisting, or pairing + * checks. Callers must run preflightInbound() first unless the envelope was + * already preflighted, such as during collect-buffer drain. + */ + protected async processInbound(envelope: Envelope): Promise { + if (!this.preflightedEnvelopes.delete(envelope)) { + throw new Error( + 'processInbound called without a successful preflightInbound check.', + ); } // 3. Slash command handling — before session/agent routing @@ -2369,6 +2466,17 @@ export abstract class ChannelBase { this.collectBuffers.set(sessionId, buffer); } buffer.push({ text: promptText, envelope }); + try { + this.onPromptBuffered( + envelope.chatId, + sessionId, + envelope.messageId, + ); + } catch (err) { + process.stderr.write( + `[${this.name}] onPromptBuffered threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`, + ); + } return; } case 'steer': { @@ -2743,6 +2851,11 @@ export abstract class ChannelBase { const lost = buffer.length; const coalesced = buffer.map((b) => b.text).join('\n\n'); const lastEnvelope = buffer[buffer.length - 1]!.envelope; + this.notifyPromptBufferDrained( + lastEnvelope.chatId, + sessionId, + buffer, + ); // Re-enter handleInbound with the coalesced message const syntheticEnvelope: Envelope = { ...lastEnvelope, @@ -2755,9 +2868,10 @@ export abstract class ChannelBase { imageBase64: undefined, imageMimeType: undefined, }; + this.markPreflighted(syntheticEnvelope); // Queue the coalesced prompt (don't await to avoid deadlock on the queue). // Surface a drain failure instead of silently losing buffered turns. - this.handleInbound(syntheticEnvelope).catch((err) => { + this.processInbound(syntheticEnvelope).catch((err) => { process.stderr.write( `[${this.name}] dropped ${lost} buffered message(s) on collect re-entry for session ${sessionId} (last sender ${lastEnvelope.senderId}): ${ err instanceof Error ? err.message : String(err) @@ -2796,6 +2910,14 @@ function truncateGroupHistoryField(value: string): string { return value.slice(0, GROUP_HISTORY_ENTRY_METADATA_LIMIT); } +function isPromiseLike(value: T | PromiseLike): value is PromiseLike { + return ( + value !== null && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as { then?: unknown }).then === 'function' + ); +} + function truncateLoopLabel(prompt: string): string { const chars = Array.from(prompt); return chars.length > 60 ? `${chars.slice(0, 57).join('')}...` : prompt; diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 47c17216140..6ec887066c1 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -227,6 +227,9 @@ export interface ChannelPlugin { */ requiredConfigFields?: string[]; + /** Optional config fields whose string values may reference environment vars. */ + envResolvableConfigFields?: string[]; + /** Create a channel adapter instance. */ createChannel( name: string, diff --git a/packages/channels/wecom/package.json b/packages/channels/wecom/package.json new file mode 100644 index 00000000000..6745446c891 --- /dev/null +++ b/packages/channels/wecom/package.json @@ -0,0 +1,29 @@ +{ + "name": "@qwen-code/channel-wecom", + "version": "0.19.6", + "description": "WeCom channel adapter for Qwen Code", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build", + "test": "vitest run", + "test:ci": "vitest run" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "@wecom/aibot-node-sdk": "^1.0.7" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts new file mode 100644 index 00000000000..7db6c3b5859 --- /dev/null +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -0,0 +1,4181 @@ +import { + chmodSync, + constants, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { + ChannelAgentBridge, + ChannelConfig, + Envelope, +} from '@qwen-code/channel-base'; + +const mocks = vi.hoisted(() => { + type MockHttpResponse = { + statusCode: number; + headers: Record; + on( + event: string, + handler: (value?: Buffer | Error) => void, + ): MockHttpResponse; + resume: ReturnType; + }; + type MockHttpRequest = { + on: ReturnType; + end: ReturnType; + destroy: ReturnType; + setTimeout: ReturnType; + }; + type MockHttpCall = { + options: unknown; + request: MockHttpRequest; + response: MockHttpResponse; + }; + type MockFileHandle = { + stat: ReturnType; + readFile: ReturnType; + close: ReturnType; + }; + + const instances: MockWSClient[] = []; + const httpCalls: MockHttpCall[] = []; + const openHandles: MockFileHandle[] = []; + const lookup = vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]); + const decryptFile = vi.fn((buffer: Buffer, _aesKey: string) => buffer); + const open = vi.fn(async (_path: string, _flags: string | number) => { + const handle: MockFileHandle = { + stat: vi.fn(async () => ({ isFile: () => true, size: 4 })), + readFile: vi.fn(async () => Buffer.from([0x89, 0x50, 0x4e, 0x47])), + close: vi.fn(async () => {}), + }; + openHandles.push(handle); + return handle; + }); + const readFile = vi.fn(async (_path: string) => + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); + const writeFile = vi.fn( + async (_path: string, _data: Buffer, _options: unknown) => {}, + ); + const httpResponse = { + statusCode: 200, + headers: {} as Record, + body: Buffer.from('downloaded'), + }; + const httpsRequest = vi.fn( + ( + _url: string, + _options: unknown, + callback: (response: MockHttpResponse) => void, + ) => { + const handlers = new Map< + string, + Array<(value?: Buffer | Error) => void> + >(); + const response: MockHttpResponse = { + statusCode: httpResponse.statusCode, + headers: httpResponse.headers, + on(event, handler) { + const eventHandlers = handlers.get(event) ?? []; + eventHandlers.push(handler); + handlers.set(event, eventHandlers); + return response; + }, + resume: vi.fn(), + }; + const emit = (event: string, value?: Buffer | Error): void => { + for (const handler of handlers.get(event) ?? []) { + handler(value); + } + }; + const request: MockHttpRequest = { + on: vi.fn(() => request), + end: vi.fn(() => { + queueMicrotask(() => { + callback(response); + queueMicrotask(() => { + emit('data', httpResponse.body); + if (state.mediaResponseNeverEnds) return; + emit('end'); + }); + }); + return request; + }), + destroy: vi.fn(() => request), + setTimeout: vi.fn(() => request), + }; + httpCalls.push({ options: _options, request, response }); + return request; + }, + ); + const state = { + autoAuthenticate: true, + connectErrorsRemaining: 0, + connectNeverSettles: false, + connectResolvers: [] as Array<() => void>, + connectWaitsForRelease: false, + kickAfterConnectsRemaining: 0, + mediaResponseNeverEnds: false, + }; + + class MockWSClient { + readonly options: Record; + readonly handlers = new Map void>>(); + connect = vi.fn(() => { + if (state.connectErrorsRemaining > 0) { + state.connectErrorsRemaining -= 1; + return Promise.reject(new Error('connect failed')); + } + if (state.connectNeverSettles) { + return new Promise(() => {}); + } + if (state.connectWaitsForRelease) { + if (state.autoAuthenticate) { + queueMicrotask(() => this.emit('authenticated', {})); + } + return new Promise((resolve) => { + state.connectResolvers.push(() => resolve(this)); + }); + } + if (state.autoAuthenticate) { + queueMicrotask(() => this.emit('authenticated', {})); + } + if (state.kickAfterConnectsRemaining > 0) { + state.kickAfterConnectsRemaining -= 1; + queueMicrotask(() => + this.emit('event.disconnected_event', 'kicked again'), + ); + } + return this; + }); + disconnect = vi.fn(); + sendMessage = vi.fn(async (_chatId: string, _message: unknown) => ({ + headers: { req_id: 'req-1' }, + })); + uploadMedia = vi.fn( + async (_data: Buffer, _options: { type: string; filename: string }) => ({ + media_id: 'media-1', + }), + ); + sendMediaMessage = vi.fn( + async (_chatId: string, _mediaType: string, _mediaId: string) => ({ + headers: { req_id: 'media-req-1' }, + }), + ); + downloadFile = vi.fn(async (_url: string, _aesKey?: string) => ({ + buffer: Buffer.from('downloaded'), + })); + + constructor(options: Record) { + this.options = options; + instances.push(this); + } + + on(event: string, handler: (payload: unknown) => void): void { + const handlers = this.handlers.get(event) ?? []; + handlers.push(handler); + this.handlers.set(event, handlers); + } + + off(event: string, handler: (payload: unknown) => void): void { + const handlers = this.handlers.get(event) ?? []; + this.handlers.set( + event, + handlers.filter((candidate) => candidate !== handler), + ); + } + + emit(event: string, payload: unknown): void { + for (const handler of this.handlers.get(event) ?? []) { + handler(payload); + } + } + } + + return { + MockWSClient, + instances, + httpCalls, + openHandles, + lookup, + decryptFile, + open, + readFile, + writeFile, + httpResponse, + httpsRequest, + state, + }; +}); + +vi.mock('@wecom/aibot-node-sdk', () => ({ + WSClient: mocks.MockWSClient, + decryptFile: mocks.decryptFile, +})); +vi.mock('node:dns/promises', () => ({ + lookup: mocks.lookup, +})); +vi.mock('node:https', () => ({ + request: mocks.httpsRequest, +})); +vi.mock('node:fs/promises', () => ({ + open: mocks.open, + readFile: mocks.readFile, + writeFile: mocks.writeFile, +})); + +import { WeComChannel } from './WeComAdapter.js'; +import { plugin } from './index.js'; + +type MockWSClient = InstanceType; + +function makeConfig( + overrides: Partial> = {}, +): ChannelConfig & Record { + return { + type: 'wecom', + token: '', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'user', + cwd: process.cwd(), + groupPolicy: 'disabled', + groups: {}, + botId: 'bot-id', + secret: 'bot-secret', + ...overrides, + }; +} + +function makeBridge(): ChannelAgentBridge { + return { + availableCommands: [], + on: vi.fn(), + off: vi.fn(), + newSession: vi.fn(async () => 'session-1'), + loadSession: vi.fn(async (id: string) => id), + prompt: vi.fn(async () => ''), + cancelSession: vi.fn(async () => {}), + } as unknown as ChannelAgentBridge; +} + +class TestWeComChannel extends WeComChannel { + readonly envelopes: Envelope[] = []; + + protected override async processInbound(envelope: Envelope): Promise { + this.envelopes.push(envelope); + } +} + +class PromptEndWeComChannel extends WeComChannel { + finishPrompt(chatId: string, sessionId: string, messageId?: string): void { + this.onPromptEnd(chatId, sessionId, messageId); + } +} + +class FailingPreflightWeComChannel extends WeComChannel { + readonly preflights = vi.fn(async (_envelope: Envelope) => { + throw new Error('preflight failed'); + }); + + protected override async preflightInbound( + envelope: Envelope, + ): Promise { + return this.preflights(envelope); + } +} + +class RejectingPreflightWeComChannel extends WeComChannel { + readonly preflights = vi.fn(async (_envelope: Envelope) => false); + + protected override async preflightInbound( + envelope: Envelope, + ): Promise { + return this.preflights(envelope); + } +} + +class BlockingPreflightWeComChannel extends TestWeComChannel { + readonly preflights = vi.fn(); + private readonly preflightResolvers: Array<() => void> = []; + + releasePreflights(): void { + this.preflightResolvers.splice(0).forEach((resolve) => resolve()); + } + + protected override async preflightInbound( + envelope: Envelope, + ): Promise { + this.preflights(envelope); + await new Promise((resolve) => { + this.preflightResolvers.push(resolve); + }); + return super.preflightInbound(envelope); + } +} + +class FailingProcessWeComChannel extends WeComChannel { + readonly processes = vi.fn(async (_envelope: Envelope) => { + throw new Error('process failed after side effects started'); + }); + + protected override async processInbound(envelope: Envelope): Promise { + return this.processes(envelope); + } +} + +class BlockingAliceProcessWeComChannel extends WeComChannel { + private releaseAliceProcess?: () => void; + readonly aliceBlocked = new Promise((resolve) => { + this.releaseAliceProcess = resolve; + }); + + releaseAlice(): void { + this.releaseAliceProcess?.(); + } + + protected override async processInbound(envelope: Envelope): Promise { + if (envelope.senderId === 'alice') { + await this.aliceBlocked; + } + await super.processInbound(envelope); + } +} + +function lastClient(): MockWSClient { + const client = mocks.instances.at(-1); + if (!client) throw new Error('missing mock client'); + return client; +} + +function channelFileDirs(): string[] { + const parent = join(tmpdir(), 'channel-files'); + if (!existsSync(parent)) return []; + return readdirSync(parent).map((entry) => join(parent, entry)); +} + +describe('WeComChannel', () => { + beforeEach(() => { + mocks.instances.length = 0; + mocks.httpCalls.length = 0; + mocks.openHandles.length = 0; + mocks.state.autoAuthenticate = true; + mocks.state.connectErrorsRemaining = 0; + mocks.state.connectNeverSettles = false; + mocks.state.connectResolvers.length = 0; + mocks.state.connectWaitsForRelease = false; + mocks.state.kickAfterConnectsRemaining = 0; + mocks.state.mediaResponseNeverEnds = false; + mocks.httpResponse.statusCode = 200; + mocks.httpResponse.headers = {}; + mocks.httpResponse.body = Buffer.from('downloaded'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('ok')), + ); + vi.clearAllMocks(); + mocks.decryptFile.mockImplementation((buffer: Buffer) => buffer); + mocks.readFile.mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + mocks.writeFile.mockImplementation( + async (path: string, data: Buffer, _options: unknown) => { + writeFileSync(path, data); + }, + ); + mocks.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + rmSync(join(tmpdir(), 'channel-files'), { recursive: true, force: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + rmSync(join(tmpdir(), 'channel-files'), { recursive: true, force: true }); + }); + + it('requires botId and secret', () => { + expect( + () => new WeComChannel('bot', makeConfig({ botId: '' }), makeBridge()), + ).toThrow('requires botId and secret'); + expect( + () => new WeComChannel('bot', makeConfig({ secret: '' }), makeBridge()), + ).toThrow('requires botId and secret'); + expect( + () => + new WeComChannel( + 'bot', + makeConfig({ wsUrl: 'ws://example.invalid/ws' }), + makeBridge(), + ), + ).toThrow('requires wsUrl to use wss://'); + }); + + it('supports proactive sends', () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + expect(channel.supportsProactiveSend()).toBe(true); + }); + + it('connects the official SDK with bot credentials', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel( + 'bot', + makeConfig({ wsUrl: 'wss://example.invalid/ws' }), + makeBridge(), + ); + + await channel.connect(); + + const client = lastClient(); + expect(client.options).toMatchObject({ + botId: 'bot-id', + secret: 'bot-secret', + wsUrl: 'wss://example.invalid/ws', + }); + expect(client.connect).toHaveBeenCalledTimes(1); + const logger = client.options['logger'] as { + debug(message: string): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + }; + expect(logger).toBeDefined(); + + logger.debug('body={"text":"secret","aeskey":"key"}'); + expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining('secret')); + + logger.warn('No aesKey provided:', 'https://example.invalid/file'); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] SDK warn: No aesKey provided:\n', + ); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('https://example.invalid/file'), + ); + + client.emit('error', { + config: { + headers: { authorization: 'Bearer nested-token' }, + data: { secret: 'nested-secret' }, + }, + response: { status: 401 }, + }); + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('[REDACTED]')); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('nested-token'), + ); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('nested-secret'), + ); + stderr.mockRestore(); + }); + + it('waits for SDK authentication before reporting connected', async () => { + mocks.state.autoAuthenticate = false; + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + const connecting = channel.connect(); + const client = lastClient(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(stderr).not.toHaveBeenCalledWith( + '[WeCom:bot] Connected via smart bot.\n', + ); + + client.emit('authenticated', {}); + await connecting; + + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] Connected via smart bot.\n', + ); + stderr.mockRestore(); + }); + + it('removes temporary authentication listeners after connect settles', async () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + await channel.connect(); + + const client = lastClient(); + expect(client.handlers.get('authenticated')).toHaveLength(0); + expect(client.handlers.get('error')).toHaveLength(1); + }); + + it('keeps waiting when the SDK disconnects before authentication', async () => { + mocks.state.autoAuthenticate = false; + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + const connecting = channel.connect(); + const client = lastClient(); + client.emit('disconnected', 'auth failed'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.disconnect).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] WebSocket auth failed; waiting for SDK reconnect.\n', + ); + + client.emit('authenticated', {}); + await connecting; + + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] Connected via smart bot.\n', + ); + stderr.mockRestore(); + }); + + it('drops messages received before authentication completes', async () => { + mocks.state.autoAuthenticate = false; + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + + const connecting = channel.connect(); + const client = lastClient(); + client.emit('message.text', { + msgid: 'msg-before-auth', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.envelopes).toHaveLength(0); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] dropping message before authentication.\n', + ); + + client.emit('authenticated', {}); + await connecting; + stderr.mockRestore(); + }); + + it('keeps the active SDK client when the websocket disconnects', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('disconnected', 'closed'); + await channel.sendMessage('chat-1', 'hello'); + + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] WebSocket closed; waiting for SDK reconnect.\n', + ); + expect(client.sendMessage).toHaveBeenCalledWith('chat-1', { + msgtype: 'markdown', + markdown: { content: 'hello' }, + }); + stderr.mockRestore(); + }); + + it('preserves structured websocket disconnect reasons in logs', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('disconnected', { + code: 1006, + reason: 'abnormal closure', + wasClean: false, + }); + + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining( + 'code=1006 reason=abnormal closure wasClean=false', + ), + ); + stderr.mockRestore(); + }); + + it('reconnects when WeCom kicks the connection for a newer client', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const oldClient = lastClient(); + + oldClient.emit('event.disconnected_event', { + errcode: 45009, + errmsg: 'another client connected', + }); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(2)); + expect(oldClient.disconnect).toHaveBeenCalled(); + expect(lastClient().connect).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] WebSocket errcode=45009 errmsg=another client connected; reconnecting after server kick.\n', + ); + channel.disconnect(); + stderr.mockRestore(); + }); + + it('keeps retrying later after kick reconnect attempts are exhausted', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const oldClient = lastClient(); + mocks.state.connectErrorsRemaining = 3; + + oldClient.emit('event.disconnected_event', 'kicked'); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(4_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(4)); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] reconnect after server kick gave up after 3 attempts; retrying later.\n', + ); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(5)); + expect(lastClient().connect).toHaveBeenCalledTimes(1); + + channel.disconnect(); + stderr.mockRestore(); + }); + + it('does not keep the delayed kick reconnect retry alive', () => { + const unref = vi.fn(); + const timeout = { unref } as unknown as ReturnType; + const setTimeoutSpy = vi + .spyOn(globalThis, 'setTimeout') + .mockReturnValue(timeout); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + const inspectable = channel as unknown as { + kickReconnectRetry?: ReturnType; + scheduleKickReconnectRetry( + reason: unknown, + disconnectGeneration: number, + ): void; + }; + + inspectable.scheduleKickReconnectRetry('kicked', 0); + + expect(inspectable.kickReconnectRetry).toBe(timeout); + expect(unref).toHaveBeenCalledTimes(1); + setTimeoutSpy.mockRestore(); + }); + + it('resets exhausted kick attempts when a fresh kick cancels a delayed retry', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const retry = setTimeout(() => {}, 60_000); + const inspectable = channel as unknown as { + kickReconnectAttempts: number; + kickReconnectRetry?: ReturnType; + }; + inspectable.kickReconnectAttempts = 3; + inspectable.kickReconnectRetry = retry; + + lastClient().emit('event.disconnected_event', 'kicked'); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(2)); + expect(inspectable.kickReconnectAttempts).toBe(0); + + channel.disconnect(); + }); + + it('schedules a long retry when kick reconnect fails unexpectedly', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + const inspectable = channel as unknown as { + reconnectAfterKick( + reason: unknown, + reconnectReason?: string, + ): Promise; + startKickReconnect(reason: unknown, reconnectReason?: string): void; + kickReconnectRetry?: ReturnType; + }; + inspectable.reconnectAfterKick = vi.fn(async () => { + throw new Error('state corrupt'); + }); + + inspectable.startKickReconnect('kicked', 'server kick'); + + await vi.waitFor(() => + expect(inspectable.kickReconnectRetry).toBeDefined(), + ); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] kick-reconnect failed: state corrupt\n', + ); + + stderr.mockRestore(); + }); + + it('resets retry cycles for long retry after unexpected kick reconnect failure', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + const inspectable = channel as unknown as { + reconnectAfterKick( + reason: unknown, + reconnectReason?: string, + ): Promise; + startKickReconnect(reason: unknown, reconnectReason?: string): void; + kickReconnectRetryCycles: number; + }; + inspectable.kickReconnectRetryCycles = 3; + inspectable.reconnectAfterKick = vi.fn(async () => { + throw new Error('state corrupt'); + }); + + inspectable.startKickReconnect('kicked', 'server kick'); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] kick-reconnect failed: state corrupt\n', + ), + ); + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + expect(inspectable.kickReconnectRetryCycles).toBe(0); + + stderr.mockRestore(); + }); + + it('does not keep the SDK disconnect fallback reconnect alive', () => { + const unref = vi.fn(); + const timeout = { unref } as unknown as ReturnType; + const setTimeoutSpy = vi + .spyOn(globalThis, 'setTimeout') + .mockReturnValue(timeout); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + const client = {}; + const inspectable = channel as unknown as { + disconnectReconnectFallback?: ReturnType; + scheduleDisconnectReconnectFallback( + reason: unknown, + client: unknown, + disconnectGeneration: number, + ): void; + }; + + inspectable.scheduleDisconnectReconnectFallback('closed', client, 0); + + expect(inspectable.disconnectReconnectFallback).toBe(timeout); + expect(unref).toHaveBeenCalledTimes(1); + setTimeoutSpy.mockRestore(); + }); + + it('falls back to adapter reconnect when SDK disconnect stalls', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const oldClient = lastClient(); + + oldClient.emit('disconnected', 'closed'); + + await vi.advanceTimersByTimeAsync(30_000); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(2)); + expect(oldClient.disconnect).toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] SDK reconnect did not recover after WebSocket closed; reconnecting adapter.\n', + ); + + channel.disconnect(); + stderr.mockRestore(); + }); + + it('reconnects when the SDK stays silent past the activity watchdog', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const oldClient = lastClient(); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + await vi.advanceTimersByTimeAsync(1_000); + + await vi.waitFor(() => expect(mocks.instances).toHaveLength(2)); + expect(oldClient.disconnect).toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] no SDK activity for 5 minutes; reconnecting adapter.\n', + ); + + channel.disconnect(); + stderr.mockRestore(); + }); + + it('keeps kick reconnect alive when SDK disconnect throws', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const oldClient = lastClient(); + oldClient.disconnect.mockImplementationOnce(() => { + throw new Error('socket already closed'); + }); + + oldClient.emit('event.disconnected_event', 'kicked'); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(2)); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] client.disconnect() threw: socket already closed\n', + ); + + channel.disconnect(); + stderr.mockRestore(); + }); + + it('does not reconnect when disconnect emits a kick event', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const oldClient = lastClient(); + oldClient.off = undefined as unknown as MockWSClient['off']; + oldClient.disconnect.mockImplementationOnce(() => { + oldClient.emit('event.disconnected_event', 'kicked'); + }); + + channel.disconnect(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(mocks.instances).toHaveLength(1); + }); + + it('does not reconnect when disconnect emits a disconnected event', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const oldClient = lastClient(); + oldClient.off = undefined as unknown as MockWSClient['off']; + oldClient.disconnect.mockImplementationOnce(() => { + oldClient.emit('disconnected', 'closed'); + }); + + channel.disconnect(); + await vi.advanceTimersByTimeAsync(31_000); + + expect(mocks.instances).toHaveLength(1); + }); + + it('resets exhausted kick retry cycles before SDK-disconnect fallback reconnect', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const inspectable = channel as unknown as { + kickReconnectRetryCycles: number; + }; + inspectable.kickReconnectRetryCycles = 3; + + lastClient().emit('disconnected', 'closed'); + + await vi.advanceTimersByTimeAsync(30_000); + expect(inspectable.kickReconnectRetryCycles).toBe(0); + + channel.disconnect(); + }); + + it('continues kick reconnect after repeated retry cycles are exhausted', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const inspectable = channel as unknown as { + kickReconnectRetryCycles: number; + }; + const oldClient = lastClient(); + mocks.state.connectErrorsRemaining = 9; + + oldClient.emit('event.disconnected_event', 'kicked'); + + for (let cycle = 0; cycle < 3; cycle += 1) { + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(4_000); + await vi.waitFor(() => + expect(mocks.instances).toHaveLength(4 + cycle * 3), + ); + if (cycle < 2) { + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + } + } + + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] reconnect after server kick exhausted 3 retry cycles; next attempt in 15 minutes.\n', + ); + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(11)); + expect(inspectable.kickReconnectRetryCycles).toBe(0); + + channel.disconnect(); + stderr.mockRestore(); + }); + + it('resets the kick reconnect attempt budget after a successful reconnect', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + + for (let kick = 0; kick < 4; kick += 1) { + lastClient().emit('event.disconnected_event', 'kicked'); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(kick + 2)); + await vi.waitFor(() => + expect( + ( + channel as unknown as { + reconnectingAfterKick: boolean; + } + ).reconnectingAfterKick, + ).toBe(false), + ); + } + + channel.disconnect(); + }); + + it('retries failed kick reconnect attempts with exponential backoff', async () => { + vi.useFakeTimers(); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + mocks.state.connectErrorsRemaining = 2; + + lastClient().emit('event.disconnected_event', 'kicked'); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(4_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(4)); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('reconnect after server kick attempt 1 failed'), + ); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('reconnect after server kick attempt 2 failed'), + ); + expect(lastClient().connect).toHaveBeenCalledTimes(1); + + channel.disconnect(); + stderr.mockRestore(); + }); + + it('stops kick reconnect when the channel disconnects mid-retry', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + + lastClient().emit('event.disconnected_event', 'kicked'); + channel.disconnect(); + + await vi.advanceTimersByTimeAsync(1_000); + expect(mocks.instances).toHaveLength(1); + }); + + it('clears pending kick reconnect state on disconnect', async () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + ( + channel as unknown as { + pendingKickReconnect: boolean; + } + ).pendingKickReconnect = true; + + channel.disconnect(); + + expect( + ( + channel as unknown as { + pendingKickReconnect: boolean; + } + ).pendingKickReconnect, + ).toBe(false); + }); + + it('does not replay a pending kick after a successful reconnect', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + + lastClient().emit('event.disconnected_event', 'kicked'); + ( + channel as unknown as { + pendingKickReconnect: boolean; + } + ).pendingKickReconnect = true; + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(2)); + await vi.waitFor(() => + expect( + ( + channel as unknown as { + reconnectingAfterKick: boolean; + } + ).reconnectingAfterKick, + ).toBe(false), + ); + + await vi.advanceTimersByTimeAsync(1_000); + expect(mocks.instances).toHaveLength(2); + expect( + ( + channel as unknown as { + pendingKickReconnect: boolean; + } + ).pendingKickReconnect, + ).toBe(false); + + channel.disconnect(); + }); + + it('does not schedule reconnect reset after disconnect races with connect success', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + mocks.state.connectWaitsForRelease = true; + + lastClient().emit('event.disconnected_event', 'kicked'); + await vi.advanceTimersByTimeAsync(1_000); + expect(mocks.instances).toHaveLength(2); + + channel.disconnect(); + mocks.state.connectResolvers.splice(0).forEach((resolve) => resolve()); + await vi.runAllTicks(); + + expect( + ( + channel as unknown as { + kickReconnectReset?: ReturnType; + } + ).kickReconnectReset, + ).toBeUndefined(); + }); + + it('does not tear down a recovered client for a pending kick', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + mocks.state.kickAfterConnectsRemaining = 1; + + lastClient().emit('event.disconnected_event', 'kicked'); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(2)); + await vi.waitFor(() => + expect( + ( + channel as unknown as { + reconnectingAfterKick: boolean; + } + ).reconnectingAfterKick, + ).toBe(false), + ); + + await vi.advanceTimersByTimeAsync(1_000); + expect(mocks.instances).toHaveLength(2); + + channel.disconnect(); + }); + + it('resets exhausted kick attempts before replaying a pending kick', async () => { + vi.useFakeTimers(); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + mocks.state.connectErrorsRemaining = 3; + + lastClient().emit('event.disconnected_event', 'kicked'); + ( + channel as unknown as { + pendingKickReconnect: boolean; + } + ).pendingKickReconnect = true; + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(4_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(4)); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(5)); + + channel.disconnect(); + }); + + it('does not clear adapter state when reconnecting after a kick', async () => { + vi.useFakeTimers(); + const bridge = makeBridge(); + (bridge.prompt as ReturnType).mockImplementationOnce( + () => new Promise(() => {}), + ); + const channel = new WeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + const oldClient = lastClient(); + + oldClient.emit('message.file', { + msgid: 'msg-kick-preserve', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const prompt = (bridge.prompt as ReturnType).mock + .calls[0][1] as string; + const filePath = prompt.match(/saved to: (.*report\.txt)/)?.[1]; + expect(filePath).toBeDefined(); + + oldClient.emit('event.disconnected_event', { + errcode: 45009, + errmsg: 'another client connected', + }); + expect(oldClient.disconnect).toHaveBeenCalled(); + expect(existsSync(filePath!)).toBe(true); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(2)); + const newClient = lastClient(); + newClient.emit('message.file', { + msgid: 'msg-kick-preserve', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await Promise.resolve(); + expect(bridge.prompt).toHaveBeenCalledTimes(1); + expect(existsSync(filePath!)).toBe(true); + channel.disconnect(); + }); + + it('fails a pending authentication promptly when the connection is kicked', async () => { + mocks.state.autoAuthenticate = false; + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + const connecting = channel.connect(); + const client = lastClient(); + client.emit('event.disconnected_event', { + errcode: 45009, + errmsg: 'another client connected', + }); + + await expect(connecting).rejects.toThrow('kicked'); + expect(client.disconnect).toHaveBeenCalled(); + }); + + it('fails pending authentication promptly on SDK errors', async () => { + mocks.state.autoAuthenticate = false; + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + const connecting = channel.connect(); + lastClient().emit('error', new Error('auth failed')); + + await expect(connecting).rejects.toThrow( + 'WeCom authentication failed: Error: auth failed', + ); + stderr.mockRestore(); + }); + + it('fails pending authentication when the timeout elapses', async () => { + vi.useFakeTimers(); + mocks.state.autoAuthenticate = false; + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + const connecting = channel.connect(); + const rejection = expect(connecting).rejects.toThrow( + 'WeCom authentication timed out.', + ); + await vi.advanceTimersByTimeAsync(30_000); + + await rejection; + }); + + it('fails authentication timeout even when SDK connect hangs', async () => { + vi.useFakeTimers(); + mocks.state.autoAuthenticate = false; + mocks.state.connectNeverSettles = true; + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + const connecting = channel.connect(); + const rejection = expect(connecting).rejects.toThrow( + 'WeCom authentication timed out.', + ); + await vi.advanceTimersByTimeAsync(30_000); + + await rejection; + expect(lastClient().disconnect).toHaveBeenCalled(); + }); + + it('fails when SDK authenticates but connect never settles', async () => { + vi.useFakeTimers(); + mocks.state.autoAuthenticate = false; + mocks.state.connectNeverSettles = true; + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + const connecting = channel.connect(); + const client = lastClient(); + let settled: string | undefined; + connecting.then( + () => { + settled = 'resolved'; + }, + (err: unknown) => { + settled = err instanceof Error ? err.message : String(err); + }, + ); + + client.emit('authenticated', {}); + await vi.advanceTimersByTimeAsync(30_000); + await Promise.resolve(); + + expect(settled).toBe('WeCom SDK connect timed out.'); + expect(client.disconnect).toHaveBeenCalled(); + }); + + it('removes SDK event handlers on disconnect', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + channel.disconnect(); + client.emit('message.text', { + msgid: 'msg-after-disconnect', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.envelopes).toHaveLength(0); + expect(client.handlers.get('message.text')).toHaveLength(0); + expect(client.handlers.get('message.image')).toHaveLength(0); + expect(client.handlers.get('error')).toHaveLength(0); + expect(client.handlers.get('disconnected')).toHaveLength(0); + expect(client.handlers.get('event.disconnected_event')).toHaveLength(0); + }); + + it('rejects sends when no SDK client is active', async () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + + await expect(channel.sendMessage('chat-1', 'hello')).rejects.toThrow( + '[WeCom:bot] No active SDK client, cannot send.', + ); + }); + + it('logs when sendMessage produces an empty payload', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', ' '); + + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] sendMessage produced empty payload for chatId=chat-1.\n', + ); + stderr.mockRestore(); + }); + + it('normalizes text messages into envelopes', async () => { + const channel = new TestWeComChannel( + 'bot', + makeConfig({ groupPolicy: 'open', groups: { '*': {} } }), + makeBridge(), + ); + await channel.connect(); + + lastClient().emit('message.text', { + msgid: 'msg-1', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + const envelope = channel.envelopes[0]!; + expect(envelope).toMatchObject({ + channelName: 'bot', + senderId: 'alice', + senderName: 'alice', + chatId: 'alice', + text: 'hello', + messageId: 'msg-1', + isGroup: false, + isMentioned: true, + isReplyToBot: false, + }); + }); + + it('logs malformed message payloads without processing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + + lastClient().emit('message.text', undefined); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.envelopes).toHaveLength(0); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] dropping message with unrecognized payload structure.\n', + ); + stderr.mockRestore(); + }); + + it('normalizes group, voice, mixed, quote, and file messages', async () => { + const channel = new TestWeComChannel( + 'bot', + makeConfig({ + groupPolicy: 'open', + groups: { '*': { requireMention: false } }, + }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + mocks.httpResponse.body = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + + client.emit('message.mixed', { + msgid: 'msg-2', + msgtype: 'mixed', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'bob', name: 'Bob' }, + mixed: { + msg_item: [ + { msgtype: 'text', text: { content: '@bot inspect this' } }, + { msgtype: 'voice', voice: { content: 'voice transcript' } }, + { + msgtype: 'image', + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, + }, + ], + }, + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, + quote: { + msgtype: 'voice', + voice: { content: 'previous voice text' }, + }, + }); + + client.emit('message.file', { + msgid: 'msg-3', + msgtype: 'file', + chattype: 'single', + from: { userid: 'carol' }, + file: { + url: 'https://example.invalid/file', + aeskey: 'k2', + filename: '../report.pdf', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(2)); + expect(mocks.httpsRequest).toHaveBeenCalledWith( + 'https://example.invalid/image', + expect.objectContaining({ method: 'GET' }), + expect.any(Function), + ); + expect(mocks.httpsRequest).toHaveBeenCalledWith( + 'https://example.invalid/file', + expect.objectContaining({ method: 'GET' }), + expect.any(Function), + ); + expect(mocks.httpsRequest).toHaveBeenCalledTimes(2); + expect(mocks.decryptFile).toHaveBeenCalledWith(expect.any(Buffer), 'k1'); + expect(mocks.decryptFile).toHaveBeenCalledWith(expect.any(Buffer), 'k2'); + const mixed = channel.envelopes[0]!; + expect(mixed.chatId).toBe('group-1'); + expect(mixed.isGroup).toBe(true); + expect(mixed.text).toBe('@bot inspect this\nvoice transcript'); + expect(mixed.referencedText).toBe('previous voice text'); + expect(mixed.attachments?.[0]).toMatchObject({ + type: 'image', + data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'), + mimeType: 'image/png', + }); + + const file = channel.envelopes[1]!; + expect(file.text).toBe('(file: report.pdf)'); + expect(file.attachments?.[0]?.type).toBe('file'); + expect(file.attachments?.[0]?.fileName).toBe('report.pdf'); + expect(file.attachments?.[0]?.filePath).toContain('report.pdf'); + }); + + it('preserves distinct unicode inbound filenames', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.mixed', { + msgid: 'msg-unicode-files', + msgtype: 'mixed', + chattype: 'single', + from: { userid: 'alice' }, + mixed: { + msg_item: [ + { + msgtype: 'file', + file: { + url: 'https://example.invalid/report', + filename: '報告.pdf', + }, + }, + { + msgtype: 'file', + file: { + url: 'https://example.invalid/photo', + filename: '写真.pdf', + }, + }, + ], + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + const attachments = channel.envelopes[0]?.attachments ?? []; + expect(attachments.map((attachment) => attachment.fileName)).toEqual([ + '報告.pdf', + '写真.pdf', + ]); + expect( + new Set(attachments.map((attachment) => attachment.filePath)).size, + ).toBe(2); + }); + + it('allows group replies to the bot without an explicit mention', async () => { + const channel = new TestWeComChannel( + 'bot', + makeConfig({ + groupPolicy: 'open', + groups: { '*': {} }, + }), + makeBridge(), + ); + await channel.connect(); + + lastClient().emit('message.text', { + msgid: 'msg-reply', + msgtype: 'text', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'alice' }, + mentions: [], + text: { content: 'follow up' }, + quote: { + msgtype: 'text', + from: { userid: 'bot-id' }, + text: { content: 'bot response' }, + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]).toMatchObject({ + isGroup: true, + isMentioned: false, + isReplyToBot: true, + referencedText: 'bot response', + }); + }); + + it('sanitizes downloaded image filenames before adding attachments', async () => { + mocks.httpResponse.headers = { + 'content-disposition': 'attachment; filename="../secret.png"', + }; + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-image-filename', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments?.[0]?.fileName).toBe('secret.png'); + }); + + it('honors explicit group mention metadata when present', async () => { + const channel = new TestWeComChannel( + 'bot', + makeConfig({ + groupPolicy: 'open', + groups: { '*': { requireMention: false } }, + }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-unmentioned', + msgtype: 'text', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'bob' }, + text: { content: 'background' }, + mentions: [{ userid: 'other-bot' }], + }); + client.emit('message.text', { + msgid: 'msg-mentioned', + msgtype: 'text', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'bob' }, + text: { content: '@bot inspect' }, + mentions: [{ userid: 'bot-id' }], + }); + client.emit('message.text', { + msgid: 'msg-other-mentioned', + msgtype: 'text', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'bob' }, + text: { content: '@someone else' }, + isMentioned: true, + isInAtList: false, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(3)); + expect(channel.envelopes.map((envelope) => envelope.isMentioned)).toEqual([ + false, + true, + false, + ]); + }); + + it('treats empty mention metadata as explicitly unmentioned', async () => { + const channel = new TestWeComChannel( + 'bot', + makeConfig({ groupPolicy: 'open', groups: { '*': {} } }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-empty-mentions', + msgtype: 'text', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'bob' }, + text: { content: 'background' }, + mentions: [], + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.envelopes).toHaveLength(0); + }); + + it('treats missing group mention metadata as unmentioned', async () => { + const channel = new TestWeComChannel( + 'bot', + makeConfig({ groupPolicy: 'open', groups: { '*': {} } }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-missing-mention-metadata', + msgtype: 'text', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'bob' }, + text: { content: 'background' }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.envelopes).toHaveLength(0); + }); + + it('does not download attachments for messages rejected by mention gate', async () => { + const channel = new TestWeComChannel( + 'bot', + makeConfig({ groupPolicy: 'open', groups: { '*': {} } }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-unmentioned-image', + msgtype: 'image', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'bob' }, + mentions: [], + image: { url: 'https://example.invalid/private-image', aeskey: 'k1' }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(channel.envelopes).toHaveLength(0); + }); + + it('drops malformed group messages without falling back to sender chat', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel( + 'bot', + makeConfig({ groupPolicy: 'open', groups: { '*': {} } }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-missing-chat', + msgtype: 'text', + chattype: 'group', + from: { userid: 'bob' }, + text: { content: '@bot inspect' }, + mentions: [{ userid: 'bot-id' }], + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.envelopes).toHaveLength(0); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('missing chatId'), + ); + stderr.mockRestore(); + }); + + it('does not create a session for local commands before base dispatch', async () => { + const bridge = makeBridge(); + const channel = new WeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-who', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: '/who' }, + }); + + await vi.waitFor(() => expect(client.sendMessage).toHaveBeenCalled()); + expect(bridge.newSession).not.toHaveBeenCalled(); + expect(bridge.prompt).not.toHaveBeenCalled(); + expect(client.sendMessage).toHaveBeenCalledWith( + 'alice', + expect.objectContaining({ + markdown: expect.objectContaining({ + content: expect.stringContaining('Session: none'), + }), + }), + ); + }); + + it('does not create a session for refused group shell commands', async () => { + const bridge = makeBridge(); + const channel = new WeComChannel( + 'bot', + makeConfig({ groupPolicy: 'open', groups: { '*': {} } }), + bridge, + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-shell', + msgtype: 'text', + chattype: 'group', + chatid: 'group-1', + from: { userid: 'alice' }, + text: { content: '!pwd' }, + mentions: [{ userid: 'bot-id' }], + }); + + await vi.waitFor(() => expect(client.sendMessage).toHaveBeenCalled()); + expect(bridge.newSession).not.toHaveBeenCalled(); + expect(bridge.prompt).not.toHaveBeenCalled(); + expect(client.sendMessage).toHaveBeenCalledWith( + 'group-1', + expect.objectContaining({ + markdown: expect.objectContaining({ + content: expect.stringContaining('Shell commands'), + }), + }), + ); + }); + + it('rolls back message dedup when preflight work fails', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new FailingPreflightWeComChannel( + 'bot', + makeConfig(), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + const payload = { + msgid: 'msg-preflight-fails', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + }; + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] message handling failed for msg-preflight-fails: preflight failed\n', + ), + ); + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(2)); + stderr.mockRestore(); + }); + + it('allows retries for messages rejected by preflight', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const bridge = makeBridge(); + const channel = new RejectingPreflightWeComChannel( + 'bot', + makeConfig(), + bridge, + ); + await channel.connect(); + const client = lastClient(); + const payload = { + msgid: 'msg-preflight-rejected', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + }; + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(1)); + expect(bridge.newSession).not.toHaveBeenCalled(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] dropping message msg-preflight-rejected: preflight rejected.\n', + ), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(2)); + expect(bridge.newSession).not.toHaveBeenCalled(); + stderr.mockRestore(); + }); + + it('deduplicates repeated messages while preflight is pending', async () => { + const channel = new BlockingPreflightWeComChannel( + 'bot', + makeConfig(), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + const payload = { + msgid: 'msg-preflight-pending', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + }; + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(1)); + client.emit('message.text', payload); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(channel.preflights).toHaveBeenCalledTimes(1); + + channel.releasePreflights(); + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + }); + + it('keeps message dedup when processing fails after side effects start', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new FailingProcessWeComChannel( + 'bot', + makeConfig(), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + const payload = { + msgid: 'msg-process-fails', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + }; + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.processes).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] message handling failed for msg-process-fails: process failed after side effects started\n', + ), + ); + + client.emit('message.text', payload); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.processes).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] dropping duplicate message msg-process-fails (already seen).\n', + ); + stderr.mockRestore(); + }); + + it('logs duplicate messages that are already in flight', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new BlockingPreflightWeComChannel( + 'bot', + makeConfig(), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + const payload = { + msgid: 'msg-in-flight', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + }; + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(1)); + + client.emit('message.text', payload); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(channel.preflights).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] dropping duplicate message msg-in-flight (already in flight).\n', + ); + channel.releasePreflights(); + stderr.mockRestore(); + }); + + it('does not download attachments from unsafe media URLs', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-unsafe-image', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://169.254.169.254/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks non-HTTPS media URLs before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-http-image', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'http://169.254.169.254/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.lookup).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks media URLs with embedded credentials before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-userinfo-image', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://token:secret@example.com/file.png', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.lookup).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('URL contains embedded credentials'), + ); + stderr.mockRestore(); + }); + + it('blocks local and bare media hostnames before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + for (const [index, url] of [ + 'https://localhost/latest/meta-data/', + 'https://metadata.local/latest/meta-data/', + 'https://metadata.local./latest/meta-data/', + 'https://metadata/latest/meta-data/', + ].entries()) { + client.emit('message.image', { + msgid: `msg-local-host-${index}`, + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url, aeskey: 'k1' }, + }); + } + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(4)); + expect(channel.envelopes.every((entry) => !entry.attachments)).toBe(true); + expect(mocks.lookup).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks IPv4-mapped IPv6 media URLs before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-mapped-ip', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://[::ffff:169.254.169.254]/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks non-canonical IPv6 unspecified addresses before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-unspecified-ipv6', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://[0:0:0:0:0:0:0:0]/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL (private address ::)'), + ); + stderr.mockRestore(); + }); + + it('blocks uncompressed IPv4-mapped IPv6 media URLs before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-uncompressed-mapped-ip', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://[0:0:0:0:0:ffff:7f00:1]/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks SIIT IPv4-mapped IPv6 media URLs before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-siit-mapped-ip', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://[::ffff:0:a00:1]/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks SIIT IPv4-mapped metadata URLs before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-siit-metadata-ip', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://[::ffff:0:a9fe:a9fe]/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks IPv6 transition media URLs before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + for (const [index, url] of [ + 'https://[::]/internal-api', + 'https://[::a9fe:a9fe]/latest/meta-data/', + 'https://[2002:a9fe:a9fe::]/latest/meta-data/', + 'https://[2001::ffff:ffff:ffff:5601:5601]/latest/meta-data/', + ].entries()) { + client.emit('message.image', { + msgid: `msg-ipv6-transition-${index}`, + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url, aeskey: 'k1' }, + }); + } + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(4)); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks CGNAT media URLs before probing them', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-cgnat-ip', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://100.100.100.200/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('blocks media hostnames that resolve to private addresses', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mocks.lookup.mockResolvedValueOnce([ + { address: '169.254.169.254', family: 4 }, + ]); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-rebinding-host', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://metadata.example.com/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(mocks.lookup).toHaveBeenCalledWith('metadata.example.com', { + all: true, + }); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining( + 'unsafe media URL (metadata.example.com resolved to private address 169.254.169.254)', + ), + ); + stderr.mockRestore(); + }); + + it('includes DNS lookup errors when rejecting media hostnames', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mocks.lookup.mockRejectedValueOnce(new Error('queryA ETIMEDOUT')); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-dns-failure', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://metadata.example.com/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining( + 'unsafe media URL (DNS lookup failed for metadata.example.com: queryA ETIMEDOUT)', + ), + ); + stderr.mockRestore(); + }); + + it('blocks media hostnames with mixed public and private DNS results', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mocks.lookup.mockResolvedValueOnce([ + { address: '93.184.216.34', family: 4 }, + { address: '169.254.169.254', family: 4 }, + ]); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-mixed-dns-host', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://metadata.example.com/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(mocks.lookup).toHaveBeenCalledWith('metadata.example.com', { + all: true, + }); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('returns all resolved addresses when Node requests lookup all mode', async () => { + type LookupCallback = ( + err: Error | null, + address: string | Array<{ address: string; family: number }>, + family?: number, + ) => void; + type RequestOptionsWithLookup = { + lookup?: ( + hostname: string, + options: { all?: boolean }, + callback: LookupCallback, + ) => void; + }; + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-lookup-all', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://example.invalid/image', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(mocks.httpCalls).toHaveLength(1)); + const options = mocks.httpCalls[0]?.options as RequestOptionsWithLookup; + const callback = vi.fn(); + + options.lookup?.('example.invalid', { all: true }, callback); + + await vi.waitFor(() => + expect(callback).toHaveBeenCalledWith(null, [ + { address: '93.184.216.34', family: 4 }, + ]), + ); + }); + + it('does not follow redirects while probing inbound media', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mocks.httpResponse.statusCode = 302; + mocks.httpResponse.headers = { + location: 'http://169.254.169.254/latest/meta-data/', + }; + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-redirect-image', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://example.invalid/redirect', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(mocks.httpsRequest).toHaveBeenCalledWith( + 'https://example.invalid/redirect', + expect.objectContaining({ + method: 'GET', + lookup: expect.any(Function), + }), + expect.any(Function), + ); + expect(mocks.httpCalls[0]?.request.setTimeout).toHaveBeenCalledWith( + 10_000, + expect.any(Function), + ); + expect(mocks.httpCalls[0]?.request.destroy).toHaveBeenCalled(); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('redirected media URL'), + ); + stderr.mockRestore(); + }); + + it('skips inbound attachments that exceed the media size cap', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + mocks.httpResponse.headers = { + 'content-length': String(20 * 1024 * 1024 + 1), + }; + + client.emit('message.image', { + msgid: 'msg-large-image', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/large-image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).toHaveBeenCalled(); + expect(mocks.httpCalls[0]?.request.destroy).toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('oversized attachment'), + ); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('https://example.invalid/large-image'), + ); + stderr.mockRestore(); + }); + + it('aborts inbound attachments that exceed the streaming size cap', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + mocks.httpResponse.body = Buffer.alloc(20 * 1024 * 1024 + 1); + + client.emit('message.image', { + msgid: 'msg-stream-large-image', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/stream-large', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpCalls[0]?.request.destroy).toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('oversized attachment'), + ); + stderr.mockRestore(); + }); + + it('aborts inbound attachments on absolute download timeout', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + vi.useFakeTimers(); + const client = lastClient(); + mocks.state.mediaResponseNeverEnds = true; + + client.emit('message.image', { + msgid: 'msg-slow-image', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/slow-image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(mocks.httpCalls).toHaveLength(1)); + await vi.advanceTimersByTimeAsync(60_000); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpCalls[0]?.request.destroy).toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('media download absolute timeout'), + ); + stderr.mockRestore(); + }); + + it('skips inbound attachments when the media request returns non-success', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mocks.httpResponse.statusCode = 500; + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-download-fails', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/fails', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpCalls[0]?.request.destroy).toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('media download failed: HTTP 500'), + ); + stderr.mockRestore(); + }); + + it('skips inbound attachments when the media response is empty', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mocks.httpResponse.body = Buffer.alloc(0); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-empty-media', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/empty', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('empty media response'), + ); + stderr.mockRestore(); + }); + + it('rejects private addresses during request-time lookup validation', async () => { + type LookupCallback = ( + err: Error | null, + address: string | Array<{ address: string; family: number }>, + family?: number, + ) => void; + type RequestOptionsWithLookup = { + lookup?: ( + hostname: string, + options: { all?: boolean }, + callback: LookupCallback, + ) => void; + }; + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-lookup-private', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://example.invalid/image', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(mocks.httpCalls).toHaveLength(1)); + mocks.lookup.mockResolvedValueOnce([ + { address: '169.254.169.254', family: 4 }, + ]); + const options = mocks.httpCalls[0]?.options as RequestOptionsWithLookup; + const callback = vi.fn(); + + options.lookup?.('example.invalid', { all: true }, callback); + + await vi.waitFor(() => expect(callback).toHaveBeenCalled()); + expect(callback.mock.calls[0]?.[0]).toEqual( + new Error( + 'unsafe resolved media address: example.invalid resolved to private address 169.254.169.254', + ), + ); + expect(callback).toHaveBeenCalledWith(expect.any(Error), '', 0); + }); + + it('rejects uncompressed mapped private addresses during request-time lookup validation', async () => { + type LookupCallback = ( + err: Error | null, + address: string | Array<{ address: string; family: number }>, + family?: number, + ) => void; + type RequestOptionsWithLookup = { + lookup?: ( + hostname: string, + options: { all?: boolean }, + callback: LookupCallback, + ) => void; + }; + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-lookup-uncompressed-mapped', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://example.invalid/image', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(mocks.httpCalls).toHaveLength(1)); + mocks.lookup.mockResolvedValueOnce([ + { address: '0:0:0:0:0:ffff:7f00:1', family: 6 }, + ]); + const options = mocks.httpCalls[0]?.options as RequestOptionsWithLookup; + const callback = vi.fn(); + + options.lookup?.('example.invalid', { all: true }, callback); + + await vi.waitFor(() => + expect(callback).toHaveBeenCalledWith(expect.any(Error), '', 0), + ); + }); + + it('rejects low-zero IPv6 private IPv4 embeddings during request-time lookup validation', async () => { + type LookupCallback = ( + err: Error | null, + address: string | Array<{ address: string; family: number }>, + family?: number, + ) => void; + type RequestOptionsWithLookup = { + lookup?: ( + hostname: string, + options: { all?: boolean }, + callback: LookupCallback, + ) => void; + }; + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-lookup-low-zero-private', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://example.invalid/image', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(mocks.httpCalls).toHaveLength(1)); + mocks.lookup.mockResolvedValueOnce([{ address: '::1:a9fe:1', family: 6 }]); + const options = mocks.httpCalls[0]?.options as RequestOptionsWithLookup; + const callback = vi.fn(); + + options.lookup?.('example.invalid', { all: true }, callback); + + await vi.waitFor(() => + expect(callback).toHaveBeenCalledWith(expect.any(Error), '', 0), + ); + }); + + it('limits quote recursion when collecting inbound attachments', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-deep-quote', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + quote: { + msgtype: 'text', + quote: { + msgtype: 'text', + quote: { + msgtype: 'text', + quote: { + msgtype: 'text', + quote: { + msgtype: 'image', + image: { url: 'https://example.invalid/deep-image' }, + }, + }, + }, + }, + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + }); + + it('deduplicates media URLs across quoted messages', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-quote-duplicate', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: 'hello' }, + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, + quote: { + msgtype: 'image', + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(mocks.httpsRequest).toHaveBeenCalledTimes(1); + }); + + it('stores inbound file attachments in private temp files asynchronously', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-private-file', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + const filePath = channel.envelopes[0]?.attachments?.[0]?.filePath; + expect(filePath).toBeDefined(); + expect(mocks.writeFile).toHaveBeenCalledWith( + filePath, + Buffer.from('downloaded'), + { mode: 0o600 }, + ); + }); + + it('skips inbound file attachments when temp file writing fails', async () => { + mocks.writeFile.mockRejectedValueOnce(new Error('ENAMETOOLONG')); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + try { + client.emit('message.file', { + msgid: 'msg-write-fails', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('skipping file attachment: ENAMETOOLONG'), + ); + } finally { + stderr.mockRestore(); + } + }); + + it('removes file attachment dirs created during disconnect', async () => { + let finishWrite: (() => void) | undefined; + mocks.writeFile.mockImplementationOnce( + () => + new Promise((resolve) => { + finishWrite = resolve; + }), + ); + const bridge = makeBridge(); + const channel = new WeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-disconnect-during-download', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await vi.waitFor(() => expect(mocks.writeFile).toHaveBeenCalledTimes(1)); + const filePath = mocks.writeFile.mock.calls[0]?.[0] as string; + const dir = dirname(filePath); + expect(existsSync(dir)).toBe(true); + + channel.disconnect(); + finishWrite?.(); + + await vi.waitFor(() => expect(existsSync(dir)).toBe(false)); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('rejects media URLs that resolve to IPv6 link-local addresses', async () => { + mocks.lookup.mockResolvedValue([{ address: 'fe90::1', family: 6 }]); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-link-local', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + }); + + it('rejects media URLs that resolve to IPv6 site-local addresses', async () => { + mocks.lookup.mockResolvedValue([{ address: 'fec0::1', family: 6 }]); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-site-local', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + }); + + it('rejects media URLs that resolve to IPv6 multicast addresses', async () => { + mocks.lookup.mockResolvedValue([{ address: 'ff02::1', family: 6 }]); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-multicast', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + }); + + it('decodes Teredo media URLs before checking embedded IPv4 safety', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-teredo-xor', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://[2001::ffff:ffff:ffff:561:561]/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('decodes NAT64 media URLs before checking embedded IPv4 safety', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-nat64', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://[64:ff9b::10.0.0.1]/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('decodes NAT64 /48 media URLs before checking embedded IPv4 safety', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-nat64-48', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { + url: 'https://[64:ff9b:1::127.0.0.1]/latest/meta-data/', + aeskey: 'k1', + }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('allows public IPv4 addresses adjacent to documentation ranges', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-public-ipv4-adjacent', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://192.0.32.1/image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(mocks.httpsRequest).toHaveBeenCalledWith( + 'https://192.0.32.1/image', + expect.objectContaining({ method: 'GET' }), + expect.any(Function), + ); + expect(channel.envelopes[0]?.attachments).toHaveLength(1); + }); + + it('still rejects IPv4 documentation ranges', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-doc-ipv4', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://192.0.2.1/image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + }); + + it('rejects IPv6 documentation ranges', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-doc-ipv6', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://[2001:db8::1]/image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + }); + + it('rejects deprecated 6to4 relay anycast IPv4 addresses', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.image', { + msgid: 'msg-6to4-relay', + msgtype: 'image', + chattype: 'single', + from: { userid: 'alice' }, + image: { url: 'https://192.88.99.1/image', aeskey: 'k1' }, + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); + expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); + }); + + it('keeps downloaded file attachments for base prompt consumers', async () => { + vi.useFakeTimers(); + const bridge = makeBridge(); + (bridge.prompt as ReturnType).mockImplementationOnce( + () => new Promise(() => {}), + ); + const channel = new WeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-cleanup-dir', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const prompt = (bridge.prompt as ReturnType).mock + .calls[0][1] as string; + const filePath = prompt.match(/saved to: (.*report\.txt)/)?.[1]; + expect(filePath).toBeDefined(); + expect(existsSync(dirname(filePath!))).toBe(true); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(existsSync(filePath!)).toBe(true); + expect(existsSync(dirname(filePath!))).toBe(true); + }); + + it('removes downloaded file attachments after the prompt finishes', async () => { + const bridge = makeBridge(); + const channel = new WeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-cleanup-after-prompt', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const prompt = (bridge.prompt as ReturnType).mock + .calls[0][1] as string; + const filePath = prompt.match(/saved to: (.*report\.txt)/)?.[1]; + expect(filePath).toBeDefined(); + await vi.waitFor(() => expect(existsSync(dirname(filePath!))).toBe(false)); + }); + + it('continues attachment cleanup when one dir removal fails', async () => { + const parent = join(tmpdir(), 'channel-files'); + mkdirSync(parent, { recursive: true }); + const blockedParent = mkdtempSync(join(parent, 'wecom-blocked-')); + const firstDir = join(blockedParent, 'first'); + mkdirSync(firstDir); + chmodSync(blockedParent, 0o500); + const secondDir = mkdtempSync(join(parent, 'wecom-test-')); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + const harness = channel as unknown as { + rememberAttachmentDir( + dir: string, + messageId?: string, + routeKey?: string, + ): void; + cleanupAllAttachmentDirs(): void; + }; + harness.rememberAttachmentDir(firstDir, 'msg-first'); + harness.rememberAttachmentDir(secondDir, 'msg-second'); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + try { + harness.cleanupAllAttachmentDirs(); + + expect(existsSync(firstDir)).toBe(true); + expect(existsSync(secondDir)).toBe(false); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('failed to remove attachment dir'), + ); + } finally { + stderr.mockRestore(); + chmodSync(blockedParent, 0o700); + rmSync(blockedParent, { recursive: true, force: true }); + rmSync(secondDir, { recursive: true, force: true }); + } + }); + + it('removes session attachment dirs when prompt end has no message id', async () => { + const bridge = makeBridge(); + (bridge.prompt as ReturnType).mockImplementationOnce( + () => new Promise(() => {}), + ); + const channel = new PromptEndWeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-session-cleanup', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const prompt = (bridge.prompt as ReturnType).mock + .calls[0][1] as string; + const attachmentPath = prompt.match(/saved to: (.*report\.txt)/)?.[1]; + expect(attachmentPath).toBeDefined(); + expect(existsSync(dirname(attachmentPath!))).toBe(true); + + channel.finishPrompt('alice', 'session-1'); + + await vi.waitFor(() => + expect(existsSync(dirname(attachmentPath!))).toBe(false), + ); + }); + + it('removes no-message-id attachment dirs when prompt end has no message id', async () => { + const bridge = makeBridge(); + (bridge.prompt as ReturnType).mockImplementationOnce( + () => new Promise(() => {}), + ); + const channel = new PromptEndWeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + }); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const prompt = (bridge.prompt as ReturnType).mock + .calls[0][1] as string; + const attachmentPath = prompt.match(/saved to: (.*report\.txt)/)?.[1]; + expect(attachmentPath).toBeDefined(); + expect(existsSync(dirname(attachmentPath!))).toBe(true); + + channel.finishPrompt('alice', 'session-1'); + + await vi.waitFor(() => + expect(existsSync(dirname(attachmentPath!))).toBe(false), + ); + }); + + it('keeps no-message-id attachment dirs scoped to their session while another session completes', async () => { + const bridge = makeBridge(); + const channel = new BlockingAliceProcessWeComChannel( + 'bot', + makeConfig(), + bridge, + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'alice.txt', + }, + }); + + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(1)); + const aliceDir = channelFileDirs().find((dir) => + existsSync(join(dir, 'alice.txt')), + ); + expect(aliceDir).toBeDefined(); + expect(bridge.prompt).not.toHaveBeenCalled(); + + client.emit('message.file', { + msgtype: 'file', + chattype: 'single', + from: { userid: 'bob' }, + file: { + url: 'https://example.invalid/file', + filename: 'bob.txt', + }, + }); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(1)); + expect(existsSync(aliceDir!)).toBe(true); + + channel.releaseAlice(); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(0)); + }); + + it('sanitizes message ids before writing drop logs', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + client.emit('message.text', { + msgid: 'msg-1\nfake log', + msgtype: 'text', + chattype: 'single', + text: { content: 'hello' }, + }); + + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] dropping message msg-1\\nfake log: missing senderId.\n', + ), + ); + stderr.mockRestore(); + }); + + it('cleans untracked session dirs without scanning every message dir', () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + const untrackedDir = mkdtempSync(join(tmpdir(), 'wecom-untracked-')); + const trackedDir = mkdtempSync(join(tmpdir(), 'wecom-tracked-')); + const inspectable = channel as unknown as { + attachmentDirsBySession: Map; + attachmentDirsByMessage: Map; + attachmentMessageByDir: Map; + cleanupUntrackedAttachmentDirsForSession(sessionId: string): void; + }; + const messageDirs = new Map([['msg-1', [trackedDir]]]); + messageDirs.values = vi.fn(() => { + throw new Error('full message-dir scan'); + }); + inspectable.attachmentDirsBySession.set('session-1', [ + trackedDir, + untrackedDir, + ]); + inspectable.attachmentDirsByMessage = messageDirs; + inspectable.attachmentMessageByDir = new Map([[trackedDir, 'msg-1']]); + + inspectable.cleanupUntrackedAttachmentDirsForSession('session-1'); + + expect(existsSync(untrackedDir)).toBe(false); + expect(existsSync(trackedDir)).toBe(true); + }); + + it('removes downloaded file attachments when no prompt starts', async () => { + const bridge = makeBridge(); + const channel = new WeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-command-cleanup', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'report.txt', + }, + text: { + content: '/status', + }, + }); + + await vi.waitFor(() => + expect(client.sendMessage).toHaveBeenCalledWith( + 'alice', + expect.objectContaining({ msgtype: 'markdown' }), + ), + ); + expect(bridge.prompt).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(0)); + }); + + it('removes every collected file attachment after the coalesced prompt finishes', async () => { + const bridge = makeBridge(); + let finishPrompt: (() => void) | undefined; + (bridge.prompt as ReturnType).mockImplementationOnce( + () => + new Promise((resolve) => { + finishPrompt = () => resolve(''); + }), + ); + const channel = new WeComChannel( + 'bot', + makeConfig({ dispatchMode: 'collect' }), + bridge, + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-active', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'active.txt', + }, + }); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + + client.emit('message.file', { + msgid: 'msg-buffered-1', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'first.txt', + }, + }); + client.emit('message.file', { + msgid: 'msg-buffered-2', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'second.txt', + }, + }); + + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(3)); + + finishPrompt?.(); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(0)); + }); + + it('removes no-message-id attachments from a coalesced collect prompt', async () => { + const bridge = makeBridge(); + let finishFirst: (() => void) | undefined; + let finishSecond: (() => void) | undefined; + (bridge.prompt as ReturnType) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => resolve(''); + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecond = () => resolve(''); + }), + ); + const channel = new WeComChannel( + 'bot', + makeConfig({ dispatchMode: 'collect' }), + bridge, + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-active', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'active.txt', + }, + }); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + + client.emit('message.file', { + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'untracked.txt', + }, + }); + client.emit('message.file', { + msgid: 'msg-buffered', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'buffered.txt', + }, + }); + + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(3)); + + finishFirst?.(); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(2)); + + finishSecond?.(); + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(0)); + }); + + it('keeps buffered attachment files until their coalesced prompt runs', async () => { + const bridge = makeBridge(); + let finishFirst: (() => void) | undefined; + let finishSecond: (() => void) | undefined; + (bridge.prompt as ReturnType) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => resolve(''); + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecond = () => resolve(''); + }), + ); + const channel = new WeComChannel( + 'bot', + makeConfig({ dispatchMode: 'collect' }), + bridge, + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-active', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'active.txt', + }, + }); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + + client.emit('message.file', { + msgid: 'msg-buffered', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'buffered.txt', + }, + }); + + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(2)); + const bufferedDir = channelFileDirs().find((dir) => + existsSync(join(dir, 'buffered.txt')), + ); + expect(bufferedDir).toBeDefined(); + + finishFirst?.(); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(2)); + expect(existsSync(join(bufferedDir!, 'buffered.txt'))).toBe(true); + + finishSecond?.(); + + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(0)); + }); + + it('removes buffered attachment files when a collect buffer is cleared', async () => { + const bridge = makeBridge(); + (bridge.prompt as ReturnType).mockImplementationOnce( + () => new Promise(() => {}), + ); + const channel = new WeComChannel( + 'bot', + makeConfig({ dispatchMode: 'collect' }), + bridge, + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-active', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'active.txt', + }, + }); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + + client.emit('message.file', { + msgid: 'msg-buffered', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'buffered.txt', + }, + }); + + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(2)); + const bufferedDir = channelFileDirs().find((dir) => + existsSync(join(dir, 'buffered.txt')), + ); + expect(bufferedDir).toBeDefined(); + + client.emit('message.text', { + msgid: 'msg-clear', + msgtype: 'text', + chattype: 'single', + from: { userid: 'alice' }, + text: { content: '/clear' }, + }); + + await vi.waitFor(() => expect(existsSync(bufferedDir!)).toBe(false)); + }); + + it('keeps files buffered during a coalesced prompt for the next prompt', async () => { + const bridge = makeBridge(); + let finishFirst: (() => void) | undefined; + let finishSecond: (() => void) | undefined; + let finishThird: (() => void) | undefined; + (bridge.prompt as ReturnType) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => resolve(''); + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecond = () => resolve(''); + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishThird = () => resolve(''); + }), + ); + const channel = new WeComChannel( + 'bot', + makeConfig({ dispatchMode: 'collect' }), + bridge, + ); + await channel.connect(); + const client = lastClient(); + + client.emit('message.file', { + msgid: 'msg-active', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'active.txt', + }, + }); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + + client.emit('message.file', { + msgid: 'msg-buffered-1', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'first.txt', + }, + }); + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(2)); + + finishFirst?.(); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(2)); + + client.emit('message.file', { + msgid: 'msg-buffered-2', + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'second.txt', + }, + }); + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(2)); + + finishSecond?.(); + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(3)); + const prompt = (bridge.prompt as ReturnType).mock + .calls[2][1] as string; + const filePath = prompt.match(/saved to: (.*second\.txt)/)?.[1]; + expect(filePath).toBeDefined(); + expect(existsSync(filePath!)).toBe(true); + + finishThird?.(); + await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(0)); + }); + + it('tracks attachment dirs for messages without msgid by synthetic message id', async () => { + const bridge = makeBridge(); + (bridge.prompt as ReturnType).mockImplementation( + () => new Promise(() => {}), + ); + const channel = new WeComChannel('bot', makeConfig(), bridge); + await channel.connect(); + + lastClient().emit('message.file', { + msgtype: 'file', + chattype: 'single', + from: { userid: 'alice' }, + file: { + url: 'https://example.invalid/file', + filename: 'no-id.txt', + }, + }); + + await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1)); + const prompt = (bridge.prompt as ReturnType).mock + .calls[0][1] as string; + const filePath = prompt.match(/saved to: (.*no-id\.txt)/)?.[1]; + expect(filePath).toBeDefined(); + expect(existsSync(filePath!)).toBe(true); + expect( + Array.from( + ( + channel as unknown as { + attachmentDirsByMessage: Map; + } + ).attachmentDirsByMessage.keys(), + ).some((messageId) => messageId.startsWith('synthetic-')), + ).toBe(true); + expect( + ( + channel as unknown as { + attachmentDirsWithoutMessageByRoute: Map; + } + ).attachmentDirsWithoutMessageByRoute.size, + ).toBe(0); + + channel.disconnect(); + }); + + it('sends markdown text and local media through the SDK', async () => { + const parent = join(tmpdir(), 'channel-files'); + mkdirSync(parent, { recursive: true }); + const dir = mkdtempSync(join(parent, 'wecom-test-')); + const imagePath = join(dir, 'out.png'); + writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage( + 'chat-1', + `result\n[IMAGE: ${imagePath}]\n\n\`[IMAGE: /tmp/example.png]\``, + ); + + expect(client.sendMessage).toHaveBeenCalledWith('chat-1', { + msgtype: 'markdown', + markdown: { content: 'result\n\n`[IMAGE: /tmp/example.png]`' }, + }); + expect(client.uploadMedia).toHaveBeenCalledWith(expect.any(Buffer), { + type: 'image', + filename: 'out.png', + }); + expect(client.sendMediaMessage).toHaveBeenCalledWith( + 'chat-1', + 'image', + 'media-1', + ); + }); + + it('closes unclosed fenced code blocks while leaving media markers as text', async () => { + const parent = join(tmpdir(), 'channel-files'); + mkdirSync(parent, { recursive: true }); + const dir = mkdtempSync(join(parent, 'wecom-test-')); + const imagePath = join(dir, 'out.png'); + writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const text = `debug:\n\`\`\`text\n[IMAGE: ${imagePath}]`; + + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', text); + + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(client.sendMediaMessage).not.toHaveBeenCalled(); + expect(client.sendMessage).toHaveBeenCalledWith('chat-1', { + msgtype: 'markdown', + markdown: { content: `${text}\n\`\`\`` }, + }); + }); + + it('leaves media markers inside tilde fenced code blocks as text', async () => { + const parent = join(tmpdir(), 'channel-files'); + mkdirSync(parent, { recursive: true }); + const dir = mkdtempSync(join(parent, 'wecom-test-')); + const imagePath = join(dir, 'out.png'); + writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const text = `debug:\n~~~text\n[IMAGE: ${imagePath}]\n~~~`; + + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', text); + + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(client.sendMediaMessage).not.toHaveBeenCalled(); + expect(client.sendMessage).toHaveBeenCalledWith('chat-1', { + msgtype: 'markdown', + markdown: { content: text }, + }); + }); + + it('leaves media markers inside multi-backtick inline code spans as text', async () => { + const dir = mkdtempSync(join(tmpdir(), 'wecom-test-')); + const filePath = join(dir, 'secret.txt'); + writeFileSync(filePath, 'secret'); + const text = `debug \`\`[FILE: ${filePath}]\`\``; + + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', text); + + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(client.sendMediaMessage).not.toHaveBeenCalled(); + expect(client.sendMessage).toHaveBeenCalledWith('chat-1', { + msgtype: 'markdown', + markdown: { content: text }, + }); + }); + + it('splits long markdown responses before sending', async () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', 'a'.repeat(3900)); + + expect(client.sendMessage).toHaveBeenCalledTimes(2); + const first = client.sendMessage.mock.calls[0]?.[1] as unknown as { + markdown: { content: string }; + }; + const second = client.sendMessage.mock.calls[1]?.[1] as unknown as { + markdown: { content: string }; + }; + expect(Buffer.byteLength(first.markdown.content, 'utf8')).toBeLessThan( + 4096, + ); + expect(Buffer.byteLength(second.markdown.content, 'utf8')).toBeLessThan( + 4096, + ); + expect(first.markdown.content + second.markdown.content).toBe( + 'a'.repeat(3900), + ); + }); + + it('splits long markdown responses without array-copying the remaining line', async () => { + const arrayFrom = vi.spyOn(Array, 'from'); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + + await channel.sendMessage('chat-1', 'a'.repeat(3900)); + + expect( + arrayFrom.mock.calls.some( + ([value]) => typeof value === 'string' && value.length > 100, + ), + ).toBe(false); + arrayFrom.mockRestore(); + }); + + it('keeps fenced code blocks balanced across markdown chunks', async () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + const text = `intro\n\`\`\`ts\n${'a'.repeat(3900)}\n\`\`\`\noutro`; + + await channel.sendMessage('chat-1', text); + + const chunks = client.sendMessage.mock.calls.map((call) => { + const message = call[1] as { markdown: { content: string } }; + return message.markdown.content; + }); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(Buffer.byteLength(chunk, 'utf8')).toBeLessThan(4096); + expect((chunk.match(/```/g) ?? []).length % 2).toBe(0); + } + expect(chunks[0]).toMatch(/^intro\n```ts\n/); + expect(chunks[0]).toMatch(/\n```$/); + expect(chunks[1]).toMatch(/^```/); + expect(chunks.at(-1)).toContain('outro'); + }); + + it('keeps fenced code blocks balanced when a long line is split', async () => { + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + const text = `${'a'.repeat(3790)}\`\`\`${'b'.repeat(100)}\`\`\`outro`; + + await channel.sendMessage('chat-1', text); + + const chunks = client.sendMessage.mock.calls.map((call) => { + const message = call[1] as { markdown: { content: string } }; + return message.markdown.content; + }); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(Buffer.byteLength(chunk, 'utf8')).toBeLessThanOrEqual(3800); + expect((chunk.match(/```/g) ?? []).length % 2).toBe(0); + } + expect(chunks.join('')).toContain('outro'); + }); + + it('does not switch fence type when splitting a long fenced line', async () => { + const parent = join(tmpdir(), 'channel-files'); + mkdirSync(parent, { recursive: true }); + const dir = mkdtempSync(join(parent, 'wecom-test-')); + const imagePath = join(dir, 'out.png'); + writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + const text = `intro\n\`\`\`text\n${'a'.repeat(3790)}~~~[IMAGE: ${imagePath}]${'b'.repeat(100)}\n\`\`\``; + + await channel.sendMessage('chat-1', text); + + const chunks = client.sendMessage.mock.calls.map((call) => { + const message = call[1] as { markdown: { content: string } }; + return message.markdown.content; + }); + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(client.sendMediaMessage).not.toHaveBeenCalled(); + expect(chunks.length).toBeGreaterThan(1); + expect((chunks.join('\n').match(/~~~/g) ?? []).length).toBe(1); + for (const chunk of chunks) { + expect((chunk.match(/```/g) ?? []).length % 2).toBe(0); + } + }); + + it('resolves relative outbound image paths from channel cwd', async () => { + const parent = join(tmpdir(), 'channel-files'); + mkdirSync(parent, { recursive: true }); + const dir = mkdtempSync(join(parent, 'wecom-test-')); + writeFileSync(join(dir, 'out.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const channel = new WeComChannel( + 'bot', + makeConfig({ cwd: dir }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', '[IMAGE: out.png]'); + + expect(mocks.open).toHaveBeenCalledWith( + realpathSync(join(dir, 'out.png')), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + const handle = mocks.openHandles.at(-1); + expect(handle?.stat).toHaveBeenCalled(); + expect(handle?.readFile).toHaveBeenCalled(); + expect(handle?.close).toHaveBeenCalled(); + expect(mocks.readFile).not.toHaveBeenCalled(); + expect(client.uploadMedia).toHaveBeenCalledWith(expect.any(Buffer), { + type: 'image', + filename: 'out.png', + }); + expect(client.sendMediaMessage).toHaveBeenCalledWith( + 'chat-1', + 'image', + 'media-1', + ); + }); + + it('logs and sends later media when one upload returns no media id', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const parent = join(tmpdir(), 'channel-files'); + mkdirSync(parent, { recursive: true }); + const dir = mkdtempSync(join(parent, 'wecom-test-')); + writeFileSync( + join(dir, 'first.png'), + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); + writeFileSync( + join(dir, 'second.png'), + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); + const channel = new WeComChannel( + 'bot', + makeConfig({ cwd: dir }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + client.uploadMedia = vi + .fn() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ media_id: 'media-2' }); + + await channel.sendMessage( + 'chat-1', + '[IMAGE: first.png]\n[IMAGE: second.png]', + ); + + expect(client.uploadMedia).toHaveBeenCalledTimes(2); + expect(client.sendMediaMessage).toHaveBeenCalledTimes(1); + expect(client.sendMediaMessage).toHaveBeenCalledWith( + 'chat-1', + 'image', + 'media-2', + ); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('upload returned no media_id'), + ); + stderr.mockRestore(); + }); + + it('logs and sends later media when one media send fails', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const parent = join(tmpdir(), 'channel-files'); + mkdirSync(parent, { recursive: true }); + const dir = mkdtempSync(join(parent, 'wecom-test-')); + writeFileSync( + join(dir, 'first.png'), + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); + writeFileSync( + join(dir, 'second.png'), + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); + const channel = new WeComChannel( + 'bot', + makeConfig({ cwd: dir }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + client.uploadMedia = vi + .fn() + .mockResolvedValueOnce({ media_id: 'media-1' }) + .mockResolvedValueOnce({ media_id: 'media-2' }); + client.sendMediaMessage = vi + .fn() + .mockRejectedValueOnce({ + errcode: 45009, + errmsg: 'api freq out of limit', + }) + .mockResolvedValueOnce({ headers: { req_id: 'media-req-2' } }); + + await channel.sendMessage( + 'chat-1', + '[IMAGE: first.png]\n[IMAGE: second.png]', + ); + + expect(client.uploadMedia).toHaveBeenCalledTimes(2); + expect(client.sendMediaMessage).toHaveBeenCalledTimes(2); + expect(client.sendMediaMessage).toHaveBeenLastCalledWith( + 'chat-1', + 'image', + 'media-2', + ); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining( + 'media send failed for image: errcode=45009 errmsg=api freq out of limit', + ), + ); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] 1 media send(s) failed (markdown text may already be delivered): image: errcode=45009 errmsg=api freq out of limit\n', + ); + stderr.mockRestore(); + }); + + it('does not upload arbitrary files from non-image media markers', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const dir = mkdtempSync(join(tmpdir(), 'wecom-test-')); + const filePath = join(dir, 'secret.txt'); + writeFileSync(filePath, 'secret'); + const channel = new WeComChannel( + 'bot', + makeConfig({ cwd: dir }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', `[FILE: ${filePath}]`); + + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(client.sendMediaMessage).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('skipping unsupported outbound media marker'), + ); + stderr.mockRestore(); + }); + + it('skips model-emitted image paths outside the channel file directory', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const dir = mkdtempSync(join(tmpdir(), 'wecom-cwd-')); + const secretPath = join(dir, '.env'); + writeFileSync(secretPath, 'OPENAI_API_KEY=sk-secret'); + const channel = new WeComChannel( + 'bot', + makeConfig({ cwd: dir }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', `[IMAGE: ${secretPath}]`); + + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('outside allowed outbound directory'), + ); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining(secretPath), + ); + stderr.mockRestore(); + }); + + it('reports unsafe channel file directory setup failures', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const parent = join(tmpdir(), 'channel-files'); + writeFileSync(parent, 'not a directory'); + const dir = mkdtempSync(join(tmpdir(), 'wecom-cwd-')); + const imagePath = join(dir, 'out.png'); + writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const channel = new WeComChannel( + 'bot', + makeConfig({ cwd: dir }), + makeBridge(), + ); + await channel.connect(); + const client = lastClient(); + + await channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`); + + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('Cannot prepare outbound media directory'), + ); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('channel-files'), + ); + stderr.mockRestore(); + }); + + it('does not allow a hardcoded /tmp channel-files fallback', async () => { + if (tmpdir() === '/tmp') return; + + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const parent = '/tmp/channel-files'; + mkdirSync(parent, { recursive: true }); + const dir = mkdtempSync(join(parent, 'wecom-test-')); + const imagePath = join(dir, 'out.png'); + writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + + try { + await channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`); + + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('outside allowed outbound directory'), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + stderr.mockRestore(); + } + }); + + it('registers the wecom plugin with botId and secret fields', () => { + expect(plugin.channelType).toBe('wecom'); + expect(plugin.requiredConfigFields).toEqual(['botId', 'secret']); + }); +}); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts new file mode 100644 index 00000000000..34ca3f8c7c5 --- /dev/null +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -0,0 +1,2109 @@ +import { constants, lstatSync, mkdirSync, realpathSync, rmSync } from 'node:fs'; +import { open, writeFile } from 'node:fs/promises'; +import { request as httpsRequest } from 'node:https'; +import type { IncomingHttpHeaders } from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { basename, join, resolve, win32, posix } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Buffer } from 'node:buffer'; +import { isIP, type LookupFunction } from 'node:net'; +import { lookup } from 'node:dns/promises'; +import { WSClient, decryptFile } from '@wecom/aibot-node-sdk'; +import { ChannelBase, sanitizeLogText } from '@qwen-code/channel-base'; +import type { + Attachment, + ChannelAgentBridge, + ChannelBaseOptions, + ChannelConfig, + Envelope, +} from '@qwen-code/channel-base'; + +type WeComMediaType = 'image' | 'file' | 'voice' | 'video'; + +interface WeComConfig { + botId: string; + secret: string; + wsUrl?: string; +} + +interface WeComClientOptions { + botId: string; + secret: string; + wsUrl?: string; + logger?: WeComLogger; +} + +interface WeComClient { + connect(): unknown; + disconnect(): void; + on(event: string, handler: (payload: unknown) => void): void; + off?(event: string, handler: (payload: unknown) => void): void; + sendMessage(chatId: string, message: unknown): Promise; + uploadMedia( + data: Buffer, + options: { type: WeComMediaType; filename: string }, + ): Promise; + sendMediaMessage( + chatId: string, + mediaType: WeComMediaType, + mediaId: string, + ): Promise; +} + +interface WeComLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +const ClientCtor = WSClient as unknown as new ( + options: WeComClientOptions, +) => WeComClient; + +const MESSAGE_EVENTS = [ + 'message.text', + 'message.image', + 'message.mixed', + 'message.voice', + 'message.file', + 'message.video', +] as const; + +const SENSITIVE_ERROR_FIELDS = new Set([ + 'secret', + 'aeskey', + 'token', + 'password', + 'authorization', +]); +const DEDUP_TTL_MS = 5 * 60 * 1000; +const MAX_MEDIA_BYTES = 20 * 1024 * 1024; +const MARKDOWN_CHUNK_BYTES = 3800; +const AUTHENTICATION_TIMEOUT_MS = 30_000; +const KICK_RECONNECT_MAX_ATTEMPTS = 3; +const KICK_RECONNECT_MAX_RETRY_CYCLES = 3; +const KICK_RECONNECT_BASE_DELAY_MS = 1_000; +const KICK_RECONNECT_RESET_MS = 60_000; +const KICK_RECONNECT_RETRY_MS = 5 * 60 * 1000; +const KICK_RECONNECT_LONG_RETRY_MS = 15 * 60 * 1000; +const DISCONNECT_RECONNECT_FALLBACK_MS = 30_000; +const ACTIVITY_WATCHDOG_INTERVAL_MS = 60_000; +const ACTIVITY_STALE_MS = 5 * 60_000; + +export class WeComChannel extends ChannelBase { + private readonly wecom: WeComConfig; + private client?: WeComClient; + private readonly seenMessages = new Map(); + private readonly inFlightMessages = new Set(); + private readonly attachmentDirsByMessage = new Map(); + private readonly attachmentMessageByDir = new Map(); + private readonly attachmentDirsBySession = new Map(); + private readonly attachmentDirsWithoutMessageByRoute = new Map< + string, + string[] + >(); + private readonly bufferedAttachmentMessages = new Set(); + private readonly coalescedAttachmentMessages = new Map(); + private dedupTimer?: ReturnType; + private kickReconnectReset?: ReturnType; + private kickReconnectRetry?: ReturnType; + private disconnectReconnectFallback?: ReturnType; + private activityWatchdog?: ReturnType; + private lastActivityAt = 0; + private connecting?: Promise; + private connectingClient?: WeComClient; + private authentication?: ReturnType; + private disconnectGeneration = 0; + private clientHandlers?: { + message: (payload: unknown) => void; + error: (payload: unknown) => void; + disconnected: (payload: unknown) => void; + kicked: (payload: unknown) => void; + }; + private reconnectingAfterKick = false; + private pendingKickReconnect = false; + private kickReconnectAttempts = 0; + private kickReconnectRetryCycles = 0; + + constructor( + name: string, + config: ChannelConfig & Record, + bridge: ChannelAgentBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + this.wecom = parseWeComConfig(name, config); + } + + async connect(): Promise { + if (this.client) return; + if (this.connecting) return this.connecting; + + const connecting = this.openClient(); + this.connecting = connecting; + try { + await connecting; + } finally { + if (this.connecting === connecting) this.connecting = undefined; + } + } + + private async openClient(): Promise { + const options: WeComClientOptions = { + botId: this.wecom.botId, + secret: this.wecom.secret, + logger: createWeComLogger(this.name), + }; + if (this.wecom.wsUrl) { + options.wsUrl = this.wecom.wsUrl; + } + + const client = new ClientCtor(options); + let authenticated = false; + const connectionGeneration = this.disconnectGeneration; + const messageHandler = (payload: unknown) => { + this.recordActivity(); + if (!authenticated) { + process.stderr.write( + `[WeCom:${this.name}] dropping message before authentication.\n`, + ); + return; + } + this.clearDisconnectReconnectFallback(); + this.onMessage(payload, connectionGeneration).catch((err: unknown) => { + const logMessageId = getLogMessageId(payload); + process.stderr.write( + `[WeCom:${this.name}] message handling failed for ${logMessageId}: ${sanitizeLogText( + formatSdkError(err), + 200, + )}\n`, + ); + }); + }; + const errorHandler = (err: unknown) => { + this.recordActivity(); + process.stderr.write( + `[WeCom:${this.name}] SDK error: ${sanitizeLogText(formatSdkError(err), 200)}\n`, + ); + }; + const disconnectedHandler = (reason: unknown) => { + this.recordActivity(); + if (this.disconnectGeneration !== connectionGeneration) return; + process.stderr.write( + `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; waiting for SDK reconnect.\n`, + ); + if (authenticated) { + this.scheduleDisconnectReconnectFallback( + reason, + client, + this.disconnectGeneration, + ); + } + }; + const kickedHandler = (reason: unknown) => { + this.recordActivity(); + if (this.disconnectGeneration !== connectionGeneration) return; + this.clearDisconnectReconnectFallback(); + this.startKickReconnect(reason); + }; + const handlers = { + message: messageHandler, + error: errorHandler, + disconnected: disconnectedHandler, + kicked: kickedHandler, + }; + for (const event of MESSAGE_EVENTS) { + client.on(event, messageHandler); + } + client.on('error', errorHandler); + client.on('disconnected', disconnectedHandler); + client.on('event.disconnected_event', kickedHandler); + this.clientHandlers = handlers; + this.connectingClient = client; + + const authentication = waitForAuthentication(client); + this.authentication = authentication; + try { + authentication.promise.catch(() => {}); + const connected = client.connect(); + const connectedPromise = isPromiseLike(connected) + ? withTimeout( + Promise.resolve(connected).then(() => {}), + AUTHENTICATION_TIMEOUT_MS, + 'WeCom SDK connect timed out.', + ) + : Promise.resolve(); + await Promise.all([connectedPromise, authentication.promise]); + authenticated = true; + if (this.connectingClient !== client) { + throw new Error('WeCom connection was replaced before authentication.'); + } + this.client = client; + this.connectingClient = undefined; + this.authentication = undefined; + this.recordActivity(); + this.startActivityWatchdog(connectionGeneration); + } catch (err) { + authentication.cancel(); + this.detachClientHandlers(client, handlers); + if (this.connectingClient === client) this.connectingClient = undefined; + if (this.authentication === authentication) + this.authentication = undefined; + try { + client.disconnect(); + } catch { + // cleanup must not mask the original connection error + } + throw err; + } + if (!this.dedupTimer) { + this.dedupTimer = setInterval(() => this.cleanupSeenMessages(), 60_000); + this.dedupTimer.unref?.(); + } + process.stderr.write(`[WeCom:${this.name}] Connected via smart bot.\n`); + } + + disconnect(): void { + this.disconnectGeneration += 1; + this.kickReconnectAttempts = 0; + this.kickReconnectRetryCycles = 0; + this.pendingKickReconnect = false; + if (this.kickReconnectReset) { + clearTimeout(this.kickReconnectReset); + this.kickReconnectReset = undefined; + } + if (this.kickReconnectRetry) { + clearTimeout(this.kickReconnectRetry); + this.kickReconnectRetry = undefined; + } + this.clearDisconnectReconnectFallback(); + this.clearActivityWatchdog(); + if (this.dedupTimer) { + clearInterval(this.dedupTimer); + this.dedupTimer = undefined; + } + this.seenMessages.clear(); + this.inFlightMessages.clear(); + this.cleanupAllAttachmentDirs(); + this.disconnectClientOnly(new Error('WeCom channel disconnected.')); + process.stderr.write(`[WeCom:${this.name}] Disconnected.\n`); + } + + override supportsProactiveSend(): boolean { + return true; + } + + async sendMessage(chatId: string, text: string): Promise { + const client = this.client; + if (!client) { + throw new Error( + `[WeCom:${this.name}] No active SDK client, cannot send.`, + ); + } + + const { cleanedText, media } = parseOutboundMediaMarkers(text); + const chunks = splitMarkdownChunks(cleanedText); + if (chunks.length === 0 && media.length === 0) { + process.stderr.write( + `[WeCom:${this.name}] sendMessage produced empty payload for chatId=${sanitizeLogText( + chatId, + 100, + )}.\n`, + ); + return; + } + + for (const chunk of chunks) { + await client.sendMessage(chatId, { + msgtype: 'markdown', + markdown: { content: chunk }, + }); + } + + const mediaErrors: string[] = []; + for (const item of media) { + if (item.type !== 'image') { + process.stderr.write( + `[WeCom:${this.name}] skipping unsupported outbound media marker: ${item.type}\n`, + ); + continue; + } + try { + const file = await readOutboundMedia(item.path, this.config.cwd); + const upload = await client.uploadMedia(file.data, { + type: item.type, + filename: file.fileName, + }); + const mediaId = extractMediaId(upload); + if (!mediaId) { + mediaErrors.push(`upload returned no media_id for ${item.type}`); + process.stderr.write( + `[WeCom:${this.name}] upload returned no media_id, skipping.\n`, + ); + continue; + } + await client.sendMediaMessage(chatId, item.type, mediaId); + } catch (err) { + const message = sanitizeLogText(formatSdkError(err), 200); + mediaErrors.push(`${item.type}: ${message}`); + process.stderr.write( + `[WeCom:${this.name}] media send failed for ${item.type}: ${message}\n`, + ); + } + } + if (mediaErrors.length > 0) { + const message = `[WeCom:${this.name}] ${mediaErrors.length} media send(s) failed (markdown text may already be delivered): ${mediaErrors.join('; ')}`; + process.stderr.write(`${message}\n`); + } + } + + private async onMessage( + payload: unknown, + connectionGeneration: number, + ): Promise { + const body = extractBody(payload); + if (!body) { + process.stderr.write( + `[WeCom:${this.name}] dropping message with unrecognized payload structure.\n`, + ); + return; + } + + const rawMessageId = getString(body, 'msgid') || undefined; + const messageId = rawMessageId ?? `synthetic-${randomUUID()}`; + const logMessageId = sanitizeLogText(rawMessageId || '(no id)', 100); + if (rawMessageId && this.seenMessages.has(rawMessageId)) { + process.stderr.write( + `[WeCom:${this.name}] dropping duplicate message ${logMessageId} (already seen).\n`, + ); + return; + } + + const from = getRecord(body, 'from'); + const senderId = getString(from, 'userid') || ''; + const senderName = getString(from, 'name') || senderId || 'Unknown'; + const isGroup = getString(body, 'chattype') === 'group'; + const rawChatId = getString(body, 'chatid'); + const chatId = isGroup ? rawChatId : rawChatId || senderId; + if (!chatId || !senderId) { + process.stderr.write( + `[WeCom:${this.name}] dropping message ${logMessageId}: missing ${ + !senderId ? 'senderId' : 'chatId' + }.\n`, + ); + return; + } + if (rawMessageId) { + if (this.inFlightMessages.has(rawMessageId)) { + process.stderr.write( + `[WeCom:${this.name}] dropping duplicate message ${logMessageId} (already in flight).\n`, + ); + return; + } + this.inFlightMessages.add(rawMessageId); + } + + const text = extractText(body); + const explicitMention = getExplicitMention(body, this.wecom.botId); + const quote = getRecord(body, 'quote'); + const envelope: Envelope = { + channelName: this.name, + senderId, + senderName, + chatId, + text, + messageId: rawMessageId ?? messageId, + isGroup, + isMentioned: !isGroup || (explicitMention ?? false), + isReplyToBot: + getString(getRecord(quote, 'from'), 'userid') === this.wecom.botId, + referencedText: extractQuoteText(quote), + }; + let attachments: Attachment[] = []; + const attachmentRouteKey = this.attachmentRouteKey( + senderId, + chatId, + envelope.threadId, + ); + let processStarted = false; + try { + if (!(await this.preflightInbound(envelope))) { + process.stderr.write( + `[WeCom:${this.name}] dropping message ${logMessageId}: preflight rejected.\n`, + ); + return; + } + attachments = await this.downloadAttachments( + body, + attachments, + messageId, + attachmentRouteKey, + connectionGeneration, + ); + if (this.disconnectGeneration !== connectionGeneration) { + process.stderr.write( + `[WeCom:${this.name}] dropping message ${logMessageId}: connection changed during attachment download.\n`, + ); + return; + } + if (rawMessageId) this.seenMessages.set(rawMessageId, Date.now()); + if (attachments.length) { + envelope.attachments = attachments; + } + if (!envelope.text && attachments.length) { + envelope.text = attachments.some((a) => a.type === 'image') + ? '(image)' + : `(file: ${attachments[0]?.fileName ?? 'file'})`; + } + processStarted = true; + await this.processInbound(envelope); + } catch (err) { + if (rawMessageId && !processStarted) { + this.seenMessages.delete(rawMessageId); + } else if (rawMessageId) { + process.stderr.write( + `[WeCom:${this.name}] message ${logMessageId} failed after processing started; dedup entry retained.\n`, + ); + } + throw err; + } finally { + if (rawMessageId) this.inFlightMessages.delete(rawMessageId); + if ( + messageId && + !this.bufferedAttachmentMessages.has(messageId) && + this.attachmentDirsByMessage.has(messageId) + ) { + this.cleanupAttachmentDirsForMessage(messageId); + } + } + } + + private async downloadAttachments( + body: Record, + attachments: Attachment[] = [], + messageId?: string, + routeKey?: string, + connectionGeneration = this.disconnectGeneration, + ): Promise { + const refs = collectInboundMediaRefs(body); + for (const ref of refs) { + if (this.disconnectGeneration !== connectionGeneration) + return attachments; + let downloaded: { buffer: Buffer; filename?: string }; + try { + downloaded = await downloadInboundMedia(ref); + } catch (err) { + process.stderr.write( + `[WeCom:${this.name}] skipping ${ref.type} attachment: ${sanitizeLogText( + err instanceof Error ? err.message : String(err), + 160, + )}.\n`, + ); + continue; + } + if (this.disconnectGeneration !== connectionGeneration) + return attachments; + const data = downloaded.buffer; + const fileName = sanitizeFileName(ref.fileName || downloaded.filename); + if (ref.type === 'image') { + attachments.push({ + type: 'image', + data: data.toString('base64'), + mimeType: detectImageMime(data), + fileName, + }); + } else { + const dir = join(tmpdir(), 'channel-files', randomUUID()); + const safeName = fileName || `wecom_${ref.type}`; + const filePath = join(dir, safeName); + try { + if (this.disconnectGeneration !== connectionGeneration) { + return attachments; + } + mkdirSync(dir, { recursive: true, mode: 0o700 }); + await writeFile(filePath, data, { mode: 0o600 }); + if (this.disconnectGeneration !== connectionGeneration) { + cleanupAttachmentDirs([dir]); + return attachments; + } + this.rememberAttachmentDir(dir, messageId, routeKey); + } catch (err) { + cleanupAttachmentDirs([dir]); + process.stderr.write( + `[WeCom:${this.name}] skipping ${ref.type} attachment: ${sanitizeLogText( + err instanceof Error ? err.message : String(err), + 160, + )}.\n`, + ); + continue; + } + attachments.push({ + type: ref.type === 'voice' ? 'audio' : ref.type, + filePath, + mimeType: mediaTypeToMime(ref.type), + fileName: safeName, + }); + } + } + return attachments; + } + + protected override onPromptBuffered( + _chatId: string, + sessionId: string, + messageId?: string, + ): void { + if (!messageId) { + this.rememberUntrackedDirsForSession(sessionId); + return; + } + this.bufferedAttachmentMessages.add(messageId); + this.rememberMessageDirsForSession(messageId, sessionId); + } + + protected override onPromptStart( + _chatId: string, + sessionId: string, + messageId?: string, + ): void { + if (messageId) { + this.rememberMessageDirsForSession(messageId, sessionId); + } else { + this.rememberUntrackedDirsForSession(sessionId); + } + } + + protected override onPromptBufferDrained( + _chatId: string, + _sessionId: string, + messageIds: string[], + ): void { + const lastMessageId = messageIds.at(-1); + if (lastMessageId) { + this.coalescedAttachmentMessages.set(lastMessageId, messageIds); + } + } + + protected override onPromptBufferDropped( + _chatId: string, + sessionId: string, + messageIds: string[], + ): void { + for (const messageId of messageIds) { + this.cleanupAttachmentDirsForMessage(messageId); + } + this.cleanupUntrackedAttachmentDirsForSession(sessionId); + } + + protected override onPromptEnd( + _chatId: string, + sessionId: string, + messageId?: string, + ): void { + if (!messageId) { + this.cleanupAttachmentDirsForSession(sessionId); + return; + } + const coalescedMessageIds = this.coalescedAttachmentMessages.get(messageId); + if (coalescedMessageIds) { + this.coalescedAttachmentMessages.delete(messageId); + for (const coalescedMessageId of coalescedMessageIds) { + this.cleanupAttachmentDirsForMessage(coalescedMessageId); + } + this.cleanupUntrackedAttachmentDirsForSession(sessionId); + return; + } + this.cleanupAttachmentDirsForMessage(messageId); + } + + private rememberAttachmentDir( + dir: string, + messageId?: string, + routeKey?: string, + ): void { + if (messageId) { + const messageDirs = this.attachmentDirsByMessage.get(messageId) ?? []; + messageDirs.push(dir); + this.attachmentDirsByMessage.set(messageId, messageDirs); + this.attachmentMessageByDir.set(dir, messageId); + } else if (routeKey) { + const dirs = this.attachmentDirsWithoutMessageByRoute.get(routeKey) ?? []; + dirs.push(dir); + this.attachmentDirsWithoutMessageByRoute.set(routeKey, dirs); + } + } + + private rememberMessageDirsForSession( + messageId: string, + sessionId: string, + ): void { + const dirs = this.attachmentDirsByMessage.get(messageId); + if (!dirs) return; + const sessionDirs = this.attachmentDirsBySession.get(sessionId) ?? []; + for (const dir of dirs) { + if (!sessionDirs.includes(dir)) sessionDirs.push(dir); + } + this.attachmentDirsBySession.set(sessionId, sessionDirs); + } + + private cleanupAttachmentDirsForMessage(messageId: string): void { + this.bufferedAttachmentMessages.delete(messageId); + const dirs = this.attachmentDirsByMessage.get(messageId); + if (!dirs) return; + this.attachmentDirsByMessage.delete(messageId); + for (const dir of dirs) { + this.attachmentMessageByDir.delete(dir); + } + this.removeAttachmentDirsFromSessions(dirs); + cleanupAttachmentDirs(dirs); + } + + private cleanupAttachmentDirsForSession(sessionId: string): void { + const dirs = this.attachmentDirsBySession.get(sessionId); + if (!dirs) return; + this.attachmentDirsBySession.delete(sessionId); + this.removeAttachmentDirsFromMessages(dirs); + for (const dir of dirs) { + this.attachmentMessageByDir.delete(dir); + } + cleanupAttachmentDirs(dirs); + } + + private cleanupUntrackedAttachmentDirsForSession(sessionId: string): void { + const dirs = this.attachmentDirsBySession.get(sessionId); + if (!dirs) return; + const untrackedDirs = dirs.filter( + (dir) => !this.attachmentMessageByDir.has(dir), + ); + if (untrackedDirs.length === 0) return; + const remainingDirs = dirs.filter((dir) => + this.attachmentMessageByDir.has(dir), + ); + if (remainingDirs.length > 0) { + this.attachmentDirsBySession.set(sessionId, remainingDirs); + } else { + this.attachmentDirsBySession.delete(sessionId); + } + cleanupAttachmentDirs(untrackedDirs); + } + + private rememberUntrackedDirsForSession(sessionId: string): void { + const routeKey = this.attachmentRouteKeyForSession(sessionId); + if (!routeKey) return; + const dirs = this.attachmentDirsWithoutMessageByRoute.get(routeKey); + if (!dirs || dirs.length === 0) return; + const sessionDirs = this.attachmentDirsBySession.get(sessionId) ?? []; + for (const dir of dirs) { + if (!sessionDirs.includes(dir)) sessionDirs.push(dir); + } + this.attachmentDirsBySession.set(sessionId, sessionDirs); + this.attachmentDirsWithoutMessageByRoute.delete(routeKey); + } + + private removeAttachmentDirsFromSessions(dirs: string[]): void { + const removed = new Set(dirs); + for (const [sessionId, sessionDirs] of this.attachmentDirsBySession) { + const remaining = sessionDirs.filter((dir) => !removed.has(dir)); + if (remaining.length) { + this.attachmentDirsBySession.set(sessionId, remaining); + } else { + this.attachmentDirsBySession.delete(sessionId); + } + } + } + + private removeAttachmentDirsFromMessages(dirs: string[]): void { + const removed = new Set(dirs); + for (const [messageId, messageDirs] of this.attachmentDirsByMessage) { + const remaining = messageDirs.filter((dir) => !removed.has(dir)); + if (remaining.length) { + this.attachmentDirsByMessage.set(messageId, remaining); + } else { + this.attachmentDirsByMessage.delete(messageId); + this.bufferedAttachmentMessages.delete(messageId); + } + } + for (const dir of dirs) { + this.attachmentMessageByDir.delete(dir); + } + } + + private cleanupAllAttachmentDirs(): void { + const dirs = Array.from( + new Set([ + ...Array.from(this.attachmentDirsBySession.values()).flat(), + ...Array.from(this.attachmentDirsByMessage.values()).flat(), + ...Array.from(this.attachmentDirsWithoutMessageByRoute.values()).flat(), + ]), + ); + this.attachmentDirsBySession.clear(); + this.attachmentDirsByMessage.clear(); + this.attachmentMessageByDir.clear(); + this.attachmentDirsWithoutMessageByRoute.clear(); + this.bufferedAttachmentMessages.clear(); + this.coalescedAttachmentMessages.clear(); + cleanupAttachmentDirs(dirs); + } + + private attachmentRouteKeyForSession(sessionId: string): string | undefined { + const target = this.router.getTarget(sessionId); + if (!target || target.channelName !== this.name) return undefined; + return this.attachmentRouteKey( + target.senderId, + target.chatId, + target.threadId, + ); + } + + private attachmentRouteKey( + senderId: string, + chatId: string, + threadId?: string, + ): string { + switch (this.config.sessionScope) { + case 'thread': + return `${this.name}:${threadId || chatId}`; + case 'single': + return `${this.name}:__single__`; + case 'user': + default: + return `${this.name}:${senderId}:${chatId}`; + } + } + + private detachClientHandlers( + client: WeComClient, + handlers = this.clientHandlers, + ): void { + if (!handlers) return; + for (const event of MESSAGE_EVENTS) { + client.off?.(event, handlers.message); + } + client.off?.('error', handlers.error); + client.off?.('disconnected', handlers.disconnected); + client.off?.('event.disconnected_event', handlers.kicked); + if (this.clientHandlers === handlers) this.clientHandlers = undefined; + } + + private disconnectClientOnly(err: Error): void { + const client = this.client ?? this.connectingClient; + this.authentication?.cancel(err); + this.authentication = undefined; + this.client = undefined; + this.connectingClient = undefined; + this.clearActivityWatchdog(); + if (client) this.detachClientHandlers(client); + try { + client?.disconnect(); + } catch (e) { + process.stderr.write( + `[WeCom:${this.name}] client.disconnect() threw: ${sanitizeLogText( + formatSdkError(e), + 200, + )}\n`, + ); + } + } + + private clearDisconnectReconnectFallback(): void { + if (!this.disconnectReconnectFallback) return; + clearTimeout(this.disconnectReconnectFallback); + this.disconnectReconnectFallback = undefined; + } + + private recordActivity(): void { + this.lastActivityAt = Date.now(); + } + + private clearActivityWatchdog(): void { + if (!this.activityWatchdog) return; + clearInterval(this.activityWatchdog); + this.activityWatchdog = undefined; + } + + private startActivityWatchdog(disconnectGeneration: number): void { + this.clearActivityWatchdog(); + this.activityWatchdog = setInterval(() => { + if (this.disconnectGeneration !== disconnectGeneration) return; + if (!this.client || this.reconnectingAfterKick) return; + if (Date.now() - this.lastActivityAt < ACTIVITY_STALE_MS) return; + + process.stderr.write( + `[WeCom:${this.name}] no SDK activity for ${ACTIVITY_STALE_MS / 60_000} minutes; reconnecting adapter.\n`, + ); + this.kickReconnectAttempts = 0; + this.kickReconnectRetryCycles = 0; + this.startKickReconnect( + new Error('WeCom SDK activity watchdog timed out.'), + 'activity watchdog', + ); + }, ACTIVITY_WATCHDOG_INTERVAL_MS); + } + + private scheduleDisconnectReconnectFallback( + reason: unknown, + client: WeComClient, + disconnectGeneration: number, + ): void { + this.clearDisconnectReconnectFallback(); + const formattedReason = formatDisconnectReason(reason); + this.disconnectReconnectFallback = setTimeout(() => { + this.disconnectReconnectFallback = undefined; + if (this.disconnectGeneration !== disconnectGeneration) return; + if (this.client !== client) return; + process.stderr.write( + `[WeCom:${this.name}] SDK reconnect did not recover after WebSocket ${formattedReason}; reconnecting adapter.\n`, + ); + this.kickReconnectAttempts = 0; + this.kickReconnectRetryCycles = 0; + this.startKickReconnect(reason, 'SDK disconnect'); + }, DISCONNECT_RECONNECT_FALLBACK_MS); + this.disconnectReconnectFallback.unref?.(); + } + + private async reconnectAfterKick( + reason: unknown, + reconnectReason = 'server kick', + ): Promise { + if (this.reconnectingAfterKick) { + this.pendingKickReconnect = true; + return; + } + if (this.kickReconnectRetry) { + clearTimeout(this.kickReconnectRetry); + this.kickReconnectRetry = undefined; + this.kickReconnectAttempts = 0; + } + this.reconnectingAfterKick = true; + const previousConnecting = this.connecting; + const disconnectGeneration = this.disconnectGeneration; + process.stderr.write( + `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; reconnecting after ${reconnectReason}.\n`, + ); + try { + this.disconnectClientOnly( + new Error( + `WeCom connection was kicked: ${formatDisconnectReason(reason)}`, + ), + ); + if (previousConnecting) { + await previousConnecting.catch(() => {}); + } + while (this.kickReconnectAttempts < KICK_RECONNECT_MAX_ATTEMPTS) { + const attempt = ++this.kickReconnectAttempts; + await delay( + KICK_RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1), + ); + if (this.disconnectGeneration !== disconnectGeneration) return; + try { + await this.connect(); + if (this.disconnectGeneration !== disconnectGeneration) return; + this.kickReconnectAttempts = 0; + this.kickReconnectRetryCycles = 0; + this.scheduleKickReconnectReset(); + process.stderr.write( + `[WeCom:${this.name}] reconnected after ${reconnectReason}.\n`, + ); + return; + } catch (err) { + process.stderr.write( + `[WeCom:${this.name}] reconnect after ${reconnectReason} attempt ${attempt} failed: ${sanitizeLogText( + formatSdkError(err), + 200, + )}\n`, + ); + } + } + this.kickReconnectRetryCycles += 1; + if (this.kickReconnectRetryCycles >= KICK_RECONNECT_MAX_RETRY_CYCLES) { + process.stderr.write( + `[WeCom:${this.name}] reconnect after ${reconnectReason} exhausted ${this.kickReconnectRetryCycles} retry cycles; next attempt in ${KICK_RECONNECT_LONG_RETRY_MS / 60_000} minutes.\n`, + ); + this.scheduleKickReconnectRetry( + reason, + disconnectGeneration, + KICK_RECONNECT_LONG_RETRY_MS, + reconnectReason, + true, + ); + return; + } + process.stderr.write( + `[WeCom:${this.name}] reconnect after ${reconnectReason} gave up after ${KICK_RECONNECT_MAX_ATTEMPTS} attempts; retrying later.\n`, + ); + this.scheduleKickReconnectRetry( + reason, + disconnectGeneration, + KICK_RECONNECT_RETRY_MS, + reconnectReason, + ); + } finally { + this.reconnectingAfterKick = false; + const shouldRetryPendingKick = + this.pendingKickReconnect && + this.disconnectGeneration === disconnectGeneration; + this.pendingKickReconnect = false; + if (shouldRetryPendingKick && !this.client) { + this.kickReconnectAttempts = 0; + this.startKickReconnect(reason, reconnectReason); + } + } + } + + private scheduleKickReconnectReset(): void { + if (this.kickReconnectRetry) { + clearTimeout(this.kickReconnectRetry); + this.kickReconnectRetry = undefined; + } + if (this.kickReconnectReset) clearTimeout(this.kickReconnectReset); + this.kickReconnectReset = setTimeout(() => { + this.kickReconnectAttempts = 0; + this.kickReconnectRetryCycles = 0; + this.kickReconnectReset = undefined; + }, KICK_RECONNECT_RESET_MS); + this.kickReconnectReset.unref?.(); + } + + private scheduleKickReconnectRetry( + reason: unknown, + disconnectGeneration: number, + delayMs = KICK_RECONNECT_RETRY_MS, + reconnectReason = 'server kick', + resetRetryCycles = false, + ): void { + this.kickReconnectRetry = setTimeout(() => { + this.kickReconnectRetry = undefined; + if (this.disconnectGeneration !== disconnectGeneration) { + process.stderr.write( + `[WeCom:${this.name}] scheduled kick-reconnect cancelled; connection generation changed.\n`, + ); + return; + } + this.kickReconnectAttempts = 0; + if (resetRetryCycles) this.kickReconnectRetryCycles = 0; + this.startKickReconnect(reason, reconnectReason); + }, delayMs); + this.kickReconnectRetry.unref?.(); + } + + private startKickReconnect( + reason: unknown, + reconnectReason = 'server kick', + ): void { + void this.reconnectAfterKick(reason, reconnectReason).catch( + (err: unknown) => { + process.stderr.write( + `[WeCom:${this.name}] kick-reconnect failed: ${sanitizeLogText( + formatSdkError(err), + 200, + )}\n`, + ); + if (this.kickReconnectRetry === undefined) { + this.scheduleKickReconnectRetry( + reason, + this.disconnectGeneration, + KICK_RECONNECT_LONG_RETRY_MS, + reconnectReason, + true, + ); + } + }, + ); + } + + private cleanupSeenMessages(): void { + const now = Date.now(); + for (const [id, ts] of this.seenMessages) { + if (now - ts > DEDUP_TTL_MS) { + this.seenMessages.delete(id); + } + } + } +} + +function waitForAuthentication(client: WeComClient): { + promise: Promise; + cancel(err?: Error): void; +} { + let timeout: ReturnType | undefined; + let settled = false; + let finish: (err?: Error) => void = () => {}; + const onAuth = () => finish(); + const onError = (err: unknown) => + finish( + new Error( + `WeCom authentication failed: ${sanitizeLogText(String(err), 200)}`, + ), + ); + const onKicked = (reason: unknown) => + finish( + new Error( + `WeCom authentication interrupted by server kick: ${formatDisconnectReason( + reason, + )}`, + ), + ); + + const promise = new Promise((resolvePromise, rejectPromise) => { + finish = (err?: Error): void => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + client.off?.('authenticated', onAuth); + client.off?.('error', onError); + client.off?.('event.disconnected_event', onKicked); + if (err) { + rejectPromise(err); + } else { + resolvePromise(); + } + }; + + timeout = setTimeout(() => { + finish(new Error('WeCom authentication timed out.')); + }, AUTHENTICATION_TIMEOUT_MS); + timeout.unref?.(); + + client.on('authenticated', onAuth); + client.on('error', onError); + client.on('event.disconnected_event', onKicked); + }); + return { + promise, + cancel: (err?: Error) => finish(err), + }; +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timeout: ReturnType | undefined; + return new Promise((resolvePromise, rejectPromise) => { + timeout = setTimeout(() => { + rejectPromise(new Error(message)); + }, timeoutMs); + timeout.unref?.(); + promise.then( + (value) => { + if (timeout) clearTimeout(timeout); + resolvePromise(value); + }, + (err: unknown) => { + if (timeout) clearTimeout(timeout); + rejectPromise(err); + }, + ); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolveDelay) => { + const timer = setTimeout(resolveDelay, ms); + timer.unref?.(); + }); +} + +function cleanupAttachmentDirs(dirs: string[]): void { + for (const dir of dirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch (err) { + process.stderr.write( + `[WeCom] failed to remove attachment dir ${sanitizeLogText( + dir, + 200, + )}: ${sanitizeLogText(formatSdkError(err), 200)}.\n`, + ); + } + } +} + +function formatDisconnectReason(reason: unknown): string { + const text = + typeof reason === 'string' && reason ? reason : formatSdkError(reason); + return sanitizeLogText(text, 120); +} + +function formatSdkError(err: unknown): string { + if (err instanceof Error) return err.message; + const record = asRecord(err); + if (record) { + const errcode = record['errcode']; + const errmsg = record['errmsg']; + if (typeof errcode === 'number' || typeof errmsg === 'string') { + return `errcode=${String(errcode)} errmsg=${String(errmsg)}`; + } + const code = record['code']; + const reason = record['reason']; + const wasClean = record['wasClean']; + if ( + typeof code === 'number' || + typeof reason === 'string' || + typeof wasClean === 'boolean' + ) { + return [ + typeof code === 'number' ? `code=${code}` : undefined, + typeof reason === 'string' ? `reason=${reason}` : undefined, + typeof wasClean === 'boolean' ? `wasClean=${wasClean}` : undefined, + ] + .filter((part): part is string => Boolean(part)) + .join(' '); + } + try { + return JSON.stringify(record, (key, value) => + SENSITIVE_ERROR_FIELDS.has(key.toLowerCase()) ? '[REDACTED]' : value, + ); + } catch { + // Fall through to String below. + } + } + return String(err); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + value !== null && + typeof value === 'object' && + 'then' in value && + typeof value.then === 'function' + ); +} + +function parseWeComConfig( + name: string, + config: ChannelConfig & Record, +): WeComConfig { + const botId = readRequiredString(config, 'botId'); + const secret = readRequiredString(config, 'secret'); + if (!botId || !secret) { + throw new Error(`Channel "${name}" requires botId and secret for WeCom.`); + } + const wsUrl = readOptionalString(config, 'wsUrl'); + if (wsUrl && !isSecureWebSocketUrl(wsUrl)) { + throw new Error(`Channel "${name}" requires wsUrl to use wss://.`); + } + return wsUrl ? { botId, secret, wsUrl } : { botId, secret }; +} + +function readRequiredString( + config: Record, + key: string, +): string { + const value = config[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +function readOptionalString( + config: Record, + key: string, +): string | undefined { + const value = config[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function isSecureWebSocketUrl(value: string): boolean { + try { + return new URL(value).protocol === 'wss:'; + } catch { + return false; + } +} + +function createWeComLogger(name: string): WeComLogger { + const write = (level: 'warn' | 'error', message: string): void => { + process.stderr.write( + `[WeCom:${name}] SDK ${level}: ${sanitizeLogText(message, 200)}\n`, + ); + }; + return { + debug: () => {}, + info: () => {}, + warn: (message: string) => write('warn', message), + error: (message: string) => write('error', message), + }; +} + +function extractBody(payload: unknown): Record | undefined { + const raw = asRecord(payload); + if (!raw) return undefined; + return getRecord(raw, 'body') ?? raw; +} + +function getLogMessageId(payload: unknown): string { + const body = extractBody(payload); + if (!body) return '(unknown id)'; + return sanitizeLogText(getString(body, 'msgid') || '(no id)', 100); +} + +function extractText(body: Record): string { + const msgType = getString(body, 'msgtype'); + if (msgType === 'mixed') { + const mixed = getRecord(body, 'mixed'); + const items = getArray(mixed, 'msg_item'); + return items + .map((item) => { + const record = asRecord(item); + if (!record) return ''; + const itemType = getString(record, 'msgtype'); + if (itemType === 'text') { + return getString(getRecord(record, 'text'), 'content'); + } + if (itemType === 'voice') { + return getString(getRecord(record, 'voice'), 'content'); + } + return ''; + }) + .filter(Boolean) + .join('\n') + .trim(); + } + const text = getString(getRecord(body, 'text'), 'content'); + const voiceText = getString(getRecord(body, 'voice'), 'content'); + if (text) return text; + if (voiceText) return voiceText; + if (msgType === 'image') return '(image)'; + if (msgType === 'voice') return '(voice)'; + if (msgType === 'video') return '(video)'; + if (msgType === 'file') { + const name = sanitizeFileName( + getString(getRecord(body, 'file'), 'filename'), + ); + return `(file: ${name || 'file'})`; + } + return ''; +} + +function extractQuoteText( + quote: Record | undefined, +): string | undefined { + if (!quote) return undefined; + return extractText(quote) || undefined; +} + +interface InboundMediaRef { + type: WeComMediaType; + url: string; + aesKey?: string; + fileName?: string; +} + +function collectInboundMediaRefs( + body: Record, + depth = 0, + seenUrls = new Set(), +): InboundMediaRef[] { + if (depth > 3) return []; + + const refs: InboundMediaRef[] = []; + const add = (type: WeComMediaType, source: Record): void => { + const url = getString(source, 'url'); + if (!url || seenUrls.has(url)) return; + seenUrls.add(url); + refs.push({ + type, + url, + aesKey: getString(source, 'aeskey') || undefined, + fileName: + getString(source, 'filename') || + getString(source, 'file_name') || + undefined, + }); + }; + + const mixed = getRecord(body, 'mixed'); + for (const item of getArray(mixed, 'msg_item')) { + const record = asRecord(item); + if (!record) continue; + const itemType = getString(record, 'msgtype'); + if (isWeComMediaType(itemType)) { + add(itemType, getRecord(record, itemType) ?? {}); + } + } + + add('image', getRecord(body, 'image') ?? {}); + add('file', getRecord(body, 'file') ?? {}); + add('video', getRecord(body, 'video') ?? {}); + add('voice', getRecord(body, 'voice') ?? {}); + + const quote = getRecord(body, 'quote'); + if (quote) refs.push(...collectInboundMediaRefs(quote, depth + 1, seenUrls)); + + return refs; +} + +interface OutboundMediaMarker { + type: WeComMediaType; + path: string; +} + +function parseOutboundMediaMarkers(text: string): { + cleanedText: string; + media: OutboundMediaMarker[]; +} { + const codeRanges = findCodeRanges(text); + const markerRe = /\[(IMAGE|FILE|VIDEO|VOICE):\s*([^\]]+)\]/gi; + const media: OutboundMediaMarker[] = []; + const rangesToRemove: Array<[number, number]> = []; + + for (const match of text.matchAll(markerRe)) { + const start = match.index ?? 0; + const end = start + match[0].length; + if (codeRanges.some(([from, to]) => start >= from && start < to)) continue; + const path = match[2]?.trim(); + const rawType = match[1]?.toLowerCase(); + if (!path || !isWeComMediaType(rawType)) continue; + media.push({ type: rawType, path }); + rangesToRemove.push([start, end]); + } + + let cleanedText = text; + for (const [start, end] of rangesToRemove.toReversed()) { + cleanedText = `${cleanedText.slice(0, start)}${cleanedText.slice(end)}`; + } + return { + cleanedText: cleanedText.replace(/\n{3,}/g, '\n\n').trim(), + media, + }; +} + +function findCodeRanges(text: string): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + let fenceStart: number | undefined; + let fenceToken: FenceToken | undefined; + for (const match of text.matchAll(/```|~~~/g)) { + const start = match.index ?? 0; + const token = match[0] as FenceToken; + if (fenceStart === undefined) { + fenceStart = start; + fenceToken = token; + } else if (token === fenceToken) { + ranges.push([fenceStart, start + 3]); + fenceStart = undefined; + fenceToken = undefined; + } + } + if (fenceStart !== undefined) { + ranges.push([fenceStart, text.length]); + } + + for (const match of text.matchAll(/(`+)[^`\n]*\1/g)) { + const start = match.index ?? 0; + if (ranges.some(([from, to]) => start >= from && start < to)) continue; + ranges.push([start, start + match[0].length]); + } + + const lineRe = /^(?: {4,}|\t).*$/gm; + for (const match of text.matchAll(lineRe)) { + const start = match.index ?? 0; + if (ranges.some(([from, to]) => start >= from && start < to)) continue; + ranges.push([start, start + match[0].length]); + } + + return ranges; +} + +type FenceToken = '```' | '~~~'; + +function isWeComMediaType(value: string | undefined): value is WeComMediaType { + return ( + value === 'image' || + value === 'file' || + value === 'voice' || + value === 'video' + ); +} + +function splitMarkdownChunks(text: string): string[] { + if (!text) return []; + + const chunks: string[] = []; + let current = ''; + let codeFence: FenceToken | undefined; + const fits = (value: string, nextCodeFence = codeFence): boolean => + Buffer.byteLength( + nextCodeFence ? `${value}\n${nextCodeFence}` : value, + 'utf8', + ) <= MARKDOWN_CHUNK_BYTES; + const flush = (closeCode = true): void => { + if (!current) return; + chunks.push(closeCode && codeFence ? `${current}\n${codeFence}` : current); + current = closeCode && codeFence ? codeFence : ''; + }; + + for (const line of text.split('\n')) { + const candidate = current ? `${current}\n${line}` : line; + const candidateCodeFence = toggleCodeFenceState(line, codeFence); + if (fits(candidate, candidateCodeFence)) { + current = candidate; + codeFence = candidateCodeFence; + continue; + } + + flush(); + const retried = current ? `${current}\n${line}` : line; + const retriedCodeFence = toggleCodeFenceState(line, codeFence); + if (fits(retried, retriedCodeFence)) { + current = retried; + codeFence = retriedCodeFence; + continue; + } + + let needsLineBreak = Boolean(current); + for (let index = 0; index < line.length; ) { + const codePoint = line.codePointAt(index); + const token = line.startsWith('```', index) + ? '```' + : line.startsWith('~~~', index) + ? '~~~' + : codePoint === undefined + ? '' + : String.fromCodePoint(codePoint); + if (!token) break; + const nextCodeFence = + token === codeFence + ? undefined + : !codeFence && isFenceToken(token) + ? token + : codeFence; + const addition = needsLineBreak && current ? `\n${token}` : token; + const candidate = `${current}${addition}`; + if (!fits(candidate, nextCodeFence)) { + flush(); + current = current ? `${current}\n${token}` : token; + } else { + current = candidate; + } + codeFence = nextCodeFence; + needsLineBreak = false; + index += token.length; + } + } + + flush(); + return chunks; +} + +function toggleCodeFenceState( + line: string, + codeFence: FenceToken | undefined, +): FenceToken | undefined { + let nextCodeFence = codeFence; + for (const match of line.matchAll(/```|~~~/g)) { + const token = match[0] as FenceToken; + if (nextCodeFence === token) { + nextCodeFence = undefined; + } else if (!nextCodeFence) { + nextCodeFence = token; + } + } + return nextCodeFence; +} + +function isFenceToken(token: string): token is FenceToken { + return token === '```' || token === '~~~'; +} + +async function readOutboundMedia( + rawPath: string, + cwd: string, +): Promise<{ + data: Buffer; + fileName: string; +}> { + const resolved = resolve(cwd, rawPath); + const real = realpathSync(resolved); + const allowedDirs = [ + ensureDirectoryRealpath(join(tmpdir(), 'channel-files')), + ]; + if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { + throw new Error('Media path outside allowed outbound directory'); + } + + const file = await open(real, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stat = await file.stat(); + if (!stat.isFile()) + throw new Error(`Not a regular file: ${basename(rawPath)}`); + if (stat.size > MAX_MEDIA_BYTES) { + throw new Error(`Media file too large: ${stat.size} bytes`); + } + return { data: await file.readFile(), fileName: basename(real) }; + } finally { + await file.close(); + } +} + +function ensureDirectoryRealpath(path: string): string { + try { + mkdirSync(path, { recursive: true }); + const stat = lstatSync(path); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('not a safe directory'); + } + return realpathSync(path); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `Cannot prepare outbound media directory ${path}: ${reason}`, + ); + } +} + +function isInsideDir(filePath: string, dir: string): boolean { + const windowsStyle = /^[a-zA-Z]:[\\/]/.test(filePath); + const pathImpl = windowsStyle ? win32 : posix; + const relative = pathImpl.relative(dir, filePath); + return ( + relative === '' || + (!relative.startsWith('..') && !pathImpl.isAbsolute(relative)) + ); +} + +type SafeInboundMediaUrlResult = + | { safe: true } + | { safe: false; reason: string }; + +async function isSafeInboundMediaUrl( + rawUrl: string, +): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { safe: false, reason: 'invalid URL' }; + } + if (url.protocol !== 'https:') { + return { safe: false, reason: 'non-HTTPS protocol' }; + } + if (url.username || url.password) { + return { safe: false, reason: 'URL contains embedded credentials' }; + } + + const host = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + if (!host || host === 'localhost' || host.endsWith('.localhost')) { + return { safe: false, reason: 'local hostname' }; + } + if (host.endsWith('.local')) { + return { safe: false, reason: 'local hostname' }; + } + + if (isIP(host)) { + return isPublicIpAddress(host) + ? { safe: true } + : { safe: false, reason: `private address ${host}` }; + } + if (!host.includes('.')) return { safe: false, reason: 'bare hostname' }; + try { + const records = await lookup(host, { all: true }); + if (records.length === 0) { + return { safe: false, reason: 'no DNS records' }; + } + const privateRecord = records.find( + (record) => !isPublicIpAddress(record.address), + ); + return privateRecord + ? { + safe: false, + reason: `${host} resolved to private address ${privateRecord.address}`, + } + : { safe: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { safe: false, reason: `DNS lookup failed for ${host}: ${message}` }; + } +} + +async function downloadInboundMedia( + ref: InboundMediaRef, +): Promise<{ buffer: Buffer; filename?: string }> { + const urlSafety = await isSafeInboundMediaUrl(ref.url); + if (!urlSafety.safe) { + throw new Error(`unsafe media URL (${urlSafety.reason})`); + } + // Intentionally bypass WSClient.downloadFile(): its axios path follows + // redirects, lacks a byte cap, and does not pin resolved public IPs. + const downloaded = await guardedHttpsDownload(ref.url); + return { + buffer: ref.aesKey + ? decryptFile(downloaded.buffer, ref.aesKey) + : downloaded.buffer, + ...(ref.fileName || downloaded.filename + ? { filename: ref.fileName || downloaded.filename } + : {}), + }; +} + +function guardedHttpsDownload( + rawUrl: string, +): Promise<{ buffer: Buffer; filename?: string }> { + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + const cleanup: { absoluteTimeout?: ReturnType } = {}; + const finish = ( + err?: Error, + value?: { buffer: Buffer; filename?: string }, + ): void => { + if (settled) return; + settled = true; + if (cleanup.absoluteTimeout) clearTimeout(cleanup.absoluteTimeout); + if (err) { + rejectPromise(err); + } else { + resolvePromise(value ?? { buffer: Buffer.alloc(0) }); + } + }; + + const req = httpsRequest( + rawUrl, + { + method: 'GET', + lookup: safePublicLookup, + }, + (res) => { + const statusCode = res.statusCode ?? 0; + if (statusCode >= 300 && statusCode < 400) { + req.destroy(); + finish(new Error('redirected media URL')); + return; + } + if (statusCode < 200 || statusCode >= 300) { + req.destroy(); + finish(new Error(`media download failed: HTTP ${statusCode}`)); + return; + } + + const contentLength = getHeaderNumber(res.headers, 'content-length'); + if (contentLength !== undefined && contentLength > MAX_MEDIA_BYTES) { + req.destroy(); + finish(new Error(`oversized attachment (${contentLength} bytes)`)); + return; + } + + const chunks: Buffer[] = []; + let total = 0; + res.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.byteLength; + if (total > MAX_MEDIA_BYTES) { + req.destroy(); + finish(new Error('oversized attachment')); + return; + } + chunks.push(buffer); + }); + res.on('end', () => { + const buffer = Buffer.concat(chunks); + if (buffer.byteLength === 0) { + finish(new Error('empty media response')); + return; + } + finish(undefined, { + buffer, + ...parseContentDispositionFileName(res.headers), + }); + }); + res.on('error', (err: Error) => finish(err)); + }, + ); + req.setTimeout(10_000, () => { + req.destroy(); + finish(new Error('media download timed out')); + }); + cleanup.absoluteTimeout = setTimeout(() => { + req.destroy(); + finish(new Error('media download absolute timeout')); + }, 60_000); + cleanup.absoluteTimeout.unref?.(); + req.on('error', (err: Error) => finish(err)); + req.end(); + }); +} + +const safePublicLookup: LookupFunction = (hostname, options, callback) => { + lookup(hostname, { all: true }) + .then((records) => { + const unsafeRecord = records.find( + (record) => !isPublicIpAddress(record.address), + ); + if (records.length === 0 || unsafeRecord) { + const reason = + records.length === 0 + ? `no DNS records for ${hostname}` + : `${hostname} resolved to private address ${unsafeRecord!.address}`; + callback(new Error(`unsafe resolved media address: ${reason}`), '', 0); + return; + } + if (options.all) { + callback(null, records); + return; + } + const record = records[0]!; + callback(null, record.address, record.family); + }) + .catch((err: unknown) => + callback(err instanceof Error ? err : new Error(String(err)), '', 0), + ); +}; + +function getHeaderNumber( + headers: IncomingHttpHeaders, + name: string, +): number | undefined { + const value = getHeaderValue(headers, name); + if (value === undefined) return undefined; + const size = Number(value); + return Number.isFinite(size) && size >= 0 ? size : undefined; +} + +function getHeaderValue( + headers: IncomingHttpHeaders, + name: string, +): string | undefined { + const value = headers[name.toLowerCase()]; + if (Array.isArray(value)) return value[0]; + return value; +} + +function parseContentDispositionFileName(headers: IncomingHttpHeaders): { + filename?: string; +} { + const value = getHeaderValue(headers, 'content-disposition'); + if (!value) return {}; + const encoded = value.match(/filename\*=UTF-8''([^;\s]+)/i)?.[1]; + if (encoded) { + try { + return { filename: decodeURIComponent(encoded) }; + } catch { + return { filename: encoded }; + } + } + const plain = value.match(/filename="?([^";]+)"?/i)?.[1]; + return plain ? { filename: plain } : {}; +} + +function isPublicIpAddress(address: string): boolean { + const host = address.toLowerCase().replace(/^\[|\]$/g, ''); + const ipVersion = isIP(host); + if (ipVersion === 4) { + const parts = parseIpv4Parts(host); + return parts ? isPublicIpv4(parts) : false; + } + if (ipVersion === 6) { + const embedded = parseEmbeddedIpv4(host); + if (embedded) return isPublicIpv4(embedded); + const groups = expandIpv6Groups(host); + if (!groups) return false; + const first = groups[0] ?? 0; + const isAllZeros = groups.every((group) => group === 0); + const isLoopback = + groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1; + const lowZeroEmbeddedIpv4 = hexGroupsToIpv4(groups[6], groups[7]); + const hasLowZeroPrivateIpv4 = + groups.slice(0, 5).every((group) => group === 0) && + lowZeroEmbeddedIpv4 !== undefined && + (groups[6] !== 0 || groups[7] !== 0) && + !isPublicIpv4(lowZeroEmbeddedIpv4); + return !( + isAllZeros || + isLoopback || + hasLowZeroPrivateIpv4 || + (first >= 0xfc00 && first <= 0xfdff) || + (first >= 0xff00 && first <= 0xffff) || + (first === 0x2001 && groups[1] === 0x0db8) || + isIpv6LinkLocalGroup(first) + ); + } + return false; +} + +function isIpv6LinkLocalGroup(firstGroup: number): boolean { + return firstGroup >= 0xfe80 && firstGroup <= 0xfeff; +} + +function parseIpv4Parts(host: string): number[] | undefined { + const parts = host.split('.').map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return undefined; + } + return parts; +} + +function parseMappedIpv4(host: string): number[] | undefined { + if (!host.startsWith('::ffff:')) return undefined; + const suffix = host.slice('::ffff:'.length); + if (suffix.includes('.')) { + return parseIpv4Parts(suffix); + } + const groups = suffix.split(':'); + if (groups.length !== 2) return undefined; + const high = parseHexGroup(groups[0]); + const low = parseHexGroup(groups[1]); + if (high === undefined || low === undefined) return undefined; + return [high >> 8, high & 0xff, low >> 8, low & 0xff]; +} + +function parseEmbeddedIpv4(host: string): number[] | undefined { + const mapped = parseMappedIpv4(host); + if (mapped) return mapped; + + const groups = expandIpv6Groups(host); + if (!groups) return undefined; + if ( + groups.slice(0, 4).every((group) => group === 0) && + groups[4] === 0xffff && + groups[5] === 0 && + (groups[6] !== 0 || groups[7] !== 0) + ) { + return hexGroupsToIpv4(groups[6], groups[7]); + } + if ( + groups.slice(0, 5).every((group) => group === 0) && + groups[5] === 0xffff && + (groups[6] !== 0 || groups[7] !== 0) + ) { + return hexGroupsToIpv4(groups[6], groups[7]); + } + if ( + groups.slice(0, 6).every((group) => group === 0) && + (groups[6] !== 0 || groups[7] !== 0) + ) { + return hexGroupsToIpv4(groups[6], groups[7]); + } + if (groups[0] === 0x2002) { + return hexGroupsToIpv4(groups[1], groups[2]); + } + if (groups[0] === 0x2001 && groups[1] === 0) { + return hexGroupsToIpv4(groups[6]! ^ 0xffff, groups[7]! ^ 0xffff); + } + if (groups[0] === 0x0064 && groups[1] === 0xff9b) { + return hexGroupsToIpv4(groups[6], groups[7]); + } + return undefined; +} + +function expandIpv6Groups(host: string): number[] | undefined { + const normalized = normalizeIpv6DottedSuffix(host); + if (!normalized) return undefined; + + const parts = normalized.split('::'); + if (parts.length > 2) return undefined; + const left = parseIpv6GroupList(parts[0]); + const right = parseIpv6GroupList(parts[1]); + if (!left || !right) return undefined; + + if (parts.length === 1) { + return left.length === 8 ? left : undefined; + } + + const fill = 8 - left.length - right.length; + if (fill < 1) return undefined; + return [...left, ...Array(fill).fill(0), ...right]; +} + +function normalizeIpv6DottedSuffix(host: string): string | undefined { + if (!host.includes('.')) return host; + const lastColon = host.lastIndexOf(':'); + if (lastColon === -1) return undefined; + const ipv4 = parseIpv4Parts(host.slice(lastColon + 1)); + if (!ipv4) return undefined; + const high = (ipv4[0] << 8) | ipv4[1]; + const low = (ipv4[2] << 8) | ipv4[3]; + return `${host.slice(0, lastColon + 1)}${high.toString( + 16, + )}:${low.toString(16)}`; +} + +function parseIpv6GroupList(value: string | undefined): number[] | undefined { + if (!value) return []; + const groups: number[] = []; + for (const group of value.split(':')) { + const parsed = parseHexGroup(group); + if (parsed === undefined) return undefined; + groups.push(parsed); + } + return groups; +} + +function hexGroupsToIpv4( + high: number | undefined, + low: number | undefined, +): number[] | undefined { + if (high === undefined || low === undefined) return undefined; + return [high >> 8, high & 0xff, low >> 8, low & 0xff]; +} + +function parseHexGroup(value: string | undefined): number | undefined { + if (!value || !/^[\da-f]{1,4}$/i.test(value)) return undefined; + const parsed = Number.parseInt(value, 16); + return parsed >= 0 && parsed <= 0xffff ? parsed : undefined; +} + +function isPublicIpv4(parts: number[]): boolean { + const [a = 0, b = 0, c = 0] = parts; + return !( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0 && (c === 0 || c === 2)) || + (a === 192 && b === 88 && c === 99) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || + (a === 203 && b === 0 && c === 113) || + a >= 224 + ); +} + +function extractMediaId(value: unknown): string | undefined { + const record = asRecord(value); + return ( + getString(record, 'media_id') || + getString(record, 'mediaId') || + getString(getRecord(record, 'body'), 'media_id') || + undefined + ); +} + +function detectImageMime(data: Buffer): string { + if ( + data[0] === 0x89 && + data[1] === 0x50 && + data[2] === 0x4e && + data[3] === 0x47 + ) { + return 'image/png'; + } + if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) { + return 'image/gif'; + } + if ( + data[0] === 0x52 && + data[1] === 0x49 && + data[2] === 0x46 && + data[3] === 0x46 && + data[8] === 0x57 && + data[9] === 0x45 && + data[10] === 0x42 && + data[11] === 0x50 + ) { + return 'image/webp'; + } + if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) { + return 'image/jpeg'; + } + return 'application/octet-stream'; +} + +function mediaTypeToMime(type: WeComMediaType): string { + switch (type) { + case 'video': + return 'video/mp4'; + case 'voice': + return 'audio/amr'; + default: + return 'application/octet-stream'; + } +} + +function sanitizeFileName(name: string | undefined): string { + const base = basename(name || '').replace(/\0/g, ''); + return base.replace(/[^\p{L}\p{N}._-]/gu, '_').replace(/^\.+/, '_'); +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function getRecord( + value: Record | undefined, + key: string, +): Record | undefined { + return asRecord(value?.[key]); +} + +function getArray( + value: Record | undefined, + key: string, +): unknown[] { + const raw = value?.[key]; + return Array.isArray(raw) ? raw : []; +} + +function getString( + value: Record | undefined, + key: string, +): string { + const raw = value?.[key]; + return typeof raw === 'string' ? raw : ''; +} + +function getBoolean( + value: Record | undefined, + key: string, +): boolean | undefined { + const raw = value?.[key]; + return typeof raw === 'boolean' ? raw : undefined; +} + +function getExplicitMention( + body: Record, + botId: string, +): boolean | undefined { + const botSpecificMention = + getBoolean(body, 'isInAtList') ?? getBoolean(body, 'is_in_at_list'); + if (botSpecificMention !== undefined) return botSpecificMention; + + const mentions = collectMentionValues(body); + if (!mentions.present) return getBoolean(body, 'isMentioned'); + + return mentions.values.some( + (mention) => mention === botId || mention === '@all' || mention === 'all', + ); +} + +function collectMentionValues(body: Record): { + present: boolean; + values: string[]; +} { + const values: string[] = []; + let present = false; + for (const key of [ + 'mentions', + 'mentioned_list', + 'mentionedList', + 'at_list', + 'atList', + 'at_userids', + 'atUserIds', + ]) { + if (Object.prototype.hasOwnProperty.call(body, key)) { + present = true; + } + for (const item of getArray(body, key)) { + if (typeof item === 'string') { + values.push(item); + continue; + } + const record = asRecord(item); + if (!record) continue; + for (const itemKey of ['userid', 'userId', 'id', 'open_id', 'openId']) { + const value = getString(record, itemKey); + if (value) values.push(value); + } + } + } + return { present, values }; +} diff --git a/packages/channels/wecom/src/index.ts b/packages/channels/wecom/src/index.ts new file mode 100644 index 00000000000..35ac9fb617a --- /dev/null +++ b/packages/channels/wecom/src/index.ts @@ -0,0 +1,13 @@ +export { WeComChannel } from './WeComAdapter.js'; + +import { WeComChannel } from './WeComAdapter.js'; +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'wecom', + displayName: 'WeCom', + requiredConfigFields: ['botId', 'secret'], + envResolvableConfigFields: ['wsUrl'], + createChannel: (name, config, bridge, options) => + new WeComChannel(name, config, bridge, options), +}; diff --git a/packages/channels/wecom/tsconfig.json b/packages/channels/wecom/tsconfig.json new file mode 100644 index 00000000000..30e3324c83a --- /dev/null +++ b/packages/channels/wecom/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], + "references": [{ "path": "../base" }] +} diff --git a/packages/channels/wecom/vitest.config.ts b/packages/channels/wecom/vitest.config.ts new file mode 100644 index 00000000000..17a81f72b90 --- /dev/null +++ b/packages/channels/wecom/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, + resolve: { + alias: { + '@qwen-code/channel-base': path.resolve( + __dirname, + '../base/src/index.ts', + ), + }, + }, +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 21b5c5d0052..aabfee6c158 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ "@qwen-code/audio-capture": "file:../audio-capture", "@qwen-code/channel-base": "file:../channels/base", "@qwen-code/channel-dingtalk": "file:../channels/dingtalk", + "@qwen-code/channel-wecom": "file:../channels/wecom", "@qwen-code/channel-qqbot": "file:../channels/qqbot", "@qwen-code/channel-feishu": "file:../channels/feishu", "@qwen-code/channel-telegram": "file:../channels/telegram", diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 840460ab90b..469e2131c66 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -10,6 +10,7 @@ function ensureBuiltins(): Promise { { name: 'telegram', promise: import('@qwen-code/channel-telegram') }, { name: 'weixin', promise: import('@qwen-code/channel-weixin') }, { name: 'dingtalk', promise: import('@qwen-code/channel-dingtalk') }, + { name: 'wecom', promise: import('@qwen-code/channel-wecom') }, { name: 'feishu', promise: import('@qwen-code/channel-feishu') }, { name: 'qqbot', promise: import('@qwen-code/channel-qqbot') }, ]; diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index c2c65165b67..1537a683fc2 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -8,18 +8,43 @@ vi.mock('./channel-registry.js', () => ({ getPlugin: async (type: string) => { const plugins: Record< string, - { channelType: string; requiredConfigFields?: string[] } + { + channelType: string; + requiredConfigFields?: string[]; + envResolvableConfigFields?: string[]; + } > = { telegram: { channelType: 'telegram', requiredConfigFields: ['token'] }, dingtalk: { channelType: 'dingtalk', requiredConfigFields: ['clientId', 'clientSecret'], }, + wecom: { + channelType: 'wecom', + requiredConfigFields: ['botId', 'secret'], + envResolvableConfigFields: ['wsUrl'], + }, + numeric: { + channelType: 'numeric', + requiredConfigFields: ['port'], + }, + overlap: { + channelType: 'overlap', + requiredConfigFields: ['endpoint'], + envResolvableConfigFields: ['endpoint'], + }, bare: { channelType: 'bare' }, // no requiredConfigFields }; return plugins[type]; }, - supportedTypes: async () => ['telegram', 'dingtalk', 'bare'], + supportedTypes: async () => [ + 'telegram', + 'dingtalk', + 'wecom', + 'numeric', + 'overlap', + 'bare', + ], })); describe('resolveEnvVars', () => { @@ -69,6 +94,32 @@ describe('parseChannelConfig', () => { ).rejects.toThrow('requires "token"'); }); + it('resolves env vars in plugin-required bot credentials', async () => { + process.env['TEST_WECOM_BOT_ID'] = 'bot-from-env'; + process.env['TEST_WECOM_SECRET'] = 'secret-from-env'; + + const result = await parseChannelConfig('bot', { + type: 'wecom', + botId: '$TEST_WECOM_BOT_ID', + secret: '$TEST_WECOM_SECRET', + }); + + expect(result['botId']).toBe('bot-from-env'); + expect(result['secret']).toBe('secret-from-env'); + + delete process.env['TEST_WECOM_BOT_ID']; + delete process.env['TEST_WECOM_SECRET']; + }); + + it('allows non-string plugin-required fields', async () => { + const result = await parseChannelConfig('bot', { + type: 'numeric', + port: 443, + }); + + expect(result['port']).toBe(443); + }); + it('throws a clear error when token is not a string', async () => { await expect( parseChannelConfig('bot', { type: 'telegram', token: 123 }), @@ -130,6 +181,110 @@ describe('parseChannelConfig', () => { delete process.env['TEST_SEC']; }); + it('resolves env vars in plugin-declared optional config fields', async () => { + process.env['TEST_WECOM_WS_URL'] = 'wss://example.invalid/ws'; + + const result = await parseChannelConfig('bot', { + type: 'wecom', + botId: 'bot-id', + secret: 'bot-secret', + wsUrl: '$TEST_WECOM_WS_URL', + }); + + expect(result['wsUrl']).toBe('wss://example.invalid/ws'); + + delete process.env['TEST_WECOM_WS_URL']; + }); + + it('does not resolve plugin fields twice when declarations overlap', async () => { + process.env['TEST_OVERLAP_ENDPOINT'] = '$ENDPOINT_LITERAL'; + + const result = await parseChannelConfig( + 'bot', + { + type: 'overlap', + endpoint: '$TEST_OVERLAP_ENDPOINT', + }, + process.cwd(), + { resolveEnvVars: 'available' }, + ); + + expect(result['endpoint']).toBe('$ENDPOINT_LITERAL'); + + delete process.env['TEST_OVERLAP_ENDPOINT']; + }); + + it('does not resolve known credential fields twice', async () => { + process.env['TEST_TOKEN'] = '$TOKEN_LITERAL_VALUE'; + + const result = await parseChannelConfig('bot', { + type: 'telegram', + token: '$TEST_TOKEN', + }); + + expect(result.token).toBe('$TOKEN_LITERAL_VALUE'); + + delete process.env['TEST_TOKEN']; + }); + + it('rejects available env vars when explicitly empty', async () => { + process.env['TEST_EMPTY_SECRET'] = ''; + + await expect( + parseChannelConfig( + 'bot', + { + type: 'wecom', + botId: 'bot-id', + secret: '$TEST_EMPTY_SECRET', + }, + process.cwd(), + { resolveEnvVars: 'available' }, + ), + ).rejects.toThrow( + 'Environment variable TEST_EMPTY_SECRET is empty (referenced as $TEST_EMPTY_SECRET)', + ); + + delete process.env['TEST_EMPTY_SECRET']; + }); + + it('rejects available env vars when unset', async () => { + delete process.env['TEST_MISSING_SECRET']; + + await expect( + parseChannelConfig( + 'bot', + { + type: 'wecom', + botId: 'bot-id', + secret: '$TEST_MISSING_SECRET', + }, + process.cwd(), + { resolveEnvVars: 'available' }, + ), + ).rejects.toThrow( + 'Environment variable TEST_MISSING_SECRET is not set (referenced as $TEST_MISSING_SECRET). Set the variable or remove the $ prefix to use a literal value.', + ); + }); + + it('rejects unavailable required credential env vars', async () => { + delete process.env['TEST_MISSING_TOKEN']; + + await expect( + parseChannelConfig( + 'bot', + { + type: 'telegram', + token: '$TEST_MISSING_TOKEN', + }, + process.cwd(), + { resolveEnvVars: 'available' }, + ), + ).rejects.toThrow( + 'Environment variable TEST_MISSING_TOKEN is not set (referenced as $TEST_MISSING_TOKEN). Set the variable or remove the $ prefix to use a literal value.', + ); + }); + it('preserves explicit config values over defaults', async () => { const result = await parseChannelConfig('bot', { type: 'bare', diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 763c85f9947..0da79c2b509 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -22,6 +22,7 @@ function resolveOptionalStringField( channelName: string, rawConfig: Record, field: 'token' | 'clientId' | 'clientSecret', + envResolution: EnvResolution, ): string | undefined { const value = rawConfig[field]; if (value === undefined || value === null || value === '') { @@ -32,6 +33,35 @@ function resolveOptionalStringField( `Channel "${channelName}" field "${field}" must be a string.`, ); } + return resolveConfigEnvVar(value, envResolution); +} + +/** + * false: leave string values unchanged. + * true: resolve $VAR references with the legacy generic not-set error. + * 'available': resolve $VAR references with explicit unset vs empty errors. + */ +type EnvResolution = boolean | 'available'; +const KNOWN_CREDENTIAL_FIELDS = new Set(['token', 'clientId', 'clientSecret']); + +function resolveConfigEnvVar(value: string, mode: EnvResolution): string { + if (mode === false) return value; + if (mode === 'available' && value.startsWith('$')) { + const envName = value.substring(1); + const envValue = process.env[envName]; + if (envValue === undefined) { + throw new Error( + `Environment variable ${envName} is not set (referenced as ${value}). ` + + 'Set the variable or remove the $ prefix to use a literal value.', + ); + } + if (envValue === '') { + throw new Error( + `Environment variable ${envName} is empty (referenced as ${value})`, + ); + } + return envValue; + } return resolveEnvVars(value); } @@ -94,6 +124,7 @@ export async function parseChannelConfig( name: string, rawConfig: Record, defaultCwd: string = process.cwd(), + options: { resolveEnvVars?: EnvResolution } = {}, ): Promise> { if (!rawConfig['type']) { throw new Error(`Channel "${name}" is missing required field "type".`); @@ -108,6 +139,10 @@ export async function parseChannelConfig( ); } + const resolvedRawConfig = { ...rawConfig }; + const envResolution = options.resolveEnvVars ?? true; + const resolvedPluginFields = new Set(); + // Validate plugin-required fields for (const field of plugin.requiredConfigFields ?? []) { const value = rawConfig[field]; @@ -116,19 +151,37 @@ export async function parseChannelConfig( `Channel "${name}" (${channelType}) requires "${field}".`, ); } + if (typeof value === 'string' && !KNOWN_CREDENTIAL_FIELDS.has(field)) { + resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); + resolvedPluginFields.add(field); + } + } + for (const field of plugin.envResolvableConfigFields ?? []) { + if (resolvedPluginFields.has(field)) continue; + const value = rawConfig[field]; + if (typeof value === 'string' && value !== '') { + resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); + } } // Resolve env vars for known credential fields - const token = resolveOptionalStringField(name, rawConfig, 'token') ?? ''; - const clientId = resolveOptionalStringField(name, rawConfig, 'clientId'); + const token = + resolveOptionalStringField(name, rawConfig, 'token', envResolution) ?? ''; + const clientId = resolveOptionalStringField( + name, + rawConfig, + 'clientId', + envResolution, + ); const clientSecret = resolveOptionalStringField( name, rawConfig, 'clientSecret', + envResolution, ); return { - ...rawConfig, + ...resolvedRawConfig, type: channelType, token, clientId, diff --git a/packages/cli/src/commands/channel/runtime.test.ts b/packages/cli/src/commands/channel/runtime.test.ts index 1fc00509069..3df5bdc78fa 100644 --- a/packages/cli/src/commands/channel/runtime.test.ts +++ b/packages/cli/src/commands/channel/runtime.test.ts @@ -1,6 +1,20 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parseConfiguredChannels } from './runtime.js'; +vi.mock('@qwen-code/qwen-code-core', () => ({ + Storage: { getGlobalQwenDir: () => '/tmp/qwen' }, +})); + +vi.mock('../../config/settings.js', () => ({ + loadSettings: () => ({ merged: {} }), +})); + +vi.mock('../extensions/utils.js', () => ({ + getExtensionManager: async () => ({ + getLoadedExtensions: () => [], + }), +})); + vi.mock('./channel-registry.js', () => ({ getPlugin: async (type: string) => type === 'telegram' @@ -10,6 +24,15 @@ vi.mock('./channel-registry.js', () => ({ })); describe('parseConfiguredChannels', () => { + beforeEach(() => { + delete process.env['TOKEN_LITERAL_VALUE']; + }); + + afterEach(() => { + delete process.env['TEST_CHANNEL_TOKEN']; + delete process.env['TOKEN_LITERAL_VALUE']; + }); + it('throws a clear error when a selected channel is missing config', async () => { await expect( parseConfiguredChannels({}, ['telegram'], { defaultCwd: '/workspace' }), @@ -41,4 +64,38 @@ describe('parseConfiguredChannels', () => { }), ]); }); + + it('rejects unresolved credential env vars', async () => { + await expect( + parseConfiguredChannels( + { + telegram: { + type: 'telegram', + token: '$TOKEN_LITERAL_VALUE', + }, + }, + ['telegram'], + { defaultCwd: '/workspace' }, + ), + ).rejects.toThrow( + 'Error in channel "telegram": Environment variable TOKEN_LITERAL_VALUE is not set (referenced as $TOKEN_LITERAL_VALUE). Set the variable or remove the $ prefix to use a literal value.', + ); + }); + + it('resolves channel credentials from environment loaded after settings', async () => { + process.env['TEST_CHANNEL_TOKEN'] = 'token-from-env'; + + const parsed = await parseConfiguredChannels( + { + telegram: { + type: 'telegram', + token: '$TEST_CHANNEL_TOKEN', + }, + }, + ['telegram'], + { defaultCwd: '/workspace' }, + ); + + expect(parsed[0]?.config.token).toBe('token-from-env'); + }); }); diff --git a/packages/cli/src/commands/channel/runtime.ts b/packages/cli/src/commands/channel/runtime.ts index 3b85c64c825..cf7cc6f1135 100644 --- a/packages/cli/src/commands/channel/runtime.ts +++ b/packages/cli/src/commands/channel/runtime.ts @@ -205,6 +205,7 @@ export async function parseConfiguredChannels( name, rawConfig as Record, opts.defaultCwd, + { resolveEnvVars: 'available' }, ), }); } catch (err) { diff --git a/packages/cli/src/commands/channel/start.test.ts b/packages/cli/src/commands/channel/start.test.ts index 2384c956241..52e476dc55a 100644 --- a/packages/cli/src/commands/channel/start.test.ts +++ b/packages/cli/src/commands/channel/start.test.ts @@ -350,6 +350,29 @@ describe('startCommand.handler', () => { expect(mockChannelLoopStoreCreateForTarget).toHaveBeenCalledWith(input, 3); }); + it('uses available env-var resolution for single-channel config', async () => { + const channels = { telegram: { type: 'telegram', token: '$BOT_TOKEN' } }; + mockLoadSettings.mockReturnValue({ merged: { channels } }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit: ${String(code)}`); + }); + + try { + await expect(invokeStartHandler({ name: 'telegram' })).rejects.toThrow( + 'process.exit: 1', + ); + } finally { + exitSpy.mockRestore(); + } + + expect(mockParseChannelConfig).toHaveBeenCalledWith( + 'telegram', + channels.telegram, + process.cwd(), + { resolveEnvVars: 'available' }, + ); + }); + it('rejects cron expressions that cannot fire', async () => { const channels = { telegram: { type: 'telegram' } }; mockLoadSettings.mockReturnValue({ merged: { channels } }); diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index e099742ef28..a62f7b5a7b2 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -157,6 +157,8 @@ async function startSingle(name: string, proxy?: string): Promise { config = await parseChannelConfig( name, channelsConfig[name] as Record, + process.cwd(), + { resolveEnvVars: 'available' }, ); } catch (err) { writeStderrLine( diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index eb7ccb0ebd6..d01a1362728 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -106,6 +106,7 @@ { "path": "../channels/telegram" }, { "path": "../channels/weixin" }, { "path": "../channels/dingtalk" }, + { "path": "../channels/wecom" }, { "path": "../channels/feishu" } ] } diff --git a/scripts/build.js b/scripts/build.js index cb1243ae02e..4c7047328cc 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -56,6 +56,7 @@ const buildOrder = [ 'packages/channels/telegram', 'packages/channels/weixin', 'packages/channels/dingtalk', + 'packages/channels/wecom', 'packages/channels/feishu', 'packages/channels/qqbot', 'packages/channels/plugin-example',