From de769d5ad114dbf8b2c9e844585907d86728cad3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 11:11:49 +0800 Subject: [PATCH 01/73] feat(channels): add WeCom smart bot channel --- docs/users/features/channels/_meta.ts | 1 + docs/users/features/channels/wecom.md | 112 ++++ package-lock.json | 30 +- package.json | 1 + packages/channels/wecom/package.json | 29 + .../channels/wecom/src/WeComAdapter.test.ts | 272 +++++++++ packages/channels/wecom/src/WeComAdapter.ts | 540 ++++++++++++++++++ packages/channels/wecom/src/index.ts | 12 + packages/channels/wecom/tsconfig.json | 10 + packages/channels/wecom/vitest.config.ts | 17 + packages/cli/package.json | 1 + .../src/commands/channel/channel-registry.ts | 1 + .../src/commands/channel/config-utils.test.ts | 33 +- .../cli/src/commands/channel/config-utils.ts | 19 +- scripts/build.js | 1 + 15 files changed, 1072 insertions(+), 7 deletions(-) create mode 100644 docs/users/features/channels/wecom.md create mode 100644 packages/channels/wecom/package.json create mode 100644 packages/channels/wecom/src/WeComAdapter.test.ts create mode 100644 packages/channels/wecom/src/WeComAdapter.ts create mode 100644 packages/channels/wecom/src/index.ts create mode 100644 packages/channels/wecom/tsconfig.json create mode 100644 packages/channels/wecom/vitest.config.ts 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..b67c88c2f17 --- /dev/null +++ b/docs/users/features/channels/wecom.md @@ -0,0 +1,112 @@ +# 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`. + +## 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 local media generated by the agent, include one marker outside code blocks: + +```text +[IMAGE: /absolute/path/to/image.png] +[FILE: /absolute/path/to/report.pdf] +[VIDEO: /absolute/path/to/demo.mp4] +[VOICE: /absolute/path/to/audio.amr] +``` + +For safety, local media paths must be inside the channel `cwd` or the system temporary directory. + +## 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 fd328894a14..82faafa253a 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", @@ -3442,6 +3443,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 @@ -6549,6 +6554,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", @@ -10914,7 +10930,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": { @@ -17894,7 +17909,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -23188,6 +23202,17 @@ "typescript": "^5.0.0" } }, + "packages/channels/wecom": { + "name": "@qwen-code/channel-wecom", + "version": "0.19.5", + "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.5", @@ -23226,6 +23251,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 fa1cff9b29d..860ff481012 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/wecom/package.json b/packages/channels/wecom/package.json new file mode 100644 index 00000000000..bcfc401581c --- /dev/null +++ b/packages/channels/wecom/package.json @@ -0,0 +1,29 @@ +{ + "name": "@qwen-code/channel-wecom", + "version": "0.19.5", + "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..bb06a91711a --- /dev/null +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -0,0 +1,272 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { + ChannelAgentBridge, + ChannelConfig, + Envelope, +} from '@qwen-code/channel-base'; + +const mocks = vi.hoisted(() => { + const instances: MockWSClient[] = []; + + class MockWSClient { + readonly options: Record; + readonly handlers = new Map< + string, + Array<(payload: unknown) => void> + >(); + connect = vi.fn(async () => {}); + disconnect = vi.fn(); + sendMessage = vi.fn(async () => ({ headers: { req_id: 'req-1' } })); + uploadMedia = vi.fn(async () => ({ media_id: 'media-1' })); + sendMediaMessage = vi.fn(async () => ({ + headers: { req_id: 'media-req-1' }, + })); + downloadFile = vi.fn(async () => ({ + 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); + } + + emit(event: string, payload: unknown): void { + for (const handler of this.handlers.get(event) ?? []) { + handler(payload); + } + } + } + + return { MockWSClient, instances }; +}); + +vi.mock('@wecom/aibot-node-sdk', () => ({ + WSClient: mocks.MockWSClient, +})); + +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[] = []; + + override async handleInbound(envelope: Envelope): Promise { + this.envelopes.push(envelope); + } +} + +function lastClient(): MockWSClient { + const client = mocks.instances.at(-1); + if (!client) throw new Error('missing mock client'); + return client; +} + +describe('WeComChannel', () => { + beforeEach(() => { + mocks.instances.length = 0; + vi.clearAllMocks(); + }); + + 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'); + }); + + it('connects the official SDK with bot credentials', async () => { + 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); + }); + + it('normalizes text messages into envelopes', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), 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('normalizes group, voice, mixed, quote, and file messages', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + await channel.connect(); + const client = lastClient(); + client.downloadFile = vi.fn(async (url: string) => + url.includes('image') + ? { buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47]) } + : { buffer: Buffer.from('file-body'), filename: 'downloaded.txt' }, + ); + + 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: 'image', + 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(client.downloadFile).toHaveBeenCalledWith( + 'https://example.invalid/image', + 'k1', + ); + expect(client.downloadFile).toHaveBeenCalledWith( + 'https://example.invalid/file', + 'k2', + ); + const mixed = channel.envelopes[0]!; + expect(mixed.chatId).toBe('group-1'); + expect(mixed.isGroup).toBe(true); + expect(mixed.text).toBe('@bot inspect this'); + 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('sends markdown text and local media through the SDK', async () => { + const dir = mkdtempSync(join(tmpdir(), 'wecom-test-')); + 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', + `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), { + mediaType: 'image', + filename: 'out.png', + }); + expect(client.sendMediaMessage).toHaveBeenCalledWith( + 'chat-1', + 'image', + 'media-1', + ); + }); + + 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..f60f1d3196a --- /dev/null +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -0,0 +1,540 @@ +import { + mkdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { basename, join, resolve, win32, posix } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { Buffer } from 'node:buffer'; +import { WSClient } 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; +} + +interface WeComClient { + connect(): Promise; + disconnect(): void; + on(event: string, handler: (payload: unknown) => void): void; + sendMessage(chatId: string, message: unknown): Promise; + uploadMedia( + data: Buffer, + options: { mediaType: WeComMediaType; filename: string }, + ): Promise; + sendMediaMessage( + chatId: string, + mediaType: WeComMediaType, + mediaId: string, + ): Promise; + downloadFile( + url: string, + aesKey?: string, + ): Promise<{ buffer: Buffer; filename?: string }>; +} + +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 DEDUP_TTL_MS = 5 * 60 * 1000; +const MAX_OUTBOUND_MEDIA_BYTES = 20 * 1024 * 1024; + +export class WeComChannel extends ChannelBase { + private readonly wecom: WeComConfig; + private client?: WeComClient; + private readonly seenMessages = new Map(); + private dedupTimer?: ReturnType; + + 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; + + const options: WeComClientOptions = { + botId: this.wecom.botId, + secret: this.wecom.secret, + }; + if (this.wecom.wsUrl) { + options.wsUrl = this.wecom.wsUrl; + } + + const client = new ClientCtor(options); + for (const event of MESSAGE_EVENTS) { + client.on(event, (payload) => { + this.onMessage(payload).catch((err: unknown) => { + process.stderr.write( + `[WeCom:${this.name}] message handling failed: ${sanitizeLogText( + String(err), + 200, + )}\n`, + ); + }); + }); + } + client.on('error', (err) => { + process.stderr.write( + `[WeCom:${this.name}] SDK error: ${sanitizeLogText(String(err), 200)}\n`, + ); + }); + + await client.connect(); + this.client = client; + this.dedupTimer = setInterval(() => this.cleanupSeenMessages(), 60_000); + process.stderr.write(`[WeCom:${this.name}] Connected via smart bot.\n`); + } + + disconnect(): void { + if (this.dedupTimer) { + clearInterval(this.dedupTimer); + this.dedupTimer = undefined; + } + this.seenMessages.clear(); + this.client?.disconnect(); + this.client = undefined; + process.stderr.write(`[WeCom:${this.name}] Disconnected.\n`); + } + + async sendMessage(chatId: string, text: string): Promise { + if (!this.client) { + process.stderr.write( + `[WeCom:${this.name}] No active SDK client, cannot send.\n`, + ); + return; + } + + const { cleanedText, media } = parseOutboundMediaMarkers(text); + if (cleanedText) { + await this.client.sendMessage(chatId, { + msgtype: 'markdown', + markdown: { content: cleanedText }, + }); + } + + for (const item of media) { + const file = readOutboundMedia(item.path, this.config.cwd); + const upload = await this.client.uploadMedia(file.data, { + mediaType: item.type, + filename: file.fileName, + }); + const mediaId = extractMediaId(upload); + if (!mediaId) { + throw new Error('WeCom media upload failed: missing media_id'); + } + await this.client.sendMediaMessage(chatId, item.type, mediaId); + } + } + + private async onMessage(payload: unknown): Promise { + const body = extractBody(payload); + if (!body) return; + + const messageId = getString(body, 'msgid'); + if (messageId && this.seenMessages.has(messageId)) return; + if (messageId) this.seenMessages.set(messageId, Date.now()); + + const from = getRecord(body, 'from'); + const senderId = getString(from, 'userid') || ''; + const senderName = getString(from, 'name') || senderId || 'Unknown'; + const isGroup = getString(body, 'chattype') === 'group'; + const chatId = getString(body, 'chatid') || senderId; + if (!chatId || !senderId) return; + + const envelope: Envelope = { + channelName: this.name, + senderId, + senderName, + chatId, + text: extractText(body), + messageId, + isGroup, + isMentioned: true, + isReplyToBot: false, + referencedText: extractQuoteText(body), + }; + + try { + const attachments = await this.downloadAttachments(body); + 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'})`; + } + await this.handleInbound(envelope); + } catch (err) { + if (messageId) this.seenMessages.delete(messageId); + throw err; + } + } + + private async downloadAttachments( + body: Record, + ): Promise { + const client = this.client; + if (!client) return []; + + const refs = collectInboundMediaRefs(body); + const attachments: Attachment[] = []; + for (const ref of refs) { + const downloaded = await client.downloadFile(ref.url, ref.aesKey); + const data = downloaded.buffer; + const fileName = 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()); + mkdirSync(dir, { recursive: true }); + const safeName = sanitizeFileName(fileName) || `wecom_${ref.type}`; + const filePath = join(dir, safeName); + writeFileSync(filePath, data); + attachments.push({ + type: ref.type === 'voice' ? 'audio' : ref.type, + filePath, + mimeType: mediaTypeToMime(ref.type), + fileName: safeName, + }); + } + } + return attachments; + } + + 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 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'); + 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 extractBody(payload: unknown): Record | undefined { + const raw = asRecord(payload); + if (!raw) return undefined; + return getRecord(raw, 'body') ?? raw; +} + +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 || getString(record, 'msgtype') !== 'text') return ''; + return getString(getRecord(record, 'text'), 'content'); + }) + .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(body: Record): string | undefined { + const quote = getRecord(body, 'quote'); + if (!quote) return undefined; + return extractText(quote) || undefined; +} + +interface InboundMediaRef { + type: WeComMediaType; + url: string; + aesKey?: string; + fileName?: string; +} + +function collectInboundMediaRefs( + body: Record, +): InboundMediaRef[] { + const refs: InboundMediaRef[] = []; + const add = (type: WeComMediaType, source: Record): void => { + const url = getString(source, 'url'); + if (!url) return; + 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 (itemType === 'image') add('image', getRecord(record, 'image') ?? {}); + } + + add('image', getRecord(body, 'image') ?? {}); + add('file', getRecord(body, 'file') ?? {}); + add('video', getRecord(body, 'video') ?? {}); + + const quote = getRecord(body, 'quote'); + if (quote) refs.push(...collectInboundMediaRefs(quote)); + + 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]> = []; + for (const match of text.matchAll(/```[\s\S]*?```|`[^`]*`/g)) { + const start = match.index ?? 0; + ranges.push([start, start + match[0].length]); + } + return ranges; +} + +function isWeComMediaType(value: string | undefined): value is WeComMediaType { + return ( + value === 'image' || + value === 'file' || + value === 'voice' || + value === 'video' + ); +} + +function readOutboundMedia( + rawPath: string, + cwd: string, +): { data: Buffer; fileName: string } { + const resolved = resolve(rawPath); + const real = realpathSync(resolved); + const stat = statSync(real); + if (!stat.isFile()) throw new Error(`Not a regular file: ${real}`); + if (stat.size > MAX_OUTBOUND_MEDIA_BYTES) { + throw new Error(`Media file too large: ${stat.size} bytes`); + } + + const allowedDirs = [ + realpathSync(tmpdir()), + realpathSync(resolve(cwd)), + realpathSync('/tmp/'), + ]; + if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { + throw new Error(`Media path outside allowed directories: ${real}`); + } + return { data: readFileSync(real), fileName: basename(real) }; +} + +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)) + ); +} + +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(/[^\w.-]/g, '_').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 : ''; +} diff --git a/packages/channels/wecom/src/index.ts b/packages/channels/wecom/src/index.ts new file mode 100644 index 00000000000..0b29aa34174 --- /dev/null +++ b/packages/channels/wecom/src/index.ts @@ -0,0 +1,12 @@ +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'], + 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 a2437be2b14..4466051f6b0 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..2944e836041 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -15,11 +15,15 @@ vi.mock('./channel-registry.js', () => ({ channelType: 'dingtalk', requiredConfigFields: ['clientId', 'clientSecret'], }, + wecom: { + channelType: 'wecom', + requiredConfigFields: ['botId', 'secret'], + }, bare: { channelType: 'bare' }, // no requiredConfigFields }; return plugins[type]; }, - supportedTypes: async () => ['telegram', 'dingtalk', 'bare'], + supportedTypes: async () => ['telegram', 'dingtalk', 'wecom', 'bare'], })); describe('resolveEnvVars', () => { @@ -69,6 +73,33 @@ 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('throws a clear error when plugin-required fields are not strings', async () => { + await expect( + parseChannelConfig('bot', { + type: 'wecom', + botId: 123, + secret: 'secret', + }), + ).rejects.toThrow('Channel "bot" field "botId" must be a string.'); + }); + it('throws a clear error when token is not a string', async () => { await expect( parseChannelConfig('bot', { type: 'telegram', token: 123 }), diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 763c85f9947..2a5217ea28c 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -108,6 +108,8 @@ export async function parseChannelConfig( ); } + const resolvedRawConfig = { ...rawConfig }; + // Validate plugin-required fields for (const field of plugin.requiredConfigFields ?? []) { const value = rawConfig[field]; @@ -116,19 +118,28 @@ export async function parseChannelConfig( `Channel "${name}" (${channelType}) requires "${field}".`, ); } + if (typeof value !== 'string') { + throw new Error(`Channel "${name}" field "${field}" must be a string.`); + } + resolvedRawConfig[field] = resolveEnvVars(value); } // Resolve env vars for known credential fields - const token = resolveOptionalStringField(name, rawConfig, 'token') ?? ''; - const clientId = resolveOptionalStringField(name, rawConfig, 'clientId'); + const token = + resolveOptionalStringField(name, resolvedRawConfig, 'token') ?? ''; + const clientId = resolveOptionalStringField( + name, + resolvedRawConfig, + 'clientId', + ); const clientSecret = resolveOptionalStringField( name, - rawConfig, + resolvedRawConfig, 'clientSecret', ); return { - ...rawConfig, + ...resolvedRawConfig, type: channelType, token, clientId, 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', From f32f098cee0bdead2cde985da530b8aff206b44e Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 12:08:37 +0800 Subject: [PATCH 02/73] fix(channels): harden wecom review suggestions --- docs/users/features/channels/wecom.md | 2 + .../channels/wecom/src/WeComAdapter.test.ts | 102 +++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 89 +++++++++++++-- 3 files changed, 181 insertions(+), 12 deletions(-) diff --git a/docs/users/features/channels/wecom.md b/docs/users/features/channels/wecom.md index b67c88c2f17..3cb5bffa942 100644 --- a/docs/users/features/channels/wecom.md +++ b/docs/users/features/channels/wecom.md @@ -78,6 +78,8 @@ Open WeCom and send a message to the intelligent robot. 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 mentioned, matching WeCom intelligent robot API mode where group delivery is expected to be scoped by WeCom. + ## 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. diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index bb06a91711a..3414debc74d 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -13,10 +13,7 @@ const mocks = vi.hoisted(() => { class MockWSClient { readonly options: Record; - readonly handlers = new Map< - string, - Array<(payload: unknown) => void> - >(); + readonly handlers = new Map void>>(); connect = vi.fn(async () => {}); disconnect = vi.fn(); sendMessage = vi.fn(async () => ({ headers: { req_id: 'req-1' } })); @@ -53,7 +50,7 @@ vi.mock('@wecom/aibot-node-sdk', () => ({ WSClient: mocks.MockWSClient, })); -import { WeComChannel } from './WeComAdapter.js'; +import { WeComChannel, safeRealpath } from './WeComAdapter.js'; import { plugin } from './index.js'; type MockWSClient = InstanceType; @@ -232,6 +229,97 @@ describe('WeComChannel', () => { expect(file.attachments?.[0]?.filePath).toContain('report.pdf'); }); + it('honors explicit group mention metadata when present', async () => { + const channel = new TestWeComChannel('bot', makeConfig(), 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' }], + }); + + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(2)); + expect(channel.envelopes.map((envelope) => envelope.isMentioned)).toEqual([ + false, + true, + ]); + }); + + 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(); + client.downloadFile = vi.fn(async () => ({ + buffer: Buffer.alloc(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(stderr).toHaveBeenCalledWith( + expect.stringContaining('skipping oversized attachment'), + ); + stderr.mockRestore(); + }); + + 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(); + }); + it('sends markdown text and local media through the SDK', async () => { const dir = mkdtempSync(join(tmpdir(), 'wecom-test-')); const imagePath = join(dir, 'out.png'); @@ -265,6 +353,10 @@ describe('WeComChannel', () => { ); }); + it('ignores missing optional media allowlist directories', () => { + expect(safeRealpath('/definitely/missing/wecom-dir')).toBe(undefined); + }); + 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 index f60f1d3196a..9918c2b802c 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -177,15 +177,17 @@ export class WeComChannel extends ChannelBase { const chatId = getString(body, 'chatid') || senderId; if (!chatId || !senderId) return; + const text = extractText(body); + const explicitMention = getExplicitMention(body, this.wecom.botId); const envelope: Envelope = { channelName: this.name, senderId, senderName, chatId, - text: extractText(body), + text, messageId, isGroup, - isMentioned: true, + isMentioned: !isGroup || (explicitMention ?? true), isReplyToBot: false, referencedText: extractQuoteText(body), }; @@ -218,6 +220,15 @@ export class WeComChannel extends ChannelBase { for (const ref of refs) { const downloaded = await client.downloadFile(ref.url, ref.aesKey); const data = downloaded.buffer; + if (data.length > MAX_OUTBOUND_MEDIA_BYTES) { + process.stderr.write( + `[WeCom:${this.name}] skipping oversized attachment (${data.length} bytes): ${sanitizeLogText( + ref.url, + 200, + )}\n`, + ); + continue; + } const fileName = ref.fileName || downloaded.filename; if (ref.type === 'image') { attachments.push({ @@ -334,7 +345,10 @@ interface InboundMediaRef { function collectInboundMediaRefs( body: Record, + depth = 0, ): InboundMediaRef[] { + if (depth > 3) return []; + const refs: InboundMediaRef[] = []; const add = (type: WeComMediaType, source: Record): void => { const url = getString(source, 'url'); @@ -363,7 +377,7 @@ function collectInboundMediaRefs( add('video', getRecord(body, 'video') ?? {}); const quote = getRecord(body, 'quote'); - if (quote) refs.push(...collectInboundMediaRefs(quote)); + if (quote) refs.push(...collectInboundMediaRefs(quote, depth + 1)); return refs; } @@ -434,16 +448,24 @@ function readOutboundMedia( } const allowedDirs = [ - realpathSync(tmpdir()), - realpathSync(resolve(cwd)), - realpathSync('/tmp/'), - ]; + safeRealpath(tmpdir()), + safeRealpath(resolve(cwd)), + safeRealpath('/tmp/'), + ].filter((dir): dir is string => Boolean(dir)); if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { throw new Error(`Media path outside allowed directories: ${real}`); } return { data: readFileSync(real), fileName: basename(real) }; } +export function safeRealpath(path: string): string | undefined { + try { + return realpathSync(path); + } catch { + return undefined; + } +} + function isInsideDir(filePath: string, dir: string): boolean { const windowsStyle = /^[a-zA-Z]:[\\/]/.test(filePath); const pathImpl = windowsStyle ? win32 : posix; @@ -538,3 +560,56 @@ function getString( 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 explicitBoolean = + getBoolean(body, 'isMentioned') ?? + getBoolean(body, 'isInAtList') ?? + getBoolean(body, 'is_in_at_list'); + if (explicitBoolean !== undefined) return explicitBoolean; + + const mentions = collectMentionValues(body); + if (!mentions.length) return undefined; + + return mentions.some( + (mention) => mention === botId || mention === '@all' || mention === 'all', + ); +} + +function collectMentionValues(body: Record): string[] { + const values: string[] = []; + for (const key of [ + 'mentions', + 'mentioned_list', + 'mentionedList', + 'at_list', + 'atList', + 'at_userids', + 'atUserIds', + ]) { + 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 values; +} From b2dbd5ec1d48413af09210d5131688d34e2b6f1e Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 18:50:00 +0800 Subject: [PATCH 03/73] fix(channels): address wecom critical review --- docs/users/features/channels/wecom.md | 7 +-- .../channels/wecom/src/WeComAdapter.test.ts | 45 ++++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 34 +++++++++++++- .../src/commands/channel/config-utils.test.ts | 13 ++++++ .../cli/src/commands/channel/config-utils.ts | 11 ++--- 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/docs/users/features/channels/wecom.md b/docs/users/features/channels/wecom.md index 3cb5bffa942..dd1455ca013 100644 --- a/docs/users/features/channels/wecom.md +++ b/docs/users/features/channels/wecom.md @@ -84,16 +84,13 @@ When the WeCom SDK includes explicit mention metadata, Qwen Code uses it for thi 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 local media generated by the agent, include one marker outside code blocks: +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] -[FILE: /absolute/path/to/report.pdf] -[VIDEO: /absolute/path/to/demo.mp4] -[VOICE: /absolute/path/to/audio.amr] ``` -For safety, local media paths must be inside the channel `cwd` or the system temporary directory. +For safety, local image paths must be inside the channel `cwd` or the system temporary directory. Generic file, video, and voice upload markers are ignored because model-produced file paths could otherwise upload arbitrary workspace files. ## Troubleshooting diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 3414debc74d..32ef4acb29e 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -115,6 +115,9 @@ describe('WeComChannel', () => { }); 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' }), @@ -130,6 +133,21 @@ describe('WeComChannel', () => { 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( + expect.stringContaining('[WeCom:bot] SDK warn: No aesKey provided:'), + ); + stderr.mockRestore(); }); it('normalizes text messages into envelopes', async () => { @@ -343,7 +361,7 @@ describe('WeComChannel', () => { markdown: { content: 'result\n\n`[IMAGE: /tmp/example.png]`' }, }); expect(client.uploadMedia).toHaveBeenCalledWith(expect.any(Buffer), { - mediaType: 'image', + type: 'image', filename: 'out.png', }); expect(client.sendMediaMessage).toHaveBeenCalledWith( @@ -353,6 +371,31 @@ describe('WeComChannel', () => { ); }); + 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('ignores missing optional media allowlist directories', () => { expect(safeRealpath('/definitely/missing/wecom-dir')).toBe(undefined); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 9918c2b802c..f69192db90a 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -31,6 +31,7 @@ interface WeComClientOptions { botId: string; secret: string; wsUrl?: string; + logger?: WeComLogger; } interface WeComClient { @@ -40,7 +41,7 @@ interface WeComClient { sendMessage(chatId: string, message: unknown): Promise; uploadMedia( data: Buffer, - options: { mediaType: WeComMediaType; filename: string }, + options: { type: WeComMediaType; filename: string }, ): Promise; sendMediaMessage( chatId: string, @@ -53,6 +54,13 @@ interface WeComClient { ): Promise<{ buffer: Buffer; filename?: string }>; } +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; @@ -91,6 +99,7 @@ export class WeComChannel extends ChannelBase { const options: WeComClientOptions = { botId: this.wecom.botId, secret: this.wecom.secret, + logger: createWeComLogger(this.name), }; if (this.wecom.wsUrl) { options.wsUrl = this.wecom.wsUrl; @@ -149,9 +158,15 @@ export class WeComChannel extends ChannelBase { } for (const item of media) { + if (item.type !== 'image') { + process.stderr.write( + `[WeCom:${this.name}] skipping unsupported outbound media marker: ${item.type}\n`, + ); + continue; + } const file = readOutboundMedia(item.path, this.config.cwd); const upload = await this.client.uploadMedia(file.data, { - mediaType: item.type, + type: item.type, filename: file.fileName, }); const mediaId = extractMediaId(upload); @@ -293,6 +308,21 @@ function readOptionalString( return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +function createWeComLogger(name: string): WeComLogger { + const write = (level: 'warn' | 'error', parts: unknown[]): void => { + const text = parts.map((part) => String(part)).join(' '); + process.stderr.write( + `[WeCom:${name}] SDK ${level}: ${sanitizeLogText(text, 300)}\n`, + ); + }; + return { + debug: () => {}, + info: () => {}, + warn: (...parts) => write('warn', parts), + error: (...parts) => write('error', parts), + }; +} + function extractBody(payload: unknown): Record | undefined { const raw = asRecord(payload); if (!raw) return undefined; diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 2944e836041..15297795c45 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -161,6 +161,19 @@ describe('parseChannelConfig', () => { delete process.env['TEST_SEC']; }); + 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('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 2a5217ea28c..27a28196a2a 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -125,16 +125,11 @@ export async function parseChannelConfig( } // Resolve env vars for known credential fields - const token = - resolveOptionalStringField(name, resolvedRawConfig, 'token') ?? ''; - const clientId = resolveOptionalStringField( - name, - resolvedRawConfig, - 'clientId', - ); + const token = resolveOptionalStringField(name, rawConfig, 'token') ?? ''; + const clientId = resolveOptionalStringField(name, rawConfig, 'clientId'); const clientSecret = resolveOptionalStringField( name, - resolvedRawConfig, + rawConfig, 'clientSecret', ); From 6100fd165ca8b8ecca01f2ab748bb41092bb94e3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 18:58:26 +0800 Subject: [PATCH 04/73] fix(channels): include wecom mixed voice text --- packages/channels/wecom/src/WeComAdapter.test.ts | 3 ++- packages/channels/wecom/src/WeComAdapter.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 32ef4acb29e..03ff0a9c905 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -196,6 +196,7 @@ describe('WeComChannel', () => { 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' }, @@ -232,7 +233,7 @@ describe('WeComChannel', () => { const mixed = channel.envelopes[0]!; expect(mixed.chatId).toBe('group-1'); expect(mixed.isGroup).toBe(true); - expect(mixed.text).toBe('@bot inspect this'); + expect(mixed.text).toBe('@bot inspect this\nvoice transcript'); expect(mixed.referencedText).toBe('previous voice text'); expect(mixed.attachments?.[0]).toMatchObject({ type: 'image', diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index f69192db90a..a03d3c37adc 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -337,8 +337,15 @@ function extractText(body: Record): string { return items .map((item) => { const record = asRecord(item); - if (!record || getString(record, 'msgtype') !== 'text') return ''; - return getString(getRecord(record, 'text'), 'content'); + 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') From 1f86d110ca6f5f5ebef20b031758d220656b8f87 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 19:02:32 +0800 Subject: [PATCH 05/73] fix(channels): tighten wecom outbound media --- docs/users/features/channels/wecom.md | 2 +- .../channels/wecom/src/WeComAdapter.test.ts | 30 ++++++++++++++----- packages/channels/wecom/src/WeComAdapter.ts | 17 +++++------ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/docs/users/features/channels/wecom.md b/docs/users/features/channels/wecom.md index dd1455ca013..ba38ca14671 100644 --- a/docs/users/features/channels/wecom.md +++ b/docs/users/features/channels/wecom.md @@ -90,7 +90,7 @@ Assistant responses are sent as WeCom markdown. To send a local image generated [IMAGE: /absolute/path/to/image.png] ``` -For safety, local image paths must be inside the channel `cwd` or the system temporary directory. Generic file, video, and voice upload markers are ignored because model-produced file paths could otherwise upload arbitrary workspace files. +For safety, local image paths must be inside the channel file directory under the system temporary directory, such as `/tmp/channel-files/...`. Generic file, video, and voice upload markers are ignored because model-produced file paths could otherwise upload arbitrary workspace files. ## Troubleshooting diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 03ff0a9c905..ab8cdfc830e 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -340,15 +340,13 @@ describe('WeComChannel', () => { }); it('sends markdown text and local media through the SDK', async () => { - const dir = mkdtempSync(join(tmpdir(), 'wecom-test-')); + 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({ cwd: dir }), - makeBridge(), - ); + const channel = new WeComChannel('bot', makeConfig(), makeBridge()); await channel.connect(); const client = lastClient(); @@ -397,6 +395,24 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + it('rejects model-emitted image paths outside the channel file directory', async () => { + 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 expect( + channel.sendMessage('chat-1', `[IMAGE: ${secretPath}]`), + ).rejects.toThrow('outside allowed outbound directory'); + expect(client.uploadMedia).not.toHaveBeenCalled(); + }); + it('ignores missing optional media allowlist directories', () => { expect(safeRealpath('/definitely/missing/wecom-dir')).toBe(undefined); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index a03d3c37adc..0e6726a5cb3 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -164,7 +164,7 @@ export class WeComChannel extends ChannelBase { ); continue; } - const file = readOutboundMedia(item.path, this.config.cwd); + const file = readOutboundMedia(item.path); const upload = await this.client.uploadMedia(file.data, { type: item.type, filename: file.fileName, @@ -472,10 +472,10 @@ function isWeComMediaType(value: string | undefined): value is WeComMediaType { ); } -function readOutboundMedia( - rawPath: string, - cwd: string, -): { data: Buffer; fileName: string } { +function readOutboundMedia(rawPath: string): { + data: Buffer; + fileName: string; +} { const resolved = resolve(rawPath); const real = realpathSync(resolved); const stat = statSync(real); @@ -485,12 +485,11 @@ function readOutboundMedia( } const allowedDirs = [ - safeRealpath(tmpdir()), - safeRealpath(resolve(cwd)), - safeRealpath('/tmp/'), + safeRealpath(join(tmpdir(), 'channel-files')), + safeRealpath('/tmp/channel-files'), ].filter((dir): dir is string => Boolean(dir)); if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { - throw new Error(`Media path outside allowed directories: ${real}`); + throw new Error(`Media path outside allowed outbound directory: ${real}`); } return { data: readFileSync(real), fileName: basename(real) }; } From 30076d41623c7b357cd80ec638d4be64253e1cbd Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 20:07:02 +0800 Subject: [PATCH 06/73] fix(channels): harden wecom outbound sends --- .../channels/wecom/src/WeComAdapter.test.ts | 96 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 64 ++++++++++--- 2 files changed, 149 insertions(+), 11 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index ab8cdfc830e..981f05cadcf 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -370,6 +370,102 @@ describe('WeComChannel', () => { ); }); + 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 { + markdown: { content: string }; + }; + const second = client.sendMessage.mock.calls[1]?.[1] 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('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(client.uploadMedia).toHaveBeenCalledWith(expect.any(Buffer), { + type: 'image', + filename: 'out.png', + }); + expect(client.sendMediaMessage).toHaveBeenCalledWith( + 'chat-1', + 'image', + 'media-1', + ); + }); + + it('continues sending 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('does not upload arbitrary files from non-image media markers', async () => { const stderr = vi .spyOn(process.stderr, 'write') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 0e6726a5cb3..97daedc4254 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -8,7 +8,7 @@ import { import { randomUUID } from 'node:crypto'; import { basename, join, resolve, win32, posix } from 'node:path'; import { tmpdir } from 'node:os'; -import type { Buffer } from 'node:buffer'; +import { Buffer } from 'node:buffer'; import { WSClient } from '@wecom/aibot-node-sdk'; import { ChannelBase, sanitizeLogText } from '@qwen-code/channel-base'; import type { @@ -76,6 +76,7 @@ const MESSAGE_EVENTS = [ const DEDUP_TTL_MS = 5 * 60 * 1000; const MAX_OUTBOUND_MEDIA_BYTES = 20 * 1024 * 1024; +const MARKDOWN_CHUNK_BYTES = 3800; export class WeComChannel extends ChannelBase { private readonly wecom: WeComConfig; @@ -142,7 +143,8 @@ export class WeComChannel extends ChannelBase { } async sendMessage(chatId: string, text: string): Promise { - if (!this.client) { + const client = this.client; + if (!client) { process.stderr.write( `[WeCom:${this.name}] No active SDK client, cannot send.\n`, ); @@ -150,10 +152,10 @@ export class WeComChannel extends ChannelBase { } const { cleanedText, media } = parseOutboundMediaMarkers(text); - if (cleanedText) { - await this.client.sendMessage(chatId, { + for (const chunk of splitMarkdownChunks(cleanedText)) { + await client.sendMessage(chatId, { msgtype: 'markdown', - markdown: { content: cleanedText }, + markdown: { content: chunk }, }); } @@ -164,16 +166,19 @@ export class WeComChannel extends ChannelBase { ); continue; } - const file = readOutboundMedia(item.path); - const upload = await this.client.uploadMedia(file.data, { + const file = 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) { - throw new Error('WeCom media upload failed: missing media_id'); + process.stderr.write( + `[WeCom:${this.name}] upload returned no media_id, skipping.\n`, + ); + continue; } - await this.client.sendMediaMessage(chatId, item.type, mediaId); + await client.sendMediaMessage(chatId, item.type, mediaId); } } @@ -472,11 +477,48 @@ function isWeComMediaType(value: string | undefined): value is WeComMediaType { ); } -function readOutboundMedia(rawPath: string): { +function splitMarkdownChunks(text: string): string[] { + if (!text) return []; + + const chunks: string[] = []; + let current = ''; + for (const line of text.split('\n')) { + const candidate = current ? `${current}\n${line}` : line; + if (Buffer.byteLength(candidate, 'utf8') <= MARKDOWN_CHUNK_BYTES) { + current = candidate; + continue; + } + + if (current) { + chunks.push(current); + current = ''; + } + + let slice = ''; + for (const char of line) { + const next = slice + char; + if (Buffer.byteLength(next, 'utf8') > MARKDOWN_CHUNK_BYTES) { + if (slice) chunks.push(slice); + slice = char; + } else { + slice = next; + } + } + current = slice; + } + + if (current) chunks.push(current); + return chunks; +} + +function readOutboundMedia( + rawPath: string, + cwd: string, +): { data: Buffer; fileName: string; } { - const resolved = resolve(rawPath); + const resolved = resolve(cwd, rawPath); const real = realpathSync(resolved); const stat = statSync(real); if (!stat.isFile()) throw new Error(`Not a regular file: ${real}`); From 067d74c386fc1b99e15a8811f80aadad2c308476 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 21:14:30 +0800 Subject: [PATCH 07/73] fix(channels): address wecom review blockers --- packages/channels/base/src/ChannelBase.ts | 14 ++-- .../channels/wecom/src/WeComAdapter.test.ts | 81 +++++++++++++++++-- packages/channels/wecom/src/WeComAdapter.ts | 59 +++++++++----- .../cli/src/commands/channel/config-utils.ts | 25 +++++- .../cli/src/commands/channel/runtime.test.ts | 15 ++++ packages/cli/src/commands/channel/runtime.ts | 1 + packages/cli/src/commands/channel/start.ts | 2 + 7 files changed, 162 insertions(+), 35 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index c5dfabf18ea..02fef99b39b 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -2146,25 +2146,29 @@ 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 async preflightInbound(envelope: Envelope): 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; + return false; } + return true; + } + + async handleInbound(envelope: Envelope): Promise { + if (!(await this.preflightInbound(envelope))) return; + // 3. Slash command handling — before session/agent routing const parsed = this.parseCommand(envelope.text); if (parsed) { diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 981f05cadcf..c5d14e9c809 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -144,14 +144,19 @@ describe('WeComChannel', () => { expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining('secret')); logger.warn('No aesKey provided:', 'https://example.invalid/file'); - expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('[WeCom:bot] SDK warn: No aesKey provided:'), + expect(stderr).toHaveBeenCalledWith('[WeCom:bot] SDK warn event.\n'); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('https://example.invalid/file'), ); stderr.mockRestore(); }); it('normalizes text messages into envelopes', async () => { - const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + const channel = new TestWeComChannel( + 'bot', + makeConfig({ groupPolicy: 'open', groups: { '*': {} } }), + makeBridge(), + ); await channel.connect(); lastClient().emit('message.text', { @@ -178,7 +183,14 @@ describe('WeComChannel', () => { }); it('normalizes group, voice, mixed, quote, and file messages', async () => { - const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + const channel = new TestWeComChannel( + 'bot', + makeConfig({ + groupPolicy: 'open', + groups: { '*': { requireMention: false } }, + }), + makeBridge(), + ); await channel.connect(); const client = lastClient(); client.downloadFile = vi.fn(async (url: string) => @@ -249,7 +261,14 @@ describe('WeComChannel', () => { }); it('honors explicit group mention metadata when present', async () => { - const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + const channel = new TestWeComChannel( + 'bot', + makeConfig({ + groupPolicy: 'open', + groups: { '*': { requireMention: false } }, + }), + makeBridge(), + ); await channel.connect(); const client = lastClient(); @@ -279,6 +298,53 @@ describe('WeComChannel', () => { ]); }); + 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('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(channel.envelopes).toHaveLength(0); + }); + it('skips inbound attachments that exceed the media size cap', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -301,7 +367,10 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); expect(channel.envelopes[0]?.attachments).toBeUndefined(); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('skipping oversized attachment'), + expect.stringContaining('skipping oversized image attachment'), + ); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('https://example.invalid/large-image'), ); stderr.mockRestore(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 97daedc4254..ebb370995d1 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -3,6 +3,7 @@ import { readFileSync, realpathSync, statSync, + unlinkSync, writeFileSync, } from 'node:fs'; import { randomUUID } from 'node:crypto'; @@ -75,7 +76,7 @@ const MESSAGE_EVENTS = [ ] as const; const DEDUP_TTL_MS = 5 * 60 * 1000; -const MAX_OUTBOUND_MEDIA_BYTES = 20 * 1024 * 1024; +const MAX_MEDIA_BYTES = 20 * 1024 * 1024; const MARKDOWN_CHUNK_BYTES = 3800; export class WeComChannel extends ChannelBase { @@ -125,7 +126,12 @@ export class WeComChannel extends ChannelBase { ); }); - await client.connect(); + try { + await client.connect(); + } catch (err) { + client.disconnect(); + throw err; + } this.client = client; this.dedupTimer = setInterval(() => this.cleanupSeenMessages(), 60_000); process.stderr.write(`[WeCom:${this.name}] Connected via smart bot.\n`); @@ -212,8 +218,11 @@ export class WeComChannel extends ChannelBase { referencedText: extractQuoteText(body), }; + if (!(await this.preflightInbound(envelope))) return; + + let attachments: Attachment[] = []; try { - const attachments = await this.downloadAttachments(body); + attachments = await this.downloadAttachments(body); if (attachments.length) { envelope.attachments = attachments; } @@ -225,6 +234,14 @@ export class WeComChannel extends ChannelBase { await this.handleInbound(envelope); } catch (err) { if (messageId) this.seenMessages.delete(messageId); + for (const attachment of attachments) { + if (!attachment.filePath) continue; + try { + unlinkSync(attachment.filePath); + } catch { + // Best effort cleanup only. + } + } throw err; } } @@ -240,12 +257,9 @@ export class WeComChannel extends ChannelBase { for (const ref of refs) { const downloaded = await client.downloadFile(ref.url, ref.aesKey); const data = downloaded.buffer; - if (data.length > MAX_OUTBOUND_MEDIA_BYTES) { + if (data.length > MAX_MEDIA_BYTES) { process.stderr.write( - `[WeCom:${this.name}] skipping oversized attachment (${data.length} bytes): ${sanitizeLogText( - ref.url, - 200, - )}\n`, + `[WeCom:${this.name}] skipping oversized ${ref.type} attachment (${data.length} bytes).\n`, ); continue; } @@ -314,17 +328,14 @@ function readOptionalString( } function createWeComLogger(name: string): WeComLogger { - const write = (level: 'warn' | 'error', parts: unknown[]): void => { - const text = parts.map((part) => String(part)).join(' '); - process.stderr.write( - `[WeCom:${name}] SDK ${level}: ${sanitizeLogText(text, 300)}\n`, - ); + const write = (level: 'warn' | 'error'): void => { + process.stderr.write(`[WeCom:${name}] SDK ${level} event.\n`); }; return { debug: () => {}, info: () => {}, - warn: (...parts) => write('warn', parts), - error: (...parts) => write('error', parts), + warn: () => write('warn'), + error: () => write('error'), }; } @@ -417,6 +428,7 @@ function collectInboundMediaRefs( 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)); @@ -522,7 +534,7 @@ function readOutboundMedia( const real = realpathSync(resolved); const stat = statSync(real); if (!stat.isFile()) throw new Error(`Not a regular file: ${real}`); - if (stat.size > MAX_OUTBOUND_MEDIA_BYTES) { + if (stat.size > MAX_MEDIA_BYTES) { throw new Error(`Media file too large: ${stat.size} bytes`); } @@ -658,15 +670,19 @@ function getExplicitMention( if (explicitBoolean !== undefined) return explicitBoolean; const mentions = collectMentionValues(body); - if (!mentions.length) return undefined; + if (!mentions.present) return undefined; - return mentions.some( + return mentions.values.some( (mention) => mention === botId || mention === '@all' || mention === 'all', ); } -function collectMentionValues(body: Record): string[] { +function collectMentionValues(body: Record): { + present: boolean; + values: string[]; +} { const values: string[] = []; + let present = false; for (const key of [ 'mentions', 'mentioned_list', @@ -676,6 +692,9 @@ function collectMentionValues(body: Record): string[] { '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); @@ -689,5 +708,5 @@ function collectMentionValues(body: Record): string[] { } } } - return values; + return { present, values }; } diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 27a28196a2a..76446be76a6 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', + shouldResolveEnvVars: boolean, ): string | undefined { const value = rawConfig[field]; if (value === undefined || value === null || value === '') { @@ -32,7 +33,7 @@ function resolveOptionalStringField( `Channel "${channelName}" field "${field}" must be a string.`, ); } - return resolveEnvVars(value); + return shouldResolveEnvVars ? resolveEnvVars(value) : value; } /** @@ -94,6 +95,7 @@ export async function parseChannelConfig( name: string, rawConfig: Record, defaultCwd: string = process.cwd(), + options: { resolveEnvVars?: boolean } = {}, ): Promise> { if (!rawConfig['type']) { throw new Error(`Channel "${name}" is missing required field "type".`); @@ -109,6 +111,7 @@ export async function parseChannelConfig( } const resolvedRawConfig = { ...rawConfig }; + const shouldResolveEnvVars = options.resolveEnvVars ?? true; // Validate plugin-required fields for (const field of plugin.requiredConfigFields ?? []) { @@ -121,16 +124,30 @@ export async function parseChannelConfig( if (typeof value !== 'string') { throw new Error(`Channel "${name}" field "${field}" must be a string.`); } - resolvedRawConfig[field] = resolveEnvVars(value); + resolvedRawConfig[field] = shouldResolveEnvVars + ? resolveEnvVars(value) + : value; } // 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', + shouldResolveEnvVars, + ) ?? ''; + const clientId = resolveOptionalStringField( + name, + rawConfig, + 'clientId', + shouldResolveEnvVars, + ); const clientSecret = resolveOptionalStringField( name, rawConfig, 'clientSecret', + shouldResolveEnvVars, ); return { diff --git a/packages/cli/src/commands/channel/runtime.test.ts b/packages/cli/src/commands/channel/runtime.test.ts index 1fc00509069..d67b2086f1d 100644 --- a/packages/cli/src/commands/channel/runtime.test.ts +++ b/packages/cli/src/commands/channel/runtime.test.ts @@ -41,4 +41,19 @@ describe('parseConfiguredChannels', () => { }), ]); }); + + it('does not re-expand credentials already resolved by settings loading', async () => { + const parsed = await parseConfiguredChannels( + { + telegram: { + type: 'telegram', + token: '$TOKEN_LITERAL_VALUE', + }, + }, + ['telegram'], + { defaultCwd: '/workspace' }, + ); + + expect(parsed[0]?.config.token).toBe('$TOKEN_LITERAL_VALUE'); + }); }); diff --git a/packages/cli/src/commands/channel/runtime.ts b/packages/cli/src/commands/channel/runtime.ts index 3b85c64c825..32877a16e12 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: false }, ), }); } catch (err) { diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index e099742ef28..2f508ab4ba4 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: false }, ); } catch (err) { writeStderrLine( From 74563a1cc9d80fc9d57b8e4eaae219b97818a427 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 22:06:17 +0800 Subject: [PATCH 08/73] fix(channels): address wecom review followups --- packages/channels/wecom/src/WeComAdapter.test.ts | 10 ++++++++-- packages/channels/wecom/src/WeComAdapter.ts | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index c5d14e9c809..664b13b3b37 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -114,6 +114,12 @@ describe('WeComChannel', () => { ).toThrow('requires botId and secret'); }); + 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') @@ -447,10 +453,10 @@ describe('WeComChannel', () => { await channel.sendMessage('chat-1', 'a'.repeat(3900)); expect(client.sendMessage).toHaveBeenCalledTimes(2); - const first = client.sendMessage.mock.calls[0]?.[1] as { + const first = client.sendMessage.mock.calls[0]?.[1] as unknown as { markdown: { content: string }; }; - const second = client.sendMessage.mock.calls[1]?.[1] as { + const second = client.sendMessage.mock.calls[1]?.[1] as unknown as { markdown: { content: string }; }; expect(Buffer.byteLength(first.markdown.content, 'utf8')).toBeLessThan( diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index ebb370995d1..09eb61bc9b3 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -148,6 +148,10 @@ export class WeComChannel extends ChannelBase { 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) { @@ -538,8 +542,10 @@ function readOutboundMedia( throw new Error(`Media file too large: ${stat.size} bytes`); } + const channelFilesDir = join(tmpdir(), 'channel-files'); + mkdirSync(channelFilesDir, { recursive: true }); const allowedDirs = [ - safeRealpath(join(tmpdir(), 'channel-files')), + safeRealpath(channelFilesDir), safeRealpath('/tmp/channel-files'), ].filter((dir): dir is string => Boolean(dir)); if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { From 3c14ef92bc922ca4ae92ff9b693604559d86fd97 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 3 Jul 2026 22:15:43 +0800 Subject: [PATCH 09/73] fix(channels): harden wecom inbound handling --- packages/channels/base/src/ChannelBase.ts | 4 + .../channels/wecom/src/WeComAdapter.test.ts | 70 ++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 116 ++++++++++++++++-- 3 files changed, 178 insertions(+), 12 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 02fef99b39b..a86ad922aea 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -2169,6 +2169,10 @@ export abstract class ChannelBase { async handleInbound(envelope: Envelope): Promise { if (!(await this.preflightInbound(envelope))) return; + await this.processInbound(envelope); + } + + protected async processInbound(envelope: Envelope): Promise { // 3. Slash command handling — before session/agent routing const parsed = this.parseCommand(envelope.text); if (parsed) { diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 664b13b3b37..2a5582520f0 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { ChannelAgentBridge, ChannelConfig, @@ -88,7 +88,7 @@ function makeBridge(): ChannelAgentBridge { class TestWeComChannel extends WeComChannel { readonly envelopes: Envelope[] = []; - override async handleInbound(envelope: Envelope): Promise { + protected override async processInbound(envelope: Envelope): Promise { this.envelopes.push(envelope); } } @@ -102,9 +102,19 @@ function lastClient(): MockWSClient { describe('WeComChannel', () => { beforeEach(() => { mocks.instances.length = 0; + vi.stubGlobal( + 'fetch', + vi.fn( + async () => new Response(null, { headers: { 'content-length': '10' } }), + ), + ); vi.clearAllMocks(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('requires botId and secret', () => { expect( () => new WeComChannel('bot', makeConfig({ botId: '' }), makeBridge()), @@ -157,6 +167,25 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + it('clears the active SDK client when the websocket closes', 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('close', {}); + await channel.sendMessage('chat-1', 'hello'); + + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith('[WeCom:bot] WebSocket closed.\n'); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('No active SDK client'), + ); + stderr.mockRestore(); + }); + it('normalizes text messages into envelopes', async () => { const channel = new TestWeComChannel( 'bot', @@ -351,6 +380,34 @@ describe('WeComChannel', () => { expect(channel.envelopes).toHaveLength(0); }); + 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(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + it('skips inbound attachments that exceed the media size cap', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -358,9 +415,11 @@ describe('WeComChannel', () => { const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); await channel.connect(); const client = lastClient(); - client.downloadFile = vi.fn(async () => ({ - buffer: Buffer.alloc(20 * 1024 * 1024 + 1), - })); + vi.mocked(fetch).mockResolvedValueOnce( + new Response(null, { + headers: { 'content-length': String(20 * 1024 * 1024 + 1) }, + }), + ); client.emit('message.image', { msgid: 'msg-large-image', @@ -372,6 +431,7 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); expect(channel.envelopes[0]?.attachments).toBeUndefined(); + expect(client.downloadFile).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( expect.stringContaining('skipping oversized image attachment'), ); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 09eb61bc9b3..d791ce1f422 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -10,6 +10,7 @@ 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 } from 'node:net'; import { WSClient } from '@wecom/aibot-node-sdk'; import { ChannelBase, sanitizeLogText } from '@qwen-code/channel-base'; import type { @@ -125,14 +126,26 @@ export class WeComChannel extends ChannelBase { `[WeCom:${this.name}] SDK error: ${sanitizeLogText(String(err), 200)}\n`, ); }); + const markDisconnected = (reason: string): void => { + if (this.client !== client) return; + this.client = undefined; + if (this.dedupTimer) { + clearInterval(this.dedupTimer); + this.dedupTimer = undefined; + } + process.stderr.write(`[WeCom:${this.name}] WebSocket ${reason}.\n`); + }; + client.on('close', () => markDisconnected('closed')); + client.on('disconnect', () => markDisconnected('disconnected')); + this.client = client; try { await client.connect(); } catch (err) { + if (this.client === client) this.client = undefined; client.disconnect(); throw err; } - this.client = client; this.dedupTimer = setInterval(() => this.cleanupSeenMessages(), 60_000); process.stderr.write(`[WeCom:${this.name}] Connected via smart bot.\n`); } @@ -143,8 +156,9 @@ export class WeComChannel extends ChannelBase { this.dedupTimer = undefined; } this.seenMessages.clear(); - this.client?.disconnect(); + const client = this.client; this.client = undefined; + client?.disconnect(); process.stderr.write(`[WeCom:${this.name}] Disconnected.\n`); } @@ -235,7 +249,7 @@ export class WeComChannel extends ChannelBase { ? '(image)' : `(file: ${attachments[0]?.fileName ?? 'file'})`; } - await this.handleInbound(envelope); + await this.processInbound(envelope); } catch (err) { if (messageId) this.seenMessages.delete(messageId); for (const attachment of attachments) { @@ -259,6 +273,7 @@ export class WeComChannel extends ChannelBase { const refs = collectInboundMediaRefs(body); const attachments: Attachment[] = []; for (const ref of refs) { + if (!(await this.canDownloadAttachment(ref))) continue; const downloaded = await client.downloadFile(ref.url, ref.aesKey); const data = downloaded.buffer; if (data.length > MAX_MEDIA_BYTES) { @@ -292,6 +307,46 @@ export class WeComChannel extends ChannelBase { return attachments; } + private async canDownloadAttachment(ref: InboundMediaRef): Promise { + if (!isSafeInboundMediaUrl(ref.url)) { + process.stderr.write( + `[WeCom:${this.name}] skipping ${ref.type} attachment with unsafe media URL.\n`, + ); + return false; + } + try { + const response = await fetch(ref.url, { + method: 'HEAD', + signal: AbortSignal.timeout(10_000), + }); + const contentLengthHeader = response.headers.get('content-length'); + const contentLength = + contentLengthHeader === null ? Number.NaN : Number(contentLengthHeader); + if ( + !response.ok || + !Number.isFinite(contentLength) || + contentLength < 0 + ) { + process.stderr.write( + `[WeCom:${this.name}] skipping ${ref.type} attachment without verified content length.\n`, + ); + return false; + } + if (contentLength > MAX_MEDIA_BYTES) { + process.stderr.write( + `[WeCom:${this.name}] skipping oversized ${ref.type} attachment (${contentLength} bytes).\n`, + ); + return false; + } + return true; + } catch { + process.stderr.write( + `[WeCom:${this.name}] skipping ${ref.type} attachment after media metadata check failed.\n`, + ); + return false; + } + } + private cleanupSeenMessages(): void { const now = Date.now(); for (const [id, ts] of this.seenMessages) { @@ -542,11 +597,9 @@ function readOutboundMedia( throw new Error(`Media file too large: ${stat.size} bytes`); } - const channelFilesDir = join(tmpdir(), 'channel-files'); - mkdirSync(channelFilesDir, { recursive: true }); const allowedDirs = [ - safeRealpath(channelFilesDir), - safeRealpath('/tmp/channel-files'), + ensureDirectoryRealpath(join(tmpdir(), 'channel-files')), + ensureDirectoryRealpath('/tmp/channel-files'), ].filter((dir): dir is string => Boolean(dir)); if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { throw new Error(`Media path outside allowed outbound directory: ${real}`); @@ -562,6 +615,15 @@ export function safeRealpath(path: string): string | undefined { } } +function ensureDirectoryRealpath(path: string): string | undefined { + try { + mkdirSync(path, { recursive: true }); + return realpathSync(path); + } catch { + return undefined; + } +} + function isInsideDir(filePath: string, dir: string): boolean { const windowsStyle = /^[a-zA-Z]:[\\/]/.test(filePath); const pathImpl = windowsStyle ? win32 : posix; @@ -572,6 +634,46 @@ function isInsideDir(filePath: string, dir: string): boolean { ); } +function isSafeInboundMediaUrl(rawUrl: string): boolean { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return false; + } + if (url.protocol !== 'https:') return false; + + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (!host || host === 'localhost' || host.endsWith('.localhost')) { + return false; + } + if (host.endsWith('.local')) return false; + + const ipVersion = isIP(host); + if (ipVersion === 4) { + const parts = host.split('.').map((part) => Number(part)); + const [a = 0, b = 0] = parts; + return !( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); + } + if (ipVersion === 6) { + return !( + host === '::1' || + host.startsWith('fc') || + host.startsWith('fd') || + host.startsWith('fe80:') + ); + } + if (!host.includes('.')) return false; + return true; +} + function extractMediaId(value: unknown): string | undefined { const record = asRecord(value); return ( From ccd4f33404197e5e883f47a87228325adae453c0 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 00:13:46 +0800 Subject: [PATCH 10/73] fix(channels): address wecom auth and media review --- .../channels/wecom/src/WeComAdapter.test.ts | 172 +++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 185 +++++++++++++++--- 2 files changed, 321 insertions(+), 36 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 2a5582520f0..9238c267023 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -10,11 +10,19 @@ import type { const mocks = vi.hoisted(() => { const instances: MockWSClient[] = []; + const state = { + autoAuthenticate: true, + }; class MockWSClient { readonly options: Record; readonly handlers = new Map void>>(); - connect = vi.fn(async () => {}); + connect = vi.fn(() => { + if (state.autoAuthenticate) { + queueMicrotask(() => this.emit('authenticated', {})); + } + return this; + }); disconnect = vi.fn(); sendMessage = vi.fn(async () => ({ headers: { req_id: 'req-1' } })); uploadMedia = vi.fn(async () => ({ media_id: 'media-1' })); @@ -43,7 +51,7 @@ const mocks = vi.hoisted(() => { } } - return { MockWSClient, instances }; + return { MockWSClient, instances, state }; }); vi.mock('@wecom/aibot-node-sdk', () => ({ @@ -93,6 +101,18 @@ class TestWeComChannel extends WeComChannel { } } +class FailingPreflightWeComChannel extends WeComChannel { + readonly preflights = vi.fn(async () => { + throw new Error('preflight failed'); + }); + + protected override async preflightInbound( + envelope: Envelope, + ): Promise { + return this.preflights(envelope); + } +} + function lastClient(): MockWSClient { const client = mocks.instances.at(-1); if (!client) throw new Error('missing mock client'); @@ -102,11 +122,10 @@ function lastClient(): MockWSClient { describe('WeComChannel', () => { beforeEach(() => { mocks.instances.length = 0; + mocks.state.autoAuthenticate = true; vi.stubGlobal( 'fetch', - vi.fn( - async () => new Response(null, { headers: { 'content-length': '10' } }), - ), + vi.fn(async () => new Response('ok')), ); vi.clearAllMocks(); }); @@ -167,7 +186,48 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('clears the active SDK client when the websocket closes', async () => { + 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('rejects startup 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 expect(connecting).rejects.toThrow( + 'WeCom disconnected before authentication', + ); + expect(client.disconnect).toHaveBeenCalledTimes(1); + stderr.mockRestore(); + }); + + it('clears the active SDK client when the websocket disconnects', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -175,7 +235,7 @@ describe('WeComChannel', () => { await channel.connect(); const client = lastClient(); - client.emit('close', {}); + client.emit('disconnected', 'closed'); await channel.sendMessage('chat-1', 'hello'); expect(client.sendMessage).not.toHaveBeenCalled(); @@ -380,6 +440,38 @@ describe('WeComChannel', () => { expect(channel.envelopes).toHaveLength(0); }); + 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( + expect.stringContaining('message handling failed'), + ), + ); + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(2)); + stderr.mockRestore(); + }); + it('does not download attachments from unsafe media URLs', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -408,6 +500,72 @@ describe('WeComChannel', () => { 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(fetch).not.toHaveBeenCalled(); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + + it('does not follow redirects while probing inbound media', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + vi.mocked(fetch).mockResolvedValueOnce( + new Response(null, { + status: 302, + 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(fetch).toHaveBeenCalledWith('https://example.invalid/redirect', { + method: 'GET', + redirect: 'manual', + signal: expect.any(AbortSignal), + }); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('attachment with redirect'), + ); + stderr.mockRestore(); + }); + it('skips inbound attachments that exceed the media size cap', async () => { const stderr = vi .spyOn(process.stderr, 'write') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index d791ce1f422..8c5324281ea 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -37,7 +37,7 @@ interface WeComClientOptions { } interface WeComClient { - connect(): Promise; + connect(): unknown; disconnect(): void; on(event: string, handler: (payload: unknown) => void): void; sendMessage(chatId: string, message: unknown): Promise; @@ -79,6 +79,7 @@ const MESSAGE_EVENTS = [ 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; export class WeComChannel extends ChannelBase { private readonly wecom: WeComConfig; @@ -135,13 +136,18 @@ export class WeComChannel extends ChannelBase { } process.stderr.write(`[WeCom:${this.name}] WebSocket ${reason}.\n`); }; - client.on('close', () => markDisconnected('closed')); - client.on('disconnect', () => markDisconnected('disconnected')); + client.on('disconnected', (reason) => + markDisconnected(formatDisconnectReason(reason)), + ); this.client = client; + const authentication = waitForAuthentication(client); try { - await client.connect(); + const connected = client.connect(); + if (isPromiseLike(connected)) await connected; + await authentication.promise; } catch (err) { + authentication.cancel(); if (this.client === client) this.client = undefined; client.disconnect(); throw err; @@ -236,11 +242,10 @@ export class WeComChannel extends ChannelBase { referencedText: extractQuoteText(body), }; - if (!(await this.preflightInbound(envelope))) return; - let attachments: Attachment[] = []; try { - attachments = await this.downloadAttachments(body); + if (!(await this.preflightInbound(envelope))) return; + attachments = await this.downloadAttachments(body, attachments); if (attachments.length) { envelope.attachments = attachments; } @@ -266,12 +271,12 @@ export class WeComChannel extends ChannelBase { private async downloadAttachments( body: Record, + attachments: Attachment[] = [], ): Promise { const client = this.client; if (!client) return []; const refs = collectInboundMediaRefs(body); - const attachments: Attachment[] = []; for (const ref of refs) { if (!(await this.canDownloadAttachment(ref))) continue; const downloaded = await client.downloadFile(ref.url, ref.aesKey); @@ -316,28 +321,35 @@ export class WeComChannel extends ChannelBase { } try { const response = await fetch(ref.url, { - method: 'HEAD', + method: 'GET', + redirect: 'manual', signal: AbortSignal.timeout(10_000), }); - const contentLengthHeader = response.headers.get('content-length'); - const contentLength = - contentLengthHeader === null ? Number.NaN : Number(contentLengthHeader); - if ( - !response.ok || - !Number.isFinite(contentLength) || - contentLength < 0 - ) { + if (response.status >= 300 && response.status < 400) { + process.stderr.write( + `[WeCom:${this.name}] skipping ${ref.type} attachment with redirect.\n`, + ); + return false; + } + if (!response.ok) { process.stderr.write( - `[WeCom:${this.name}] skipping ${ref.type} attachment without verified content length.\n`, + `[WeCom:${this.name}] skipping ${ref.type} attachment after media probe failed.\n`, ); return false; } - if (contentLength > MAX_MEDIA_BYTES) { + const contentLength = getContentLength(response); + if (contentLength !== undefined && contentLength > MAX_MEDIA_BYTES) { process.stderr.write( `[WeCom:${this.name}] skipping oversized ${ref.type} attachment (${contentLength} bytes).\n`, ); return false; } + if (!(await responseBodyWithinLimit(response))) { + process.stderr.write( + `[WeCom:${this.name}] skipping oversized ${ref.type} attachment.\n`, + ); + return false; + } return true; } catch { process.stderr.write( @@ -357,6 +369,69 @@ export class WeComChannel extends ChannelBase { } } +function waitForAuthentication(client: WeComClient): { + promise: Promise; + cancel(): void; +} { + let timeout: ReturnType | undefined; + let settled = false; + let finish: (err?: Error) => void = () => {}; + + const promise = new Promise((resolvePromise, rejectPromise) => { + finish = (err?: Error): void => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + if (err) { + rejectPromise(err); + } else { + resolvePromise(); + } + }; + + timeout = setTimeout(() => { + finish(new Error('WeCom authentication timed out.')); + }, AUTHENTICATION_TIMEOUT_MS); + timeout.unref?.(); + + client.on('authenticated', () => finish()); + client.on('error', (err) => + finish( + new Error( + `WeCom authentication failed: ${sanitizeLogText(String(err), 200)}`, + ), + ), + ); + client.on('disconnected', (reason) => + finish( + new Error( + `WeCom disconnected before authentication: ${formatDisconnectReason( + reason, + )}`, + ), + ), + ); + }); + return { + promise, + cancel: () => finish(), + }; +} + +function formatDisconnectReason(reason: unknown): string { + const text = typeof reason === 'string' && reason ? reason : 'disconnected'; + return sanitizeLogText(text, 120); +} + +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, @@ -634,6 +709,33 @@ function isInsideDir(filePath: string, dir: string): boolean { ); } +function getContentLength(response: Response): number | undefined { + const header = response.headers.get('content-length'); + if (header === null) return undefined; + const size = Number(header); + return Number.isFinite(size) && size >= 0 ? size : undefined; +} + +async function responseBodyWithinLimit(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) return true; + + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return true; + total += value.byteLength; + if (total > MAX_MEDIA_BYTES) { + await reader.cancel().catch(() => {}); + return false; + } + } + } finally { + reader.releaseLock(); + } +} + function isSafeInboundMediaUrl(rawUrl: string): boolean { let url: URL; try { @@ -651,18 +753,11 @@ function isSafeInboundMediaUrl(rawUrl: string): boolean { const ipVersion = isIP(host); if (ipVersion === 4) { - const parts = host.split('.').map((part) => Number(part)); - const [a = 0, b = 0] = parts; - return !( - a === 0 || - a === 10 || - a === 127 || - (a === 169 && b === 254) || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) - ); + return isPublicIpv4(host.split('.').map((part) => Number(part))); } if (ipVersion === 6) { + const mapped = parseMappedIpv4(host); + if (mapped) return isPublicIpv4(mapped); return !( host === '::1' || host.startsWith('fc') || @@ -674,6 +769,38 @@ function isSafeInboundMediaUrl(rawUrl: string): boolean { return true; } +function parseMappedIpv4(host: string): number[] | undefined { + if (!host.startsWith('::ffff:')) return undefined; + const suffix = host.slice('::ffff:'.length); + if (suffix.includes('.')) { + return suffix.split('.').map((part) => Number(part)); + } + 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 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] = parts; + return !( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); +} + function extractMediaId(value: unknown): string | undefined { const record = asRecord(value); return ( From 4588045231bcfa5c49372d45d3c616c6760adfcf Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 00:16:46 +0800 Subject: [PATCH 11/73] fix(channels): tighten wecom inbound cleanup --- .../channels/wecom/src/WeComAdapter.test.ts | 40 +++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 47 +++++++++++++++---- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 9238c267023..1a04a4458e9 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -141,6 +141,14 @@ describe('WeComChannel', () => { 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', () => { @@ -353,6 +361,7 @@ describe('WeComChannel', () => { expect(file.attachments?.[0]?.type).toBe('file'); expect(file.attachments?.[0]?.fileName).toBe('report.pdf'); expect(file.attachments?.[0]?.filePath).toContain('report.pdf'); + expect(existsSync(file.attachments?.[0]?.filePath ?? '')).toBe(false); }); it('honors explicit group mention metadata when present', async () => { @@ -440,6 +449,35 @@ describe('WeComChannel', () => { 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('rolls back message dedup when preflight work fails', async () => { const stderr = vi .spyOn(process.stderr, 'write') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 8c5324281ea..7e1b5ef5638 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1,4 +1,5 @@ import { + lstatSync, mkdirSync, readFileSync, realpathSync, @@ -224,8 +225,16 @@ export class WeComChannel extends ChannelBase { const senderId = getString(from, 'userid') || ''; const senderName = getString(from, 'name') || senderId || 'Unknown'; const isGroup = getString(body, 'chattype') === 'group'; - const chatId = getString(body, 'chatid') || senderId; - if (!chatId || !senderId) return; + const rawChatId = getString(body, 'chatid'); + const chatId = isGroup ? rawChatId : rawChatId || senderId; + if (!chatId || !senderId) { + process.stderr.write( + `[WeCom:${this.name}] dropping message ${ + messageId || '(no id)' + }: missing ${!senderId ? 'senderId' : 'chatId'}.\n`, + ); + return; + } const text = extractText(body); const explicitMention = getExplicitMention(body, this.wecom.botId); @@ -255,16 +264,10 @@ export class WeComChannel extends ChannelBase { : `(file: ${attachments[0]?.fileName ?? 'file'})`; } await this.processInbound(envelope); + cleanupAttachmentFiles(attachments); } catch (err) { if (messageId) this.seenMessages.delete(messageId); - for (const attachment of attachments) { - if (!attachment.filePath) continue; - try { - unlinkSync(attachment.filePath); - } catch { - // Best effort cleanup only. - } - } + cleanupAttachmentFiles(attachments); throw err; } } @@ -442,9 +445,23 @@ function parseWeComConfig( 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 cleanupAttachmentFiles(attachments: Attachment[]): void { + for (const attachment of attachments) { + if (!attachment.filePath) continue; + try { + unlinkSync(attachment.filePath); + } catch { + // Best effort cleanup only. + } + } +} + function readRequiredString( config: Record, key: string, @@ -461,6 +478,14 @@ function readOptionalString( 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'): void => { process.stderr.write(`[WeCom:${name}] SDK ${level} event.\n`); @@ -693,6 +718,8 @@ export function safeRealpath(path: string): string | undefined { function ensureDirectoryRealpath(path: string): string | undefined { try { mkdirSync(path, { recursive: true }); + const stat = lstatSync(path); + if (!stat.isDirectory() || stat.isSymbolicLink()) return undefined; return realpathSync(path); } catch { return undefined; From 5a2abaf57f1cdc2c11b90224d7b68c16c3995907 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 01:09:32 +0800 Subject: [PATCH 12/73] fix(channels): harden wecom media safety --- .../channels/wecom/src/WeComAdapter.test.ts | 132 +++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 80 +++++++++-- 2 files changed, 193 insertions(+), 19 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 1a04a4458e9..c0d8076452c 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -10,6 +10,7 @@ import type { const mocks = vi.hoisted(() => { const instances: MockWSClient[] = []; + const lookup = vi.fn(async () => [{ address: '203.0.113.10', family: 4 }]); const state = { autoAuthenticate: true, }; @@ -51,12 +52,15 @@ const mocks = vi.hoisted(() => { } } - return { MockWSClient, instances, state }; + return { MockWSClient, instances, lookup, state }; }); vi.mock('@wecom/aibot-node-sdk', () => ({ WSClient: mocks.MockWSClient, })); +vi.mock('node:dns/promises', () => ({ + lookup: mocks.lookup, +})); import { WeComChannel, safeRealpath } from './WeComAdapter.js'; import { plugin } from './index.js'; @@ -128,6 +132,7 @@ describe('WeComChannel', () => { vi.fn(async () => new Response('ok')), ); vi.clearAllMocks(); + mocks.lookup.mockResolvedValue([{ address: '203.0.113.10', family: 4 }]); }); afterEach(() => { @@ -566,6 +571,68 @@ describe('WeComChannel', () => { 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(fetch).not.toHaveBeenCalled(); + expect(client.downloadFile).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(fetch).not.toHaveBeenCalled(); + expect(client.downloadFile).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('unsafe media URL'), + ); + stderr.mockRestore(); + }); + it('does not follow redirects while probing inbound media', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -797,6 +864,55 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + it('continues sending 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(new Error('network failed')) + .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'), + ); + stderr.mockRestore(); + }); + it('does not upload arbitrary files from non-image media markers', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -822,7 +938,10 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('rejects model-emitted image paths outside the channel file directory', async () => { + 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'); @@ -834,10 +953,13 @@ describe('WeComChannel', () => { await channel.connect(); const client = lastClient(); - await expect( - channel.sendMessage('chat-1', `[IMAGE: ${secretPath}]`), - ).rejects.toThrow('outside allowed outbound directory'); + await channel.sendMessage('chat-1', `[IMAGE: ${secretPath}]`); + expect(client.uploadMedia).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('outside allowed outbound directory'), + ); + stderr.mockRestore(); }); it('ignores missing optional media allowlist directories', () => { diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 7e1b5ef5638..391ba6d4dd6 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -12,6 +12,7 @@ import { basename, join, resolve, win32, posix } from 'node:path'; import { tmpdir } from 'node:os'; import { Buffer } from 'node:buffer'; import { isIP } from 'node:net'; +import { lookup } from 'node:dns/promises'; import { WSClient } from '@wecom/aibot-node-sdk'; import { ChannelBase, sanitizeLogText } from '@qwen-code/channel-base'; import type { @@ -197,19 +198,28 @@ export class WeComChannel extends ChannelBase { ); continue; } - const file = 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) { + try { + const file = 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) { + process.stderr.write( + `[WeCom:${this.name}] upload returned no media_id, skipping.\n`, + ); + continue; + } + await client.sendMediaMessage(chatId, item.type, mediaId); + } catch (err) { process.stderr.write( - `[WeCom:${this.name}] upload returned no media_id, skipping.\n`, + `[WeCom:${this.name}] media send failed for ${item.type}: ${sanitizeLogText( + String(err), + 200, + )}\n`, ); - continue; } - await client.sendMediaMessage(chatId, item.type, mediaId); } } @@ -316,7 +326,7 @@ export class WeComChannel extends ChannelBase { } private async canDownloadAttachment(ref: InboundMediaRef): Promise { - if (!isSafeInboundMediaUrl(ref.url)) { + if (!(await isSafeInboundMediaUrl(ref.url))) { process.stderr.write( `[WeCom:${this.name}] skipping ${ref.type} attachment with unsafe media URL.\n`, ); @@ -763,7 +773,7 @@ async function responseBodyWithinLimit(response: Response): Promise { } } -function isSafeInboundMediaUrl(rawUrl: string): boolean { +async function isSafeInboundMediaUrl(rawUrl: string): Promise { let url: URL; try { url = new URL(rawUrl); @@ -780,7 +790,8 @@ function isSafeInboundMediaUrl(rawUrl: string): boolean { const ipVersion = isIP(host); if (ipVersion === 4) { - return isPublicIpv4(host.split('.').map((part) => Number(part))); + const parts = parseIpv4Parts(host); + return parts ? isPublicIpv4(parts) : false; } if (ipVersion === 6) { const mapped = parseMappedIpv4(host); @@ -793,7 +804,47 @@ function isSafeInboundMediaUrl(rawUrl: string): boolean { ); } if (!host.includes('.')) return false; - return true; + try { + const records = await lookup(host, { all: true }); + return ( + records.length > 0 && + records.every((record) => isPublicIpAddress(record.address)) + ); + } catch { + return false; + } +} + +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 mapped = parseMappedIpv4(host); + if (mapped) return isPublicIpv4(mapped); + return !( + host === '::' || + host === '::1' || + host.startsWith('fc') || + host.startsWith('fd') || + host.startsWith('fe80:') + ); + } + return false; +} + +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 { @@ -822,6 +873,7 @@ function isPublicIpv4(parts: number[]): boolean { 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 === 168) From 547f67f85b36a81dde32103c0f18788239cac5f3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 02:11:53 +0800 Subject: [PATCH 13/73] fix(channels): address wecom review typecheck --- .../channels/wecom/src/WeComAdapter.test.ts | 33 +++++++++++++++---- packages/channels/wecom/src/WeComAdapter.ts | 19 ++++++----- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index c0d8076452c..511bc9378d3 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -25,12 +25,20 @@ const mocks = vi.hoisted(() => { return this; }); disconnect = vi.fn(); - sendMessage = vi.fn(async () => ({ headers: { req_id: 'req-1' } })); - uploadMedia = vi.fn(async () => ({ media_id: 'media-1' })); - sendMediaMessage = vi.fn(async () => ({ - headers: { req_id: 'media-req-1' }, + sendMessage = vi.fn(async (_chatId: string, _message: unknown) => ({ + headers: { req_id: 'req-1' }, })); - downloadFile = vi.fn(async () => ({ + 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'), })); @@ -106,7 +114,7 @@ class TestWeComChannel extends WeComChannel { } class FailingPreflightWeComChannel extends WeComChannel { - readonly preflights = vi.fn(async () => { + readonly preflights = vi.fn(async (_envelope: Envelope) => { throw new Error('preflight failed'); }); @@ -399,11 +407,22 @@ describe('WeComChannel', () => { 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(2)); + await vi.waitFor(() => expect(channel.envelopes).toHaveLength(3)); expect(channel.envelopes.map((envelope) => envelope.isMentioned)).toEqual([ false, true, + false, ]); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 391ba6d4dd6..2077a07f7ca 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -676,13 +676,16 @@ function splitMarkdownChunks(text: string): string[] { } let slice = ''; + let sliceBytes = 0; for (const char of line) { - const next = slice + char; - if (Buffer.byteLength(next, 'utf8') > MARKDOWN_CHUNK_BYTES) { + const charBytes = Buffer.byteLength(char, 'utf8'); + if (sliceBytes + charBytes > MARKDOWN_CHUNK_BYTES) { if (slice) chunks.push(slice); slice = char; + sliceBytes = charBytes; } else { - slice = next; + slice += char; + sliceBytes += charBytes; } } current = slice; @@ -977,14 +980,12 @@ function getExplicitMention( body: Record, botId: string, ): boolean | undefined { - const explicitBoolean = - getBoolean(body, 'isMentioned') ?? - getBoolean(body, 'isInAtList') ?? - getBoolean(body, 'is_in_at_list'); - if (explicitBoolean !== undefined) return explicitBoolean; + const botSpecificMention = + getBoolean(body, 'isInAtList') ?? getBoolean(body, 'is_in_at_list'); + if (botSpecificMention !== undefined) return botSpecificMention; const mentions = collectMentionValues(body); - if (!mentions.present) return undefined; + if (!mentions.present) return getBoolean(body, 'isMentioned'); return mentions.values.some( (mention) => mention === botId || mention === '@all' || mention === 'all', From 46a2efc1e83466141ffe44e5d4f5b07876e9daa2 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 03:08:43 +0800 Subject: [PATCH 14/73] fix(channels): harden wecom media review gaps --- .../channels/wecom/src/WeComAdapter.test.ts | 55 ++++++++- packages/channels/wecom/src/WeComAdapter.ts | 108 ++++++++++++++++-- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 511bc9378d3..8c2a313ba6a 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -200,7 +200,9 @@ describe('WeComChannel', () => { expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining('secret')); logger.warn('No aesKey provided:', 'https://example.invalid/file'); - expect(stderr).toHaveBeenCalledWith('[WeCom:bot] SDK warn event.\n'); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] SDK warn: No aesKey provided:\n', + ); expect(stderr).not.toHaveBeenCalledWith( expect.stringContaining('https://example.invalid/file'), ); @@ -298,6 +300,23 @@ describe('WeComChannel', () => { }); }); + 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', @@ -331,6 +350,7 @@ describe('WeComChannel', () => { }, ], }, + image: { url: 'https://example.invalid/image', aeskey: 'k1' }, quote: { msgtype: 'voice', voice: { content: 'previous voice text' }, @@ -358,6 +378,7 @@ describe('WeComChannel', () => { 'https://example.invalid/file', 'k2', ); + expect(client.downloadFile).toHaveBeenCalledTimes(2); const mixed = channel.envelopes[0]!; expect(mixed.chatId).toBe('group-1'); expect(mixed.isGroup).toBe(true); @@ -590,6 +611,38 @@ describe('WeComChannel', () => { 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::a9fe:a9fe]/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(fetch).not.toHaveBeenCalled(); + expect(client.downloadFile).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') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 2077a07f7ca..eaf020a11d6 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -225,7 +225,12 @@ export class WeComChannel extends ChannelBase { private async onMessage(payload: unknown): Promise { const body = extractBody(payload); - if (!body) return; + if (!body) { + process.stderr.write( + `[WeCom:${this.name}] dropping message with unrecognized payload structure.\n`, + ); + return; + } const messageId = getString(body, 'msgid'); if (messageId && this.seenMessages.has(messageId)) return; @@ -497,14 +502,16 @@ function isSecureWebSocketUrl(value: string): boolean { } function createWeComLogger(name: string): WeComLogger { - const write = (level: 'warn' | 'error'): void => { - process.stderr.write(`[WeCom:${name}] SDK ${level} event.\n`); + const write = (level: 'warn' | 'error', message: string): void => { + process.stderr.write( + `[WeCom:${name}] SDK ${level}: ${sanitizeLogText(message, 200)}\n`, + ); }; return { debug: () => {}, info: () => {}, - warn: () => write('warn'), - error: () => write('error'), + warn: (message: string) => write('warn', message), + error: (message: string) => write('error', message), }; } @@ -572,9 +579,11 @@ function collectInboundMediaRefs( if (depth > 3) return []; const refs: InboundMediaRef[] = []; + const seenUrls = new Set(); const add = (type: WeComMediaType, source: Record): void => { const url = getString(source, 'url'); - if (!url) return; + if (!url || seenUrls.has(url)) return; + seenUrls.add(url); refs.push({ type, url, @@ -591,7 +600,9 @@ function collectInboundMediaRefs( const record = asRecord(item); if (!record) continue; const itemType = getString(record, 'msgtype'); - if (itemType === 'image') add('image', getRecord(record, 'image') ?? {}); + if (isWeComMediaType(itemType)) { + add(itemType, getRecord(record, itemType) ?? {}); + } } add('image', getRecord(body, 'image') ?? {}); @@ -797,9 +808,10 @@ async function isSafeInboundMediaUrl(rawUrl: string): Promise { return parts ? isPublicIpv4(parts) : false; } if (ipVersion === 6) { - const mapped = parseMappedIpv4(host); - if (mapped) return isPublicIpv4(mapped); + const embedded = parseEmbeddedIpv4(host); + if (embedded) return isPublicIpv4(embedded); return !( + host === '::' || host === '::1' || host.startsWith('fc') || host.startsWith('fd') || @@ -826,8 +838,8 @@ function isPublicIpAddress(address: string): boolean { return parts ? isPublicIpv4(parts) : false; } if (ipVersion === 6) { - const mapped = parseMappedIpv4(host); - if (mapped) return isPublicIpv4(mapped); + const embedded = parseEmbeddedIpv4(host); + if (embedded) return isPublicIpv4(embedded); return !( host === '::' || host === '::1' || @@ -854,7 +866,7 @@ function parseMappedIpv4(host: string): number[] | undefined { if (!host.startsWith('::ffff:')) return undefined; const suffix = host.slice('::ffff:'.length); if (suffix.includes('.')) { - return suffix.split('.').map((part) => Number(part)); + return parseIpv4Parts(suffix); } const groups = suffix.split(':'); if (groups.length !== 2) return undefined; @@ -864,6 +876,78 @@ function parseMappedIpv4(host: string): number[] | 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, 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], 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); From 6c6609467d010e917ee1c4c0556d639f93917a03 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 05:21:31 +0800 Subject: [PATCH 15/73] fix(channels): address wecom review blockers --- .../channels/wecom/src/WeComAdapter.test.ts | 182 ++++++++--- packages/channels/wecom/src/WeComAdapter.ts | 305 ++++++++++-------- .../cli/src/commands/channel/config-utils.ts | 33 +- .../cli/src/commands/channel/runtime.test.ts | 23 +- packages/cli/src/commands/channel/runtime.ts | 2 +- packages/cli/src/commands/channel/start.ts | 2 +- packages/cli/tsconfig.json | 1 + 7 files changed, 347 insertions(+), 201 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 8c2a313ba6a..3f09661b23e 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -9,8 +9,67 @@ import type { } 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; + }; + const instances: MockWSClient[] = []; - const lookup = vi.fn(async () => [{ address: '203.0.113.10', family: 4 }]); + const lookup = vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]); + const decryptFile = vi.fn((buffer: Buffer, _aesKey: string) => buffer); + 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 = { + on: vi.fn(() => request), + end: vi.fn(() => { + queueMicrotask(() => { + callback(response); + queueMicrotask(() => { + emit('data', httpResponse.body); + emit('end'); + }); + }); + return request; + }), + destroy: vi.fn(() => request), + }; + return request; + }, + ); const state = { autoAuthenticate: true, }; @@ -60,17 +119,29 @@ const mocks = vi.hoisted(() => { } } - return { MockWSClient, instances, lookup, state }; + return { + MockWSClient, + instances, + lookup, + decryptFile, + 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, +})); -import { WeComChannel, safeRealpath } from './WeComAdapter.js'; +import { WeComChannel } from './WeComAdapter.js'; import { plugin } from './index.js'; type MockWSClient = InstanceType; @@ -135,12 +206,16 @@ describe('WeComChannel', () => { beforeEach(() => { mocks.instances.length = 0; mocks.state.autoAuthenticate = true; + 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.lookup.mockResolvedValue([{ address: '203.0.113.10', family: 4 }]); + mocks.decryptFile.mockImplementation((buffer: Buffer) => buffer); + mocks.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); }); afterEach(() => { @@ -232,7 +307,7 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('rejects startup when the SDK disconnects before authentication', async () => { + it('keeps waiting when the SDK disconnects before authentication', async () => { mocks.state.autoAuthenticate = false; const stderr = vi .spyOn(process.stderr, 'write') @@ -243,14 +318,22 @@ describe('WeComChannel', () => { const client = lastClient(); client.emit('disconnected', 'auth failed'); - await expect(connecting).rejects.toThrow( - 'WeCom disconnected before authentication', + 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', ); - expect(client.disconnect).toHaveBeenCalledTimes(1); stderr.mockRestore(); }); - it('clears the active SDK client when the websocket disconnects', async () => { + it('keeps the active SDK client when the websocket disconnects', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -261,11 +344,13 @@ describe('WeComChannel', () => { client.emit('disconnected', 'closed'); await channel.sendMessage('chat-1', 'hello'); - expect(client.sendMessage).not.toHaveBeenCalled(); - expect(stderr).toHaveBeenCalledWith('[WeCom:bot] WebSocket closed.\n'); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('No active SDK client'), + '[WeCom:bot] WebSocket closed; waiting for SDK reconnect.\n', ); + expect(client.sendMessage).toHaveBeenCalledWith('chat-1', { + msgtype: 'markdown', + markdown: { content: 'hello' }, + }); stderr.mockRestore(); }); @@ -328,11 +413,7 @@ describe('WeComChannel', () => { ); await channel.connect(); const client = lastClient(); - client.downloadFile = vi.fn(async (url: string) => - url.includes('image') - ? { buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47]) } - : { buffer: Buffer.from('file-body'), filename: 'downloaded.txt' }, - ); + mocks.httpResponse.body = Buffer.from([0x89, 0x50, 0x4e, 0x47]); client.emit('message.mixed', { msgid: 'msg-2', @@ -370,15 +451,19 @@ describe('WeComChannel', () => { }); await vi.waitFor(() => expect(channel.envelopes).toHaveLength(2)); - expect(client.downloadFile).toHaveBeenCalledWith( + expect(mocks.httpsRequest).toHaveBeenCalledWith( 'https://example.invalid/image', - 'k1', + expect.objectContaining({ method: 'GET' }), + expect.any(Function), ); - expect(client.downloadFile).toHaveBeenCalledWith( + expect(mocks.httpsRequest).toHaveBeenCalledWith( 'https://example.invalid/file', - 'k2', + expect.objectContaining({ method: 'GET' }), + expect.any(Function), ); - expect(client.downloadFile).toHaveBeenCalledTimes(2); + 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); @@ -395,7 +480,7 @@ describe('WeComChannel', () => { expect(file.attachments?.[0]?.type).toBe('file'); expect(file.attachments?.[0]?.fileName).toBe('report.pdf'); expect(file.attachments?.[0]?.filePath).toContain('report.pdf'); - expect(existsSync(file.attachments?.[0]?.filePath ?? '')).toBe(false); + expect(existsSync(file.attachments?.[0]?.filePath ?? '')).toBe(true); }); it('honors explicit group mention metadata when present', async () => { @@ -491,6 +576,7 @@ describe('WeComChannel', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); expect(channel.envelopes).toHaveLength(0); }); @@ -576,6 +662,7 @@ describe('WeComChannel', () => { 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'), @@ -603,8 +690,8 @@ describe('WeComChannel', () => { }); await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); - expect(fetch).not.toHaveBeenCalled(); expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( expect.stringContaining('unsafe media URL'), ); @@ -635,8 +722,8 @@ describe('WeComChannel', () => { } await vi.waitFor(() => expect(channel.envelopes).toHaveLength(4)); - expect(fetch).not.toHaveBeenCalled(); expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( expect.stringContaining('unsafe media URL'), ); @@ -663,8 +750,8 @@ describe('WeComChannel', () => { }); await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); - expect(fetch).not.toHaveBeenCalled(); expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( expect.stringContaining('unsafe media URL'), ); @@ -697,8 +784,8 @@ describe('WeComChannel', () => { expect(mocks.lookup).toHaveBeenCalledWith('metadata.example.com', { all: true, }); - expect(fetch).not.toHaveBeenCalled(); expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( expect.stringContaining('unsafe media URL'), ); @@ -709,12 +796,10 @@ describe('WeComChannel', () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); - vi.mocked(fetch).mockResolvedValueOnce( - new Response(null, { - status: 302, - headers: { location: 'http://169.254.169.254/latest/meta-data/' }, - }), - ); + 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(); @@ -731,14 +816,17 @@ describe('WeComChannel', () => { }); await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); - expect(fetch).toHaveBeenCalledWith('https://example.invalid/redirect', { - method: 'GET', - redirect: 'manual', - signal: expect.any(AbortSignal), - }); + expect(mocks.httpsRequest).toHaveBeenCalledWith( + 'https://example.invalid/redirect', + expect.objectContaining({ + method: 'GET', + signal: expect.any(AbortSignal), + }), + expect.any(Function), + ); expect(client.downloadFile).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('attachment with redirect'), + expect.stringContaining('redirected media URL'), ); stderr.mockRestore(); }); @@ -750,11 +838,9 @@ describe('WeComChannel', () => { const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); await channel.connect(); const client = lastClient(); - vi.mocked(fetch).mockResolvedValueOnce( - new Response(null, { - headers: { 'content-length': String(20 * 1024 * 1024 + 1) }, - }), - ); + mocks.httpResponse.headers = { + 'content-length': String(20 * 1024 * 1024 + 1), + }; client.emit('message.image', { msgid: 'msg-large-image', @@ -767,8 +853,9 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); expect(channel.envelopes[0]?.attachments).toBeUndefined(); expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('skipping oversized image attachment'), + expect.stringContaining('oversized attachment'), ); expect(stderr).not.toHaveBeenCalledWith( expect.stringContaining('https://example.invalid/large-image'), @@ -807,6 +894,7 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); expect(client.downloadFile).not.toHaveBeenCalled(); + expect(mocks.httpsRequest).not.toHaveBeenCalled(); }); it('sends markdown text and local media through the SDK', async () => { @@ -1034,10 +1122,6 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('ignores missing optional media allowlist directories', () => { - expect(safeRealpath('/definitely/missing/wecom-dir')).toBe(undefined); - }); - 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 index eaf020a11d6..eb09a48a197 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -7,13 +7,15 @@ import { unlinkSync, writeFileSync, } from 'node:fs'; +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 } from 'node:net'; +import { isIP, type LookupFunction } from 'node:net'; import { lookup } from 'node:dns/promises'; -import { WSClient } from '@wecom/aibot-node-sdk'; +import { WSClient, decryptFile } from '@wecom/aibot-node-sdk'; import { ChannelBase, sanitizeLogText } from '@qwen-code/channel-base'; import type { Attachment, @@ -52,10 +54,6 @@ interface WeComClient { mediaType: WeComMediaType, mediaId: string, ): Promise; - downloadFile( - url: string, - aesKey?: string, - ): Promise<{ buffer: Buffer; filename?: string }>; } interface WeComLogger { @@ -129,17 +127,10 @@ export class WeComChannel extends ChannelBase { `[WeCom:${this.name}] SDK error: ${sanitizeLogText(String(err), 200)}\n`, ); }); - const markDisconnected = (reason: string): void => { - if (this.client !== client) return; - this.client = undefined; - if (this.dedupTimer) { - clearInterval(this.dedupTimer); - this.dedupTimer = undefined; - } - process.stderr.write(`[WeCom:${this.name}] WebSocket ${reason}.\n`); - }; client.on('disconnected', (reason) => - markDisconnected(formatDisconnectReason(reason)), + process.stderr.write( + `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; waiting for SDK reconnect.\n`, + ), ); this.client = client; @@ -279,10 +270,10 @@ export class WeComChannel extends ChannelBase { : `(file: ${attachments[0]?.fileName ?? 'file'})`; } await this.processInbound(envelope); - cleanupAttachmentFiles(attachments); + scheduleAttachmentCleanup(attachments); } catch (err) { if (messageId) this.seenMessages.delete(messageId); - cleanupAttachmentFiles(attachments); + scheduleAttachmentCleanup(attachments); throw err; } } @@ -291,20 +282,21 @@ export class WeComChannel extends ChannelBase { body: Record, attachments: Attachment[] = [], ): Promise { - const client = this.client; - if (!client) return []; - const refs = collectInboundMediaRefs(body); for (const ref of refs) { - if (!(await this.canDownloadAttachment(ref))) continue; - const downloaded = await client.downloadFile(ref.url, ref.aesKey); - const data = downloaded.buffer; - if (data.length > MAX_MEDIA_BYTES) { + let downloaded: { buffer: Buffer; filename?: string }; + try { + downloaded = await downloadInboundMedia(ref); + } catch (err) { process.stderr.write( - `[WeCom:${this.name}] skipping oversized ${ref.type} attachment (${data.length} bytes).\n`, + `[WeCom:${this.name}] skipping ${ref.type} attachment: ${sanitizeLogText( + err instanceof Error ? err.message : String(err), + 160, + )}.\n`, ); continue; } + const data = downloaded.buffer; const fileName = ref.fileName || downloaded.filename; if (ref.type === 'image') { attachments.push({ @@ -330,53 +322,6 @@ export class WeComChannel extends ChannelBase { return attachments; } - private async canDownloadAttachment(ref: InboundMediaRef): Promise { - if (!(await isSafeInboundMediaUrl(ref.url))) { - process.stderr.write( - `[WeCom:${this.name}] skipping ${ref.type} attachment with unsafe media URL.\n`, - ); - return false; - } - try { - const response = await fetch(ref.url, { - method: 'GET', - redirect: 'manual', - signal: AbortSignal.timeout(10_000), - }); - if (response.status >= 300 && response.status < 400) { - process.stderr.write( - `[WeCom:${this.name}] skipping ${ref.type} attachment with redirect.\n`, - ); - return false; - } - if (!response.ok) { - process.stderr.write( - `[WeCom:${this.name}] skipping ${ref.type} attachment after media probe failed.\n`, - ); - return false; - } - const contentLength = getContentLength(response); - if (contentLength !== undefined && contentLength > MAX_MEDIA_BYTES) { - process.stderr.write( - `[WeCom:${this.name}] skipping oversized ${ref.type} attachment (${contentLength} bytes).\n`, - ); - return false; - } - if (!(await responseBodyWithinLimit(response))) { - process.stderr.write( - `[WeCom:${this.name}] skipping oversized ${ref.type} attachment.\n`, - ); - return false; - } - return true; - } catch { - process.stderr.write( - `[WeCom:${this.name}] skipping ${ref.type} attachment after media metadata check failed.\n`, - ); - return false; - } - } - private cleanupSeenMessages(): void { const now = Date.now(); for (const [id, ts] of this.seenMessages) { @@ -420,15 +365,6 @@ function waitForAuthentication(client: WeComClient): { ), ), ); - client.on('disconnected', (reason) => - finish( - new Error( - `WeCom disconnected before authentication: ${formatDisconnectReason( - reason, - )}`, - ), - ), - ); }); return { promise, @@ -466,6 +402,11 @@ function parseWeComConfig( return wsUrl ? { botId, secret, wsUrl } : { botId, secret }; } +function scheduleAttachmentCleanup(attachments: Attachment[]): void { + if (!attachments.some((attachment) => attachment.filePath)) return; + setTimeout(() => cleanupAttachmentFiles(attachments), 60_000).unref?.(); +} + function cleanupAttachmentFiles(attachments: Attachment[]): void { for (const attachment of attachments) { if (!attachment.filePath) continue; @@ -731,14 +672,6 @@ function readOutboundMedia( return { data: readFileSync(real), fileName: basename(real) }; } -export function safeRealpath(path: string): string | undefined { - try { - return realpathSync(path); - } catch { - return undefined; - } -} - function ensureDirectoryRealpath(path: string): string | undefined { try { mkdirSync(path, { recursive: true }); @@ -760,33 +693,6 @@ function isInsideDir(filePath: string, dir: string): boolean { ); } -function getContentLength(response: Response): number | undefined { - const header = response.headers.get('content-length'); - if (header === null) return undefined; - const size = Number(header); - return Number.isFinite(size) && size >= 0 ? size : undefined; -} - -async function responseBodyWithinLimit(response: Response): Promise { - const reader = response.body?.getReader(); - if (!reader) return true; - - let total = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) return true; - total += value.byteLength; - if (total > MAX_MEDIA_BYTES) { - await reader.cancel().catch(() => {}); - return false; - } - } - } finally { - reader.releaseLock(); - } -} - async function isSafeInboundMediaUrl(rawUrl: string): Promise { let url: URL; try { @@ -802,22 +708,7 @@ async function isSafeInboundMediaUrl(rawUrl: string): Promise { } if (host.endsWith('.local')) return false; - 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); - return !( - host === '::' || - host === '::1' || - host.startsWith('fc') || - host.startsWith('fd') || - host.startsWith('fe80:') - ); - } + if (isIP(host)) return isPublicIpAddress(host); if (!host.includes('.')) return false; try { const records = await lookup(host, { all: true }); @@ -830,6 +721,148 @@ async function isSafeInboundMediaUrl(rawUrl: string): Promise { } } +async function downloadInboundMedia( + ref: InboundMediaRef, +): Promise<{ buffer: Buffer; filename?: string }> { + if (!(await isSafeInboundMediaUrl(ref.url))) { + throw new Error('unsafe media URL'); + } + 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 finish = ( + err?: Error, + value?: { buffer: Buffer; filename?: string }, + ): void => { + if (settled) return; + settled = true; + if (err) { + rejectPromise(err); + } else { + resolvePromise(value ?? { buffer: Buffer.alloc(0) }); + } + }; + + const req = httpsRequest( + rawUrl, + { + method: 'GET', + signal: AbortSignal.timeout(10_000), + lookup: safePublicLookup, + }, + (res) => { + const statusCode = res.statusCode ?? 0; + if (statusCode >= 300 && statusCode < 400) { + res.resume(); + finish(new Error('redirected media URL')); + return; + } + if (statusCode < 200 || statusCode >= 300) { + res.resume(); + finish(new Error('media download failed')); + return; + } + + const contentLength = getHeaderNumber(res.headers, 'content-length'); + if (contentLength !== undefined && contentLength > MAX_MEDIA_BYTES) { + res.resume(); + 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', () => { + finish(undefined, { + buffer: Buffer.concat(chunks), + ...parseContentDispositionFileName(res.headers), + }); + }); + res.on('error', (err: Error) => finish(err)); + }, + ); + req.on('error', (err: Error) => finish(err)); + req.end(); + }); +} + +const safePublicLookup: LookupFunction = (hostname, _options, callback) => { + lookup(hostname, { all: true }) + .then((records) => { + if ( + records.length === 0 || + records.some((record) => !isPublicIpAddress(record.address)) + ) { + callback(new Error('unsafe resolved media address'), '', 0); + 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); @@ -963,7 +996,11 @@ function isPublicIpv4(parts: number[]): boolean { (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19 || b === 51)) || + (a === 203 && b === 0) || + a >= 224 ); } diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 76446be76a6..12fef9e9870 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -22,7 +22,7 @@ function resolveOptionalStringField( channelName: string, rawConfig: Record, field: 'token' | 'clientId' | 'clientSecret', - shouldResolveEnvVars: boolean, + envResolution: EnvResolution, ): string | undefined { const value = rawConfig[field]; if (value === undefined || value === null || value === '') { @@ -33,7 +33,17 @@ function resolveOptionalStringField( `Channel "${channelName}" field "${field}" must be a string.`, ); } - return shouldResolveEnvVars ? resolveEnvVars(value) : value; + return resolveConfigEnvVar(value, envResolution); +} + +type EnvResolution = boolean | 'available'; + +function resolveConfigEnvVar(value: string, mode: EnvResolution): string { + if (mode === false) return value; + if (mode === 'available' && value.startsWith('$')) { + return process.env[value.substring(1)] || value; + } + return resolveEnvVars(value); } /** @@ -95,7 +105,7 @@ export async function parseChannelConfig( name: string, rawConfig: Record, defaultCwd: string = process.cwd(), - options: { resolveEnvVars?: boolean } = {}, + options: { resolveEnvVars?: EnvResolution } = {}, ): Promise> { if (!rawConfig['type']) { throw new Error(`Channel "${name}" is missing required field "type".`); @@ -111,7 +121,7 @@ export async function parseChannelConfig( } const resolvedRawConfig = { ...rawConfig }; - const shouldResolveEnvVars = options.resolveEnvVars ?? true; + const envResolution = options.resolveEnvVars ?? true; // Validate plugin-required fields for (const field of plugin.requiredConfigFields ?? []) { @@ -124,30 +134,23 @@ export async function parseChannelConfig( if (typeof value !== 'string') { throw new Error(`Channel "${name}" field "${field}" must be a string.`); } - resolvedRawConfig[field] = shouldResolveEnvVars - ? resolveEnvVars(value) - : value; + resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); } // Resolve env vars for known credential fields const token = - resolveOptionalStringField( - name, - rawConfig, - 'token', - shouldResolveEnvVars, - ) ?? ''; + resolveOptionalStringField(name, rawConfig, 'token', envResolution) ?? ''; const clientId = resolveOptionalStringField( name, rawConfig, 'clientId', - shouldResolveEnvVars, + envResolution, ); const clientSecret = resolveOptionalStringField( name, rawConfig, 'clientSecret', - shouldResolveEnvVars, + envResolution, ); return { diff --git a/packages/cli/src/commands/channel/runtime.test.ts b/packages/cli/src/commands/channel/runtime.test.ts index d67b2086f1d..76bb61e1619 100644 --- a/packages/cli/src/commands/channel/runtime.test.ts +++ b/packages/cli/src/commands/channel/runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { parseConfiguredChannels } from './runtime.js'; vi.mock('./channel-registry.js', () => ({ @@ -10,6 +10,10 @@ vi.mock('./channel-registry.js', () => ({ })); describe('parseConfiguredChannels', () => { + afterEach(() => { + delete process.env['TEST_CHANNEL_TOKEN']; + }); + it('throws a clear error when a selected channel is missing config', async () => { await expect( parseConfiguredChannels({}, ['telegram'], { defaultCwd: '/workspace' }), @@ -56,4 +60,21 @@ describe('parseConfiguredChannels', () => { expect(parsed[0]?.config.token).toBe('$TOKEN_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 32877a16e12..cf7cc6f1135 100644 --- a/packages/cli/src/commands/channel/runtime.ts +++ b/packages/cli/src/commands/channel/runtime.ts @@ -205,7 +205,7 @@ export async function parseConfiguredChannels( name, rawConfig as Record, opts.defaultCwd, - { resolveEnvVars: false }, + { resolveEnvVars: 'available' }, ), }); } catch (err) { diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 2f508ab4ba4..a62f7b5a7b2 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -158,7 +158,7 @@ async function startSingle(name: string, proxy?: string): Promise { name, channelsConfig[name] as Record, process.cwd(), - { resolveEnvVars: false }, + { 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" } ] } From 9ccf0c98d99ce9042b49d3c844612b073fb475c1 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 06:09:42 +0800 Subject: [PATCH 16/73] fix(channels): tighten wecom media edge cases --- .../channels/wecom/src/WeComAdapter.test.ts | 72 ++++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 15 ++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 3f09661b23e..50a4863f874 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { ChannelAgentBridge, @@ -219,6 +219,7 @@ describe('WeComChannel', () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -897,6 +898,75 @@ describe('WeComChannel', () => { 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('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('removes empty attachment directories during delayed cleanup', async () => { + vi.useFakeTimers(); + const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + 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(channel.envelopes).toHaveLength(1)); + const filePath = channel.envelopes[0]?.attachments?.[0]?.filePath; + expect(filePath).toBeDefined(); + expect(existsSync(dirname(filePath!))).toBe(true); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(existsSync(filePath!)).toBe(false); + expect(existsSync(dirname(filePath!))).toBe(false); + }); + it('sends markdown text and local media through the SDK', async () => { const parent = join(tmpdir(), 'channel-files'); mkdirSync(parent, { recursive: true }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index eb09a48a197..ffecdf842c6 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, realpathSync, + rmdirSync, statSync, unlinkSync, writeFileSync, @@ -10,7 +11,7 @@ import { 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 { basename, dirname, join, resolve, win32, posix } from 'node:path'; import { tmpdir } from 'node:os'; import { Buffer } from 'node:buffer'; import { isIP, type LookupFunction } from 'node:net'; @@ -412,6 +413,7 @@ function cleanupAttachmentFiles(attachments: Attachment[]): void { if (!attachment.filePath) continue; try { unlinkSync(attachment.filePath); + rmdirSync(dirname(attachment.filePath)); } catch { // Best effort cleanup only. } @@ -516,11 +518,11 @@ interface InboundMediaRef { function collectInboundMediaRefs( body: Record, depth = 0, + seenUrls = new Set(), ): InboundMediaRef[] { if (depth > 3) return []; const refs: InboundMediaRef[] = []; - const seenUrls = new Set(); const add = (type: WeComMediaType, source: Record): void => { const url = getString(source, 'url'); if (!url || seenUrls.has(url)) return; @@ -552,7 +554,7 @@ function collectInboundMediaRefs( add('voice', getRecord(body, 'voice') ?? {}); const quote = getRecord(body, 'quote'); - if (quote) refs.push(...collectInboundMediaRefs(quote, depth + 1)); + if (quote) refs.push(...collectInboundMediaRefs(quote, depth + 1, seenUrls)); return refs; } @@ -878,12 +880,17 @@ function isPublicIpAddress(address: string): boolean { host === '::1' || host.startsWith('fc') || host.startsWith('fd') || - host.startsWith('fe80:') + isIpv6LinkLocal(host) ); } return false; } +function isIpv6LinkLocal(host: string): boolean { + const firstGroup = Number.parseInt(host.split(':', 1)[0] ?? '', 16); + return firstGroup >= 0xfe80 && firstGroup <= 0xfebf; +} + function parseIpv4Parts(host: string): number[] | undefined { const parts = host.split('.').map((part) => Number(part)); if ( From 006023d1dad8ffc5f26f247c964f787fa855091e Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 07:08:17 +0800 Subject: [PATCH 17/73] fix(channels): address wecom review blockers --- .../channels/wecom/src/WeComAdapter.test.ts | 43 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 4 +- .../src/commands/channel/config-utils.test.ts | 19 ++++++++ .../cli/src/commands/channel/config-utils.ts | 3 +- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 50a4863f874..3e3f71420cc 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -196,6 +196,16 @@ class FailingPreflightWeComChannel extends WeComChannel { } } +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); + } +} + function lastClient(): MockWSClient { const client = mocks.instances.at(-1); if (!client) throw new Error('missing mock client'); @@ -642,6 +652,39 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + 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( + expect.stringContaining('message handling failed'), + ), + ); + + client.emit('message.text', payload); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.processes).toHaveBeenCalledTimes(1); + stderr.mockRestore(); + }); + it('does not download attachments from unsafe media URLs', async () => { const stderr = vi .spyOn(process.stderr, 'write') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index ffecdf842c6..81b45df8fcc 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -259,6 +259,7 @@ export class WeComChannel extends ChannelBase { }; let attachments: Attachment[] = []; + let processingStarted = false; try { if (!(await this.preflightInbound(envelope))) return; attachments = await this.downloadAttachments(body, attachments); @@ -270,10 +271,11 @@ export class WeComChannel extends ChannelBase { ? '(image)' : `(file: ${attachments[0]?.fileName ?? 'file'})`; } + processingStarted = true; await this.processInbound(envelope); scheduleAttachmentCleanup(attachments); } catch (err) { - if (messageId) this.seenMessages.delete(messageId); + if (messageId && !processingStarted) this.seenMessages.delete(messageId); scheduleAttachmentCleanup(attachments); throw err; } diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 15297795c45..7d554c39073 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -174,6 +174,25 @@ describe('parseChannelConfig', () => { delete process.env['TEST_TOKEN']; }); + it('resolves available env vars to empty strings when explicitly set', async () => { + process.env['TEST_EMPTY_SECRET'] = ''; + + const result = await parseChannelConfig( + 'bot', + { + type: 'wecom', + botId: 'bot-id', + secret: '$TEST_EMPTY_SECRET', + }, + process.cwd(), + { resolveEnvVars: 'available' }, + ); + + expect(result['secret']).toBe(''); + + delete process.env['TEST_EMPTY_SECRET']; + }); + 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 12fef9e9870..2fec1ba9a34 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -41,7 +41,8 @@ type EnvResolution = boolean | 'available'; function resolveConfigEnvVar(value: string, mode: EnvResolution): string { if (mode === false) return value; if (mode === 'available' && value.startsWith('$')) { - return process.env[value.substring(1)] || value; + const envName = value.substring(1); + return envName in process.env ? (process.env[envName] ?? '') : value; } return resolveEnvVars(value); } From 645406b27dce5db04523816fcf0c48e48fcf1bbb Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 08:08:42 +0800 Subject: [PATCH 18/73] fix(channels): address wecom media review blockers --- .../channels/wecom/src/WeComAdapter.test.ts | 64 +++++++++++++++++-- packages/channels/wecom/src/WeComAdapter.ts | 31 ++------- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 3e3f71420cc..c7958619075 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -18,8 +18,19 @@ const mocks = vi.hoisted(() => { ): MockHttpResponse; resume: ReturnType; }; + type MockHttpRequest = { + on: ReturnType; + end: ReturnType; + destroy: ReturnType; + }; + type MockHttpCall = { + options: unknown; + request: MockHttpRequest; + response: MockHttpResponse; + }; const instances: MockWSClient[] = []; + const httpCalls: MockHttpCall[] = []; const lookup = vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]); const decryptFile = vi.fn((buffer: Buffer, _aesKey: string) => buffer); const httpResponse = { @@ -53,7 +64,7 @@ const mocks = vi.hoisted(() => { handler(value); } }; - const request = { + const request: MockHttpRequest = { on: vi.fn(() => request), end: vi.fn(() => { queueMicrotask(() => { @@ -67,6 +78,7 @@ const mocks = vi.hoisted(() => { }), destroy: vi.fn(() => request), }; + httpCalls.push({ options: _options, request, response }); return request; }, ); @@ -122,6 +134,7 @@ const mocks = vi.hoisted(() => { return { MockWSClient, instances, + httpCalls, lookup, decryptFile, httpResponse, @@ -215,6 +228,7 @@ function lastClient(): MockWSClient { describe('WeComChannel', () => { beforeEach(() => { mocks.instances.length = 0; + mocks.httpCalls.length = 0; mocks.state.autoAuthenticate = true; mocks.httpResponse.statusCode = 200; mocks.httpResponse.headers = {}; @@ -836,6 +850,47 @@ describe('WeComChannel', () => { 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, void>(); + + 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') @@ -898,6 +953,7 @@ describe('WeComChannel', () => { 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'), ); @@ -982,7 +1038,7 @@ describe('WeComChannel', () => { expect(mocks.httpsRequest).not.toHaveBeenCalled(); }); - it('removes empty attachment directories during delayed cleanup', async () => { + it('keeps downloaded file attachments for base prompt consumers', async () => { vi.useFakeTimers(); const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); await channel.connect(); @@ -1006,8 +1062,8 @@ describe('WeComChannel', () => { await vi.advanceTimersByTimeAsync(60_000); - expect(existsSync(filePath!)).toBe(false); - expect(existsSync(dirname(filePath!))).toBe(false); + expect(existsSync(filePath!)).toBe(true); + expect(existsSync(dirname(filePath!))).toBe(true); }); it('sends markdown text and local media through the SDK', async () => { diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 81b45df8fcc..1fed6cddcd6 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -3,15 +3,13 @@ import { mkdirSync, readFileSync, realpathSync, - rmdirSync, statSync, - unlinkSync, writeFileSync, } from 'node:fs'; import { request as httpsRequest } from 'node:https'; import type { IncomingHttpHeaders } from 'node:http'; import { randomUUID } from 'node:crypto'; -import { basename, dirname, join, resolve, win32, posix } from 'node:path'; +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'; @@ -273,10 +271,8 @@ export class WeComChannel extends ChannelBase { } processingStarted = true; await this.processInbound(envelope); - scheduleAttachmentCleanup(attachments); } catch (err) { if (messageId && !processingStarted) this.seenMessages.delete(messageId); - scheduleAttachmentCleanup(attachments); throw err; } } @@ -405,23 +401,6 @@ function parseWeComConfig( return wsUrl ? { botId, secret, wsUrl } : { botId, secret }; } -function scheduleAttachmentCleanup(attachments: Attachment[]): void { - if (!attachments.some((attachment) => attachment.filePath)) return; - setTimeout(() => cleanupAttachmentFiles(attachments), 60_000).unref?.(); -} - -function cleanupAttachmentFiles(attachments: Attachment[]): void { - for (const attachment of attachments) { - if (!attachment.filePath) continue; - try { - unlinkSync(attachment.filePath); - rmdirSync(dirname(attachment.filePath)); - } catch { - // Best effort cleanup only. - } - } -} - function readRequiredString( config: Record, key: string, @@ -782,7 +761,7 @@ function guardedHttpsDownload( const contentLength = getHeaderNumber(res.headers, 'content-length'); if (contentLength !== undefined && contentLength > MAX_MEDIA_BYTES) { - res.resume(); + req.destroy(); finish(new Error(`oversized attachment (${contentLength} bytes)`)); return; } @@ -813,7 +792,7 @@ function guardedHttpsDownload( }); } -const safePublicLookup: LookupFunction = (hostname, _options, callback) => { +const safePublicLookup: LookupFunction = (hostname, options, callback) => { lookup(hostname, { all: true }) .then((records) => { if ( @@ -823,6 +802,10 @@ const safePublicLookup: LookupFunction = (hostname, _options, callback) => { callback(new Error('unsafe resolved media address'), '', 0); return; } + if (options.all) { + callback(null, records); + return; + } const record = records[0]!; callback(null, record.address, record.family); }) From e6a0e3ac59d2c21b9cd72560b96fc3dcea62b469 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 09:11:59 +0800 Subject: [PATCH 19/73] fix(channels): address wecom review follow-ups --- .../channels/wecom/src/WeComAdapter.test.ts | 216 +++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 43 ++-- .../src/commands/channel/config-utils.test.ts | 17 ++ 3 files changed, 260 insertions(+), 16 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index c7958619075..972cdf197c1 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -124,6 +124,14 @@ const mocks = vi.hoisted(() => { 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); @@ -209,6 +217,16 @@ class FailingPreflightWeComChannel extends WeComChannel { } } +class RejectingPreflightWeComChannel extends WeComChannel { + readonly preflights = vi.fn(async (_envelope: Envelope) => false); + + protected override async preflightInbound( + envelope: Envelope, + ): Promise { + return this.preflights(envelope); + } +} + class FailingProcessWeComChannel extends WeComChannel { readonly processes = vi.fn(async (_envelope: Envelope) => { throw new Error('process failed after side effects started'); @@ -332,6 +350,16 @@ describe('WeComChannel', () => { 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 @@ -508,6 +536,26 @@ describe('WeComChannel', () => { expect(existsSync(file.attachments?.[0]?.filePath ?? '')).toBe(true); }); + 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', @@ -666,6 +714,29 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + it('rolls back message dedup when preflight rejects the envelope', async () => { + const channel = new RejectingPreflightWeComChannel( + 'bot', + makeConfig(), + makeBridge(), + ); + 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)); + + client.emit('message.text', payload); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(2)); + }); + it('keeps message dedup when processing fails after side effects start', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -768,7 +839,7 @@ describe('WeComChannel', () => { 'https://[::]/internal-api', 'https://[::a9fe:a9fe]/latest/meta-data/', 'https://[2002:a9fe:a9fe::]/latest/meta-data/', - 'https://[2001::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}`, @@ -963,6 +1034,99 @@ describe('WeComChannel', () => { 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('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(stderr).toHaveBeenCalledWith( + expect.stringContaining('media download failed'), + ); + 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, void>(); + + 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(); @@ -1038,6 +1202,53 @@ describe('WeComChannel', () => { 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('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('keeps downloaded file attachments for base prompt consumers', async () => { vi.useFakeTimers(); const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); @@ -1288,6 +1499,9 @@ describe('WeComChannel', () => { expect(stderr).toHaveBeenCalledWith( expect.stringContaining('outside allowed outbound directory'), ); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining(secretPath), + ); stderr.mockRestore(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 1fed6cddcd6..73b12173396 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -43,6 +43,7 @@ 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, @@ -145,6 +146,7 @@ export class WeComChannel extends ChannelBase { throw err; } this.dedupTimer = setInterval(() => this.cleanupSeenMessages(), 60_000); + this.dedupTimer.unref?.(); process.stderr.write(`[WeCom:${this.name}] Connected via smart bot.\n`); } @@ -224,7 +226,6 @@ export class WeComChannel extends ChannelBase { const messageId = getString(body, 'msgid'); if (messageId && this.seenMessages.has(messageId)) return; - if (messageId) this.seenMessages.set(messageId, Date.now()); const from = getRecord(body, 'from'); const senderId = getString(from, 'userid') || ''; @@ -260,6 +261,7 @@ export class WeComChannel extends ChannelBase { let processingStarted = false; try { if (!(await this.preflightInbound(envelope))) return; + if (messageId) this.seenMessages.set(messageId, Date.now()); attachments = await this.downloadAttachments(body, attachments); if (attachments.length) { envelope.attachments = attachments; @@ -273,6 +275,11 @@ export class WeComChannel extends ChannelBase { await this.processInbound(envelope); } catch (err) { if (messageId && !processingStarted) this.seenMessages.delete(messageId); + else if (messageId) { + process.stderr.write( + `[WeCom:${this.name}] message ${messageId} failed after processing started; dedup entry retained.\n`, + ); + } throw err; } } @@ -296,7 +303,7 @@ export class WeComChannel extends ChannelBase { continue; } const data = downloaded.buffer; - const fileName = ref.fileName || downloaded.filename; + const fileName = sanitizeFileName(ref.fileName || downloaded.filename); if (ref.type === 'image') { attachments.push({ type: 'image', @@ -307,7 +314,7 @@ export class WeComChannel extends ChannelBase { } else { const dir = join(tmpdir(), 'channel-files', randomUUID()); mkdirSync(dir, { recursive: true }); - const safeName = sanitizeFileName(fileName) || `wecom_${ref.type}`; + const safeName = fileName || `wecom_${ref.type}`; const filePath = join(dir, safeName); writeFileSync(filePath, data); attachments.push({ @@ -338,12 +345,21 @@ function waitForAuthentication(client: WeComClient): { 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 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); if (err) { rejectPromise(err); } else { @@ -356,14 +372,8 @@ function waitForAuthentication(client: WeComClient): { }, AUTHENTICATION_TIMEOUT_MS); timeout.unref?.(); - client.on('authenticated', () => finish()); - client.on('error', (err) => - finish( - new Error( - `WeCom authentication failed: ${sanitizeLogText(String(err), 200)}`, - ), - ), - ); + client.on('authenticated', onAuth); + client.on('error', onError); }); return { promise, @@ -640,7 +650,8 @@ function readOutboundMedia( const resolved = resolve(cwd, rawPath); const real = realpathSync(resolved); const stat = statSync(real); - if (!stat.isFile()) throw new Error(`Not a regular file: ${real}`); + 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`); } @@ -650,7 +661,7 @@ function readOutboundMedia( ensureDirectoryRealpath('/tmp/channel-files'), ].filter((dir): dir is string => Boolean(dir)); if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { - throw new Error(`Media path outside allowed outbound directory: ${real}`); + throw new Error('Media path outside allowed outbound directory'); } return { data: readFileSync(real), fileName: basename(real) }; } @@ -710,6 +721,8 @@ async function downloadInboundMedia( if (!(await isSafeInboundMediaUrl(ref.url))) { throw new Error('unsafe media URL'); } + // 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 @@ -873,7 +886,7 @@ function isPublicIpAddress(address: string): boolean { function isIpv6LinkLocal(host: string): boolean { const firstGroup = Number.parseInt(host.split(':', 1)[0] ?? '', 16); - return firstGroup >= 0xfe80 && firstGroup <= 0xfebf; + return firstGroup >= 0xfe80 && firstGroup <= 0xfeff; } function parseIpv4Parts(host: string): number[] | undefined { @@ -917,7 +930,7 @@ function parseEmbeddedIpv4(host: string): number[] | undefined { return hexGroupsToIpv4(groups[1], groups[2]); } if (groups[0] === 0x2001 && groups[1] === 0) { - return hexGroupsToIpv4(groups[6], groups[7]); + return hexGroupsToIpv4(groups[6]! ^ 0xffff, groups[7]! ^ 0xffff); } return undefined; } diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 7d554c39073..934a748b204 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -193,6 +193,23 @@ describe('parseChannelConfig', () => { delete process.env['TEST_EMPTY_SECRET']; }); + it('leaves unavailable env vars literal in available mode', async () => { + delete process.env['TEST_MISSING_SECRET']; + + const result = await parseChannelConfig( + 'bot', + { + type: 'wecom', + botId: 'bot-id', + secret: '$TEST_MISSING_SECRET', + }, + process.cwd(), + { resolveEnvVars: 'available' }, + ); + + expect(result['secret']).toBe('$TEST_MISSING_SECRET'); + }); + it('preserves explicit config values over defaults', async () => { const result = await parseChannelConfig('bot', { type: 'bare', From 36ea9f98fc9f243afc02be608f235a8c799a1761 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 10:16:37 +0800 Subject: [PATCH 20/73] fix(channels): address wecom review blockers --- .../channels/wecom/src/WeComAdapter.test.ts | 137 +++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 61 ++++++-- .../src/commands/channel/config-utils.test.ts | 26 ++-- .../cli/src/commands/channel/config-utils.ts | 9 +- packages/cli/src/commands/channel/runtime.ts | 1 - packages/cli/src/commands/channel/start.ts | 1 - 6 files changed, 210 insertions(+), 25 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 972cdf197c1..b429edda0fc 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => { on: ReturnType; end: ReturnType; destroy: ReturnType; + setTimeout: ReturnType; }; type MockHttpCall = { options: unknown; @@ -77,6 +78,7 @@ const mocks = vi.hoisted(() => { return request; }), destroy: vi.fn(() => request), + setTimeout: vi.fn(() => request), }; httpCalls.push({ options: _options, request, response }); return request; @@ -386,6 +388,34 @@ describe('WeComChannel', () => { 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') @@ -407,6 +437,14 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + 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('normalizes text messages into envelopes', async () => { const channel = new TestWeComChannel( 'bot', @@ -990,10 +1028,14 @@ describe('WeComChannel', () => { 'https://example.invalid/redirect', expect.objectContaining({ method: 'GET', - signal: expect.any(AbortSignal), + lookup: expect.any(Function), }), expect.any(Function), ); + expect(mocks.httpCalls[0]?.request.setTimeout).toHaveBeenCalledWith( + 10_000, + expect.any(Function), + ); expect(client.downloadFile).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( expect.stringContaining('redirected media URL'), @@ -1249,6 +1291,74 @@ describe('WeComChannel', () => { 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('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('keeps downloaded file attachments for base prompt consumers', async () => { vi.useFakeTimers(); const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); @@ -1277,6 +1387,31 @@ describe('WeComChannel', () => { 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('sends markdown text and local media through the SDK', async () => { const parent = join(tmpdir(), 'channel-files'); mkdirSync(parent, { recursive: true }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 73b12173396..13f130e3838 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, realpathSync, + rmSync, statSync, writeFileSync, } from 'node:fs'; @@ -85,6 +86,7 @@ export class WeComChannel extends ChannelBase { private readonly wecom: WeComConfig; private client?: WeComClient; private readonly seenMessages = new Map(); + private readonly attachmentDirsByMessage = new Map(); private dedupTimer?: ReturnType; constructor( @@ -110,8 +112,15 @@ export class WeComChannel extends ChannelBase { } const client = new ClientCtor(options); + let authenticated = false; for (const event of MESSAGE_EVENTS) { client.on(event, (payload) => { + if (!authenticated) { + process.stderr.write( + `[WeCom:${this.name}] dropping message before authentication.\n`, + ); + return; + } this.onMessage(payload).catch((err: unknown) => { process.stderr.write( `[WeCom:${this.name}] message handling failed: ${sanitizeLogText( @@ -139,6 +148,7 @@ export class WeComChannel extends ChannelBase { const connected = client.connect(); if (isPromiseLike(connected)) await connected; await authentication.promise; + authenticated = true; } catch (err) { authentication.cancel(); if (this.client === client) this.client = undefined; @@ -169,10 +179,9 @@ export class WeComChannel extends ChannelBase { async sendMessage(chatId: string, text: string): Promise { const client = this.client; if (!client) { - process.stderr.write( - `[WeCom:${this.name}] No active SDK client, cannot send.\n`, + throw new Error( + `[WeCom:${this.name}] No active SDK client, cannot send.`, ); - return; } const { cleanedText, media } = parseOutboundMediaMarkers(text); @@ -262,7 +271,11 @@ export class WeComChannel extends ChannelBase { try { if (!(await this.preflightInbound(envelope))) return; if (messageId) this.seenMessages.set(messageId, Date.now()); - attachments = await this.downloadAttachments(body, attachments); + attachments = await this.downloadAttachments( + body, + attachments, + messageId, + ); if (attachments.length) { envelope.attachments = attachments; } @@ -287,6 +300,7 @@ export class WeComChannel extends ChannelBase { private async downloadAttachments( body: Record, attachments: Attachment[] = [], + messageId?: string, ): Promise { const refs = collectInboundMediaRefs(body); for (const ref of refs) { @@ -314,6 +328,11 @@ export class WeComChannel extends ChannelBase { } else { const dir = join(tmpdir(), 'channel-files', randomUUID()); mkdirSync(dir, { recursive: true }); + if (messageId) { + const dirs = this.attachmentDirsByMessage.get(messageId) ?? []; + dirs.push(dir); + this.attachmentDirsByMessage.set(messageId, dirs); + } const safeName = fileName || `wecom_${ref.type}`; const filePath = join(dir, safeName); writeFileSync(filePath, data); @@ -328,6 +347,20 @@ export class WeComChannel extends ChannelBase { return attachments; } + protected override onPromptEnd( + _chatId: string, + _sessionId: string, + messageId?: string, + ): void { + if (!messageId) return; + const dirs = this.attachmentDirsByMessage.get(messageId); + if (!dirs) return; + this.attachmentDirsByMessage.delete(messageId); + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } + } + private cleanupSeenMessages(): void { const now = Date.now(); for (const [id, ts] of this.seenMessages) { @@ -756,7 +789,6 @@ function guardedHttpsDownload( rawUrl, { method: 'GET', - signal: AbortSignal.timeout(10_000), lookup: safePublicLookup, }, (res) => { @@ -800,6 +832,10 @@ function guardedHttpsDownload( res.on('error', (err: Error) => finish(err)); }, ); + req.setTimeout(10_000, () => { + req.destroy(); + finish(new Error('media download timed out')); + }); req.on('error', (err: Error) => finish(err)); req.end(); }); @@ -932,6 +968,13 @@ function parseEmbeddedIpv4(host: string): number[] | undefined { if (groups[0] === 0x2001 && groups[1] === 0) { return hexGroupsToIpv4(groups[6]! ^ 0xffff, groups[7]! ^ 0xffff); } + if ( + groups[0] === 0x0064 && + groups[1] === 0xff9b && + groups.slice(2, 6).every((group) => group === 0) + ) { + return hexGroupsToIpv4(groups[6], groups[7]); + } return undefined; } @@ -993,7 +1036,7 @@ function parseHexGroup(value: string | undefined): number | undefined { } function isPublicIpv4(parts: number[]): boolean { - const [a = 0, b = 0] = parts; + const [a = 0, b = 0, c = 0] = parts; return !( a === 0 || a === 10 || @@ -1001,10 +1044,10 @@ function isPublicIpv4(parts: number[]): boolean { (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 0) || + (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 192 && b === 168) || - (a === 198 && (b === 18 || b === 19 || b === 51)) || - (a === 203 && b === 0) || + (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || + (a === 203 && b === 0 && c === 113) || a >= 224 ); } diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 934a748b204..5aec21f2c4d 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -174,22 +174,24 @@ describe('parseChannelConfig', () => { delete process.env['TEST_TOKEN']; }); - it('resolves available env vars to empty strings when explicitly set', async () => { + it('rejects available env vars when explicitly empty', async () => { process.env['TEST_EMPTY_SECRET'] = ''; - const result = await parseChannelConfig( - 'bot', - { - type: 'wecom', - botId: 'bot-id', - secret: '$TEST_EMPTY_SECRET', - }, - process.cwd(), - { resolveEnvVars: 'available' }, + 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)', ); - expect(result['secret']).toBe(''); - delete process.env['TEST_EMPTY_SECRET']; }); diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 2fec1ba9a34..3840268e4c0 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -42,7 +42,14 @@ function resolveConfigEnvVar(value: string, mode: EnvResolution): string { if (mode === false) return value; if (mode === 'available' && value.startsWith('$')) { const envName = value.substring(1); - return envName in process.env ? (process.env[envName] ?? '') : value; + const envValue = process.env[envName]; + if (envValue === undefined) return value; + if (envValue === '') { + throw new Error( + `Environment variable ${envName} is empty (referenced as ${value})`, + ); + } + return envValue; } return resolveEnvVars(value); } diff --git a/packages/cli/src/commands/channel/runtime.ts b/packages/cli/src/commands/channel/runtime.ts index cf7cc6f1135..3b85c64c825 100644 --- a/packages/cli/src/commands/channel/runtime.ts +++ b/packages/cli/src/commands/channel/runtime.ts @@ -205,7 +205,6 @@ export async function parseConfiguredChannels( name, rawConfig as Record, opts.defaultCwd, - { resolveEnvVars: 'available' }, ), }); } catch (err) { diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index a62f7b5a7b2..44962fe3efb 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -158,7 +158,6 @@ async function startSingle(name: string, proxy?: string): Promise { name, channelsConfig[name] as Record, process.cwd(), - { resolveEnvVars: 'available' }, ); } catch (err) { writeStderrLine( From 3e923e1a1009d26219709f960a77f148467381bd Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 11:23:30 +0800 Subject: [PATCH 21/73] fix(channels): close wecom review blockers --- packages/channels/base/src/ChannelBase.ts | 17 ++ .../channels/wecom/src/WeComAdapter.test.ts | 260 +++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 202 +++++++++++--- packages/cli/src/commands/channel/runtime.ts | 1 + 4 files changed, 436 insertions(+), 44 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index a86ad922aea..3d860fe9e6b 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -925,6 +925,12 @@ export abstract class ChannelBase { _messageId?: string, ): void {} + protected onPromptBuffered( + _chatId: string, + _sessionId: string, + _messageId?: string, + ): void {} + /** * Called when a prompt finishes (response sent or cancelled). * Override to hide the working indicator. @@ -2376,6 +2382,17 @@ export abstract class ChannelBase { buffer = []; this.collectBuffers.set(sessionId, buffer); } + 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`, + ); + } buffer.push({ text: promptText, envelope }); return; } diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index b429edda0fc..654c3793686 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1,4 +1,11 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + 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'; @@ -245,6 +252,12 @@ function lastClient(): MockWSClient { 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; @@ -260,11 +273,13 @@ describe('WeComChannel', () => { vi.clearAllMocks(); mocks.decryptFile.mockImplementation((buffer: Buffer) => buffer); 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', () => { @@ -437,6 +452,28 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + 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); + }); + it('rejects sends when no SDK client is active', async () => { const channel = new WeComChannel('bot', makeConfig(), makeBridge()); @@ -571,7 +608,6 @@ describe('WeComChannel', () => { expect(file.attachments?.[0]?.type).toBe('file'); expect(file.attachments?.[0]?.fileName).toBe('report.pdf'); expect(file.attachments?.[0]?.filePath).toContain('report.pdf'); - expect(existsSync(file.attachments?.[0]?.filePath ?? '')).toBe(true); }); it('sanitizes downloaded image filenames before adding attachments', async () => { @@ -865,6 +901,34 @@ describe('WeComChannel', () => { 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 IPv6 transition media URLs before probing them', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -989,7 +1053,7 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(mocks.httpCalls).toHaveLength(1)); const options = mocks.httpCalls[0]?.options as RequestOptionsWithLookup; - const callback = vi.fn, void>(); + const callback = vi.fn(); options.lookup?.('example.invalid', { all: true }, callback); @@ -1122,7 +1186,32 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); expect(channel.envelopes[0]?.attachments).toBeUndefined(); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('media download failed'), + 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(); }); @@ -1160,7 +1249,49 @@ describe('WeComChannel', () => { { address: '169.254.169.254', family: 4 }, ]); const options = mocks.httpCalls[0]?.options as RequestOptionsWithLookup; - const callback = vi.fn, void>(); + const callback = vi.fn(); + + options.lookup?.('example.invalid', { all: true }, callback); + + await vi.waitFor(() => + 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); @@ -1263,6 +1394,25 @@ describe('WeComChannel', () => { 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') @@ -1361,7 +1511,11 @@ describe('WeComChannel', () => { it('keeps downloaded file attachments for base prompt consumers', async () => { vi.useFakeTimers(); - const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); + const bridge = makeBridge(); + (bridge.prompt as ReturnType).mockImplementationOnce( + () => new Promise(() => {}), + ); + const channel = new WeComChannel('bot', makeConfig(), bridge); await channel.connect(); const client = lastClient(); @@ -1376,8 +1530,10 @@ describe('WeComChannel', () => { }, }); - await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); - const filePath = channel.envelopes[0]?.attachments?.[0]?.filePath; + 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); @@ -1412,6 +1568,94 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(existsSync(dirname(filePath!))).toBe(false)); }); + 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('sends markdown text and local media through the SDK', async () => { const parent = join(tmpdir(), 'channel-files'); mkdirSync(parent, { recursive: true }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 13f130e3838..6e77a7af91c 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -87,7 +87,14 @@ export class WeComChannel extends ChannelBase { private client?: WeComClient; private readonly seenMessages = new Map(); private readonly attachmentDirsByMessage = new Map(); + private readonly attachmentDirsBySession = new Map(); + private readonly bufferedAttachmentMessages = new Set(); private dedupTimer?: ReturnType; + private clientHandlers?: { + message: (payload: unknown) => void; + error: (payload: unknown) => void; + disconnected: (payload: unknown) => void; + }; constructor( name: string, @@ -113,45 +120,53 @@ export class WeComChannel extends ChannelBase { const client = new ClientCtor(options); let authenticated = false; - for (const event of MESSAGE_EVENTS) { - client.on(event, (payload) => { - if (!authenticated) { - process.stderr.write( - `[WeCom:${this.name}] dropping message before authentication.\n`, - ); - return; - } - this.onMessage(payload).catch((err: unknown) => { - process.stderr.write( - `[WeCom:${this.name}] message handling failed: ${sanitizeLogText( - String(err), - 200, - )}\n`, - ); - }); + const messageHandler = (payload: unknown) => { + if (!authenticated) { + process.stderr.write( + `[WeCom:${this.name}] dropping message before authentication.\n`, + ); + return; + } + this.onMessage(payload).catch((err: unknown) => { + process.stderr.write( + `[WeCom:${this.name}] message handling failed: ${sanitizeLogText( + String(err), + 200, + )}\n`, + ); }); - } - client.on('error', (err) => { + }; + const errorHandler = (err: unknown) => { process.stderr.write( `[WeCom:${this.name}] SDK error: ${sanitizeLogText(String(err), 200)}\n`, ); - }); - client.on('disconnected', (reason) => + }; + const disconnectedHandler = (reason: unknown) => { process.stderr.write( `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; waiting for SDK reconnect.\n`, - ), - ); + ); + }; + for (const event of MESSAGE_EVENTS) { + client.on(event, messageHandler); + } + client.on('error', errorHandler); + client.on('disconnected', disconnectedHandler); + this.clientHandlers = { + message: messageHandler, + error: errorHandler, + disconnected: disconnectedHandler, + }; - this.client = client; const authentication = waitForAuthentication(client); try { const connected = client.connect(); if (isPromiseLike(connected)) await connected; await authentication.promise; authenticated = true; + this.client = client; } catch (err) { authentication.cancel(); - if (this.client === client) this.client = undefined; + this.detachClientHandlers(client); client.disconnect(); throw err; } @@ -166,8 +181,10 @@ export class WeComChannel extends ChannelBase { this.dedupTimer = undefined; } this.seenMessages.clear(); + this.cleanupAllAttachmentDirs(); const client = this.client; this.client = undefined; + if (client) this.detachClientHandlers(client); client?.disconnect(); process.stderr.write(`[WeCom:${this.name}] Disconnected.\n`); } @@ -265,17 +282,25 @@ export class WeComChannel extends ChannelBase { isReplyToBot: false, referencedText: extractQuoteText(body), }; + const sessionId = await this.router.resolve( + this.name, + envelope.senderId, + envelope.chatId, + envelope.threadId, + this.config.cwd, + ); let attachments: Attachment[] = []; let processingStarted = false; try { if (!(await this.preflightInbound(envelope))) return; - if (messageId) this.seenMessages.set(messageId, Date.now()); attachments = await this.downloadAttachments( body, attachments, messageId, + sessionId, ); + if (messageId) this.seenMessages.set(messageId, Date.now()); if (attachments.length) { envelope.attachments = attachments; } @@ -294,6 +319,14 @@ export class WeComChannel extends ChannelBase { ); } throw err; + } finally { + if ( + messageId && + !this.bufferedAttachmentMessages.has(messageId) && + this.attachmentDirsByMessage.has(messageId) + ) { + this.cleanupAttachmentDirsForMessage(messageId); + } } } @@ -301,6 +334,7 @@ export class WeComChannel extends ChannelBase { body: Record, attachments: Attachment[] = [], messageId?: string, + sessionId?: string, ): Promise { const refs = collectInboundMediaRefs(body); for (const ref of refs) { @@ -328,11 +362,7 @@ export class WeComChannel extends ChannelBase { } else { const dir = join(tmpdir(), 'channel-files', randomUUID()); mkdirSync(dir, { recursive: true }); - if (messageId) { - const dirs = this.attachmentDirsByMessage.get(messageId) ?? []; - dirs.push(dir); - this.attachmentDirsByMessage.set(messageId, dirs); - } + this.rememberAttachmentDir(dir, messageId, sessionId); const safeName = fileName || `wecom_${ref.type}`; const filePath = join(dir, safeName); writeFileSync(filePath, data); @@ -347,20 +377,101 @@ export class WeComChannel extends ChannelBase { return attachments; } - protected override onPromptEnd( + protected override onPromptBuffered( _chatId: string, _sessionId: string, messageId?: string, ): void { - if (!messageId) return; + if (messageId) this.bufferedAttachmentMessages.add(messageId); + } + + protected override onPromptEnd( + _chatId: string, + sessionId: string, + messageId?: string, + ): void { + if (messageId) this.cleanupAttachmentDirsForMessage(messageId); + this.cleanupAttachmentDirsForSession(sessionId); + } + + private rememberAttachmentDir( + dir: string, + messageId?: string, + sessionId?: string, + ): void { + if (messageId) { + const messageDirs = this.attachmentDirsByMessage.get(messageId) ?? []; + messageDirs.push(dir); + this.attachmentDirsByMessage.set(messageId, messageDirs); + } + if (sessionId) { + const sessionDirs = this.attachmentDirsBySession.get(sessionId) ?? []; + sessionDirs.push(dir); + this.attachmentDirsBySession.set(sessionId, sessionDirs); + } + } + + private cleanupAttachmentDirsForMessage(messageId: string): void { const dirs = this.attachmentDirsByMessage.get(messageId); if (!dirs) return; this.attachmentDirsByMessage.delete(messageId); - for (const dir of dirs) { - rmSync(dir, { recursive: true, force: true }); + this.bufferedAttachmentMessages.delete(messageId); + 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); + cleanupAttachmentDirs(dirs); + } + + 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); + } + } + } + + private cleanupAllAttachmentDirs(): void { + const dirs = Array.from(this.attachmentDirsBySession.values()).flat(); + this.attachmentDirsBySession.clear(); + this.attachmentDirsByMessage.clear(); + this.bufferedAttachmentMessages.clear(); + cleanupAttachmentDirs(dirs); + } + + private detachClientHandlers(client: WeComClient): void { + const handlers = this.clientHandlers; + if (!handlers) return; + for (const event of MESSAGE_EVENTS) { + client.off?.(event, handlers.message); + } + client.off?.('error', handlers.error); + client.off?.('disconnected', handlers.disconnected); + this.clientHandlers = undefined; + } + private cleanupSeenMessages(): void { const now = Date.now(); for (const [id, ts] of this.seenMessages) { @@ -414,6 +525,12 @@ function waitForAuthentication(client: WeComClient): { }; } +function cleanupAttachmentDirs(dirs: string[]): void { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } +} + function formatDisconnectReason(reason: unknown): string { const text = typeof reason === 'string' && reason ? reason : 'disconnected'; return sanitizeLogText(text, 120); @@ -800,7 +917,7 @@ function guardedHttpsDownload( } if (statusCode < 200 || statusCode >= 300) { res.resume(); - finish(new Error('media download failed')); + finish(new Error(`media download failed: HTTP ${statusCode}`)); return; } @@ -824,8 +941,13 @@ function guardedHttpsDownload( 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: Buffer.concat(chunks), + buffer, ...parseContentDispositionFileName(res.headers), }); }); @@ -914,6 +1036,7 @@ function isPublicIpAddress(address: string): boolean { host === '::1' || host.startsWith('fc') || host.startsWith('fd') || + host.startsWith('ff') || isIpv6LinkLocal(host) ); } @@ -956,6 +1079,13 @@ function parseEmbeddedIpv4(host: string): number[] | undefined { const groups = expandIpv6Groups(host); if (!groups) return undefined; + 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) 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) { From bc95cb90f4f0a598ff6013c419ef8abb3f9bc329 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 12:14:18 +0800 Subject: [PATCH 22/73] fix(channels): close wecom preflight dedup race --- .../channels/wecom/src/WeComAdapter.test.ts | 46 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 27 ++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 654c3793686..395da36f5e6 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -236,6 +236,25 @@ class RejectingPreflightWeComChannel extends WeComChannel { } } +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'); @@ -811,6 +830,33 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(2)); }); + 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') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 6e77a7af91c..91e133de3f1 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -86,6 +86,7 @@ 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 attachmentDirsBySession = new Map(); private readonly bufferedAttachmentMessages = new Set(); @@ -181,6 +182,7 @@ export class WeComChannel extends ChannelBase { this.dedupTimer = undefined; } this.seenMessages.clear(); + this.inFlightMessages.clear(); this.cleanupAllAttachmentDirs(); const client = this.client; this.client = undefined; @@ -267,6 +269,10 @@ export class WeComChannel extends ChannelBase { ); return; } + if (messageId) { + if (this.inFlightMessages.has(messageId)) return; + this.inFlightMessages.add(messageId); + } const text = extractText(body); const explicitMention = getExplicitMention(body, this.wecom.botId); @@ -282,13 +288,19 @@ export class WeComChannel extends ChannelBase { isReplyToBot: false, referencedText: extractQuoteText(body), }; - const sessionId = await this.router.resolve( - this.name, - envelope.senderId, - envelope.chatId, - envelope.threadId, - this.config.cwd, - ); + let sessionId: string; + try { + sessionId = await this.router.resolve( + this.name, + envelope.senderId, + envelope.chatId, + envelope.threadId, + this.config.cwd, + ); + } catch (err) { + if (messageId) this.inFlightMessages.delete(messageId); + throw err; + } let attachments: Attachment[] = []; let processingStarted = false; @@ -320,6 +332,7 @@ export class WeComChannel extends ChannelBase { } throw err; } finally { + if (messageId) this.inFlightMessages.delete(messageId); if ( messageId && !this.bufferedAttachmentMessages.has(messageId) && From 92d129cdd59229f5e1644bf9298f304e210ca631 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 14:16:34 +0800 Subject: [PATCH 23/73] fix(channels): close wecom review gaps --- .../channels/wecom/src/WeComAdapter.test.ts | 103 +++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 76 ++++++++++--- .../src/commands/channel/config-utils.test.ts | 27 +++-- .../cli/src/commands/channel/config-utils.ts | 5 +- .../cli/src/commands/channel/runtime.test.ts | 7 +- 5 files changed, 187 insertions(+), 31 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 395da36f5e6..9c3a61747b8 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -471,6 +471,29 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + it('reconnects when WeCom kicks the connection for a newer client', async () => { + 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.waitFor(() => expect(mocks.instances).toHaveLength(2)); + expect(oldClient.disconnect).toHaveBeenCalled(); + expect(lastClient().connect).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] WebSocket disconnected; reconnecting after server kick.\n', + ); + channel.disconnect(); + stderr.mockRestore(); + }); + it('removes SDK event handlers on disconnect', async () => { const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); await channel.connect(); @@ -491,6 +514,7 @@ describe('WeComChannel', () => { 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 () => { @@ -808,10 +832,11 @@ describe('WeComChannel', () => { }); it('rolls back message dedup when preflight rejects the envelope', async () => { + const bridge = makeBridge(); const channel = new RejectingPreflightWeComChannel( 'bot', makeConfig(), - makeBridge(), + bridge, ); await channel.connect(); const client = lastClient(); @@ -825,9 +850,12 @@ describe('WeComChannel', () => { client.emit('message.text', payload); await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(1)); + expect(bridge.newSession).not.toHaveBeenCalled(); + 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(); }); it('deduplicates repeated messages while preflight is pending', async () => { @@ -1702,6 +1730,70 @@ describe('WeComChannel', () => { 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('sends markdown text and local media through the SDK', async () => { const parent = join(tmpdir(), 'channel-files'); mkdirSync(parent, { recursive: true }); @@ -1857,7 +1949,10 @@ describe('WeComChannel', () => { .mockResolvedValueOnce({ media_id: 'media-2' }); client.sendMediaMessage = vi .fn() - .mockRejectedValueOnce(new Error('network failed')) + .mockRejectedValueOnce({ + errcode: 45009, + errmsg: 'api freq out of limit', + }) .mockResolvedValueOnce({ headers: { req_id: 'media-req-2' } }); await channel.sendMessage( @@ -1873,7 +1968,9 @@ describe('WeComChannel', () => { 'media-2', ); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('media send failed for image'), + expect.stringContaining( + 'media send failed for image: errcode=45009 errmsg=api freq out of limit', + ), ); stderr.mockRestore(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 91e133de3f1..31b7d1accf2 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -95,7 +95,9 @@ export class WeComChannel extends ChannelBase { message: (payload: unknown) => void; error: (payload: unknown) => void; disconnected: (payload: unknown) => void; + kicked: (payload: unknown) => void; }; + private reconnectingAfterKick = false; constructor( name: string, @@ -131,7 +133,7 @@ export class WeComChannel extends ChannelBase { this.onMessage(payload).catch((err: unknown) => { process.stderr.write( `[WeCom:${this.name}] message handling failed: ${sanitizeLogText( - String(err), + formatSdkError(err), 200, )}\n`, ); @@ -139,7 +141,7 @@ export class WeComChannel extends ChannelBase { }; const errorHandler = (err: unknown) => { process.stderr.write( - `[WeCom:${this.name}] SDK error: ${sanitizeLogText(String(err), 200)}\n`, + `[WeCom:${this.name}] SDK error: ${sanitizeLogText(formatSdkError(err), 200)}\n`, ); }; const disconnectedHandler = (reason: unknown) => { @@ -147,15 +149,20 @@ export class WeComChannel extends ChannelBase { `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; waiting for SDK reconnect.\n`, ); }; + const kickedHandler = (reason: unknown) => { + void this.reconnectAfterKick(reason); + }; 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 = { message: messageHandler, error: errorHandler, disconnected: disconnectedHandler, + kicked: kickedHandler, }; const authentication = waitForAuthentication(client); @@ -235,7 +242,7 @@ export class WeComChannel extends ChannelBase { } catch (err) { process.stderr.write( `[WeCom:${this.name}] media send failed for ${item.type}: ${sanitizeLogText( - String(err), + formatSdkError(err), 200, )}\n`, ); @@ -288,8 +295,11 @@ export class WeComChannel extends ChannelBase { isReplyToBot: false, referencedText: extractQuoteText(body), }; - let sessionId: string; + let sessionId: string | undefined; + let attachments: Attachment[] = []; + let processingStarted = false; try { + if (!(await this.preflightInbound(envelope))) return; sessionId = await this.router.resolve( this.name, envelope.senderId, @@ -297,15 +307,6 @@ export class WeComChannel extends ChannelBase { envelope.threadId, this.config.cwd, ); - } catch (err) { - if (messageId) this.inFlightMessages.delete(messageId); - throw err; - } - - let attachments: Attachment[] = []; - let processingStarted = false; - try { - if (!(await this.preflightInbound(envelope))) return; attachments = await this.downloadAttachments( body, attachments, @@ -403,7 +404,12 @@ export class WeComChannel extends ChannelBase { sessionId: string, messageId?: string, ): void { - if (messageId) this.cleanupAttachmentDirsForMessage(messageId); + const wasBuffered = + messageId !== undefined && this.bufferedAttachmentMessages.has(messageId); + if (messageId) { + this.cleanupAttachmentDirsForMessage(messageId); + if (!wasBuffered) return; + } this.cleanupAttachmentDirsForSession(sessionId); } @@ -425,10 +431,10 @@ export class WeComChannel extends ChannelBase { } private cleanupAttachmentDirsForMessage(messageId: string): void { + this.bufferedAttachmentMessages.delete(messageId); const dirs = this.attachmentDirsByMessage.get(messageId); if (!dirs) return; this.attachmentDirsByMessage.delete(messageId); - this.bufferedAttachmentMessages.delete(messageId); this.removeAttachmentDirsFromSessions(dirs); cleanupAttachmentDirs(dirs); } @@ -482,9 +488,31 @@ export class WeComChannel extends ChannelBase { } client.off?.('error', handlers.error); client.off?.('disconnected', handlers.disconnected); + client.off?.('event.disconnected_event', handlers.kicked); this.clientHandlers = undefined; } + private async reconnectAfterKick(reason: unknown): Promise { + if (this.reconnectingAfterKick) return; + this.reconnectingAfterKick = true; + process.stderr.write( + `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; reconnecting after server kick.\n`, + ); + this.disconnect(); + try { + await this.connect(); + } catch (err) { + process.stderr.write( + `[WeCom:${this.name}] reconnect after server kick failed: ${sanitizeLogText( + formatSdkError(err), + 200, + )}\n`, + ); + } finally { + this.reconnectingAfterKick = false; + } + } + private cleanupSeenMessages(): void { const now = Date.now(); for (const [id, ts] of this.seenMessages) { @@ -549,6 +577,24 @@ function formatDisconnectReason(reason: unknown): string { 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)}`; + } + try { + return JSON.stringify(record); + } catch { + // Fall through to String below. + } + } + return String(err); +} + function isPromiseLike(value: unknown): value is PromiseLike { return ( value !== null && diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 5aec21f2c4d..48a57cd4323 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -19,11 +19,21 @@ vi.mock('./channel-registry.js', () => ({ channelType: 'wecom', requiredConfigFields: ['botId', 'secret'], }, + numeric: { + channelType: 'numeric', + requiredConfigFields: ['port'], + }, bare: { channelType: 'bare' }, // no requiredConfigFields }; return plugins[type]; }, - supportedTypes: async () => ['telegram', 'dingtalk', 'wecom', 'bare'], + supportedTypes: async () => [ + 'telegram', + 'dingtalk', + 'wecom', + 'numeric', + 'bare', + ], })); describe('resolveEnvVars', () => { @@ -90,14 +100,13 @@ describe('parseChannelConfig', () => { delete process.env['TEST_WECOM_SECRET']; }); - it('throws a clear error when plugin-required fields are not strings', async () => { - await expect( - parseChannelConfig('bot', { - type: 'wecom', - botId: 123, - secret: 'secret', - }), - ).rejects.toThrow('Channel "bot" field "botId" must be a string.'); + 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 () => { diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 3840268e4c0..ef037eb68a5 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -139,10 +139,9 @@ export async function parseChannelConfig( `Channel "${name}" (${channelType}) requires "${field}".`, ); } - if (typeof value !== 'string') { - throw new Error(`Channel "${name}" field "${field}" must be a string.`); + if (typeof value === 'string') { + resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); } - resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); } // Resolve env vars for known credential fields diff --git a/packages/cli/src/commands/channel/runtime.test.ts b/packages/cli/src/commands/channel/runtime.test.ts index 76bb61e1619..fbc0057be85 100644 --- a/packages/cli/src/commands/channel/runtime.test.ts +++ b/packages/cli/src/commands/channel/runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parseConfiguredChannels } from './runtime.js'; vi.mock('./channel-registry.js', () => ({ @@ -10,8 +10,13 @@ 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 () => { From 7016ea5701faab2fda090f365d69a7b7ffd209ca Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 15:17:10 +0800 Subject: [PATCH 24/73] fix(channels): harden wecom kick reconnect --- packages/channels/base/src/ChannelBase.ts | 68 +++++- .../channels/wecom/src/WeComAdapter.test.ts | 154 +++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 207 +++++++++++++----- 3 files changed, 370 insertions(+), 59 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 3d860fe9e6b..35720cde881 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,7 @@ 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 bridgeToolCallListener = (event: ToolCallEvent): void => { this.dispatchToolCall(event); }; @@ -720,6 +718,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, @@ -864,12 +867,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 messageIds = this.collectBufferMessageIds(buffer); + if (messageIds.length === 0) return; + try { + this.onPromptBufferDropped(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`, @@ -931,6 +971,17 @@ export abstract class ChannelBase { _messageId?: string, ): void {} + protected onPromptBufferDrained( + _chatId: string, + _sessionId: string, + _messageIds: string[], + ): void {} + + protected onPromptBufferDropped( + _sessionId: string, + _messageIds: string[], + ): void {} + /** * Called when a prompt finishes (response sent or cancelled). * Override to hide the working indicator. @@ -1055,7 +1106,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); @@ -2768,6 +2819,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, diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 9c3a61747b8..a1a748648de 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -472,6 +472,7 @@ describe('WeComChannel', () => { }); it('reconnects when WeCom kicks the connection for a newer client', async () => { + vi.useFakeTimers(); const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -484,6 +485,7 @@ describe('WeComChannel', () => { 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); @@ -494,6 +496,75 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + 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('removes SDK event handlers on disconnect', async () => { const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); await channel.connect(); @@ -1794,6 +1865,89 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channelFileDirs()).toHaveLength(0)); }); + 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('sends markdown text and local media through the SDK', async () => { const parent = join(tmpdir(), 'channel-files'); mkdirSync(parent, { recursive: true }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 31b7d1accf2..c4aafde7d5f 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -81,6 +81,9 @@ 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_BASE_DELAY_MS = 1_000; +const KICK_RECONNECT_RESET_MS = 60_000; export class WeComChannel extends ChannelBase { private readonly wecom: WeComConfig; @@ -90,7 +93,13 @@ export class WeComChannel extends ChannelBase { private readonly attachmentDirsByMessage = new Map(); private readonly attachmentDirsBySession = new Map(); private readonly bufferedAttachmentMessages = new Set(); + private readonly coalescedAttachmentMessages = new Map(); private dedupTimer?: ReturnType; + private kickReconnectReset?: ReturnType; + private connecting?: Promise; + private connectingClient?: WeComClient; + private authentication?: ReturnType; + private disconnectGeneration = 0; private clientHandlers?: { message: (payload: unknown) => void; error: (payload: unknown) => void; @@ -98,6 +107,7 @@ export class WeComChannel extends ChannelBase { kicked: (payload: unknown) => void; }; private reconnectingAfterKick = false; + private kickReconnectAttempts = 0; constructor( name: string, @@ -111,7 +121,18 @@ export class WeComChannel extends ChannelBase { 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, @@ -152,38 +173,57 @@ export class WeComChannel extends ChannelBase { const kickedHandler = (reason: unknown) => { void this.reconnectAfterKick(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 = { - message: messageHandler, - error: errorHandler, - disconnected: disconnectedHandler, - kicked: kickedHandler, - }; + this.clientHandlers = handlers; + this.connectingClient = client; const authentication = waitForAuthentication(client); + this.authentication = authentication; try { const connected = client.connect(); if (isPromiseLike(connected)) await connected; await 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; } catch (err) { authentication.cancel(); - this.detachClientHandlers(client); + this.detachClientHandlers(client, handlers); + if (this.connectingClient === client) this.connectingClient = undefined; + if (this.authentication === authentication) + this.authentication = undefined; client.disconnect(); throw err; } - this.dedupTimer = setInterval(() => this.cleanupSeenMessages(), 60_000); - this.dedupTimer.unref?.(); + 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; + if (this.kickReconnectReset) { + clearTimeout(this.kickReconnectReset); + this.kickReconnectReset = undefined; + } if (this.dedupTimer) { clearInterval(this.dedupTimer); this.dedupTimer = undefined; @@ -191,10 +231,7 @@ export class WeComChannel extends ChannelBase { this.seenMessages.clear(); this.inFlightMessages.clear(); this.cleanupAllAttachmentDirs(); - const client = this.client; - this.client = undefined; - if (client) this.detachClientHandlers(client); - client?.disconnect(); + this.disconnectClientOnly(new Error('WeCom channel disconnected.')); process.stderr.write(`[WeCom:${this.name}] Disconnected.\n`); } @@ -399,18 +436,41 @@ export class WeComChannel extends ChannelBase { if (messageId) this.bufferedAttachmentMessages.add(messageId); } + 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( + _sessionId: string, + messageIds: string[], + ): void { + for (const messageId of messageIds) { + this.cleanupAttachmentDirsForMessage(messageId); + } + } + protected override onPromptEnd( _chatId: string, - sessionId: string, + _sessionId: string, messageId?: string, ): void { - const wasBuffered = - messageId !== undefined && this.bufferedAttachmentMessages.has(messageId); - if (messageId) { - this.cleanupAttachmentDirsForMessage(messageId); - if (!wasBuffered) return; + if (!messageId) return; + const coalescedMessageIds = this.coalescedAttachmentMessages.get(messageId); + if (coalescedMessageIds) { + this.coalescedAttachmentMessages.delete(messageId); + for (const coalescedMessageId of coalescedMessageIds) { + this.cleanupAttachmentDirsForMessage(coalescedMessageId); + } + return; } - this.cleanupAttachmentDirsForSession(sessionId); + this.cleanupAttachmentDirsForMessage(messageId); } private rememberAttachmentDir( @@ -439,14 +499,6 @@ export class WeComChannel extends ChannelBase { cleanupAttachmentDirs(dirs); } - private cleanupAttachmentDirsForSession(sessionId: string): void { - const dirs = this.attachmentDirsBySession.get(sessionId); - if (!dirs) return; - this.attachmentDirsBySession.delete(sessionId); - this.removeAttachmentDirsFromMessages(dirs); - cleanupAttachmentDirs(dirs); - } - private removeAttachmentDirsFromSessions(dirs: string[]): void { const removed = new Set(dirs); for (const [sessionId, sessionDirs] of this.attachmentDirsBySession) { @@ -459,29 +511,19 @@ export class WeComChannel extends ChannelBase { } } - 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); - } - } - } - private cleanupAllAttachmentDirs(): void { const dirs = Array.from(this.attachmentDirsBySession.values()).flat(); this.attachmentDirsBySession.clear(); this.attachmentDirsByMessage.clear(); this.bufferedAttachmentMessages.clear(); + this.coalescedAttachmentMessages.clear(); cleanupAttachmentDirs(dirs); } - private detachClientHandlers(client: WeComClient): void { - const handlers = this.clientHandlers; + private detachClientHandlers( + client: WeComClient, + handlers = this.clientHandlers, + ): void { if (!handlers) return; for (const event of MESSAGE_EVENTS) { client.off?.(event, handlers.message); @@ -489,30 +531,72 @@ export class WeComChannel extends ChannelBase { client.off?.('error', handlers.error); client.off?.('disconnected', handlers.disconnected); client.off?.('event.disconnected_event', handlers.kicked); - this.clientHandlers = undefined; + 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; + if (client) this.detachClientHandlers(client); + client?.disconnect(); } private async reconnectAfterKick(reason: unknown): Promise { if (this.reconnectingAfterKick) return; this.reconnectingAfterKick = true; + const previousConnecting = this.connecting; + const disconnectGeneration = this.disconnectGeneration; process.stderr.write( `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; reconnecting after server kick.\n`, ); - this.disconnect(); try { - await this.connect(); - } catch (err) { + 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(); + this.scheduleKickReconnectReset(); + return; + } catch (err) { + process.stderr.write( + `[WeCom:${this.name}] reconnect after server kick attempt ${attempt} failed: ${sanitizeLogText( + formatSdkError(err), + 200, + )}\n`, + ); + } + } process.stderr.write( - `[WeCom:${this.name}] reconnect after server kick failed: ${sanitizeLogText( - formatSdkError(err), - 200, - )}\n`, + `[WeCom:${this.name}] reconnect after server kick gave up after ${KICK_RECONNECT_MAX_ATTEMPTS} attempts.\n`, ); } finally { this.reconnectingAfterKick = false; } } + private scheduleKickReconnectReset(): void { + if (this.kickReconnectReset) clearTimeout(this.kickReconnectReset); + this.kickReconnectReset = setTimeout(() => { + this.kickReconnectAttempts = 0; + this.kickReconnectReset = undefined; + }, KICK_RECONNECT_RESET_MS); + this.kickReconnectReset.unref?.(); + } + private cleanupSeenMessages(): void { const now = Date.now(); for (const [id, ts] of this.seenMessages) { @@ -525,7 +609,7 @@ export class WeComChannel extends ChannelBase { function waitForAuthentication(client: WeComClient): { promise: Promise; - cancel(): void; + cancel(err?: Error): void; } { let timeout: ReturnType | undefined; let settled = false; @@ -537,6 +621,14 @@ function waitForAuthentication(client: WeComClient): { `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 => { @@ -545,6 +637,7 @@ function waitForAuthentication(client: WeComClient): { if (timeout) clearTimeout(timeout); client.off?.('authenticated', onAuth); client.off?.('error', onError); + client.off?.('event.disconnected_event', onKicked); if (err) { rejectPromise(err); } else { @@ -559,13 +652,21 @@ function waitForAuthentication(client: WeComClient): { client.on('authenticated', onAuth); client.on('error', onError); + client.on('event.disconnected_event', onKicked); }); return { promise, - cancel: () => finish(), + cancel: (err?: Error) => finish(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) { rmSync(dir, { recursive: true, force: true }); From 5ad0c9c9650aaf4b161e8e0ea26d9bcd788d1fa3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 16:13:11 +0800 Subject: [PATCH 25/73] fix(channels): defer wecom session resolution --- .../channels/wecom/src/WeComAdapter.test.ts | 59 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 16 ++--- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index a1a748648de..d0b71d80590 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -870,6 +870,65 @@ describe('WeComChannel', () => { 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' }, + }); + + 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') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index c4aafde7d5f..e8cdbd65063 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -332,23 +332,14 @@ export class WeComChannel extends ChannelBase { isReplyToBot: false, referencedText: extractQuoteText(body), }; - let sessionId: string | undefined; let attachments: Attachment[] = []; let processingStarted = false; try { if (!(await this.preflightInbound(envelope))) return; - sessionId = await this.router.resolve( - this.name, - envelope.senderId, - envelope.chatId, - envelope.threadId, - this.config.cwd, - ); attachments = await this.downloadAttachments( body, attachments, messageId, - sessionId, ); if (messageId) this.seenMessages.set(messageId, Date.now()); if (attachments.length) { @@ -512,7 +503,12 @@ export class WeComChannel extends ChannelBase { } private cleanupAllAttachmentDirs(): void { - const dirs = Array.from(this.attachmentDirsBySession.values()).flat(); + const dirs = Array.from( + new Set([ + ...Array.from(this.attachmentDirsBySession.values()).flat(), + ...Array.from(this.attachmentDirsByMessage.values()).flat(), + ]), + ); this.attachmentDirsBySession.clear(); this.attachmentDirsByMessage.clear(); this.bufferedAttachmentMessages.clear(); From 643555d2d1faa76fee148c0cd557deb3610b4e25 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 17:09:14 +0800 Subject: [PATCH 26/73] fix(channels): clean wecom session attachments --- .../channels/wecom/src/WeComAdapter.test.ts | 40 ++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 55 +++++++++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index d0b71d80590..beae3c72a43 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -214,6 +214,12 @@ class TestWeComChannel extends WeComChannel { } } +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'); @@ -1772,6 +1778,40 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(existsSync(dirname(filePath!))).toBe(false)); }); + 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 downloaded file attachments when no prompt starts', async () => { const bridge = makeBridge(); const channel = new WeComChannel('bot', makeConfig(), bridge); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index e8cdbd65063..c3134aa1262 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -421,10 +421,20 @@ export class WeComChannel extends ChannelBase { protected override onPromptBuffered( _chatId: string, - _sessionId: string, + sessionId: string, + messageId?: string, + ): void { + if (!messageId) return; + this.bufferedAttachmentMessages.add(messageId); + this.rememberMessageDirsForSession(messageId, sessionId); + } + + protected override onPromptStart( + _chatId: string, + sessionId: string, messageId?: string, ): void { - if (messageId) this.bufferedAttachmentMessages.add(messageId); + if (messageId) this.rememberMessageDirsForSession(messageId, sessionId); } protected override onPromptBufferDrained( @@ -449,10 +459,13 @@ export class WeComChannel extends ChannelBase { protected override onPromptEnd( _chatId: string, - _sessionId: string, + sessionId: string, messageId?: string, ): void { - if (!messageId) return; + if (!messageId) { + this.cleanupAttachmentDirsForSession(sessionId); + return; + } const coalescedMessageIds = this.coalescedAttachmentMessages.get(messageId); if (coalescedMessageIds) { this.coalescedAttachmentMessages.delete(messageId); @@ -481,6 +494,19 @@ export class WeComChannel extends ChannelBase { } } + 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); @@ -490,6 +516,14 @@ export class WeComChannel extends ChannelBase { cleanupAttachmentDirs(dirs); } + private cleanupAttachmentDirsForSession(sessionId: string): void { + const dirs = this.attachmentDirsBySession.get(sessionId); + if (!dirs) return; + this.attachmentDirsBySession.delete(sessionId); + this.removeAttachmentDirsFromMessages(dirs); + cleanupAttachmentDirs(dirs); + } + private removeAttachmentDirsFromSessions(dirs: string[]): void { const removed = new Set(dirs); for (const [sessionId, sessionDirs] of this.attachmentDirsBySession) { @@ -502,6 +536,19 @@ export class WeComChannel extends ChannelBase { } } + 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); + } + } + } + private cleanupAllAttachmentDirs(): void { const dirs = Array.from( new Set([ From 5a9c9b03cca65d674de71f988ef6c8652f0559c8 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 17:39:02 +0800 Subject: [PATCH 27/73] fix(channels): harden wecom reconnect and media cleanup --- .../channels/wecom/src/WeComAdapter.test.ts | 137 ++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 108 ++++++++++---- 2 files changed, 218 insertions(+), 27 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index beae3c72a43..73011ea3cf8 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -93,12 +93,17 @@ const mocks = vi.hoisted(() => { ); const state = { autoAuthenticate: true, + connectErrorsRemaining: 0, }; 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.autoAuthenticate) { queueMicrotask(() => this.emit('authenticated', {})); } @@ -288,6 +293,7 @@ describe('WeComChannel', () => { mocks.instances.length = 0; mocks.httpCalls.length = 0; mocks.state.autoAuthenticate = true; + mocks.state.connectErrorsRemaining = 0; mocks.httpResponse.statusCode = 200; mocks.httpResponse.headers = {}; mocks.httpResponse.body = Buffer.from('downloaded'); @@ -502,6 +508,35 @@ describe('WeComChannel', () => { 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 clear adapter state when reconnecting after a kick', async () => { vi.useFakeTimers(); const bridge = makeBridge(); @@ -1111,6 +1146,34 @@ describe('WeComChannel', () => { 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'), + ); + stderr.mockRestore(); + }); + it('blocks uncompressed IPv4-mapped IPv6 media URLs before probing them', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -1719,6 +1782,24 @@ describe('WeComChannel', () => { 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(); @@ -1812,6 +1893,62 @@ describe('WeComChannel', () => { ); }); + 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('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('removes downloaded file attachments when no prompt starts', async () => { const bridge = makeBridge(); const channel = new WeComChannel('bot', makeConfig(), bridge); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index c3134aa1262..e70f983f1b4 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -84,6 +84,7 @@ const AUTHENTICATION_TIMEOUT_MS = 30_000; const KICK_RECONNECT_MAX_ATTEMPTS = 3; const KICK_RECONNECT_BASE_DELAY_MS = 1_000; const KICK_RECONNECT_RESET_MS = 60_000; +const KICK_RECONNECT_RETRY_MS = 5 * 60 * 1000; export class WeComChannel extends ChannelBase { private readonly wecom: WeComConfig; @@ -92,10 +93,12 @@ export class WeComChannel extends ChannelBase { private readonly inFlightMessages = new Set(); private readonly attachmentDirsByMessage = new Map(); private readonly attachmentDirsBySession = new Map(); + private attachmentDirsWithoutMessage: string[] = []; private readonly bufferedAttachmentMessages = new Set(); private readonly coalescedAttachmentMessages = new Map(); private dedupTimer?: ReturnType; private kickReconnectReset?: ReturnType; + private kickReconnectRetry?: ReturnType; private connecting?: Promise; private connectingClient?: WeComClient; private authentication?: ReturnType; @@ -224,6 +227,10 @@ export class WeComChannel extends ChannelBase { clearTimeout(this.kickReconnectReset); this.kickReconnectReset = undefined; } + if (this.kickReconnectRetry) { + clearTimeout(this.kickReconnectRetry); + this.kickReconnectRetry = undefined; + } if (this.dedupTimer) { clearInterval(this.dedupTimer); this.dedupTimer = undefined; @@ -297,6 +304,7 @@ export class WeComChannel extends ChannelBase { } const messageId = getString(body, 'msgid'); + const logMessageId = sanitizeLogText(messageId || '(no id)', 100); if (messageId && this.seenMessages.has(messageId)) return; const from = getRecord(body, 'from'); @@ -307,9 +315,9 @@ export class WeComChannel extends ChannelBase { const chatId = isGroup ? rawChatId : rawChatId || senderId; if (!chatId || !senderId) { process.stderr.write( - `[WeCom:${this.name}] dropping message ${ - messageId || '(no id)' - }: missing ${!senderId ? 'senderId' : 'chatId'}.\n`, + `[WeCom:${this.name}] dropping message ${logMessageId}: missing ${ + !senderId ? 'senderId' : 'chatId' + }.\n`, ); return; } @@ -356,7 +364,7 @@ export class WeComChannel extends ChannelBase { if (messageId && !processingStarted) this.seenMessages.delete(messageId); else if (messageId) { process.stderr.write( - `[WeCom:${this.name}] message ${messageId} failed after processing started; dedup entry retained.\n`, + `[WeCom:${this.name}] message ${logMessageId} failed after processing started; dedup entry retained.\n`, ); } throw err; @@ -369,6 +377,7 @@ export class WeComChannel extends ChannelBase { ) { this.cleanupAttachmentDirsForMessage(messageId); } + if (!messageId) this.cleanupAttachmentDirsWithoutMessage(); } } @@ -376,7 +385,6 @@ export class WeComChannel extends ChannelBase { body: Record, attachments: Attachment[] = [], messageId?: string, - sessionId?: string, ): Promise { const refs = collectInboundMediaRefs(body); for (const ref of refs) { @@ -404,7 +412,7 @@ export class WeComChannel extends ChannelBase { } else { const dir = join(tmpdir(), 'channel-files', randomUUID()); mkdirSync(dir, { recursive: true }); - this.rememberAttachmentDir(dir, messageId, sessionId); + this.rememberAttachmentDir(dir, messageId); const safeName = fileName || `wecom_${ref.type}`; const filePath = join(dir, safeName); writeFileSync(filePath, data); @@ -424,7 +432,10 @@ export class WeComChannel extends ChannelBase { sessionId: string, messageId?: string, ): void { - if (!messageId) return; + if (!messageId) { + this.rememberUntrackedDirsForSession(sessionId); + return; + } this.bufferedAttachmentMessages.add(messageId); this.rememberMessageDirsForSession(messageId, sessionId); } @@ -434,7 +445,11 @@ export class WeComChannel extends ChannelBase { sessionId: string, messageId?: string, ): void { - if (messageId) this.rememberMessageDirsForSession(messageId, sessionId); + if (messageId) { + this.rememberMessageDirsForSession(messageId, sessionId); + } else { + this.rememberUntrackedDirsForSession(sessionId); + } } protected override onPromptBufferDrained( @@ -477,20 +492,13 @@ export class WeComChannel extends ChannelBase { this.cleanupAttachmentDirsForMessage(messageId); } - private rememberAttachmentDir( - dir: string, - messageId?: string, - sessionId?: string, - ): void { + private rememberAttachmentDir(dir: string, messageId?: string): void { if (messageId) { const messageDirs = this.attachmentDirsByMessage.get(messageId) ?? []; messageDirs.push(dir); this.attachmentDirsByMessage.set(messageId, messageDirs); - } - if (sessionId) { - const sessionDirs = this.attachmentDirsBySession.get(sessionId) ?? []; - sessionDirs.push(dir); - this.attachmentDirsBySession.set(sessionId, sessionDirs); + } else { + this.attachmentDirsWithoutMessage.push(dir); } } @@ -524,6 +532,23 @@ export class WeComChannel extends ChannelBase { cleanupAttachmentDirs(dirs); } + private rememberUntrackedDirsForSession(sessionId: string): void { + if (this.attachmentDirsWithoutMessage.length === 0) return; + const sessionDirs = this.attachmentDirsBySession.get(sessionId) ?? []; + for (const dir of this.attachmentDirsWithoutMessage) { + if (!sessionDirs.includes(dir)) sessionDirs.push(dir); + } + this.attachmentDirsBySession.set(sessionId, sessionDirs); + this.attachmentDirsWithoutMessage = []; + } + + private cleanupAttachmentDirsWithoutMessage(): void { + const dirs = this.attachmentDirsWithoutMessage; + if (dirs.length === 0) return; + this.attachmentDirsWithoutMessage = []; + cleanupAttachmentDirs(dirs); + } + private removeAttachmentDirsFromSessions(dirs: string[]): void { const removed = new Set(dirs); for (const [sessionId, sessionDirs] of this.attachmentDirsBySession) { @@ -554,10 +579,12 @@ export class WeComChannel extends ChannelBase { new Set([ ...Array.from(this.attachmentDirsBySession.values()).flat(), ...Array.from(this.attachmentDirsByMessage.values()).flat(), + ...this.attachmentDirsWithoutMessage, ]), ); this.attachmentDirsBySession.clear(); this.attachmentDirsByMessage.clear(); + this.attachmentDirsWithoutMessage = []; this.bufferedAttachmentMessages.clear(); this.coalescedAttachmentMessages.clear(); cleanupAttachmentDirs(dirs); @@ -589,6 +616,10 @@ export class WeComChannel extends ChannelBase { private async reconnectAfterKick(reason: unknown): Promise { if (this.reconnectingAfterKick) return; + if (this.kickReconnectRetry) { + clearTimeout(this.kickReconnectRetry); + this.kickReconnectRetry = undefined; + } this.reconnectingAfterKick = true; const previousConnecting = this.connecting; const disconnectGeneration = this.disconnectGeneration; @@ -624,14 +655,19 @@ export class WeComChannel extends ChannelBase { } } process.stderr.write( - `[WeCom:${this.name}] reconnect after server kick gave up after ${KICK_RECONNECT_MAX_ATTEMPTS} attempts.\n`, + `[WeCom:${this.name}] reconnect after server kick gave up after ${KICK_RECONNECT_MAX_ATTEMPTS} attempts; retrying later.\n`, ); + this.scheduleKickReconnectRetry(reason, disconnectGeneration); } finally { this.reconnectingAfterKick = false; } } private scheduleKickReconnectReset(): void { + if (this.kickReconnectRetry) { + clearTimeout(this.kickReconnectRetry); + this.kickReconnectRetry = undefined; + } if (this.kickReconnectReset) clearTimeout(this.kickReconnectReset); this.kickReconnectReset = setTimeout(() => { this.kickReconnectAttempts = 0; @@ -640,6 +676,19 @@ export class WeComChannel extends ChannelBase { this.kickReconnectReset.unref?.(); } + private scheduleKickReconnectRetry( + reason: unknown, + disconnectGeneration: number, + ): void { + this.kickReconnectRetry = setTimeout(() => { + this.kickReconnectRetry = undefined; + if (this.disconnectGeneration !== disconnectGeneration) return; + this.kickReconnectAttempts = 0; + void this.reconnectAfterKick(reason); + }, KICK_RECONNECT_RETRY_MS); + this.kickReconnectRetry.unref?.(); + } + private cleanupSeenMessages(): void { const now = Date.now(); for (const [id, ts] of this.seenMessages) { @@ -1234,20 +1283,24 @@ function isPublicIpAddress(address: string): boolean { 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; return !( - host === '::' || - host === '::1' || - host.startsWith('fc') || - host.startsWith('fd') || - host.startsWith('ff') || - isIpv6LinkLocal(host) + isAllZeros || + isLoopback || + (first >= 0xfc00 && first <= 0xfdff) || + (first >= 0xff00 && first <= 0xffff) || + isIpv6LinkLocalGroup(first) ); } return false; } -function isIpv6LinkLocal(host: string): boolean { - const firstGroup = Number.parseInt(host.split(':', 1)[0] ?? '', 16); +function isIpv6LinkLocalGroup(firstGroup: number): boolean { return firstGroup >= 0xfe80 && firstGroup <= 0xfeff; } @@ -1378,6 +1431,7 @@ function isPublicIpv4(parts: number[]): boolean { (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) || From 7947aff815b56ee6dffdda4aedbe9f46756c6c52 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 18:43:17 +0800 Subject: [PATCH 28/73] fix(channels): address wecom review diagnostics --- docs/users/features/channels/wecom.md | 2 +- .../channels/wecom/src/WeComAdapter.test.ts | 184 +++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 36 +++- .../src/commands/channel/config-utils.test.ts | 7 + .../cli/src/commands/channel/config-utils.ts | 7 +- 5 files changed, 228 insertions(+), 8 deletions(-) diff --git a/docs/users/features/channels/wecom.md b/docs/users/features/channels/wecom.md index ba38ca14671..af331ff239a 100644 --- a/docs/users/features/channels/wecom.md +++ b/docs/users/features/channels/wecom.md @@ -90,7 +90,7 @@ Assistant responses are sent as WeCom markdown. To send a local image generated [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/...`. Generic file, video, and voice upload markers are ignored because model-produced file paths could otherwise upload arbitrary workspace files. +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 diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 73011ea3cf8..b0de908e565 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -483,6 +483,28 @@ describe('WeComChannel', () => { 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 @@ -502,7 +524,7 @@ describe('WeComChannel', () => { expect(oldClient.disconnect).toHaveBeenCalled(); expect(lastClient().connect).toHaveBeenCalledTimes(1); expect(stderr).toHaveBeenCalledWith( - '[WeCom:bot] WebSocket disconnected; reconnecting after server kick.\n', + '[WeCom:bot] WebSocket errcode=45009 errmsg=another client connected; reconnecting after server kick.\n', ); channel.disconnect(); stderr.mockRestore(); @@ -1086,6 +1108,42 @@ describe('WeComChannel', () => { 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(); }); @@ -1118,6 +1176,67 @@ describe('WeComChannel', () => { 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 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/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(3)); + 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') @@ -1296,6 +1415,41 @@ describe('WeComChannel', () => { 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, @@ -2417,6 +2571,34 @@ describe('WeComChannel', () => { 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 index e70f983f1b4..5edeb1fbe31 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -303,9 +303,14 @@ export class WeComChannel extends ChannelBase { return; } - const messageId = getString(body, 'msgid'); + const messageId = getString(body, 'msgid') || undefined; const logMessageId = sanitizeLogText(messageId || '(no id)', 100); - if (messageId && this.seenMessages.has(messageId)) return; + if (messageId && this.seenMessages.has(messageId)) { + process.stderr.write( + `[WeCom:${this.name}] dropping duplicate message ${logMessageId} (already seen).\n`, + ); + return; + } const from = getRecord(body, 'from'); const senderId = getString(from, 'userid') || ''; @@ -322,7 +327,12 @@ export class WeComChannel extends ChannelBase { return; } if (messageId) { - if (this.inFlightMessages.has(messageId)) return; + if (this.inFlightMessages.has(messageId)) { + process.stderr.write( + `[WeCom:${this.name}] dropping duplicate message ${logMessageId} (already in flight).\n`, + ); + return; + } this.inFlightMessages.add(messageId); } @@ -766,7 +776,8 @@ function cleanupAttachmentDirs(dirs: string[]): void { } function formatDisconnectReason(reason: unknown): string { - const text = typeof reason === 'string' && reason ? reason : 'disconnected'; + const text = + typeof reason === 'string' && reason ? reason : formatSdkError(reason); return sanitizeLogText(text, 120); } @@ -779,6 +790,22 @@ function formatSdkError(err: unknown): string { 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); } catch { @@ -1060,7 +1087,6 @@ function readOutboundMedia( const allowedDirs = [ ensureDirectoryRealpath(join(tmpdir(), 'channel-files')), - ensureDirectoryRealpath('/tmp/channel-files'), ].filter((dir): dir is string => Boolean(dir)); if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { throw new Error('Media path outside allowed outbound directory'); diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 48a57cd4323..e2002d1cdaf 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -206,6 +206,9 @@ describe('parseChannelConfig', () => { it('leaves unavailable env vars literal in available mode', async () => { delete process.env['TEST_MISSING_SECRET']; + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); const result = await parseChannelConfig( 'bot', @@ -219,6 +222,10 @@ describe('parseChannelConfig', () => { ); expect(result['secret']).toBe('$TEST_MISSING_SECRET'); + expect(stderr).toHaveBeenCalledWith( + '[channel] warning: environment variable TEST_MISSING_SECRET is not set, using literal value.\n', + ); + stderr.mockRestore(); }); it('preserves explicit config values over defaults', async () => { diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index ef037eb68a5..420630c1ec4 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -43,7 +43,12 @@ function resolveConfigEnvVar(value: string, mode: EnvResolution): string { if (mode === 'available' && value.startsWith('$')) { const envName = value.substring(1); const envValue = process.env[envName]; - if (envValue === undefined) return value; + if (envValue === undefined) { + process.stderr.write( + `[channel] warning: environment variable ${envName} is not set, using literal value.\n`, + ); + return value; + } if (envValue === '') { throw new Error( `Environment variable ${envName} is empty (referenced as ${value})`, From 7dea3a74a542519eecae21f420ccadeb223481e2 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 20:09:01 +0800 Subject: [PATCH 29/73] fix(channels): improve wecom diagnostics --- .../channels/wecom/src/WeComAdapter.test.ts | 24 ++++++- packages/channels/wecom/src/WeComAdapter.ts | 62 ++++++++++++++----- .../src/commands/channel/config-utils.test.ts | 24 +++++++ .../cli/src/commands/channel/config-utils.ts | 3 +- 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index b0de908e565..fb15063dc03 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -659,6 +659,24 @@ describe('WeComChannel', () => { ); }); + 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', @@ -1288,7 +1306,7 @@ describe('WeComChannel', () => { expect(channel.envelopes[0]?.attachments).toBeUndefined(); expect(mocks.httpsRequest).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('unsafe media URL'), + expect.stringContaining('unsafe media URL (private address ::)'), ); stderr.mockRestore(); }); @@ -1410,7 +1428,9 @@ describe('WeComChannel', () => { expect(client.downloadFile).not.toHaveBeenCalled(); expect(mocks.httpsRequest).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('unsafe media URL'), + expect.stringContaining( + 'unsafe media URL (resolved to private address 169.254.169.254)', + ), ); stderr.mockRestore(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 5edeb1fbe31..97bbcecacaa 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -255,7 +255,18 @@ export class WeComChannel extends ChannelBase { } const { cleanedText, media } = parseOutboundMediaMarkers(text); - for (const chunk of splitMarkdownChunks(cleanedText)) { + 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 }, @@ -1115,39 +1126,62 @@ function isInsideDir(filePath: string, dir: string): boolean { ); } -async function isSafeInboundMediaUrl(rawUrl: string): Promise { +type SafeInboundMediaUrlResult = + | { safe: true } + | { safe: false; reason: string }; + +async function isSafeInboundMediaUrl( + rawUrl: string, +): Promise { let url: URL; try { url = new URL(rawUrl); } catch { - return false; + return { safe: false, reason: 'invalid URL' }; + } + if (url.protocol !== 'https:') { + return { safe: false, reason: 'non-HTTPS protocol' }; } - if (url.protocol !== 'https:') return false; const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ''); if (!host || host === 'localhost' || host.endsWith('.localhost')) { - return false; + return { safe: false, reason: 'local hostname' }; + } + if (host.endsWith('.local')) { + return { safe: false, reason: 'local hostname' }; } - if (host.endsWith('.local')) return false; - if (isIP(host)) return isPublicIpAddress(host); - if (!host.includes('.')) return false; + 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 }); - return ( - records.length > 0 && - records.every((record) => isPublicIpAddress(record.address)) + if (records.length === 0) { + return { safe: false, reason: 'no DNS records' }; + } + const privateRecord = records.find( + (record) => !isPublicIpAddress(record.address), ); + return privateRecord + ? { + safe: false, + reason: `resolved to private address ${privateRecord.address}`, + } + : { safe: true }; } catch { - return false; + return { safe: false, reason: 'DNS lookup failed' }; } } async function downloadInboundMedia( ref: InboundMediaRef, ): Promise<{ buffer: Buffer; filename?: string }> { - if (!(await isSafeInboundMediaUrl(ref.url))) { - throw new Error('unsafe media URL'); + 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. diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index e2002d1cdaf..ed287aa1561 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -228,6 +228,30 @@ describe('parseChannelConfig', () => { stderr.mockRestore(); }); + it('warns once for unavailable required credential env vars', async () => { + delete process.env['TEST_MISSING_TOKEN']; + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + const result = await parseChannelConfig( + 'bot', + { + type: 'telegram', + token: '$TEST_MISSING_TOKEN', + }, + process.cwd(), + { resolveEnvVars: 'available' }, + ); + + expect(result.token).toBe('$TEST_MISSING_TOKEN'); + expect(stderr).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalledWith( + '[channel] warning: environment variable TEST_MISSING_TOKEN is not set, using literal value.\n', + ); + stderr.mockRestore(); + }); + 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 420630c1ec4..d174c7ccf8c 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -37,6 +37,7 @@ function resolveOptionalStringField( } 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; @@ -144,7 +145,7 @@ export async function parseChannelConfig( `Channel "${name}" (${channelType}) requires "${field}".`, ); } - if (typeof value === 'string') { + if (typeof value === 'string' && !KNOWN_CREDENTIAL_FIELDS.has(field)) { resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); } } From 9ba9cc98c6540dee04580039a2310818102e5ff5 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 20:43:27 +0800 Subject: [PATCH 30/73] fix(channels): reset wecom kick retries --- .../channels/wecom/src/WeComAdapter.test.ts | 145 ++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 1 + 2 files changed, 146 insertions(+) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index fb15063dc03..281c55b07a3 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -559,6 +559,68 @@ describe('WeComChannel', () => { 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('does not clear adapter state when reconnecting after a kick', async () => { vi.useFakeTimers(); const bridge = makeBridge(); @@ -628,6 +690,36 @@ describe('WeComChannel', () => { 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('removes SDK event handlers on disconnect', async () => { const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); await channel.connect(); @@ -2275,6 +2367,59 @@ describe('WeComChannel', () => { 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; diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 97bbcecacaa..0ef84cb2776 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -664,6 +664,7 @@ export class WeComChannel extends ChannelBase { if (this.disconnectGeneration !== disconnectGeneration) return; try { await this.connect(); + this.kickReconnectAttempts = 0; this.scheduleKickReconnectReset(); return; } catch (err) { From 8ab3343c8a0c21f99d1d477a27a1fb7f57558da6 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 22:11:27 +0800 Subject: [PATCH 31/73] fix(channels): improve wecom diagnostics --- .../channels/wecom/src/WeComAdapter.test.ts | 30 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 19 +++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 281c55b07a3..5673d7a346e 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readdirSync, + realpathSync, rmSync, writeFileSync, } from 'node:fs'; @@ -41,6 +42,9 @@ const mocks = vi.hoisted(() => { const httpCalls: MockHttpCall[] = []; const lookup = vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]); const decryptFile = vi.fn((buffer: Buffer, _aesKey: string) => buffer); + const readFile = vi.fn(async (_path: string) => + Buffer.from([0x89, 0x50, 0x4e, 0x47]), + ); const httpResponse = { statusCode: 200, headers: {} as Record, @@ -159,6 +163,7 @@ const mocks = vi.hoisted(() => { httpCalls, lookup, decryptFile, + readFile, httpResponse, httpsRequest, state, @@ -175,6 +180,9 @@ vi.mock('node:dns/promises', () => ({ vi.mock('node:https', () => ({ request: mocks.httpsRequest, })); +vi.mock('node:fs/promises', () => ({ + readFile: mocks.readFile, +})); import { WeComChannel } from './WeComAdapter.js'; import { plugin } from './index.js'; @@ -303,6 +311,7 @@ describe('WeComChannel', () => { ); vi.clearAllMocks(); mocks.decryptFile.mockImplementation((buffer: Buffer) => buffer); + mocks.readFile.mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47])); mocks.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); rmSync(join(tmpdir(), 'channel-files'), { recursive: true, force: true }); }); @@ -1135,6 +1144,9 @@ describe('WeComChannel', () => { }); it('rolls back message dedup when preflight rejects the envelope', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); const bridge = makeBridge(); const channel = new RejectingPreflightWeComChannel( 'bot', @@ -1154,11 +1166,26 @@ describe('WeComChannel', () => { 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(); + await vi.waitFor(() => + expect( + stderr.mock.calls.filter( + ([message]) => + message === + '[WeCom:bot] dropping message msg-preflight-rejected: preflight rejected.\n', + ), + ).toHaveLength(2), + ); + stderr.mockRestore(); }); it('deduplicates repeated messages while preflight is pending', async () => { @@ -2574,6 +2601,9 @@ describe('WeComChannel', () => { await channel.sendMessage('chat-1', '[IMAGE: out.png]'); + expect(mocks.readFile).toHaveBeenCalledWith( + realpathSync(join(dir, 'out.png')), + ); expect(client.uploadMedia).toHaveBeenCalledWith(expect.any(Buffer), { type: 'image', filename: 'out.png', diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 0ef84cb2776..9ce7b736068 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1,12 +1,12 @@ import { lstatSync, mkdirSync, - readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { request as httpsRequest } from 'node:https'; import type { IncomingHttpHeaders } from 'node:http'; import { randomUUID } from 'node:crypto'; @@ -281,7 +281,7 @@ export class WeComChannel extends ChannelBase { continue; } try { - const file = readOutboundMedia(item.path, this.config.cwd); + const file = await readOutboundMedia(item.path, this.config.cwd); const upload = await client.uploadMedia(file.data, { type: item.type, filename: file.fileName, @@ -364,7 +364,12 @@ export class WeComChannel extends ChannelBase { let attachments: Attachment[] = []; let processingStarted = false; try { - if (!(await this.preflightInbound(envelope))) return; + if (!(await this.preflightInbound(envelope))) { + process.stderr.write( + `[WeCom:${this.name}] dropping message ${logMessageId}: preflight rejected.\n`, + ); + return; + } attachments = await this.downloadAttachments( body, attachments, @@ -1081,13 +1086,13 @@ function splitMarkdownChunks(text: string): string[] { return chunks; } -function readOutboundMedia( +async function readOutboundMedia( rawPath: string, cwd: string, -): { +): Promise<{ data: Buffer; fileName: string; -} { +}> { const resolved = resolve(cwd, rawPath); const real = realpathSync(resolved); const stat = statSync(real); @@ -1103,7 +1108,7 @@ function readOutboundMedia( if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { throw new Error('Media path outside allowed outbound directory'); } - return { data: readFileSync(real), fileName: basename(real) }; + return { data: await readFile(real), fileName: basename(real) }; } function ensureDirectoryRealpath(path: string): string | undefined { From 05936c1886818b26c745d301a03b5583e8156f79 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 4 Jul 2026 23:14:17 +0800 Subject: [PATCH 32/73] fix(channels): preserve sync cancel preflight --- packages/channels/base/src/ChannelBase.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 35720cde881..7947b2b5777 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -2203,7 +2203,7 @@ export abstract class ChannelBase { return `${GROUP_HISTORY_CONTEXT_MARKER}\n${formatted.join('\n')}\n\n${CURRENT_MESSAGE_MARKER}\n${promptText}`; } - protected async preflightInbound(envelope: Envelope): Promise { + protected preflightInbound(envelope: Envelope): boolean | Promise { const groupResult = this.groupGate.check(envelope); if (!groupResult.allowed) { if (groupResult.reason === 'mention_required') { @@ -2215,7 +2215,9 @@ export abstract class ChannelBase { 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, + ); } return false; } @@ -2224,7 +2226,8 @@ export abstract class ChannelBase { } async handleInbound(envelope: Envelope): Promise { - if (!(await this.preflightInbound(envelope))) return; + const preflight = this.preflightInbound(envelope); + if (!(preflight instanceof Promise ? await preflight : preflight)) return; await this.processInbound(envelope); } From d9a9b84e466bc60fbaddc0a43a17ee4941db3894 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 04:31:31 +0800 Subject: [PATCH 33/73] fix(channels): close wecom connection and ssrf gaps --- .../channels/wecom/src/WeComAdapter.test.ts | 67 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 16 ++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 5673d7a346e..854a7a423e9 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -98,6 +98,7 @@ const mocks = vi.hoisted(() => { const state = { autoAuthenticate: true, connectErrorsRemaining: 0, + connectNeverSettles: false, }; class MockWSClient { @@ -108,6 +109,9 @@ const mocks = vi.hoisted(() => { state.connectErrorsRemaining -= 1; return Promise.reject(new Error('connect failed')); } + if (state.connectNeverSettles) { + return new Promise(() => {}); + } if (state.autoAuthenticate) { queueMicrotask(() => this.emit('authenticated', {})); } @@ -302,6 +306,7 @@ describe('WeComChannel', () => { mocks.httpCalls.length = 0; mocks.state.autoAuthenticate = true; mocks.state.connectErrorsRemaining = 0; + mocks.state.connectNeverSettles = false; mocks.httpResponse.statusCode = 200; mocks.httpResponse.headers = {}; mocks.httpResponse.body = Buffer.from('downloaded'); @@ -729,6 +734,22 @@ describe('WeComChannel', () => { 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('removes SDK event handlers on disconnect', async () => { const channel = new TestWeComChannel('bot', makeConfig(), makeBridge()); await channel.connect(); @@ -1458,6 +1479,34 @@ describe('WeComChannel', () => { 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 IPv6 transition media URLs before probing them', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -2075,6 +2124,24 @@ describe('WeComChannel', () => { 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(); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 9ce7b736068..2c437fb43ba 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -194,9 +194,12 @@ export class WeComChannel extends ChannelBase { const authentication = waitForAuthentication(client); this.authentication = authentication; try { + authentication.promise.catch(() => {}); const connected = client.connect(); - if (isPromiseLike(connected)) await connected; - await authentication.promise; + const connectedPromise = isPromiseLike(connected) + ? Promise.resolve(connected).then(() => {}) + : Promise.resolve(); + await Promise.all([connectedPromise, authentication.promise]); authenticated = true; if (this.connectingClient !== client) { throw new Error('WeCom connection was replaced before authentication.'); @@ -1360,6 +1363,7 @@ function isPublicIpAddress(address: string): boolean { isLoopback || (first >= 0xfc00 && first <= 0xfdff) || (first >= 0xff00 && first <= 0xffff) || + (first === 0x2001 && groups[1] === 0x0db8) || isIpv6LinkLocalGroup(first) ); } @@ -1401,6 +1405,14 @@ function parseEmbeddedIpv4(host: string): number[] | undefined { 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 && From 6e25684f0d5ff1504c1412c143c0522b3de1e616 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 04:31:56 +0800 Subject: [PATCH 34/73] fix(channels): clean coalesced wecom attachments --- .../channels/wecom/src/WeComAdapter.test.ts | 66 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 22 ++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 854a7a423e9..60c0f3d8c97 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -2397,6 +2397,72 @@ describe('WeComChannel', () => { 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; diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 2c437fb43ba..2377647fc0f 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -493,12 +493,13 @@ export class WeComChannel extends ChannelBase { } protected override onPromptBufferDropped( - _sessionId: string, + sessionId: string, messageIds: string[], ): void { for (const messageId of messageIds) { this.cleanupAttachmentDirsForMessage(messageId); } + this.cleanupUntrackedAttachmentDirsForSession(sessionId); } protected override onPromptEnd( @@ -516,6 +517,7 @@ export class WeComChannel extends ChannelBase { for (const coalescedMessageId of coalescedMessageIds) { this.cleanupAttachmentDirsForMessage(coalescedMessageId); } + this.cleanupUntrackedAttachmentDirsForSession(sessionId); return; } this.cleanupAttachmentDirsForMessage(messageId); @@ -561,6 +563,24 @@ export class WeComChannel extends ChannelBase { cleanupAttachmentDirs(dirs); } + private cleanupUntrackedAttachmentDirsForSession(sessionId: string): void { + const dirs = this.attachmentDirsBySession.get(sessionId); + if (!dirs) return; + const trackedDirs = new Set(); + for (const messageDirs of this.attachmentDirsByMessage.values()) { + for (const dir of messageDirs) trackedDirs.add(dir); + } + const untrackedDirs = dirs.filter((dir) => !trackedDirs.has(dir)); + if (untrackedDirs.length === 0) return; + const remainingDirs = dirs.filter((dir) => trackedDirs.has(dir)); + if (remainingDirs.length > 0) { + this.attachmentDirsBySession.set(sessionId, remainingDirs); + } else { + this.attachmentDirsBySession.delete(sessionId); + } + cleanupAttachmentDirs(untrackedDirs); + } + private rememberUntrackedDirsForSession(sessionId: string): void { if (this.attachmentDirsWithoutMessage.length === 0) return; const sessionDirs = this.attachmentDirsBySession.get(sessionId) ?? []; From c3350a1886c5a6e8e004f7e2e3894e1a87bdb0e3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 04:46:43 +0800 Subject: [PATCH 35/73] fix(channels): bound wecom sdk connect wait --- .../channels/wecom/src/WeComAdapter.test.ts | 54 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 30 ++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 60c0f3d8c97..de4972155a8 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -750,6 +750,32 @@ describe('WeComChannel', () => { 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(); @@ -1507,6 +1533,34 @@ describe('WeComChannel', () => { 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') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 2377647fc0f..d52e0f57e6e 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -197,7 +197,11 @@ export class WeComChannel extends ChannelBase { authentication.promise.catch(() => {}); const connected = client.connect(); const connectedPromise = isPromiseLike(connected) - ? Promise.resolve(connected).then(() => {}) + ? withTimeout( + Promise.resolve(connected).then(() => {}), + AUTHENTICATION_TIMEOUT_MS, + 'WeCom SDK connect timed out.', + ) : Promise.resolve(); await Promise.all([connectedPromise, authentication.promise]); authenticated = true; @@ -802,6 +806,30 @@ function waitForAuthentication(client: WeComClient): { }; } +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); From 40ca3608936b5b754e67f1e535609414ead4f61c Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 12:00:56 +0800 Subject: [PATCH 36/73] fix(channels): scope wecom untracked attachment cleanup --- .../channels/base/src/ChannelBase.test.ts | 9 ++ packages/channels/base/src/ChannelBase.ts | 1 - .../channels/wecom/src/WeComAdapter.test.ts | 65 +++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 92 +++++++++++++++---- 4 files changed, 149 insertions(+), 18 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index 61ddbe8e32c..1e1e2870288 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -28,6 +28,7 @@ class TestChannel extends ChannelBase { }> = []; promptEnds: Array<{ chatId: string; sessionId: string; messageId?: string }> = []; + promptBufferDrops: Array<{ 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 +91,13 @@ class TestChannel extends ChannelBase { } } + protected override onPromptBufferDropped( + sessionId: string, + messageIds: string[], + ): void { + this.promptBufferDrops.push({ sessionId, messageIds }); + } + protected override onResponseChunk( chatId: string, chunk: string, @@ -5289,6 +5297,7 @@ describe('ChannelBase', () => { await expect(ch.cancelPromptForTest(sessionId)).resolves.toBe(true); expect(maps.collectBuffers.has(sessionId)).toBe(false); + expect(ch.promptBufferDrops).toEqual([{ sessionId, messageIds: [] }]); resolvePrompt('late'); await prompt; }); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 7947b2b5777..166369da076 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -878,7 +878,6 @@ export abstract class ChannelBase { if (!buffer) return; this.collectBuffers.delete(sessionId); const messageIds = this.collectBufferMessageIds(buffer); - if (messageIds.length === 0) return; try { this.onPromptBufferDropped(sessionId, messageIds); } catch (err) { diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index de4972155a8..025b57d6bf9 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -288,6 +288,24 @@ class FailingProcessWeComChannel extends WeComChannel { } } +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'); @@ -2340,6 +2358,53 @@ describe('WeComChannel', () => { ); }); + 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') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index d52e0f57e6e..572ffb97896 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -93,7 +93,10 @@ export class WeComChannel extends ChannelBase { private readonly inFlightMessages = new Set(); private readonly attachmentDirsByMessage = new Map(); private readonly attachmentDirsBySession = new Map(); - private attachmentDirsWithoutMessage: string[] = []; + private readonly attachmentDirsWithoutMessageByRoute = new Map< + string, + string[] + >(); private readonly bufferedAttachmentMessages = new Set(); private readonly coalescedAttachmentMessages = new Map(); private dedupTimer?: ReturnType; @@ -174,7 +177,7 @@ export class WeComChannel extends ChannelBase { ); }; const kickedHandler = (reason: unknown) => { - void this.reconnectAfterKick(reason); + this.startKickReconnect(reason); }; const handlers = { message: messageHandler, @@ -370,6 +373,11 @@ export class WeComChannel extends ChannelBase { }; let attachments: Attachment[] = []; let processingStarted = false; + const attachmentRouteKey = this.attachmentRouteKey( + senderId, + chatId, + envelope.threadId, + ); try { if (!(await this.preflightInbound(envelope))) { process.stderr.write( @@ -381,6 +389,7 @@ export class WeComChannel extends ChannelBase { body, attachments, messageId, + attachmentRouteKey, ); if (messageId) this.seenMessages.set(messageId, Date.now()); if (attachments.length) { @@ -410,7 +419,9 @@ export class WeComChannel extends ChannelBase { ) { this.cleanupAttachmentDirsForMessage(messageId); } - if (!messageId) this.cleanupAttachmentDirsWithoutMessage(); + if (!messageId) { + this.cleanupAttachmentDirsWithoutMessage(attachmentRouteKey); + } } } @@ -418,6 +429,7 @@ export class WeComChannel extends ChannelBase { body: Record, attachments: Attachment[] = [], messageId?: string, + routeKey?: string, ): Promise { const refs = collectInboundMediaRefs(body); for (const ref of refs) { @@ -445,7 +457,7 @@ export class WeComChannel extends ChannelBase { } else { const dir = join(tmpdir(), 'channel-files', randomUUID()); mkdirSync(dir, { recursive: true }); - this.rememberAttachmentDir(dir, messageId); + this.rememberAttachmentDir(dir, messageId, routeKey); const safeName = fileName || `wecom_${ref.type}`; const filePath = join(dir, safeName); writeFileSync(filePath, data); @@ -527,13 +539,19 @@ export class WeComChannel extends ChannelBase { this.cleanupAttachmentDirsForMessage(messageId); } - private rememberAttachmentDir(dir: string, messageId?: string): void { + 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); - } else { - this.attachmentDirsWithoutMessage.push(dir); + } else if (routeKey) { + const dirs = this.attachmentDirsWithoutMessageByRoute.get(routeKey) ?? []; + dirs.push(dir); + this.attachmentDirsWithoutMessageByRoute.set(routeKey, dirs); } } @@ -586,19 +604,22 @@ export class WeComChannel extends ChannelBase { } private rememberUntrackedDirsForSession(sessionId: string): void { - if (this.attachmentDirsWithoutMessage.length === 0) return; + 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 this.attachmentDirsWithoutMessage) { + for (const dir of dirs) { if (!sessionDirs.includes(dir)) sessionDirs.push(dir); } this.attachmentDirsBySession.set(sessionId, sessionDirs); - this.attachmentDirsWithoutMessage = []; + this.attachmentDirsWithoutMessageByRoute.delete(routeKey); } - private cleanupAttachmentDirsWithoutMessage(): void { - const dirs = this.attachmentDirsWithoutMessage; - if (dirs.length === 0) return; - this.attachmentDirsWithoutMessage = []; + private cleanupAttachmentDirsWithoutMessage(routeKey: string): void { + const dirs = this.attachmentDirsWithoutMessageByRoute.get(routeKey); + if (!dirs || dirs.length === 0) return; + this.attachmentDirsWithoutMessageByRoute.delete(routeKey); cleanupAttachmentDirs(dirs); } @@ -632,17 +653,43 @@ export class WeComChannel extends ChannelBase { new Set([ ...Array.from(this.attachmentDirsBySession.values()).flat(), ...Array.from(this.attachmentDirsByMessage.values()).flat(), - ...this.attachmentDirsWithoutMessage, + ...Array.from(this.attachmentDirsWithoutMessageByRoute.values()).flat(), ]), ); this.attachmentDirsBySession.clear(); this.attachmentDirsByMessage.clear(); - this.attachmentDirsWithoutMessage = []; + 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, @@ -738,11 +785,22 @@ export class WeComChannel extends ChannelBase { this.kickReconnectRetry = undefined; if (this.disconnectGeneration !== disconnectGeneration) return; this.kickReconnectAttempts = 0; - void this.reconnectAfterKick(reason); + this.startKickReconnect(reason); }, KICK_RECONNECT_RETRY_MS); this.kickReconnectRetry.unref?.(); } + private startKickReconnect(reason: unknown): void { + void this.reconnectAfterKick(reason).catch((err: unknown) => { + process.stderr.write( + `[WeCom:${this.name}] kick-reconnect failed: ${sanitizeLogText( + formatSdkError(err), + 200, + )}\n`, + ); + }); + } + private cleanupSeenMessages(): void { const now = Date.now(); for (const [id, ts] of this.seenMessages) { From ebb6e63d342352fb6acadc52ac990722c3f14330 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 12:50:18 +0800 Subject: [PATCH 37/73] fix(channels): block wecom nat64 local-use ssrf --- .../channels/wecom/src/WeComAdapter.test.ts | 28 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 6 +--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 025b57d6bf9..6f01b294769 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -2156,6 +2156,34 @@ describe('WeComChannel', () => { 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(); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 572ffb97896..23bfa9b3d9c 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1538,11 +1538,7 @@ function parseEmbeddedIpv4(host: string): number[] | undefined { if (groups[0] === 0x2001 && groups[1] === 0) { return hexGroupsToIpv4(groups[6]! ^ 0xffff, groups[7]! ^ 0xffff); } - if ( - groups[0] === 0x0064 && - groups[1] === 0xff9b && - groups.slice(2, 6).every((group) => group === 0) - ) { + if (groups[0] === 0x0064 && groups[1] === 0xff9b) { return hexGroupsToIpv4(groups[6], groups[7]); } return undefined; From 08dbc93854289fecf7262ecb56afd400fc47b9cc Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 14:38:31 +0800 Subject: [PATCH 38/73] fix(channels): harden wecom media handling --- .../channels/wecom/src/WeComAdapter.test.ts | 58 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 32 ++++++---- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 6f01b294769..e05700ca742 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -45,6 +45,9 @@ const mocks = vi.hoisted(() => { 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, @@ -168,6 +171,7 @@ const mocks = vi.hoisted(() => { lookup, decryptFile, readFile, + writeFile, httpResponse, httpsRequest, state, @@ -186,6 +190,7 @@ vi.mock('node:https', () => ({ })); vi.mock('node:fs/promises', () => ({ readFile: mocks.readFile, + writeFile: mocks.writeFile, })); import { WeComChannel } from './WeComAdapter.js'; @@ -335,6 +340,11 @@ describe('WeComChannel', () => { 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 }); }); @@ -2043,6 +2053,32 @@ describe('WeComChannel', () => { 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('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()); @@ -2841,6 +2877,28 @@ describe('WeComChannel', () => { ); }); + it('leaves media markers inside unclosed 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}]`; + + 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(); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 23bfa9b3d9c..1dfc161bf8d 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1,12 +1,5 @@ -import { - lstatSync, - mkdirSync, - realpathSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { lstatSync, mkdirSync, realpathSync, rmSync, statSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; import { request as httpsRequest } from 'node:https'; import type { IncomingHttpHeaders } from 'node:http'; import { randomUUID } from 'node:crypto'; @@ -456,11 +449,11 @@ export class WeComChannel extends ChannelBase { }); } else { const dir = join(tmpdir(), 'channel-files', randomUUID()); - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); this.rememberAttachmentDir(dir, messageId, routeKey); const safeName = fileName || `wecom_${ref.type}`; const filePath = join(dir, safeName); - writeFileSync(filePath, data); + await writeFile(filePath, data, { mode: 0o600 }); attachments.push({ type: ref.type === 'voice' ? 'audio' : ref.type, filePath, @@ -1142,8 +1135,23 @@ function parseOutboundMediaMarkers(text: string): { function findCodeRanges(text: string): Array<[number, number]> { const ranges: Array<[number, number]> = []; - for (const match of text.matchAll(/```[\s\S]*?```|`[^`]*`/g)) { + let fenceStart: number | undefined; + for (const match of text.matchAll(/```/g)) { const start = match.index ?? 0; + if (fenceStart === undefined) { + fenceStart = start; + } else { + ranges.push([fenceStart, start + 3]); + fenceStart = undefined; + } + } + if (fenceStart !== undefined) { + ranges.push([fenceStart, text.length]); + } + + for (const match of text.matchAll(/`[^`\n]*`/g)) { + const start = match.index ?? 0; + if (ranges.some(([from, to]) => start >= from && start < to)) continue; ranges.push([start, start + match[0].length]); } return ranges; From f58a17d73795e793db22d6093672add189bb1c03 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 15:40:36 +0800 Subject: [PATCH 39/73] fix(channels): harden wecom group gates --- .../channels/base/src/ChannelBase.test.ts | 42 +++++++++++++ packages/channels/base/src/ChannelBase.ts | 2 +- .../channels/wecom/src/WeComAdapter.test.ts | 63 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 9 ++- 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index 1e1e2870288..e6b310d12fe 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -5958,6 +5958,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' })); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 166369da076..7580b140a64 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -2840,7 +2840,7 @@ export abstract class ChannelBase { }; // 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) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index e05700ca742..41da7523899 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1073,6 +1073,28 @@ describe('WeComChannel', () => { 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', @@ -1171,6 +1193,7 @@ describe('WeComChannel', () => { chatid: 'group-1', from: { userid: 'alice' }, text: { content: '!pwd' }, + mentions: [{ userid: 'bot-id' }], }); await vi.waitFor(() => expect(client.sendMessage).toHaveBeenCalled()); @@ -1997,6 +2020,46 @@ describe('WeComChannel', () => { ); }); + 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(); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 1dfc161bf8d..ef1248c73f8 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -360,7 +360,7 @@ export class WeComChannel extends ChannelBase { text, messageId, isGroup, - isMentioned: !isGroup || (explicitMention ?? true), + isMentioned: !isGroup || (explicitMention ?? false), isReplyToBot: false, referencedText: extractQuoteText(body), }; @@ -1472,9 +1472,16 @@ function isPublicIpAddress(address: string): boolean { 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) || From d9bb9536945e5318c17ae743aed5d4d69865d5f8 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 16:09:20 +0800 Subject: [PATCH 40/73] fix(channels): bound wecom kick reconnect cycles --- .../channels/wecom/src/WeComAdapter.test.ts | 34 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 12 +++++++ 2 files changed, 46 insertions(+) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 41da7523899..cff25490ac1 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -601,6 +601,40 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + it('stops 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 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 stopped after 3 exhausted retry cycles; manual intervention required.\n', + ); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(mocks.instances).toHaveLength(10); + + 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()); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index ef1248c73f8..b9a6d4f9ef7 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -75,6 +75,7 @@ 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; @@ -107,6 +108,7 @@ export class WeComChannel extends ChannelBase { }; private reconnectingAfterKick = false; private kickReconnectAttempts = 0; + private kickReconnectRetryCycles = 0; constructor( name: string, @@ -226,6 +228,7 @@ export class WeComChannel extends ChannelBase { disconnect(): void { this.disconnectGeneration += 1; this.kickReconnectAttempts = 0; + this.kickReconnectRetryCycles = 0; if (this.kickReconnectReset) { clearTimeout(this.kickReconnectReset); this.kickReconnectReset = undefined; @@ -737,6 +740,7 @@ export class WeComChannel extends ChannelBase { try { await this.connect(); this.kickReconnectAttempts = 0; + this.kickReconnectRetryCycles = 0; this.scheduleKickReconnectReset(); return; } catch (err) { @@ -748,6 +752,13 @@ export class WeComChannel extends ChannelBase { ); } } + this.kickReconnectRetryCycles += 1; + if (this.kickReconnectRetryCycles >= KICK_RECONNECT_MAX_RETRY_CYCLES) { + process.stderr.write( + `[WeCom:${this.name}] reconnect after server kick stopped after ${this.kickReconnectRetryCycles} exhausted retry cycles; manual intervention required.\n`, + ); + return; + } process.stderr.write( `[WeCom:${this.name}] reconnect after server kick gave up after ${KICK_RECONNECT_MAX_ATTEMPTS} attempts; retrying later.\n`, ); @@ -765,6 +776,7 @@ export class WeComChannel extends ChannelBase { 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?.(); From f091c36af7241dc4caaf229efae4c1b37bd114d8 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 17:44:24 +0800 Subject: [PATCH 41/73] fix(channels): drain loop collect prompts directly --- .../channels/base/src/ChannelBase.test.ts | 62 +++++++++++++++++++ packages/channels/base/src/ChannelBase.ts | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index e6b310d12fe..88ef8610ffa 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -9272,5 +9272,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 7580b140a64..bd8855f406f 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -732,7 +732,7 @@ export abstract class ChannelBase { imageBase64: undefined, imageMimeType: undefined, }; - this.handleInbound(syntheticEnvelope).catch((err) => { + 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) From 907c23290bc17b74d15f29502e9b39e53dd541d6 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 18:13:34 +0800 Subject: [PATCH 42/73] fix(channels): align wecom buffer hooks --- .../channels/base/src/ChannelBase.test.ts | 71 +++++++++++++++++-- packages/channels/base/src/ChannelBase.ts | 6 +- packages/channels/wecom/src/WeComAdapter.ts | 1 + .../src/commands/channel/config-utils.test.ts | 15 ++++ .../cli/src/commands/channel/config-utils.ts | 10 +++ 5 files changed, 94 insertions(+), 9 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index 88ef8610ffa..aa6b89c374d 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -28,7 +28,17 @@ class TestChannel extends ChannelBase { }> = []; promptEnds: Array<{ chatId: string; sessionId: string; messageId?: string }> = []; - promptBufferDrops: Array<{ sessionId: string; messageIds: 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. */ @@ -92,10 +102,29 @@ class TestChannel extends ChannelBase { } protected override onPromptBufferDropped( + chatId: string, sessionId: string, messageIds: string[], ): void { - this.promptBufferDrops.push({ sessionId, messageIds }); + 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( @@ -5291,13 +5320,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([{ sessionId, messageIds: [] }]); + expect(ch.promptBufferDrops).toEqual([ + { chatId: 'chat1', sessionId, messageIds: ['m-buffered'] }, + ]); resolvePrompt('late'); await prompt; }); @@ -5923,8 +5957,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; @@ -5932,6 +5970,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'); @@ -7556,7 +7606,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; @@ -7581,6 +7631,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; diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index bd8855f406f..56e1baab841 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -877,9 +877,10 @@ export abstract class ChannelBase { 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(sessionId, messageIds); + this.onPromptBufferDropped(chatId, sessionId, messageIds); } catch (err) { process.stderr.write( `[${this.name}] onPromptBufferDropped threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`, @@ -977,6 +978,7 @@ export abstract class ChannelBase { ): void {} protected onPromptBufferDropped( + _chatId: string, _sessionId: string, _messageIds: string[], ): void {} @@ -2435,6 +2437,7 @@ export abstract class ChannelBase { buffer = []; this.collectBuffers.set(sessionId, buffer); } + buffer.push({ text: promptText, envelope }); try { this.onPromptBuffered( envelope.chatId, @@ -2446,7 +2449,6 @@ export abstract class ChannelBase { `[${this.name}] onPromptBuffered threw for session ${sessionId}: ${err instanceof Error ? err.message : err}\n`, ); } - buffer.push({ text: promptText, envelope }); return; } case 'steer': { diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index b9a6d4f9ef7..b78bfbffa05 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -505,6 +505,7 @@ export class WeComChannel extends ChannelBase { } protected override onPromptBufferDropped( + _chatId: string, sessionId: string, messageIds: string[], ): void { diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index ed287aa1561..83131f0f1ac 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -170,6 +170,21 @@ describe('parseChannelConfig', () => { delete process.env['TEST_SEC']; }); + it('resolves env vars in WeCom wsUrl', 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 known credential fields twice', async () => { process.env['TEST_TOKEN'] = '$TOKEN_LITERAL_VALUE'; diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index d174c7ccf8c..cc60b986bb2 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -149,6 +149,16 @@ export async function parseChannelConfig( resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); } } + if ( + channelType === 'wecom' && + typeof rawConfig['wsUrl'] === 'string' && + rawConfig['wsUrl'] !== '' + ) { + resolvedRawConfig['wsUrl'] = resolveConfigEnvVar( + rawConfig['wsUrl'], + envResolution, + ); + } // Resolve env vars for known credential fields const token = From 8bc756d35ffd72b4c65ef2d69319dc1bf6a804a0 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 18:38:25 +0800 Subject: [PATCH 43/73] fix(channels): harden wecom delivery failures --- .../channels/wecom/src/WeComAdapter.test.ts | 40 ++++++++++++++++--- packages/channels/wecom/src/WeComAdapter.ts | 16 ++++---- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index cff25490ac1..02b090f1246 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -86,6 +86,7 @@ const mocks = vi.hoisted(() => { callback(response); queueMicrotask(() => { emit('data', httpResponse.body); + if (state.mediaResponseNeverEnds) return; emit('end'); }); }); @@ -102,6 +103,7 @@ const mocks = vi.hoisted(() => { autoAuthenticate: true, connectErrorsRemaining: 0, connectNeverSettles: false, + mediaResponseNeverEnds: false, }; class MockWSClient { @@ -330,6 +332,7 @@ describe('WeComChannel', () => { mocks.state.autoAuthenticate = true; mocks.state.connectErrorsRemaining = 0; mocks.state.connectNeverSettles = false; + mocks.state.mediaResponseNeverEnds = false; mocks.httpResponse.statusCode = 200; mocks.httpResponse.headers = {}; mocks.httpResponse.body = Buffer.from('downloaded'); @@ -1347,7 +1350,7 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); }); - it('keeps message dedup when processing fails after side effects start', async () => { + it('rolls back message dedup when processing fails', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -1376,10 +1379,7 @@ describe('WeComChannel', () => { 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', - ); + await vi.waitFor(() => expect(channel.processes).toHaveBeenCalledTimes(2)); stderr.mockRestore(); }); @@ -1920,6 +1920,36 @@ describe('WeComChannel', () => { 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') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index b78bfbffa05..f27ead2aed3 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -368,7 +368,6 @@ export class WeComChannel extends ChannelBase { referencedText: extractQuoteText(body), }; let attachments: Attachment[] = []; - let processingStarted = false; const attachmentRouteKey = this.attachmentRouteKey( senderId, chatId, @@ -396,15 +395,9 @@ export class WeComChannel extends ChannelBase { ? '(image)' : `(file: ${attachments[0]?.fileName ?? 'file'})`; } - processingStarted = true; await this.processInbound(envelope); } catch (err) { - if (messageId && !processingStarted) this.seenMessages.delete(messageId); - else if (messageId) { - process.stderr.write( - `[WeCom:${this.name}] message ${logMessageId} failed after processing started; dedup entry retained.\n`, - ); - } + if (messageId) this.seenMessages.delete(messageId); throw err; } finally { if (messageId) this.inFlightMessages.delete(messageId); @@ -1337,12 +1330,14 @@ function guardedHttpsDownload( ): 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 { @@ -1406,6 +1401,11 @@ function guardedHttpsDownload( 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(); }); From 003c985fceb39090464603fefe652d01865dbff3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 19:41:44 +0800 Subject: [PATCH 44/73] fix(channels): recover from wecom attachment write failures --- .../channels/wecom/src/WeComAdapter.test.ts | 31 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 17 ++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 02b090f1246..d7394fa34ba 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -2206,6 +2206,37 @@ describe('WeComChannel', () => { ); }); + 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('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()); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index f27ead2aed3..a1b3d9c6e8f 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -445,11 +445,22 @@ export class WeComChannel extends ChannelBase { }); } else { const dir = join(tmpdir(), 'channel-files', randomUUID()); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - this.rememberAttachmentDir(dir, messageId, routeKey); const safeName = fileName || `wecom_${ref.type}`; const filePath = join(dir, safeName); - await writeFile(filePath, data, { mode: 0o600 }); + try { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + await writeFile(filePath, data, { mode: 0o600 }); + 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, From 3ba8e95cf2d2df4836921ec35d1014b6dbf8c16a Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 20:42:34 +0800 Subject: [PATCH 45/73] fix(channels): surface wecom media send failures --- .../channels/wecom/src/WeComAdapter.test.ts | 26 +++++++++++-------- packages/channels/wecom/src/WeComAdapter.ts | 16 +++++++++--- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index d7394fa34ba..3699b1afe57 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -3111,7 +3111,7 @@ describe('WeComChannel', () => { ); }); - it('continues sending later media when one upload returns no media id', async () => { + it('throws after sending later media when one upload returns no media id', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -3138,10 +3138,9 @@ describe('WeComChannel', () => { .mockResolvedValueOnce({}) .mockResolvedValueOnce({ media_id: 'media-2' }); - await channel.sendMessage( - 'chat-1', - '[IMAGE: first.png]\n[IMAGE: second.png]', - ); + await expect( + channel.sendMessage('chat-1', '[IMAGE: first.png]\n[IMAGE: second.png]'), + ).rejects.toThrow('1 media send(s) failed'); expect(client.uploadMedia).toHaveBeenCalledTimes(2); expect(client.sendMediaMessage).toHaveBeenCalledTimes(1); @@ -3156,7 +3155,7 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('continues sending later media when one media send fails', async () => { + it('throws after sending later media when one media send fails', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -3190,9 +3189,10 @@ describe('WeComChannel', () => { }) .mockResolvedValueOnce({ headers: { req_id: 'media-req-2' } }); - await channel.sendMessage( - 'chat-1', - '[IMAGE: first.png]\n[IMAGE: second.png]', + await expect( + channel.sendMessage('chat-1', '[IMAGE: first.png]\n[IMAGE: second.png]'), + ).rejects.toThrow( + '1 media send(s) failed: image: errcode=45009 errmsg=api freq out of limit', ); expect(client.uploadMedia).toHaveBeenCalledTimes(2); @@ -3250,7 +3250,9 @@ describe('WeComChannel', () => { await channel.connect(); const client = lastClient(); - await channel.sendMessage('chat-1', `[IMAGE: ${secretPath}]`); + await expect( + channel.sendMessage('chat-1', `[IMAGE: ${secretPath}]`), + ).rejects.toThrow('1 media send(s) failed'); expect(client.uploadMedia).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( @@ -3278,7 +3280,9 @@ describe('WeComChannel', () => { const client = lastClient(); try { - await channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`); + await expect( + channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`), + ).rejects.toThrow('1 media send(s) failed'); expect(client.uploadMedia).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index a1b3d9c6e8f..72efd80d441 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -279,6 +279,7 @@ export class WeComChannel extends ChannelBase { }); } + const mediaErrors: string[] = []; for (const item of media) { if (item.type !== 'image') { process.stderr.write( @@ -294,6 +295,7 @@ export class WeComChannel extends ChannelBase { }); 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`, ); @@ -301,14 +303,20 @@ export class WeComChannel extends ChannelBase { } 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}: ${sanitizeLogText( - formatSdkError(err), - 200, - )}\n`, + `[WeCom:${this.name}] media send failed for ${item.type}: ${message}\n`, ); } } + if (mediaErrors.length > 0) { + throw new Error( + `[WeCom:${this.name}] ${mediaErrors.length} media send(s) failed: ${mediaErrors.join( + '; ', + )}`, + ); + } } private async onMessage(payload: unknown): Promise { From f03c008077ec72f00f884ffd4bdafa15bb3e4446 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 22:09:21 +0800 Subject: [PATCH 46/73] fix(channels): harden wecom replay and reconnect --- .../channels/wecom/src/WeComAdapter.test.ts | 83 ++++++++++++++++--- packages/channels/wecom/src/WeComAdapter.ts | 18 +++- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 3699b1afe57..58c26b2a42c 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -103,6 +103,9 @@ const mocks = vi.hoisted(() => { autoAuthenticate: true, connectErrorsRemaining: 0, connectNeverSettles: false, + connectResolvers: [] as Array<() => void>, + connectWaitsForRelease: false, + kickAfterConnectsRemaining: 0, mediaResponseNeverEnds: false, }; @@ -117,9 +120,23 @@ const mocks = vi.hoisted(() => { 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(); @@ -332,6 +349,9 @@ describe('WeComChannel', () => { 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 = {}; @@ -700,6 +720,54 @@ describe('WeComChannel', () => { expect(mocks.instances).toHaveLength(1); }); + 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('retries when a new client is kicked during reconnect', 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); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(3)); + + channel.disconnect(); + }); + it('does not clear adapter state when reconnecting after a kick', async () => { vi.useFakeTimers(); const bridge = makeBridge(); @@ -1278,7 +1346,7 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('rolls back message dedup when preflight rejects the envelope', async () => { + it('deduplicates messages rejected by preflight', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -1309,16 +1377,11 @@ describe('WeComChannel', () => { await new Promise((resolve) => setTimeout(resolve, 0)); client.emit('message.text', payload); - await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(channel.preflights).toHaveBeenCalledTimes(1); expect(bridge.newSession).not.toHaveBeenCalled(); - await vi.waitFor(() => - expect( - stderr.mock.calls.filter( - ([message]) => - message === - '[WeCom:bot] dropping message msg-preflight-rejected: preflight rejected.\n', - ), - ).toHaveLength(2), + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] dropping duplicate message msg-preflight-rejected (already seen).\n', ); stderr.mockRestore(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 72efd80d441..4390afa5e9f 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -107,6 +107,7 @@ export class WeComChannel extends ChannelBase { kicked: (payload: unknown) => void; }; private reconnectingAfterKick = false; + private pendingKickReconnect = false; private kickReconnectAttempts = 0; private kickReconnectRetryCycles = 0; @@ -386,6 +387,7 @@ export class WeComChannel extends ChannelBase { process.stderr.write( `[WeCom:${this.name}] dropping message ${logMessageId}: preflight rejected.\n`, ); + if (messageId) this.seenMessages.set(messageId, Date.now()); return; } attachments = await this.downloadAttachments( @@ -724,7 +726,10 @@ export class WeComChannel extends ChannelBase { } private async reconnectAfterKick(reason: unknown): Promise { - if (this.reconnectingAfterKick) return; + if (this.reconnectingAfterKick) { + this.pendingKickReconnect = true; + return; + } if (this.kickReconnectRetry) { clearTimeout(this.kickReconnectRetry); this.kickReconnectRetry = undefined; @@ -752,9 +757,13 @@ export class WeComChannel extends ChannelBase { 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 server kick.\n`, + ); return; } catch (err) { process.stderr.write( @@ -778,6 +787,13 @@ export class WeComChannel extends ChannelBase { this.scheduleKickReconnectRetry(reason, disconnectGeneration); } finally { this.reconnectingAfterKick = false; + const shouldRetryPendingKick = + this.pendingKickReconnect && + this.disconnectGeneration === disconnectGeneration; + this.pendingKickReconnect = false; + if (shouldRetryPendingKick) { + this.startKickReconnect(reason); + } } } From f1998d1923f1648915b3d95a701374796e971c16 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 23:08:20 +0800 Subject: [PATCH 47/73] fix(channels): clarify wecom partial delivery cleanup --- .../channels/wecom/src/WeComAdapter.test.ts | 22 ++++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 5 ++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 58c26b2a42c..170b60833ee 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -720,6 +720,26 @@ describe('WeComChannel', () => { 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 schedule reconnect reset after disconnect races with connect success', async () => { vi.useFakeTimers(); const channel = new WeComChannel('bot', makeConfig(), makeBridge()); @@ -3255,7 +3275,7 @@ describe('WeComChannel', () => { await expect( channel.sendMessage('chat-1', '[IMAGE: first.png]\n[IMAGE: second.png]'), ).rejects.toThrow( - '1 media send(s) failed: image: errcode=45009 errmsg=api freq out of limit', + '1 media send(s) failed (markdown text may already be delivered): image: errcode=45009 errmsg=api freq out of limit', ); expect(client.uploadMedia).toHaveBeenCalledTimes(2); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 4390afa5e9f..1807bb396f1 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -230,6 +230,7 @@ export class WeComChannel extends ChannelBase { this.disconnectGeneration += 1; this.kickReconnectAttempts = 0; this.kickReconnectRetryCycles = 0; + this.pendingKickReconnect = false; if (this.kickReconnectReset) { clearTimeout(this.kickReconnectReset); this.kickReconnectReset = undefined; @@ -313,9 +314,7 @@ export class WeComChannel extends ChannelBase { } if (mediaErrors.length > 0) { throw new Error( - `[WeCom:${this.name}] ${mediaErrors.length} media send(s) failed: ${mediaErrors.join( - '; ', - )}`, + `[WeCom:${this.name}] ${mediaErrors.length} media send(s) failed (markdown text may already be delivered): ${mediaErrors.join('; ')}`, ); } } From 8a4b6bd1f23de5d1428dd320e308175745f445aa Mon Sep 17 00:00:00 2001 From: qqqys Date: Sun, 5 Jul 2026 23:38:20 +0800 Subject: [PATCH 48/73] fix(channels): close wecom rejected downloads --- docs/users/features/channels/wecom.md | 2 +- package-lock.json | 2 +- packages/channels/wecom/package.json | 2 +- packages/channels/wecom/src/WeComAdapter.test.ts | 2 ++ packages/channels/wecom/src/WeComAdapter.ts | 4 ++-- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/users/features/channels/wecom.md b/docs/users/features/channels/wecom.md index af331ff239a..2f0d87fcc85 100644 --- a/docs/users/features/channels/wecom.md +++ b/docs/users/features/channels/wecom.md @@ -78,7 +78,7 @@ Open WeCom and send a message to the intelligent robot. 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 mentioned, matching WeCom intelligent robot API mode where group delivery is expected to be scoped by WeCom. +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 diff --git a/package-lock.json b/package-lock.json index 5c6939a7a86..24902751b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24136,7 +24136,7 @@ }, "packages/channels/wecom": { "name": "@qwen-code/channel-wecom", - "version": "0.19.5", + "version": "0.19.6", "dependencies": { "@qwen-code/channel-base": "file:../base", "@wecom/aibot-node-sdk": "^1.0.7" diff --git a/packages/channels/wecom/package.json b/packages/channels/wecom/package.json index bcfc401581c..6745446c891 100644 --- a/packages/channels/wecom/package.json +++ b/packages/channels/wecom/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-wecom", - "version": "0.19.5", + "version": "0.19.6", "description": "WeCom channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 170b60833ee..cc3c5fbbcdf 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1937,6 +1937,7 @@ describe('WeComChannel', () => { 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'), @@ -2052,6 +2053,7 @@ describe('WeComChannel', () => { 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'), ); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 1807bb396f1..e743f2ce93d 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1388,12 +1388,12 @@ function guardedHttpsDownload( (res) => { const statusCode = res.statusCode ?? 0; if (statusCode >= 300 && statusCode < 400) { - res.resume(); + req.destroy(); finish(new Error('redirected media URL')); return; } if (statusCode < 200 || statusCode >= 300) { - res.resume(); + req.destroy(); finish(new Error(`media download failed: HTTP ${statusCode}`)); return; } From 2bfe8b3617866d2f0701fb36f2f45c555032ee0b Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 00:36:48 +0800 Subject: [PATCH 49/73] fix(channels): retain wecom dedup after processing starts --- packages/channels/wecom/src/WeComAdapter.test.ts | 7 +++++-- packages/channels/wecom/src/WeComAdapter.ts | 10 +++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index cc3c5fbbcdf..0fd17cb6c4e 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1433,7 +1433,7 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.envelopes).toHaveLength(1)); }); - it('rolls back message dedup when processing fails', async () => { + it('keeps message dedup when processing fails after side effects start', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -1462,7 +1462,10 @@ describe('WeComChannel', () => { client.emit('message.text', payload); await new Promise((resolve) => setTimeout(resolve, 0)); - await vi.waitFor(() => expect(channel.processes).toHaveBeenCalledTimes(2)); + expect(channel.processes).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalledWith( + '[WeCom:bot] dropping duplicate message msg-process-fails (already seen).\n', + ); stderr.mockRestore(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index e743f2ce93d..746df4b65a9 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -381,6 +381,7 @@ export class WeComChannel extends ChannelBase { chatId, envelope.threadId, ); + let processStarted = false; try { if (!(await this.preflightInbound(envelope))) { process.stderr.write( @@ -404,9 +405,16 @@ export class WeComChannel extends ChannelBase { ? '(image)' : `(file: ${attachments[0]?.fileName ?? 'file'})`; } + processStarted = true; await this.processInbound(envelope); } catch (err) { - if (messageId) this.seenMessages.delete(messageId); + if (messageId && !processStarted) { + this.seenMessages.delete(messageId); + } else if (messageId) { + process.stderr.write( + `[WeCom:${this.name}] message ${logMessageId} failed after processing started; dedup entry retained.\n`, + ); + } throw err; } finally { if (messageId) this.inFlightMessages.delete(messageId); From dcd4170cd5cf6415516c7f6c4921499d9835d36f Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 01:07:00 +0800 Subject: [PATCH 50/73] fix(channels): harden wecom reconnect and media errors --- .../channels/wecom/src/WeComAdapter.test.ts | 27 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 7 ++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 0fd17cb6c4e..68009f9a4d4 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -788,6 +788,30 @@ describe('WeComChannel', () => { 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(); @@ -3295,6 +3319,9 @@ describe('WeComChannel', () => { '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(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 746df4b65a9..464d2826e8c 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -313,9 +313,9 @@ export class WeComChannel extends ChannelBase { } } if (mediaErrors.length > 0) { - throw new Error( - `[WeCom:${this.name}] ${mediaErrors.length} media send(s) failed (markdown text may already be delivered): ${mediaErrors.join('; ')}`, - ); + const message = `[WeCom:${this.name}] ${mediaErrors.length} media send(s) failed (markdown text may already be delivered): ${mediaErrors.join('; ')}`; + process.stderr.write(`${message}\n`); + throw new Error(message); } } @@ -799,6 +799,7 @@ export class WeComChannel extends ChannelBase { this.disconnectGeneration === disconnectGeneration; this.pendingKickReconnect = false; if (shouldRetryPendingKick) { + this.kickReconnectAttempts = 0; this.startKickReconnect(reason); } } From 8f56b3fd7061d4675c49aa0044afad54feea602f Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 01:38:03 +0800 Subject: [PATCH 51/73] fix(channels): add wecom media error context --- packages/channels/wecom/src/WeComAdapter.test.ts | 6 ++++++ packages/channels/wecom/src/WeComAdapter.ts | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 68009f9a4d4..5b1b059cf1b 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1969,6 +1969,9 @@ describe('WeComChannel', () => { expect(stderr).toHaveBeenCalledWith( expect.stringContaining('redirected media URL'), ); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('https://example.invalid/redirect'), + ); stderr.mockRestore(); }); @@ -2084,6 +2087,9 @@ describe('WeComChannel', () => { expect(stderr).toHaveBeenCalledWith( expect.stringContaining('media download failed: HTTP 500'), ); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining('https://example.invalid/fails'), + ); stderr.mockRestore(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 464d2826e8c..31b9eae3e8b 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1398,12 +1398,19 @@ function guardedHttpsDownload( const statusCode = res.statusCode ?? 0; if (statusCode >= 300 && statusCode < 400) { req.destroy(); - finish(new Error('redirected media URL')); + finish(new Error(`redirected media URL: ${rawUrl.slice(0, 120)}`)); return; } if (statusCode < 200 || statusCode >= 300) { req.destroy(); - finish(new Error(`media download failed: HTTP ${statusCode}`)); + finish( + new Error( + `media download failed: HTTP ${statusCode}: ${rawUrl.slice( + 0, + 120, + )}`, + ), + ); return; } From 81d6d1c450b324c03209b46d36c64cb9c284493c Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 02:07:24 +0800 Subject: [PATCH 52/73] fix(channels): improve wecom dns diagnostics --- .../channels/wecom/src/WeComAdapter.test.ts | 41 +++++++++++++++++-- packages/channels/wecom/src/WeComAdapter.ts | 21 ++++++---- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 5b1b059cf1b..2aac3597fab 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1846,7 +1846,38 @@ describe('WeComChannel', () => { expect(mocks.httpsRequest).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( expect.stringContaining( - 'unsafe media URL (resolved to private address 169.254.169.254)', + '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(); @@ -2155,9 +2186,13 @@ describe('WeComChannel', () => { options.lookup?.('example.invalid', { all: true }, callback); - await vi.waitFor(() => - expect(callback).toHaveBeenCalledWith(expect.any(Error), '', 0), + 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 () => { diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 31b9eae3e8b..f953f83d52c 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1340,11 +1340,12 @@ async function isSafeInboundMediaUrl( return privateRecord ? { safe: false, - reason: `resolved to private address ${privateRecord.address}`, + reason: `${host} resolved to private address ${privateRecord.address}`, } : { safe: true }; - } catch { - return { safe: false, reason: 'DNS lookup failed' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { safe: false, reason: `DNS lookup failed for ${host}: ${message}` }; } } @@ -1464,11 +1465,15 @@ function guardedHttpsDownload( const safePublicLookup: LookupFunction = (hostname, options, callback) => { lookup(hostname, { all: true }) .then((records) => { - if ( - records.length === 0 || - records.some((record) => !isPublicIpAddress(record.address)) - ) { - callback(new Error('unsafe resolved media address'), '', 0); + 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) { From c988e155ddd0f8ba77e8430cd709b476ab7be0c4 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 02:36:48 +0800 Subject: [PATCH 53/73] fix(channels): keep wecom kick retry alive --- .../channels/wecom/src/WeComAdapter.test.ts | 22 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 1 - 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 2aac3597fab..df8b0008bf4 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -624,6 +624,28 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); + it('keeps the delayed kick reconnect retry referenced', () => { + 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).not.toHaveBeenCalled(); + setTimeoutSpy.mockRestore(); + }); + it('stops kick reconnect after repeated retry cycles are exhausted', async () => { vi.useFakeTimers(); const stderr = vi diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index f953f83d52c..9db27bb5b29 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -829,7 +829,6 @@ export class WeComChannel extends ChannelBase { this.kickReconnectAttempts = 0; this.startKickReconnect(reason); }, KICK_RECONNECT_RETRY_MS); - this.kickReconnectRetry.unref?.(); } private startKickReconnect(reason: unknown): void { From 951c1b53ea7c48114b675958f59e081d8e7885ff Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 03:06:36 +0800 Subject: [PATCH 54/73] fix(channels): allow wecom quoted bot replies --- .../channels/wecom/src/WeComAdapter.test.ts | 35 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 11 +++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index df8b0008bf4..09d980759a9 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1152,6 +1152,41 @@ describe('WeComChannel', () => { expect(file.attachments?.[0]?.filePath).toContain('report.pdf'); }); + 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"', diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 9db27bb5b29..7f098f82a7a 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -363,6 +363,7 @@ export class WeComChannel extends ChannelBase { const text = extractText(body); const explicitMention = getExplicitMention(body, this.wecom.botId); + const quote = getRecord(body, 'quote'); const envelope: Envelope = { channelName: this.name, senderId, @@ -372,8 +373,9 @@ export class WeComChannel extends ChannelBase { messageId, isGroup, isMentioned: !isGroup || (explicitMention ?? false), - isReplyToBot: false, - referencedText: extractQuoteText(body), + isReplyToBot: + getString(getRecord(quote, 'from'), 'userid') === this.wecom.botId, + referencedText: extractQuoteText(quote), }; let attachments: Attachment[] = []; const attachmentRouteKey = this.attachmentRouteKey( @@ -1089,8 +1091,9 @@ function extractText(body: Record): string { return ''; } -function extractQuoteText(body: Record): string | undefined { - const quote = getRecord(body, 'quote'); +function extractQuoteText( + quote: Record | undefined, +): string | undefined { if (!quote) return undefined; return extractText(quote) || undefined; } From bfaf08fd89ca20474505cc05733960f26557a5f5 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 03:39:25 +0800 Subject: [PATCH 55/73] fix(channels): preserve wecom code fences across chunks --- .../channels/wecom/src/WeComAdapter.test.ts | 23 +++++++++ packages/channels/wecom/src/WeComAdapter.ts | 48 +++++++++++++------ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 09d980759a9..5c2f30771e1 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -3292,6 +3292,29 @@ describe('WeComChannel', () => { ); }); + 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('resolves relative outbound image paths from channel cwd', async () => { const parent = join(tmpdir(), 'channel-files'); mkdirSync(parent, { recursive: true }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 7f098f82a7a..3a88992a5e9 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1222,38 +1222,56 @@ function splitMarkdownChunks(text: string): string[] { const chunks: string[] = []; let current = ''; + let inCode = false; + const fits = (value: string): boolean => + Buffer.byteLength(inCode ? `${value}\n\`\`\`` : value, 'utf8') <= + MARKDOWN_CHUNK_BYTES; + const flush = (closeCode = true): void => { + if (!current) return; + chunks.push(closeCode && inCode ? `${current}\n\`\`\`` : current); + current = closeCode && inCode ? '```' : ''; + }; + for (const line of text.split('\n')) { const candidate = current ? `${current}\n${line}` : line; - if (Buffer.byteLength(candidate, 'utf8') <= MARKDOWN_CHUNK_BYTES) { + if (fits(candidate)) { current = candidate; + inCode = toggleCodeFenceState(line, inCode); continue; } - if (current) { - chunks.push(current); - current = ''; + flush(); + const retried = current ? `${current}\n${line}` : line; + if (fits(retried)) { + current = retried; + inCode = toggleCodeFenceState(line, inCode); + continue; } - let slice = ''; - let sliceBytes = 0; + let needsLineBreak = Boolean(current); for (const char of line) { - const charBytes = Buffer.byteLength(char, 'utf8'); - if (sliceBytes + charBytes > MARKDOWN_CHUNK_BYTES) { - if (slice) chunks.push(slice); - slice = char; - sliceBytes = charBytes; + const addition = needsLineBreak && current ? `\n${char}` : char; + const candidate = `${current}${addition}`; + if (!fits(candidate)) { + flush(); + current = current ? `${current}\n${char}` : char; } else { - slice += char; - sliceBytes += charBytes; + current = candidate; } + needsLineBreak = false; } - current = slice; + inCode = toggleCodeFenceState(line, inCode); } - if (current) chunks.push(current); + flush(false); return chunks; } +function toggleCodeFenceState(line: string, inCode: boolean): boolean { + const fenceCount = line.match(/```/g)?.length ?? 0; + return fenceCount % 2 === 0 ? inCode : !inCode; +} + async function readOutboundMedia( rawPath: string, cwd: string, From 3dbcc91295d03688ac9437266b01da3ead6d1e91 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 04:38:59 +0800 Subject: [PATCH 56/73] fix(channels): harden wecom reconnect lifecycle --- .../channels/wecom/src/WeComAdapter.test.ts | 56 +++++++- packages/channels/wecom/src/WeComAdapter.ts | 129 ++++++++++++++---- 2 files changed, 149 insertions(+), 36 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 5c2f30771e1..a0929d6db57 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -624,7 +624,7 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('keeps the delayed kick reconnect retry referenced', () => { + it('unrefs the delayed kick reconnect retry', () => { const unref = vi.fn(); const timeout = { unref } as unknown as ReturnType; const setTimeoutSpy = vi @@ -642,11 +642,34 @@ describe('WeComChannel', () => { inspectable.scheduleKickReconnectRetry('kicked', 0); expect(inspectable.kickReconnectRetry).toBe(timeout); - expect(unref).not.toHaveBeenCalled(); + expect(unref).toHaveBeenCalledTimes(1); setTimeoutSpy.mockRestore(); }); - it('stops kick reconnect after repeated retry cycles are exhausted', async () => { + 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('continues kick reconnect after repeated retry cycles are exhausted', async () => { vi.useFakeTimers(); const stderr = vi .spyOn(process.stderr, 'write') @@ -671,10 +694,11 @@ describe('WeComChannel', () => { } expect(stderr).toHaveBeenCalledWith( - '[WeCom:bot] reconnect after server kick stopped after 3 exhausted retry cycles; manual intervention required.\n', + '[WeCom:bot] reconnect after server kick exhausted 3 retry cycles; manual intervention required; retrying later.\n', ); - await vi.advanceTimersByTimeAsync(5 * 60 * 1000); - expect(mocks.instances).toHaveLength(10); + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(mocks.instances).toHaveLength(11)); channel.disconnect(); stderr.mockRestore(); @@ -3315,6 +3339,26 @@ describe('WeComChannel', () => { 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('resolves relative outbound image paths from channel cwd', async () => { const parent = join(tmpdir(), 'channel-files'); mkdirSync(parent, { recursive: true }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 3a88992a5e9..01f02a23773 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -79,6 +79,8 @@ 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; export class WeComChannel extends ChannelBase { private readonly wecom: WeComConfig; @@ -96,6 +98,7 @@ export class WeComChannel extends ChannelBase { private dedupTimer?: ReturnType; private kickReconnectReset?: ReturnType; private kickReconnectRetry?: ReturnType; + private disconnectReconnectFallback?: ReturnType; private connecting?: Promise; private connectingClient?: WeComClient; private authentication?: ReturnType; @@ -153,6 +156,7 @@ export class WeComChannel extends ChannelBase { ); return; } + this.clearDisconnectReconnectFallback(); this.onMessage(payload).catch((err: unknown) => { process.stderr.write( `[WeCom:${this.name}] message handling failed: ${sanitizeLogText( @@ -171,8 +175,16 @@ export class WeComChannel extends ChannelBase { 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.clearDisconnectReconnectFallback(); this.startKickReconnect(reason); }; const handlers = { @@ -239,6 +251,7 @@ export class WeComChannel extends ChannelBase { clearTimeout(this.kickReconnectRetry); this.kickReconnectRetry = undefined; } + this.clearDisconnectReconnectFallback(); if (this.dedupTimer) { clearInterval(this.dedupTimer); this.dedupTimer = undefined; @@ -734,7 +747,36 @@ export class WeComChannel extends ChannelBase { client?.disconnect(); } - private async reconnectAfterKick(reason: unknown): Promise { + private clearDisconnectReconnectFallback(): void { + if (!this.disconnectReconnectFallback) return; + clearTimeout(this.disconnectReconnectFallback); + this.disconnectReconnectFallback = undefined; + } + + 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.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; @@ -747,7 +789,7 @@ export class WeComChannel extends ChannelBase { const previousConnecting = this.connecting; const disconnectGeneration = this.disconnectGeneration; process.stderr.write( - `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; reconnecting after server kick.\n`, + `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; reconnecting after ${reconnectReason}.\n`, ); try { this.disconnectClientOnly( @@ -771,12 +813,12 @@ export class WeComChannel extends ChannelBase { this.kickReconnectRetryCycles = 0; this.scheduleKickReconnectReset(); process.stderr.write( - `[WeCom:${this.name}] reconnected after server kick.\n`, + `[WeCom:${this.name}] reconnected after ${reconnectReason}.\n`, ); return; } catch (err) { process.stderr.write( - `[WeCom:${this.name}] reconnect after server kick attempt ${attempt} failed: ${sanitizeLogText( + `[WeCom:${this.name}] reconnect after ${reconnectReason} attempt ${attempt} failed: ${sanitizeLogText( formatSdkError(err), 200, )}\n`, @@ -786,14 +828,25 @@ export class WeComChannel extends ChannelBase { this.kickReconnectRetryCycles += 1; if (this.kickReconnectRetryCycles >= KICK_RECONNECT_MAX_RETRY_CYCLES) { process.stderr.write( - `[WeCom:${this.name}] reconnect after server kick stopped after ${this.kickReconnectRetryCycles} exhausted retry cycles; manual intervention required.\n`, + `[WeCom:${this.name}] reconnect after ${reconnectReason} exhausted ${this.kickReconnectRetryCycles} retry cycles; manual intervention required; retrying later.\n`, + ); + this.scheduleKickReconnectRetry( + reason, + disconnectGeneration, + KICK_RECONNECT_LONG_RETRY_MS, + reconnectReason, ); return; } process.stderr.write( - `[WeCom:${this.name}] reconnect after server kick gave up after ${KICK_RECONNECT_MAX_ATTEMPTS} attempts; retrying later.\n`, + `[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, ); - this.scheduleKickReconnectRetry(reason, disconnectGeneration); } finally { this.reconnectingAfterKick = false; const shouldRetryPendingKick = @@ -802,7 +855,7 @@ export class WeComChannel extends ChannelBase { this.pendingKickReconnect = false; if (shouldRetryPendingKick) { this.kickReconnectAttempts = 0; - this.startKickReconnect(reason); + this.startKickReconnect(reason, reconnectReason); } } } @@ -824,24 +877,32 @@ export class WeComChannel extends ChannelBase { private scheduleKickReconnectRetry( reason: unknown, disconnectGeneration: number, + delayMs = KICK_RECONNECT_RETRY_MS, + reconnectReason = 'server kick', ): void { this.kickReconnectRetry = setTimeout(() => { this.kickReconnectRetry = undefined; if (this.disconnectGeneration !== disconnectGeneration) return; this.kickReconnectAttempts = 0; - this.startKickReconnect(reason); - }, KICK_RECONNECT_RETRY_MS); + this.startKickReconnect(reason, reconnectReason); + }, delayMs); + this.kickReconnectRetry.unref?.(); } - private startKickReconnect(reason: unknown): void { - void this.reconnectAfterKick(reason).catch((err: unknown) => { - process.stderr.write( - `[WeCom:${this.name}] kick-reconnect failed: ${sanitizeLogText( - formatSdkError(err), - 200, - )}\n`, - ); - }); + 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`, + ); + }, + ); } private cleanupSeenMessages(): void { @@ -1223,8 +1284,8 @@ function splitMarkdownChunks(text: string): string[] { const chunks: string[] = []; let current = ''; let inCode = false; - const fits = (value: string): boolean => - Buffer.byteLength(inCode ? `${value}\n\`\`\`` : value, 'utf8') <= + const fits = (value: string, codeState = inCode): boolean => + Buffer.byteLength(codeState ? `${value}\n\`\`\`` : value, 'utf8') <= MARKDOWN_CHUNK_BYTES; const flush = (closeCode = true): void => { if (!current) return; @@ -1234,33 +1295,41 @@ function splitMarkdownChunks(text: string): string[] { for (const line of text.split('\n')) { const candidate = current ? `${current}\n${line}` : line; - if (fits(candidate)) { + const candidateInCode = toggleCodeFenceState(line, inCode); + if (fits(candidate, candidateInCode)) { current = candidate; - inCode = toggleCodeFenceState(line, inCode); + inCode = candidateInCode; continue; } flush(); const retried = current ? `${current}\n${line}` : line; - if (fits(retried)) { + const retriedInCode = toggleCodeFenceState(line, inCode); + if (fits(retried, retriedInCode)) { current = retried; - inCode = toggleCodeFenceState(line, inCode); + inCode = retriedInCode; continue; } let needsLineBreak = Boolean(current); - for (const char of line) { - const addition = needsLineBreak && current ? `\n${char}` : char; + for (let index = 0; index < line.length; ) { + const token = line.startsWith('```', index) + ? '```' + : (Array.from(line.slice(index))[0] ?? ''); + if (!token) break; + const nextInCode = token === '```' ? !inCode : inCode; + const addition = needsLineBreak && current ? `\n${token}` : token; const candidate = `${current}${addition}`; - if (!fits(candidate)) { + if (!fits(candidate, nextInCode)) { flush(); - current = current ? `${current}\n${char}` : char; + current = current ? `${current}\n${token}` : token; } else { current = candidate; } + inCode = nextInCode; needsLineBreak = false; + index += token.length; } - inCode = toggleCodeFenceState(line, inCode); } flush(false); From 0249240aa7cf62759ed3688710100432c75c7662 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 06:07:46 +0800 Subject: [PATCH 57/73] fix(channels): report wecom media dir setup failures --- .../channels/wecom/src/WeComAdapter.test.ts | 31 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 15 ++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index a0929d6db57..66b8d57164f 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -3544,6 +3544,37 @@ describe('WeComChannel', () => { 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 expect( + channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`), + ).rejects.toThrow('1 media send(s) failed'); + + 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; diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 01f02a23773..3f6d0dda4a9 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1359,21 +1359,26 @@ async function readOutboundMedia( const allowedDirs = [ ensureDirectoryRealpath(join(tmpdir(), 'channel-files')), - ].filter((dir): dir is string => Boolean(dir)); + ]; if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { throw new Error('Media path outside allowed outbound directory'); } return { data: await readFile(real), fileName: basename(real) }; } -function ensureDirectoryRealpath(path: string): string | undefined { +function ensureDirectoryRealpath(path: string): string { try { mkdirSync(path, { recursive: true }); const stat = lstatSync(path); - if (!stat.isDirectory() || stat.isSymbolicLink()) return undefined; + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('not a safe directory'); + } return realpathSync(path); - } catch { - return undefined; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `Cannot prepare outbound media directory ${path}: ${reason}`, + ); } } From 60d191b5dc296ae161b0bb42c79f700731174589 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 07:11:53 +0800 Subject: [PATCH 58/73] fix(channels): harden wecom reconnect recovery --- .../channels/wecom/src/WeComAdapter.test.ts | 41 ++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 12 +++- .../src/commands/channel/config-utils.test.ts | 65 ++++++++----------- .../cli/src/commands/channel/config-utils.ts | 6 +- 4 files changed, 82 insertions(+), 42 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 66b8d57164f..af7a1246e3e 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -669,6 +669,47 @@ describe('WeComChannel', () => { 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('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 diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 3f6d0dda4a9..cde13a5a67b 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -744,7 +744,16 @@ export class WeComChannel extends ChannelBase { this.client = undefined; this.connectingClient = undefined; if (client) this.detachClientHandlers(client); - client?.disconnect(); + try { + client?.disconnect(); + } catch (e) { + process.stderr.write( + `[WeCom:${this.name}] client.disconnect() threw: ${sanitizeLogText( + formatSdkError(e), + 200, + )}\n`, + ); + } } private clearDisconnectReconnectFallback(): void { @@ -768,6 +777,7 @@ export class WeComChannel extends ChannelBase { `[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?.(); diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 83131f0f1ac..4b36a1c1343 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -219,52 +219,41 @@ describe('parseChannelConfig', () => { delete process.env['TEST_EMPTY_SECRET']; }); - it('leaves unavailable env vars literal in available mode', async () => { + it('rejects available env vars when unset', async () => { delete process.env['TEST_MISSING_SECRET']; - const stderr = vi - .spyOn(process.stderr, 'write') - .mockImplementation(() => true); - - const result = await parseChannelConfig( - 'bot', - { - type: 'wecom', - botId: 'bot-id', - secret: '$TEST_MISSING_SECRET', - }, - process.cwd(), - { resolveEnvVars: 'available' }, - ); - expect(result['secret']).toBe('$TEST_MISSING_SECRET'); - expect(stderr).toHaveBeenCalledWith( - '[channel] warning: environment variable TEST_MISSING_SECRET is not set, using literal value.\n', + 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.', ); - stderr.mockRestore(); }); - it('warns once for unavailable required credential env vars', async () => { + it('rejects unavailable required credential env vars', async () => { delete process.env['TEST_MISSING_TOKEN']; - const stderr = vi - .spyOn(process.stderr, 'write') - .mockImplementation(() => true); - - const result = await parseChannelConfig( - 'bot', - { - type: 'telegram', - token: '$TEST_MISSING_TOKEN', - }, - process.cwd(), - { resolveEnvVars: 'available' }, - ); - expect(result.token).toBe('$TEST_MISSING_TOKEN'); - expect(stderr).toHaveBeenCalledTimes(1); - expect(stderr).toHaveBeenCalledWith( - '[channel] warning: environment variable TEST_MISSING_TOKEN is not set, using literal value.\n', + 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.', ); - stderr.mockRestore(); }); it('preserves explicit config values over defaults', async () => { diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index cc60b986bb2..60e6c982e93 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -45,10 +45,10 @@ function resolveConfigEnvVar(value: string, mode: EnvResolution): string { const envName = value.substring(1); const envValue = process.env[envName]; if (envValue === undefined) { - process.stderr.write( - `[channel] warning: environment variable ${envName} is not set, using literal value.\n`, + throw new Error( + `Environment variable ${envName} is not set (referenced as ${value}). ` + + 'Set the variable or remove the $ prefix to use a literal value.', ); - return value; } if (envValue === '') { throw new Error( From 6e7fab1ad3fba666451903844d5f9d7702b86cb4 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 08:16:24 +0800 Subject: [PATCH 59/73] fix(channels): align wecom review fixes --- .../channels/wecom/src/WeComAdapter.test.ts | 3 +- packages/channels/wecom/src/WeComAdapter.ts | 5 ++- .../cli/src/commands/channel/runtime.test.ts | 38 +++++++++++++------ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index af7a1246e3e..a7db9df1a73 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1717,6 +1717,7 @@ describe('WeComChannel', () => { 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', { @@ -1728,7 +1729,7 @@ describe('WeComChannel', () => { }); } - await vi.waitFor(() => expect(channel.envelopes).toHaveLength(3)); + 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(); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index cde13a5a67b..b636c9f04fb 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1419,7 +1419,10 @@ async function isSafeInboundMediaUrl( return { safe: false, reason: 'non-HTTPS protocol' }; } - const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + const host = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); if (!host || host === 'localhost' || host.endsWith('.localhost')) { return { safe: false, reason: 'local hostname' }; } diff --git a/packages/cli/src/commands/channel/runtime.test.ts b/packages/cli/src/commands/channel/runtime.test.ts index fbc0057be85..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 { 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' @@ -51,19 +65,21 @@ describe('parseConfiguredChannels', () => { ]); }); - it('does not re-expand credentials already resolved by settings loading', async () => { - const parsed = await parseConfiguredChannels( - { - telegram: { - type: 'telegram', - token: '$TOKEN_LITERAL_VALUE', + it('rejects unresolved credential env vars', async () => { + await expect( + parseConfiguredChannels( + { + telegram: { + type: 'telegram', + token: '$TOKEN_LITERAL_VALUE', + }, }, - }, - ['telegram'], - { defaultCwd: '/workspace' }, + ['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.', ); - - expect(parsed[0]?.config.token).toBe('$TOKEN_LITERAL_VALUE'); }); it('resolves channel credentials from environment loaded after settings', async () => { From 7661af0391840dc581f91d76ae0d62e3e1fbf6d0 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 10:14:53 +0800 Subject: [PATCH 60/73] fix(channels): harden wecom marker parsing --- packages/channels/base/src/types.ts | 3 +++ .../channels/wecom/src/WeComAdapter.test.ts | 20 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 2 +- packages/channels/wecom/src/index.ts | 1 + .../src/commands/channel/config-utils.test.ts | 9 +++++++-- .../cli/src/commands/channel/config-utils.ts | 14 +++++-------- 6 files changed, 37 insertions(+), 12 deletions(-) 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/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index a7db9df1a73..188427dbc4c 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -3333,6 +3333,26 @@ describe('WeComChannel', () => { }); }); + 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(); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index b636c9f04fb..0d625e3f5cc 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1271,7 +1271,7 @@ function findCodeRanges(text: string): Array<[number, number]> { ranges.push([fenceStart, text.length]); } - for (const match of text.matchAll(/`[^`\n]*`/g)) { + 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]); diff --git a/packages/channels/wecom/src/index.ts b/packages/channels/wecom/src/index.ts index 0b29aa34174..35ac9fb617a 100644 --- a/packages/channels/wecom/src/index.ts +++ b/packages/channels/wecom/src/index.ts @@ -7,6 +7,7 @@ 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/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 4b36a1c1343..418e1e16873 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -8,7 +8,11 @@ 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: { @@ -18,6 +22,7 @@ vi.mock('./channel-registry.js', () => ({ wecom: { channelType: 'wecom', requiredConfigFields: ['botId', 'secret'], + envResolvableConfigFields: ['wsUrl'], }, numeric: { channelType: 'numeric', @@ -170,7 +175,7 @@ describe('parseChannelConfig', () => { delete process.env['TEST_SEC']; }); - it('resolves env vars in WeCom wsUrl', async () => { + 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', { diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 60e6c982e93..36909a484be 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -149,15 +149,11 @@ export async function parseChannelConfig( resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); } } - if ( - channelType === 'wecom' && - typeof rawConfig['wsUrl'] === 'string' && - rawConfig['wsUrl'] !== '' - ) { - resolvedRawConfig['wsUrl'] = resolveConfigEnvVar( - rawConfig['wsUrl'], - envResolution, - ); + for (const field of plugin.envResolvableConfigFields ?? []) { + const value = rawConfig[field]; + if (typeof value === 'string' && value !== '') { + resolvedRawConfig[field] = resolveConfigEnvVar(value, envResolution); + } } // Resolve env vars for known credential fields From 2380b64e9076599b8b5e104f5a0d6b3a6d658b5c Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 11:40:30 +0800 Subject: [PATCH 61/73] fix(channels): keep wecom reconnect timers alive --- .../channels/wecom/src/WeComAdapter.test.ts | 44 ++++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 5 ++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 188427dbc4c..933a3bb07e2 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -624,7 +624,7 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('unrefs the delayed kick reconnect retry', () => { + it('keeps the delayed kick reconnect retry alive', () => { const unref = vi.fn(); const timeout = { unref } as unknown as ReturnType; const setTimeoutSpy = vi @@ -642,7 +642,31 @@ describe('WeComChannel', () => { inspectable.scheduleKickReconnectRetry('kicked', 0); expect(inspectable.kickReconnectRetry).toBe(timeout); - expect(unref).toHaveBeenCalledTimes(1); + expect(unref).not.toHaveBeenCalled(); + setTimeoutSpy.mockRestore(); + }); + + it('keeps 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).not.toHaveBeenCalled(); setTimeoutSpy.mockRestore(); }); @@ -693,6 +717,22 @@ describe('WeComChannel', () => { 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('resets exhausted kick retry cycles before SDK-disconnect fallback reconnect', async () => { vi.useFakeTimers(); const channel = new WeComChannel('bot', makeConfig(), makeBridge()); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 0d625e3f5cc..8e9984580a8 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -149,6 +149,7 @@ export class WeComChannel extends ChannelBase { const client = new ClientCtor(options); let authenticated = false; + const connectionGeneration = this.disconnectGeneration; const messageHandler = (payload: unknown) => { if (!authenticated) { process.stderr.write( @@ -172,6 +173,7 @@ export class WeComChannel extends ChannelBase { ); }; const disconnectedHandler = (reason: unknown) => { + if (this.disconnectGeneration !== connectionGeneration) return; process.stderr.write( `[WeCom:${this.name}] WebSocket ${formatDisconnectReason(reason)}; waiting for SDK reconnect.\n`, ); @@ -184,6 +186,7 @@ export class WeComChannel extends ChannelBase { } }; const kickedHandler = (reason: unknown) => { + if (this.disconnectGeneration !== connectionGeneration) return; this.clearDisconnectReconnectFallback(); this.startKickReconnect(reason); }; @@ -780,7 +783,6 @@ export class WeComChannel extends ChannelBase { this.kickReconnectRetryCycles = 0; this.startKickReconnect(reason, 'SDK disconnect'); }, DISCONNECT_RECONNECT_FALLBACK_MS); - this.disconnectReconnectFallback.unref?.(); } private async reconnectAfterKick( @@ -896,7 +898,6 @@ export class WeComChannel extends ChannelBase { this.kickReconnectAttempts = 0; this.startKickReconnect(reason, reconnectReason); }, delayMs); - this.kickReconnectRetry.unref?.(); } private startKickReconnect( From e2da90abf642eca84674fe767a6549fb7014230d Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 12:09:06 +0800 Subject: [PATCH 62/73] fix(channels): handle wecom tilde fences --- .../channels/wecom/src/WeComAdapter.test.ts | 24 +++++- packages/channels/wecom/src/WeComAdapter.ts | 74 +++++++++++++------ 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 933a3bb07e2..e28407fc3d3 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -3351,7 +3351,7 @@ describe('WeComChannel', () => { ); }); - it('leaves media markers inside unclosed fenced code blocks as text', async () => { + 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-')); @@ -3365,6 +3365,28 @@ describe('WeComChannel', () => { 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', { diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 8e9984580a8..c1411833199 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1259,13 +1259,17 @@ function parseOutboundMediaMarkers(text: string): { function findCodeRanges(text: string): Array<[number, number]> { const ranges: Array<[number, number]> = []; let fenceStart: number | undefined; - for (const match of text.matchAll(/```/g)) { + 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; - } else { + fenceToken = token; + } else if (token === fenceToken) { ranges.push([fenceStart, start + 3]); fenceStart = undefined; + fenceToken = undefined; } } if (fenceStart !== undefined) { @@ -1280,6 +1284,8 @@ function findCodeRanges(text: string): Array<[number, number]> { return ranges; } +type FenceToken = '```' | '~~~'; + function isWeComMediaType(value: string | undefined): value is WeComMediaType { return ( value === 'image' || @@ -1294,31 +1300,33 @@ function splitMarkdownChunks(text: string): string[] { const chunks: string[] = []; let current = ''; - let inCode = false; - const fits = (value: string, codeState = inCode): boolean => - Buffer.byteLength(codeState ? `${value}\n\`\`\`` : value, 'utf8') <= - MARKDOWN_CHUNK_BYTES; + 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 && inCode ? `${current}\n\`\`\`` : current); - current = closeCode && inCode ? '```' : ''; + 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 candidateInCode = toggleCodeFenceState(line, inCode); - if (fits(candidate, candidateInCode)) { + const candidateCodeFence = toggleCodeFenceState(line, codeFence); + if (fits(candidate, candidateCodeFence)) { current = candidate; - inCode = candidateInCode; + codeFence = candidateCodeFence; continue; } flush(); const retried = current ? `${current}\n${line}` : line; - const retriedInCode = toggleCodeFenceState(line, inCode); - if (fits(retried, retriedInCode)) { + const retriedCodeFence = toggleCodeFenceState(line, codeFence); + if (fits(retried, retriedCodeFence)) { current = retried; - inCode = retriedInCode; + codeFence = retriedCodeFence; continue; } @@ -1326,30 +1334,52 @@ function splitMarkdownChunks(text: string): string[] { for (let index = 0; index < line.length; ) { const token = line.startsWith('```', index) ? '```' - : (Array.from(line.slice(index))[0] ?? ''); + : line.startsWith('~~~', index) + ? '~~~' + : (Array.from(line.slice(index))[0] ?? ''); if (!token) break; - const nextInCode = token === '```' ? !inCode : inCode; + const nextCodeFence = + token === codeFence + ? undefined + : isFenceToken(token) + ? token + : codeFence; const addition = needsLineBreak && current ? `\n${token}` : token; const candidate = `${current}${addition}`; - if (!fits(candidate, nextInCode)) { + if (!fits(candidate, nextCodeFence)) { flush(); current = current ? `${current}\n${token}` : token; } else { current = candidate; } - inCode = nextInCode; + codeFence = nextCodeFence; needsLineBreak = false; index += token.length; } } - flush(false); + flush(); return chunks; } -function toggleCodeFenceState(line: string, inCode: boolean): boolean { - const fenceCount = line.match(/```/g)?.length ?? 0; - return fenceCount % 2 === 0 ? inCode : !inCode; +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( From 0fa9f03aa7adf1ecf9aa29664e0cef68e36bcb50 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 13:39:58 +0800 Subject: [PATCH 63/73] fix(channels): preserve wecom fence state --- .../channels/wecom/src/WeComAdapter.test.ts | 42 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index e28407fc3d3..930c000404e 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -733,6 +733,22 @@ describe('WeComChannel', () => { 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()); @@ -3483,6 +3499,32 @@ describe('WeComChannel', () => { 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 }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index c1411833199..37822bb461d 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1341,7 +1341,7 @@ function splitMarkdownChunks(text: string): string[] { const nextCodeFence = token === codeFence ? undefined - : isFenceToken(token) + : !codeFence && isFenceToken(token) ? token : codeFence; const addition = needsLineBreak && current ? `\n${token}` : token; From e0ae77600a73eebe3456aeea340647eb1a5dcdd9 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 14:08:26 +0800 Subject: [PATCH 64/73] fix(channels): clean up wecom attachment races --- .../channels/wecom/src/WeComAdapter.test.ts | 76 +++++++++++++++++++ packages/channels/wecom/src/WeComAdapter.ts | 41 +++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 930c000404e..b921bceecab 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -2569,6 +2570,42 @@ describe('WeComChannel', () => { } }); + 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()); @@ -2845,6 +2882,45 @@ describe('WeComChannel', () => { 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( diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 37822bb461d..b4e1d08f3eb 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -158,7 +158,7 @@ export class WeComChannel extends ChannelBase { return; } this.clearDisconnectReconnectFallback(); - this.onMessage(payload).catch((err: unknown) => { + this.onMessage(payload, connectionGeneration).catch((err: unknown) => { process.stderr.write( `[WeCom:${this.name}] message handling failed: ${sanitizeLogText( formatSdkError(err), @@ -335,7 +335,10 @@ export class WeComChannel extends ChannelBase { } } - private async onMessage(payload: unknown): Promise { + private async onMessage( + payload: unknown, + connectionGeneration: number, + ): Promise { const body = extractBody(payload); if (!body) { process.stderr.write( @@ -413,7 +416,14 @@ export class WeComChannel extends ChannelBase { 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 (messageId) this.seenMessages.set(messageId, Date.now()); if (attachments.length) { envelope.attachments = attachments; @@ -454,9 +464,12 @@ export class WeComChannel extends ChannelBase { 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); @@ -469,6 +482,8 @@ export class WeComChannel extends ChannelBase { ); continue; } + if (this.disconnectGeneration !== connectionGeneration) + return attachments; const data = downloaded.buffer; const fileName = sanitizeFileName(ref.fileName || downloaded.filename); if (ref.type === 'image') { @@ -485,6 +500,10 @@ export class WeComChannel extends ChannelBase { try { 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]); @@ -894,7 +913,12 @@ export class WeComChannel extends ChannelBase { ): void { this.kickReconnectRetry = setTimeout(() => { this.kickReconnectRetry = undefined; - if (this.disconnectGeneration !== disconnectGeneration) return; + if (this.disconnectGeneration !== disconnectGeneration) { + process.stderr.write( + `[WeCom:${this.name}] scheduled kick-reconnect cancelled; connection generation changed.\n`, + ); + return; + } this.kickReconnectAttempts = 0; this.startKickReconnect(reason, reconnectReason); }, delayMs); @@ -1012,7 +1036,16 @@ function delay(ms: number): Promise { function cleanupAttachmentDirs(dirs: string[]): void { for (const dir of dirs) { - rmSync(dir, { recursive: true, force: true }); + 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`, + ); + } } } From b650c4c97ab931c9f0530bc867ae6746b48baf0b Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 14:38:21 +0800 Subject: [PATCH 65/73] fix(channels): bind wecom media reads to file handles --- .../channels/wecom/src/WeComAdapter.test.ts | 27 ++++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 25 ++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index b921bceecab..c5c7fbe3ba2 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -38,11 +38,26 @@ const mocks = vi.hoisted(() => { 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) => { + 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]), ); @@ -188,8 +203,10 @@ const mocks = vi.hoisted(() => { MockWSClient, instances, httpCalls, + openHandles, lookup, decryptFile, + open, readFile, writeFile, httpResponse, @@ -209,6 +226,7 @@ vi.mock('node:https', () => ({ request: mocks.httpsRequest, })); vi.mock('node:fs/promises', () => ({ + open: mocks.open, readFile: mocks.readFile, writeFile: mocks.writeFile, })); @@ -347,6 +365,7 @@ 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; @@ -3616,9 +3635,15 @@ describe('WeComChannel', () => { await channel.sendMessage('chat-1', '[IMAGE: out.png]'); - expect(mocks.readFile).toHaveBeenCalledWith( + expect(mocks.open).toHaveBeenCalledWith( realpathSync(join(dir, 'out.png')), + 'r', ); + 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', diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index b4e1d08f3eb..066eca0b5ac 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1,5 +1,5 @@ -import { lstatSync, mkdirSync, realpathSync, rmSync, statSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; +import { 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'; @@ -1424,20 +1424,25 @@ async function readOutboundMedia( }> { const resolved = resolve(cwd, rawPath); const real = realpathSync(resolved); - const stat = statSync(real); - 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`); - } - const allowedDirs = [ ensureDirectoryRealpath(join(tmpdir(), 'channel-files')), ]; if (!allowedDirs.some((dir) => isInsideDir(real, dir))) { throw new Error('Media path outside allowed outbound directory'); } - return { data: await readFile(real), fileName: basename(real) }; + + const file = await open(real, 'r'); + 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 { From 0fda2a839345309396a409b767c6ffb40794f58f Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 15:08:57 +0800 Subject: [PATCH 66/73] fix(channels): prevent wecom symlink media opens --- packages/channels/wecom/src/WeComAdapter.test.ts | 5 +++-- packages/channels/wecom/src/WeComAdapter.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index c5c7fbe3ba2..97886062c70 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -1,5 +1,6 @@ import { chmodSync, + constants, existsSync, mkdirSync, mkdtempSync, @@ -49,7 +50,7 @@ const mocks = vi.hoisted(() => { 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) => { + 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])), @@ -3637,7 +3638,7 @@ describe('WeComChannel', () => { expect(mocks.open).toHaveBeenCalledWith( realpathSync(join(dir, 'out.png')), - 'r', + constants.O_RDONLY | constants.O_NOFOLLOW, ); const handle = mocks.openHandles.at(-1); expect(handle?.stat).toHaveBeenCalled(); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 066eca0b5ac..88486893803 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1,4 +1,4 @@ -import { lstatSync, mkdirSync, realpathSync, rmSync } from 'node:fs'; +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'; @@ -1431,7 +1431,7 @@ async function readOutboundMedia( throw new Error('Media path outside allowed outbound directory'); } - const file = await open(real, 'r'); + const file = await open(real, constants.O_RDONLY | constants.O_NOFOLLOW); try { const stat = await file.stat(); if (!stat.isFile()) From 4ada37ecbc9e552c952a8a028323cef97b007dae Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 17:14:22 +0800 Subject: [PATCH 67/73] fix(channels): address wecom review blockers --- .../channels/base/src/ChannelBase.test.ts | 29 ++++++ packages/channels/base/src/ChannelBase.ts | 21 +++- .../channels/wecom/src/WeComAdapter.test.ts | 99 +++++++++++++++++-- packages/channels/wecom/src/WeComAdapter.ts | 34 +++++-- .../src/commands/channel/config-utils.test.ts | 24 +++++ .../cli/src/commands/channel/config-utils.ts | 3 + .../cli/src/commands/channel/start.test.ts | 23 +++++ packages/cli/src/commands/channel/start.ts | 1 + 8 files changed, 212 insertions(+), 22 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index aa6b89c374d..e96a09623d4 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -5868,6 +5868,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', () => { diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 56e1baab841..0ee87a89c22 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -2216,9 +2216,17 @@ export abstract class ChannelBase { const result = this.gate.check(envelope.senderId, envelope.senderName); if (!result.allowed) { if (result.pairingCode !== undefined) { - return this.onPairingRequired(envelope.chatId, result.pairingCode).then( - () => false, - ); + 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 false; } @@ -2233,6 +2241,13 @@ export abstract class ChannelBase { await this.processInbound(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 { // 3. Slash command handling — before session/agent routing const parsed = this.parseCommand(envelope.text); diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 97886062c70..6219b310d14 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -645,7 +645,7 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('keeps the delayed kick reconnect retry alive', () => { + it('does not keep the delayed kick reconnect retry alive', () => { const unref = vi.fn(); const timeout = { unref } as unknown as ReturnType; const setTimeoutSpy = vi @@ -663,11 +663,11 @@ describe('WeComChannel', () => { inspectable.scheduleKickReconnectRetry('kicked', 0); expect(inspectable.kickReconnectRetry).toBe(timeout); - expect(unref).not.toHaveBeenCalled(); + expect(unref).toHaveBeenCalledTimes(1); setTimeoutSpy.mockRestore(); }); - it('keeps the SDK disconnect fallback reconnect alive', () => { + it('does not keep the SDK disconnect fallback reconnect alive', () => { const unref = vi.fn(); const timeout = { unref } as unknown as ReturnType; const setTimeoutSpy = vi @@ -687,7 +687,7 @@ describe('WeComChannel', () => { inspectable.scheduleDisconnectReconnectFallback('closed', client, 0); expect(inspectable.disconnectReconnectFallback).toBe(timeout); - expect(unref).not.toHaveBeenCalled(); + expect(unref).toHaveBeenCalledTimes(1); setTimeoutSpy.mockRestore(); }); @@ -1294,6 +1294,47 @@ describe('WeComChannel', () => { 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', @@ -1589,7 +1630,7 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('deduplicates messages rejected by preflight', async () => { + it('allows retries for messages rejected by preflight', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -1620,12 +1661,8 @@ describe('WeComChannel', () => { await new Promise((resolve) => setTimeout(resolve, 0)); client.emit('message.text', payload); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(channel.preflights).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(2)); expect(bridge.newSession).not.toHaveBeenCalled(); - expect(stderr).toHaveBeenCalledWith( - '[WeCom:bot] dropping duplicate message msg-preflight-rejected (already seen).\n', - ); stderr.mockRestore(); }); @@ -3078,6 +3115,33 @@ describe('WeComChannel', () => { 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); @@ -3552,6 +3616,21 @@ describe('WeComChannel', () => { ); }); + 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(); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 88486893803..a11ce9a683d 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -88,6 +88,7 @@ export class WeComChannel extends ChannelBase { 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, @@ -408,7 +409,6 @@ export class WeComChannel extends ChannelBase { process.stderr.write( `[WeCom:${this.name}] dropping message ${logMessageId}: preflight rejected.\n`, ); - if (messageId) this.seenMessages.set(messageId, Date.now()); return; } attachments = await this.downloadAttachments( @@ -603,6 +603,7 @@ export class WeComChannel extends ChannelBase { 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); @@ -628,6 +629,9 @@ export class WeComChannel extends ChannelBase { 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); } @@ -637,19 +641,22 @@ export class WeComChannel extends ChannelBase { 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 trackedDirs = new Set(); - for (const messageDirs of this.attachmentDirsByMessage.values()) { - for (const dir of messageDirs) trackedDirs.add(dir); - } - const untrackedDirs = dirs.filter((dir) => !trackedDirs.has(dir)); + const untrackedDirs = dirs.filter( + (dir) => !this.attachmentMessageByDir.has(dir), + ); if (untrackedDirs.length === 0) return; - const remainingDirs = dirs.filter((dir) => trackedDirs.has(dir)); + const remainingDirs = dirs.filter((dir) => + this.attachmentMessageByDir.has(dir), + ); if (remainingDirs.length > 0) { this.attachmentDirsBySession.set(sessionId, remainingDirs); } else { @@ -701,6 +708,9 @@ export class WeComChannel extends ChannelBase { this.bufferedAttachmentMessages.delete(messageId); } } + for (const dir of dirs) { + this.attachmentMessageByDir.delete(dir); + } } private cleanupAllAttachmentDirs(): void { @@ -713,6 +723,7 @@ export class WeComChannel extends ChannelBase { ); this.attachmentDirsBySession.clear(); this.attachmentDirsByMessage.clear(); + this.attachmentMessageByDir.clear(); this.attachmentDirsWithoutMessageByRoute.clear(); this.bufferedAttachmentMessages.clear(); this.coalescedAttachmentMessages.clear(); @@ -802,6 +813,7 @@ export class WeComChannel extends ChannelBase { this.kickReconnectRetryCycles = 0; this.startKickReconnect(reason, 'SDK disconnect'); }, DISCONNECT_RECONNECT_FALLBACK_MS); + this.disconnectReconnectFallback.unref?.(); } private async reconnectAfterKick( @@ -922,6 +934,7 @@ export class WeComChannel extends ChannelBase { this.kickReconnectAttempts = 0; this.startKickReconnect(reason, reconnectReason); }, delayMs); + this.kickReconnectRetry.unref?.(); } private startKickReconnect( @@ -1365,11 +1378,14 @@ function splitMarkdownChunks(text: string): string[] { let needsLineBreak = Boolean(current); for (let index = 0; index < line.length; ) { + const codePoint = line.codePointAt(index); const token = line.startsWith('```', index) ? '```' : line.startsWith('~~~', index) ? '~~~' - : (Array.from(line.slice(index))[0] ?? ''); + : codePoint === undefined + ? '' + : String.fromCodePoint(codePoint); if (!token) break; const nextCodeFence = token === codeFence @@ -1931,7 +1947,7 @@ function mediaTypeToMime(type: WeComMediaType): string { function sanitizeFileName(name: string | undefined): string { const base = basename(name || '').replace(/\0/g, ''); - return base.replace(/[^\w.-]/g, '_').replace(/^\.+/, '_'); + return base.replace(/[^\p{L}\p{N}._-]/gu, '_').replace(/^\.+/, '_'); } function asRecord(value: unknown): Record | undefined { diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index 418e1e16873..1537a683fc2 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -28,6 +28,11 @@ vi.mock('./channel-registry.js', () => ({ channelType: 'numeric', requiredConfigFields: ['port'], }, + overlap: { + channelType: 'overlap', + requiredConfigFields: ['endpoint'], + envResolvableConfigFields: ['endpoint'], + }, bare: { channelType: 'bare' }, // no requiredConfigFields }; return plugins[type]; @@ -37,6 +42,7 @@ vi.mock('./channel-registry.js', () => ({ 'dingtalk', 'wecom', 'numeric', + 'overlap', 'bare', ], })); @@ -190,6 +196,24 @@ describe('parseChannelConfig', () => { 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'; diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 36909a484be..520b2a95631 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -136,6 +136,7 @@ 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 ?? []) { @@ -147,9 +148,11 @@ export async function parseChannelConfig( } 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); 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 44962fe3efb..a62f7b5a7b2 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -158,6 +158,7 @@ async function startSingle(name: string, proxy?: string): Promise { name, channelsConfig[name] as Record, process.cwd(), + { resolveEnvVars: 'available' }, ); } catch (err) { writeStderrLine( From 7d634af2f273c6d8ece6bc65bcd1b5995c20a8a9 Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 19:48:26 +0800 Subject: [PATCH 68/73] fix(wecom): remove media URL from error messages to prevent credential leakage The guardedHttpsDownload error messages included rawUrl (truncated to 120 chars), which leaks private WeCom media download URLs into stderr and log aggregation systems. Remove the URL from redirect and HTTP error messages. --- packages/channels/wecom/src/WeComAdapter.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index a11ce9a683d..4c3ad53c229 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -1591,19 +1591,12 @@ function guardedHttpsDownload( const statusCode = res.statusCode ?? 0; if (statusCode >= 300 && statusCode < 400) { req.destroy(); - finish(new Error(`redirected media URL: ${rawUrl.slice(0, 120)}`)); + finish(new Error('redirected media URL')); return; } if (statusCode < 200 || statusCode >= 300) { req.destroy(); - finish( - new Error( - `media download failed: HTTP ${statusCode}: ${rawUrl.slice( - 0, - 120, - )}`, - ), - ); + finish(new Error(`media download failed: HTTP ${statusCode}`)); return; } From 9b5a3cd51289ff50b679cff560d8c7e9c56582af Mon Sep 17 00:00:00 2001 From: qqqys Date: Mon, 6 Jul 2026 21:53:48 +0800 Subject: [PATCH 69/73] =?UTF-8?q?fix(wecom):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20tests,=20security,=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove stale URL assertions from media download error tests (the error messages no longer include raw URLs after the credential-leak fix) - Redact sensitive fields (secret, aeskey, token, password, authorization) in formatSdkError's JSON.stringify fallback to prevent credential leakage in logs - Add indented code block detection to findCodeRanges so [IMAGE: path] inside 4-space/tab-indented code is not stripped as a media marker - Add disconnectGeneration guard before mkdirSync in downloadAttachments to prevent orphaned temp directories when disconnect() races with in-flight attachment downloads --- .../channels/wecom/src/WeComAdapter.test.ts | 6 ----- packages/channels/wecom/src/WeComAdapter.ts | 25 ++++++++++++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 6219b310d14..3d9809ba5b0 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -2237,9 +2237,6 @@ describe('WeComChannel', () => { expect(stderr).toHaveBeenCalledWith( expect.stringContaining('redirected media URL'), ); - expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('https://example.invalid/redirect'), - ); stderr.mockRestore(); }); @@ -2355,9 +2352,6 @@ describe('WeComChannel', () => { expect(stderr).toHaveBeenCalledWith( expect.stringContaining('media download failed: HTTP 500'), ); - expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('https://example.invalid/fails'), - ); stderr.mockRestore(); }); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 4c3ad53c229..a97152d92e4 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -70,6 +70,13 @@ const MESSAGE_EVENTS = [ '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; @@ -498,6 +505,9 @@ export class WeComChannel extends ChannelBase { 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) { @@ -1094,7 +1104,12 @@ function formatSdkError(err: unknown): string { .join(' '); } try { - return JSON.stringify(record); + const redacted = Object.fromEntries( + Object.entries(record).map(([key, value]) => + SENSITIVE_ERROR_FIELDS.has(key) ? [key, '[REDACTED]'] : [key, value], + ), + ); + return JSON.stringify(redacted); } catch { // Fall through to String below. } @@ -1327,6 +1342,14 @@ function findCodeRanges(text: string): Array<[number, number]> { 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; } From 5f4b2ab442ab18e8f8fd274c96705a4b737a02ea Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 7 Jul 2026 01:07:11 +0800 Subject: [PATCH 70/73] fix(wecom): wrap client.disconnect() in catch block to preserve connection error In the connect() catch block, client.disconnect() could throw (e.g. if the WebSocket was already destroyed), masking the original connection error. Wrap in try/catch so cleanup failures never shadow the root cause. --- packages/channels/wecom/src/WeComAdapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index a97152d92e4..e9a66d8cba4 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -239,7 +239,11 @@ export class WeComChannel extends ChannelBase { if (this.connectingClient === client) this.connectingClient = undefined; if (this.authentication === authentication) this.authentication = undefined; - client.disconnect(); + try { + client.disconnect(); + } catch { + // cleanup must not mask the original connection error + } throw err; } if (!this.dedupTimer) { From 8d7c1b6d2b92dcfea13cf4062b8419662a5c4ba1 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 7 Jul 2026 10:24:12 +0800 Subject: [PATCH 71/73] fix(channels): address wecom reconnect review blockers --- .../channels/wecom/src/WeComAdapter.test.ts | 125 +++++++++++++++--- packages/channels/wecom/src/WeComAdapter.ts | 45 +++---- 2 files changed, 129 insertions(+), 41 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 3d9809ba5b0..90f61b8f57c 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -667,6 +667,57 @@ describe('WeComChannel', () => { 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('does not keep the SDK disconnect fallback reconnect alive', () => { const unref = vi.fn(); const timeout = { unref } as unknown as ReturnType; @@ -3490,6 +3541,50 @@ describe('WeComChannel', () => { 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 }); @@ -3729,7 +3824,7 @@ describe('WeComChannel', () => { ); }); - it('throws after sending later media when one upload returns no media id', async () => { + it('logs and sends later media when one upload returns no media id', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -3756,9 +3851,10 @@ describe('WeComChannel', () => { .mockResolvedValueOnce({}) .mockResolvedValueOnce({ media_id: 'media-2' }); - await expect( - channel.sendMessage('chat-1', '[IMAGE: first.png]\n[IMAGE: second.png]'), - ).rejects.toThrow('1 media send(s) failed'); + await channel.sendMessage( + 'chat-1', + '[IMAGE: first.png]\n[IMAGE: second.png]', + ); expect(client.uploadMedia).toHaveBeenCalledTimes(2); expect(client.sendMediaMessage).toHaveBeenCalledTimes(1); @@ -3773,7 +3869,7 @@ describe('WeComChannel', () => { stderr.mockRestore(); }); - it('throws after sending later media when one media send fails', async () => { + it('logs and sends later media when one media send fails', async () => { const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -3807,10 +3903,9 @@ describe('WeComChannel', () => { }) .mockResolvedValueOnce({ headers: { req_id: 'media-req-2' } }); - await expect( - channel.sendMessage('chat-1', '[IMAGE: first.png]\n[IMAGE: second.png]'), - ).rejects.toThrow( - '1 media send(s) failed (markdown text may already be delivered): image: errcode=45009 errmsg=api freq out of limit', + await channel.sendMessage( + 'chat-1', + '[IMAGE: first.png]\n[IMAGE: second.png]', ); expect(client.uploadMedia).toHaveBeenCalledTimes(2); @@ -3871,9 +3966,7 @@ describe('WeComChannel', () => { await channel.connect(); const client = lastClient(); - await expect( - channel.sendMessage('chat-1', `[IMAGE: ${secretPath}]`), - ).rejects.toThrow('1 media send(s) failed'); + await channel.sendMessage('chat-1', `[IMAGE: ${secretPath}]`); expect(client.uploadMedia).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( @@ -3902,9 +3995,7 @@ describe('WeComChannel', () => { await channel.connect(); const client = lastClient(); - await expect( - channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`), - ).rejects.toThrow('1 media send(s) failed'); + await channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`); expect(client.uploadMedia).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( @@ -3932,9 +4023,7 @@ describe('WeComChannel', () => { const client = lastClient(); try { - await expect( - channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`), - ).rejects.toThrow('1 media send(s) failed'); + await channel.sendMessage('chat-1', `[IMAGE: ${imagePath}]`); expect(client.uploadMedia).not.toHaveBeenCalled(); expect(stderr).toHaveBeenCalledWith( diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index e9a66d8cba4..3c8a756ac92 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -343,7 +343,6 @@ export class WeComChannel extends ChannelBase { 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`); - throw new Error(message); } } @@ -359,9 +358,10 @@ export class WeComChannel extends ChannelBase { return; } - const messageId = getString(body, 'msgid') || undefined; - const logMessageId = sanitizeLogText(messageId || '(no id)', 100); - if (messageId && this.seenMessages.has(messageId)) { + 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`, ); @@ -382,14 +382,14 @@ export class WeComChannel extends ChannelBase { ); return; } - if (messageId) { - if (this.inFlightMessages.has(messageId)) { + 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(messageId); + this.inFlightMessages.add(rawMessageId); } const text = extractText(body); @@ -401,7 +401,7 @@ export class WeComChannel extends ChannelBase { senderName, chatId, text, - messageId, + messageId: rawMessageId ?? messageId, isGroup, isMentioned: !isGroup || (explicitMention ?? false), isReplyToBot: @@ -435,7 +435,7 @@ export class WeComChannel extends ChannelBase { ); return; } - if (messageId) this.seenMessages.set(messageId, Date.now()); + if (rawMessageId) this.seenMessages.set(rawMessageId, Date.now()); if (attachments.length) { envelope.attachments = attachments; } @@ -447,16 +447,16 @@ export class WeComChannel extends ChannelBase { processStarted = true; await this.processInbound(envelope); } catch (err) { - if (messageId && !processStarted) { - this.seenMessages.delete(messageId); - } else if (messageId) { + 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 (messageId) this.inFlightMessages.delete(messageId); + if (rawMessageId) this.inFlightMessages.delete(rawMessageId); if ( messageId && !this.bufferedAttachmentMessages.has(messageId) && @@ -464,9 +464,6 @@ export class WeComChannel extends ChannelBase { ) { this.cleanupAttachmentDirsForMessage(messageId); } - if (!messageId) { - this.cleanupAttachmentDirsWithoutMessage(attachmentRouteKey); - } } } @@ -692,13 +689,6 @@ export class WeComChannel extends ChannelBase { this.attachmentDirsWithoutMessageByRoute.delete(routeKey); } - private cleanupAttachmentDirsWithoutMessage(routeKey: string): void { - const dirs = this.attachmentDirsWithoutMessageByRoute.get(routeKey); - if (!dirs || dirs.length === 0) return; - this.attachmentDirsWithoutMessageByRoute.delete(routeKey); - cleanupAttachmentDirs(dirs); - } - private removeAttachmentDirsFromSessions(dirs: string[]): void { const removed = new Set(dirs); for (const [sessionId, sessionDirs] of this.attachmentDirsBySession) { @@ -841,6 +831,7 @@ export class WeComChannel extends ChannelBase { if (this.kickReconnectRetry) { clearTimeout(this.kickReconnectRetry); this.kickReconnectRetry = undefined; + this.kickReconnectAttempts = 0; } this.reconnectingAfterKick = true; const previousConnecting = this.connecting; @@ -963,6 +954,14 @@ export class WeComChannel extends ChannelBase { 200, )}\n`, ); + if (this.kickReconnectRetry === undefined) { + this.scheduleKickReconnectRetry( + reason, + this.disconnectGeneration, + KICK_RECONNECT_LONG_RETRY_MS, + reconnectReason, + ); + } }, ); } From 75bf5b960ffbf31fc49777d5d2f5d885da86d3c7 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 7 Jul 2026 12:11:00 +0800 Subject: [PATCH 72/73] fix(channels): harden wecom reconnect review fixes --- .../channels/wecom/src/WeComAdapter.test.ts | 91 ++++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 17 ++-- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/packages/channels/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 90f61b8f57c..5934887a4e5 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -458,6 +458,21 @@ describe('WeComChannel', () => { 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(); }); @@ -845,6 +860,9 @@ describe('WeComChannel', () => { .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; @@ -863,11 +881,12 @@ describe('WeComChannel', () => { } expect(stderr).toHaveBeenCalledWith( - '[WeCom:bot] reconnect after server kick exhausted 3 retry cycles; manual intervention required; retrying later.\n', + '[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(); @@ -955,6 +974,43 @@ describe('WeComChannel', () => { ).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()); @@ -978,7 +1034,7 @@ describe('WeComChannel', () => { ).toBeUndefined(); }); - it('retries when a new client is kicked during reconnect', async () => { + 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(); @@ -998,7 +1054,7 @@ describe('WeComChannel', () => { ); await vi.advanceTimersByTimeAsync(1_000); - await vi.waitFor(() => expect(mocks.instances).toHaveLength(3)); + expect(mocks.instances).toHaveLength(2); channel.disconnect(); }); @@ -1871,6 +1927,35 @@ describe('WeComChannel', () => { 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') diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index 3c8a756ac92..d34a3f65cd7 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -876,13 +876,14 @@ export class WeComChannel extends ChannelBase { 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; manual intervention required; retrying later.\n`, + `[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; } @@ -901,7 +902,7 @@ export class WeComChannel extends ChannelBase { this.pendingKickReconnect && this.disconnectGeneration === disconnectGeneration; this.pendingKickReconnect = false; - if (shouldRetryPendingKick) { + if (shouldRetryPendingKick && !this.client) { this.kickReconnectAttempts = 0; this.startKickReconnect(reason, reconnectReason); } @@ -927,6 +928,7 @@ export class WeComChannel extends ChannelBase { disconnectGeneration: number, delayMs = KICK_RECONNECT_RETRY_MS, reconnectReason = 'server kick', + resetRetryCycles = false, ): void { this.kickReconnectRetry = setTimeout(() => { this.kickReconnectRetry = undefined; @@ -937,6 +939,7 @@ export class WeComChannel extends ChannelBase { return; } this.kickReconnectAttempts = 0; + if (resetRetryCycles) this.kickReconnectRetryCycles = 0; this.startKickReconnect(reason, reconnectReason); }, delayMs); this.kickReconnectRetry.unref?.(); @@ -1107,12 +1110,9 @@ function formatSdkError(err: unknown): string { .join(' '); } try { - const redacted = Object.fromEntries( - Object.entries(record).map(([key, value]) => - SENSITIVE_ERROR_FIELDS.has(key) ? [key, '[REDACTED]'] : [key, value], - ), + return JSON.stringify(record, (key, value) => + SENSITIVE_ERROR_FIELDS.has(key.toLowerCase()) ? '[REDACTED]' : value, ); - return JSON.stringify(redacted); } catch { // Fall through to String below. } @@ -1529,6 +1529,9 @@ async function isSafeInboundMediaUrl( 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() From 71fc8600a766326723bed09175ac4faca4157bc9 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 7 Jul 2026 14:15:23 +0800 Subject: [PATCH 73/73] fix(channels): harden wecom review blockers --- .../channels/base/src/ChannelBase.test.ts | 31 ++++++++++ packages/channels/base/src/ChannelBase.ts | 24 +++++++- .../channels/wecom/src/WeComAdapter.test.ts | 58 ++++++++++++++++++- packages/channels/wecom/src/WeComAdapter.ts | 51 +++++++++++++++- .../cli/src/commands/channel/config-utils.ts | 5 ++ 5 files changed, 165 insertions(+), 4 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index e96a09623d4..1f0c40803ad 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -145,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; @@ -230,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', diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 0ee87a89c22..8fd103f4444 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -190,6 +190,7 @@ export abstract class ChannelBase { private activePrompts: Map = new Map(); /** Per-session message buffer for collect mode. */ private collectBuffers: Map = new Map(); + private readonly preflightedEnvelopes = new WeakSet(); private readonly bridgeToolCallListener = (event: ToolCallEvent): void => { this.dispatchToolCall(event); }; @@ -732,6 +733,7 @@ export abstract class ChannelBase { imageBase64: undefined, imageMimeType: undefined, }; + 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}): ${ @@ -2231,16 +2233,21 @@ export abstract class ChannelBase { return false; } + this.markPreflighted(envelope); return true; } async handleInbound(envelope: Envelope): Promise { const preflight = this.preflightInbound(envelope); - if (!(preflight instanceof Promise ? await preflight : preflight)) return; + 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. * @@ -2249,6 +2256,12 @@ export abstract class ChannelBase { * 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 const parsed = this.parseCommand(envelope.text); if (parsed) { @@ -2855,6 +2868,7 @@ 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.processInbound(syntheticEnvelope).catch((err) => { @@ -2896,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/wecom/src/WeComAdapter.test.ts b/packages/channels/wecom/src/WeComAdapter.test.ts index 5934887a4e5..7db6c3b5859 100644 --- a/packages/channels/wecom/src/WeComAdapter.test.ts +++ b/packages/channels/wecom/src/WeComAdapter.test.ts @@ -733,6 +733,38 @@ describe('WeComChannel', () => { 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; @@ -780,6 +812,28 @@ describe('WeComChannel', () => { 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 @@ -1728,7 +1782,7 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.preflights).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('message handling failed'), + '[WeCom:bot] message handling failed for msg-preflight-fails: preflight failed\n', ), ); @@ -1823,7 +1877,7 @@ describe('WeComChannel', () => { await vi.waitFor(() => expect(channel.processes).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('message handling failed'), + '[WeCom:bot] message handling failed for msg-process-fails: process failed after side effects started\n', ), ); diff --git a/packages/channels/wecom/src/WeComAdapter.ts b/packages/channels/wecom/src/WeComAdapter.ts index d34a3f65cd7..34ca3f8c7c5 100644 --- a/packages/channels/wecom/src/WeComAdapter.ts +++ b/packages/channels/wecom/src/WeComAdapter.ts @@ -88,6 +88,8 @@ 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; @@ -107,6 +109,8 @@ export class WeComChannel extends ChannelBase { private kickReconnectReset?: ReturnType; private kickReconnectRetry?: ReturnType; private disconnectReconnectFallback?: ReturnType; + private activityWatchdog?: ReturnType; + private lastActivityAt = 0; private connecting?: Promise; private connectingClient?: WeComClient; private authentication?: ReturnType; @@ -159,6 +163,7 @@ export class WeComChannel extends ChannelBase { 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`, @@ -167,8 +172,9 @@ export class WeComChannel extends ChannelBase { } this.clearDisconnectReconnectFallback(); this.onMessage(payload, connectionGeneration).catch((err: unknown) => { + const logMessageId = getLogMessageId(payload); process.stderr.write( - `[WeCom:${this.name}] message handling failed: ${sanitizeLogText( + `[WeCom:${this.name}] message handling failed for ${logMessageId}: ${sanitizeLogText( formatSdkError(err), 200, )}\n`, @@ -176,11 +182,13 @@ export class WeComChannel extends ChannelBase { }); }; 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`, @@ -194,6 +202,7 @@ export class WeComChannel extends ChannelBase { } }; const kickedHandler = (reason: unknown) => { + this.recordActivity(); if (this.disconnectGeneration !== connectionGeneration) return; this.clearDisconnectReconnectFallback(); this.startKickReconnect(reason); @@ -233,6 +242,8 @@ export class WeComChannel extends ChannelBase { this.client = client; this.connectingClient = undefined; this.authentication = undefined; + this.recordActivity(); + this.startActivityWatchdog(connectionGeneration); } catch (err) { authentication.cancel(); this.detachClientHandlers(client, handlers); @@ -267,6 +278,7 @@ export class WeComChannel extends ChannelBase { this.kickReconnectRetry = undefined; } this.clearDisconnectReconnectFallback(); + this.clearActivityWatchdog(); if (this.dedupTimer) { clearInterval(this.dedupTimer); this.dedupTimer = undefined; @@ -780,6 +792,7 @@ export class WeComChannel extends ChannelBase { this.authentication = undefined; this.client = undefined; this.connectingClient = undefined; + this.clearActivityWatchdog(); if (client) this.detachClientHandlers(client); try { client?.disconnect(); @@ -799,6 +812,35 @@ export class WeComChannel extends ChannelBase { 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, @@ -963,6 +1005,7 @@ export class WeComChannel extends ChannelBase { this.disconnectGeneration, KICK_RECONNECT_LONG_RETRY_MS, reconnectReason, + true, ); } }, @@ -1189,6 +1232,12 @@ function extractBody(payload: unknown): Record | 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') { diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 520b2a95631..0da79c2b509 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -36,6 +36,11 @@ function resolveOptionalStringField( 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']);