From 3eedc43238b49a677c18779bfa4e00763847bb52 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 24 Mar 2026 04:49:01 +0000 Subject: [PATCH 01/51] feat(channels): add Telegram channel integration with ACP bridge Implements the channels infrastructure for connecting external messaging platforms to Qwen Code via ACP. Phase 1 supports plain text round-trip: Telegram user sends message -> AcpBridge -> qwen-code --acp -> response back to Telegram. New packages: - @qwen-code/channel-base: AcpBridge, SessionRouter, SenderGate, ChannelBase - @qwen-code/channel-telegram: TelegramAdapter using telegraf CLI: `qwen channel start ` reads from settings.json channels config, spawns ACP agent, connects to Telegram via polling. --- esbuild.config.js | 4 + package-lock.json | 121 ++++++++++- package.json | 4 +- packages/channels/base/package.json | 17 ++ packages/channels/base/src/AcpBridge.ts | 188 ++++++++++++++++++ packages/channels/base/src/ChannelBase.ts | 57 ++++++ packages/channels/base/src/SenderGate.ts | 23 +++ packages/channels/base/src/SessionRouter.ts | 37 ++++ packages/channels/base/src/index.ts | 13 ++ packages/channels/base/src/types.ts | 30 +++ packages/channels/telegram/package.json | 18 ++ .../channels/telegram/src/TelegramAdapter.ts | 85 ++++++++ packages/channels/telegram/src/index.ts | 1 + packages/cli/package.json | 2 + packages/cli/src/commands/channel.ts | 13 ++ packages/cli/src/commands/channel/start.ts | 107 ++++++++++ packages/cli/src/config/config.ts | 8 +- packages/cli/src/config/settingsSchema.ts | 12 ++ 18 files changed, 736 insertions(+), 4 deletions(-) create mode 100644 packages/channels/base/package.json create mode 100644 packages/channels/base/src/AcpBridge.ts create mode 100644 packages/channels/base/src/ChannelBase.ts create mode 100644 packages/channels/base/src/SenderGate.ts create mode 100644 packages/channels/base/src/SessionRouter.ts create mode 100644 packages/channels/base/src/index.ts create mode 100644 packages/channels/base/src/types.ts create mode 100644 packages/channels/telegram/package.json create mode 100644 packages/channels/telegram/src/TelegramAdapter.ts create mode 100644 packages/channels/telegram/src/index.ts create mode 100644 packages/cli/src/commands/channel.ts create mode 100644 packages/cli/src/commands/channel/start.ts diff --git a/esbuild.config.js b/esbuild.config.js index 2b532b44e..c49eba358 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -62,6 +62,10 @@ esbuild __dirname, 'packages/cli/src/patches/is-in-ci.ts', ), + '@qwen-code/web-templates': path.resolve( + __dirname, + 'packages/web-templates/src/index.ts', + ), }, define: { 'process.env.CLI_VERSION': JSON.stringify(pkg.version), diff --git a/package-lock.json b/package-lock.json index 4bf43c5ee..2a0fedcbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "@qwen-code/qwen-code", "version": "0.13.0", "workspaces": [ - "packages/*" + "packages/*", + "packages/channels/base", + "packages/channels/telegram" ], "dependencies": { "@testing-library/dom": "^10.4.1", @@ -2990,6 +2992,14 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@qwen-code/channel-base": { + "resolved": "packages/channels/base", + "link": true + }, + "node_modules/@qwen-code/channel-telegram": { + "resolved": "packages/channels/telegram", + "link": true + }, "node_modules/@qwen-code/qwen-code": { "resolved": "packages/cli", "link": true @@ -3961,6 +3971,12 @@ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" } }, + "node_modules/@telegraf/types": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", + "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -6739,6 +6755,22 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "license": "MIT" + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -6754,6 +6786,12 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "license": "MIT" + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -12954,6 +12992,15 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -13889,6 +13936,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-timeout": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", + "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -15609,6 +15665,15 @@ ], "license": "MIT" }, + "node_modules/safe-compare": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz", + "integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==", + "license": "MIT", + "dependencies": { + "buffer-alloc": "^1.2.0" + } + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -15650,6 +15715,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sandwich-stream": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz", + "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -16933,6 +17007,28 @@ "node": ">=6" } }, + "node_modules/telegraf": { + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz", + "integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==", + "license": "MIT", + "dependencies": { + "@telegraf/types": "^7.1.0", + "abort-controller": "^3.0.0", + "debug": "^4.3.4", + "mri": "^1.2.0", + "node-fetch": "^2.7.0", + "p-timeout": "^4.1.0", + "safe-compare": "^1.1.4", + "sandwich-stream": "^2.0.2" + }, + "bin": { + "telegraf": "lib/cli.mjs" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -18798,6 +18894,27 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/channels/base": { + "name": "@qwen-code/channel-base", + "version": "0.1.0", + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, + "packages/channels/telegram": { + "name": "@qwen-code/channel-telegram", + "version": "0.1.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "telegraf": "^4.16.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, "packages/cli": { "name": "@qwen-code/qwen-code", "version": "0.13.0", @@ -18806,6 +18923,8 @@ "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", + "@qwen-code/channel-base": "file:../channels/base", + "@qwen-code/channel-telegram": "file:../channels/telegram", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/package.json b/package.json index c1dfa2448..5017b94bd 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ }, "type": "module", "workspaces": [ - "packages/*" + "packages/*", + "packages/channels/base", + "packages/channels/telegram" ], "repository": { "type": "git", diff --git a/packages/channels/base/package.json b/packages/channels/base/package.json new file mode 100644 index 000000000..7513c43b4 --- /dev/null +++ b/packages/channels/base/package.json @@ -0,0 +1,17 @@ +{ + "name": "@qwen-code/channel-base", + "version": "0.1.0", + "description": "Base channel infrastructure for Qwen Code", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts new file mode 100644 index 000000000..2860f1a7f --- /dev/null +++ b/packages/channels/base/src/AcpBridge.ts @@ -0,0 +1,188 @@ +import { spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { Readable, Writable } from 'node:stream'; +import { EventEmitter } from 'node:events'; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; +import type { + Client, + SessionNotification, + RequestPermissionRequest, + RequestPermissionResponse, +} from '@agentclientprotocol/sdk'; + +export interface AcpBridgeOptions { + cliEntryPath: string; + cwd: string; +} + +export class AcpBridge extends EventEmitter { + private child: ChildProcess | null = null; + private connection: ClientSideConnection | null = null; + private options: AcpBridgeOptions; + + constructor(options: AcpBridgeOptions) { + super(); + this.options = options; + } + + async start(): Promise { + const { cliEntryPath, cwd } = this.options; + + this.child = spawn(process.execPath, [cliEntryPath, '--acp'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + shell: false, + }); + + this.child.stderr?.on('data', (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) { + console.error('[AcpBridge]', msg); + } + }); + + this.child.on('exit', (code, signal) => { + console.error( + `[AcpBridge] Process exited (code=${code}, signal=${signal})`, + ); + this.connection = null; + this.child = null; + this.emit('disconnected', code, signal); + }); + + // Give the process a moment to start + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (!this.child || this.child.killed) { + throw new Error('ACP process failed to start'); + } + + const stdout = Readable.toWeb( + this.child.stdout!, + ) as ReadableStream; + const stdin = Writable.toWeb(this.child.stdin!) as WritableStream; + const stream = ndJsonStream(stdin, stdout); + + this.connection = new ClientSideConnection( + (): Client => ({ + sessionUpdate: (params: SessionNotification): Promise => { + const update = (params as unknown as Record) + .update as Record | undefined; + console.log( + '[AcpBridge] sessionUpdate:', + update?.sessionUpdate, + update?.content + ? JSON.stringify(update.content).substring(0, 200) + : '', + ); + this.emit('sessionUpdate', params); + return Promise.resolve(); + }, + + requestPermission: async ( + params: RequestPermissionRequest, + ): Promise => { + // Phase 1: auto-approve everything so plain text works + const options = Array.isArray(params.options) ? params.options : []; + const optionId = + options.find((o) => o.optionId === 'proceed_once')?.optionId || + options[0]?.optionId || + 'proceed_once'; + console.log( + '[AcpBridge] Permission request auto-approved:', + optionId, + params.toolCall?.name, + ); + return { outcome: { outcome: 'selected', optionId } }; + }, + + extNotification: async (): Promise => {}, + }), + stream, + ); + + await this.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + + console.log('[AcpBridge] Connected and initialized'); + } + + async newSession(cwd: string): Promise { + const conn = this.ensureConnection(); + const response = await conn.newSession({ cwd, mcpServers: [] }); + const sessionId = response.sessionId; + console.log('[AcpBridge] New session:', sessionId); + return sessionId; + } + + async prompt(sessionId: string, text: string): Promise { + const conn = this.ensureConnection(); + + // Collect text from sessionUpdate events during this prompt + // SessionNotification shape: { sessionId, update: { sessionUpdate, content: { type, text } } } + const chunks: string[] = []; + const onUpdate = (params: SessionNotification) => { + if (params.sessionId !== sessionId) return; + const update = (params as unknown as Record).update as + | Record + | undefined; + if (!update) return; + if (update.sessionUpdate !== 'agent_message_chunk') return; + const content = update.content as + | { type?: string; text?: string } + | undefined; + if (content?.type === 'text' && content.text) { + chunks.push(content.text); + } + }; + this.on('sessionUpdate', onUpdate); + + try { + console.log('[AcpBridge] Sending prompt...'); + const result = await conn.prompt({ + sessionId, + prompt: [{ type: 'text', text }], + }); + console.log( + '[AcpBridge] Prompt resolved, stopReason:', + result?.stopReason, + ); + } finally { + this.off('sessionUpdate', onUpdate); + } + + const response = chunks.join(''); + console.log( + `[AcpBridge] Collected ${chunks.length} chunks, ${response.length} chars`, + ); + return response; + } + + stop(): void { + if (this.child) { + this.child.kill(); + this.child = null; + } + this.connection = null; + } + + get isConnected(): boolean { + return ( + this.child !== null && !this.child.killed && this.child.exitCode === null + ); + } + + private ensureConnection(): ClientSideConnection { + if (!this.connection || !this.isConnected) { + throw new Error('Not connected to ACP agent'); + } + return this.connection; + } +} diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts new file mode 100644 index 000000000..2ffdb4c5f --- /dev/null +++ b/packages/channels/base/src/ChannelBase.ts @@ -0,0 +1,57 @@ +import type { ChannelConfig, Envelope } from './types.js'; +import { SenderGate } from './SenderGate.js'; +import { SessionRouter } from './SessionRouter.js'; +import type { AcpBridge } from './AcpBridge.js'; + +export abstract class ChannelBase { + protected config: ChannelConfig; + protected bridge: AcpBridge; + protected gate: SenderGate; + protected router: SessionRouter; + protected name: string; + + constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { + this.name = name; + this.config = config; + this.bridge = bridge; + this.gate = new SenderGate(config.senderPolicy, config.allowedUsers); + this.router = new SessionRouter(bridge, config.cwd); + } + + abstract connect(): Promise; + abstract sendMessage(chatId: string, text: string): Promise; + abstract disconnect(): void; + + async handleInbound(envelope: Envelope): Promise { + if (!this.gate.check(envelope.senderId)) { + console.log( + `[Channel:${this.name}] Sender ${envelope.senderId} denied by gate`, + ); + return; + } + + const sessionId = await this.router.resolve( + this.name, + envelope.senderId, + envelope.chatId, + envelope.threadId, + ); + + console.log( + `[Channel:${this.name}] Prompting session ${sessionId}: "${envelope.text.substring(0, 80)}"`, + ); + + console.log(`[Channel:${this.name}] Waiting for prompt response...`); + const response = await this.bridge.prompt(sessionId, envelope.text); + console.log( + `[Channel:${this.name}] Got response (${response.length} chars): "${response.substring(0, 100)}"`, + ); + + if (response) { + await this.sendMessage(envelope.chatId, response); + console.log( + `[Channel:${this.name}] Message sent to chat ${envelope.chatId}`, + ); + } + } +} diff --git a/packages/channels/base/src/SenderGate.ts b/packages/channels/base/src/SenderGate.ts new file mode 100644 index 000000000..b6a338c86 --- /dev/null +++ b/packages/channels/base/src/SenderGate.ts @@ -0,0 +1,23 @@ +import type { SenderPolicy } from './types.js'; + +export class SenderGate { + private policy: SenderPolicy; + private allowedUsers: Set; + + constructor(policy: SenderPolicy, allowedUsers: string[] = []) { + this.policy = policy; + this.allowedUsers = new Set(allowedUsers); + } + + check(senderId: string): boolean { + switch (this.policy) { + case 'open': + return true; + case 'allowlist': + return this.allowedUsers.has(senderId); + case 'pairing': + // Pairing will be implemented later; for now, treat as allowlist + return this.allowedUsers.has(senderId); + } + } +} diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts new file mode 100644 index 000000000..31e55ed24 --- /dev/null +++ b/packages/channels/base/src/SessionRouter.ts @@ -0,0 +1,37 @@ +import type { SessionTarget } from './types.js'; +import type { AcpBridge } from './AcpBridge.js'; + +export class SessionRouter { + private toSession: Map = new Map(); // routing key → session ID + private toTarget: Map = new Map(); // session ID → target + + private bridge: AcpBridge; + private cwd: string; + + constructor(bridge: AcpBridge, cwd: string) { + this.bridge = bridge; + this.cwd = cwd; + } + + async resolve( + channelName: string, + senderId: string, + chatId: string, + threadId?: string, + ): Promise { + const key = `${channelName}:${senderId}`; + const existing = this.toSession.get(key); + if (existing) { + return existing; + } + + const sessionId = await this.bridge.newSession(this.cwd); + this.toSession.set(key, sessionId); + this.toTarget.set(sessionId, { channelName, senderId, chatId, threadId }); + return sessionId; + } + + getTarget(sessionId: string): SessionTarget | undefined { + return this.toTarget.get(sessionId); + } +} diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts new file mode 100644 index 000000000..92f278d0d --- /dev/null +++ b/packages/channels/base/src/index.ts @@ -0,0 +1,13 @@ +export { AcpBridge } from './AcpBridge.js'; +export type { AcpBridgeOptions } from './AcpBridge.js'; +export { ChannelBase } from './ChannelBase.js'; +export { SenderGate } from './SenderGate.js'; +export { SessionRouter } from './SessionRouter.js'; +export type { + ChannelConfig, + ChannelType, + Envelope, + SenderPolicy, + SessionScope, + SessionTarget, +} from './types.js'; diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts new file mode 100644 index 000000000..5d57ef3ce --- /dev/null +++ b/packages/channels/base/src/types.ts @@ -0,0 +1,30 @@ +export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; +export type SessionScope = 'user' | 'thread' | 'single'; +export type ChannelType = 'telegram' | 'discord' | 'webhook'; + +export interface ChannelConfig { + type: ChannelType; + token: string; + senderPolicy: SenderPolicy; + allowedUsers: string[]; + sessionScope: SessionScope; + cwd: string; + approvalMode?: string; + instructions?: string; +} + +export interface Envelope { + channelName: string; + senderId: string; + senderName: string; + chatId: string; + text: string; + threadId?: string; +} + +export interface SessionTarget { + channelName: string; + senderId: string; + chatId: string; + threadId?: string; +} diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json new file mode 100644 index 000000000..76bed3298 --- /dev/null +++ b/packages/channels/telegram/package.json @@ -0,0 +1,18 @@ +{ + "name": "@qwen-code/channel-telegram", + "version": "0.1.0", + "description": "Telegram channel adapter for Qwen Code", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "telegraf": "^4.16.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts new file mode 100644 index 000000000..5bb589759 --- /dev/null +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -0,0 +1,85 @@ +import { Telegraf } from 'telegraf'; +import { ChannelBase } from '@qwen-code/channel-base'; +import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; +import type { AcpBridge } from '@qwen-code/channel-base'; + +const TELEGRAM_MSG_LIMIT = 4096; + +export class TelegramChannel extends ChannelBase { + private bot: Telegraf; + + constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { + super(name, config, bridge); + this.bot = new Telegraf(config.token); + } + + async connect(): Promise { + this.bot.on('text', async (ctx) => { + const msg = ctx.message; + const envelope: Envelope = { + channelName: this.name, + senderId: String(msg.from.id), + senderName: + msg.from.first_name + + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), + chatId: String(msg.chat.id), + text: msg.text, + }; + + try { + await this.handleInbound(envelope); + } catch (err) { + console.error(`[Telegram:${this.name}] Error handling message:`, err); + try { + await ctx.reply( + 'Sorry, something went wrong processing your message.', + ); + } catch { + // ignore send failure + } + } + }); + + console.log(`[Telegram:${this.name}] Launching bot (polling)...`); + this.bot.launch({ dropPendingUpdates: true }).catch((err) => { + console.error(`[Telegram:${this.name}] Bot launch error:`, err); + }); + console.log(`[Telegram:${this.name}] Bot started (polling)`); + + process.once('SIGINT', () => this.bot.stop('SIGINT')); + process.once('SIGTERM', () => this.bot.stop('SIGTERM')); + } + + async sendMessage(chatId: string, text: string): Promise { + // Split long messages at Telegram's 4096 char limit + const chunks = splitMessage(text, TELEGRAM_MSG_LIMIT); + for (const chunk of chunks) { + await this.bot.telegram.sendMessage(chatId, chunk); + } + } + + disconnect(): void { + this.bot.stop(); + } +} + +function splitMessage(text: string, limit: number): string[] { + if (text.length <= limit) return [text]; + + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + if (remaining.length <= limit) { + chunks.push(remaining); + break; + } + // Try to split at last newline within limit + let splitAt = remaining.lastIndexOf('\n', limit); + if (splitAt <= 0) { + splitAt = limit; + } + chunks.push(remaining.substring(0, splitAt)); + remaining = remaining.substring(splitAt).replace(/^\n/, ''); + } + return chunks; +} diff --git a/packages/channels/telegram/src/index.ts b/packages/channels/telegram/src/index.ts new file mode 100644 index 000000000..976c4ab0d --- /dev/null +++ b/packages/channels/telegram/src/index.ts @@ -0,0 +1 @@ +export { TelegramChannel } from './TelegramAdapter.js'; diff --git a/packages/cli/package.json b/packages/cli/package.json index fff36c603..6f4023813 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,8 @@ "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", + "@qwen-code/channel-base": "file:../channels/base", + "@qwen-code/channel-telegram": "file:../channels/telegram", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts new file mode 100644 index 000000000..190923d20 --- /dev/null +++ b/packages/cli/src/commands/channel.ts @@ -0,0 +1,13 @@ +import type { CommandModule, Argv } from 'yargs'; +import { startCommand } from './channel/start.js'; + +export const channelCommand: CommandModule = { + command: 'channel', + describe: 'Manage messaging channels (Telegram, Discord, etc.)', + builder: (yargs: Argv) => + yargs + .command(startCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => {}, +}; diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts new file mode 100644 index 000000000..d1ac96a35 --- /dev/null +++ b/packages/cli/src/commands/channel/start.ts @@ -0,0 +1,107 @@ +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { AcpBridge } from '@qwen-code/channel-base'; +import type { ChannelConfig } from '@qwen-code/channel-base'; +import { TelegramChannel } from '@qwen-code/channel-telegram'; +import * as path from 'node:path'; + +function resolveEnvVars(value: string): string { + if (value.startsWith('$')) { + const envName = value.substring(1); + const envValue = process.env[envName]; + if (!envValue) { + throw new Error( + `Environment variable ${envName} is not set (referenced as ${value})`, + ); + } + return envValue; + } + return value; +} + +function findCliEntryPath(): string { + // When running from bundled dist/cli.js, use that same file for --acp + const mainModule = process.argv[1]; + if (mainModule) { + return path.resolve(mainModule); + } + throw new Error('Cannot determine CLI entry path'); +} + +export const startCommand: CommandModule = { + command: 'start ', + describe: 'Start a messaging channel', + builder: (yargs) => + yargs.positional('name', { + type: 'string', + describe: 'Name of the channel (as configured in settings.json)', + demandOption: true, + }), + handler: async (argv) => { + const { name } = argv; + + const settings = loadSettings(process.cwd()); + const channels = ( + settings.merged as unknown as { channels?: Record } + ).channels; + + if (!channels || !channels[name]) { + writeStderrLine( + `Error: Channel "${name}" not found in settings. Add it to channels.${name} in settings.json.`, + ); + process.exit(1); + } + + const rawConfig = channels[name] as Record; + const config: ChannelConfig = { + type: rawConfig['type'] as ChannelConfig['type'], + token: resolveEnvVars(rawConfig['token'] as string), + senderPolicy: + (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || + 'allowlist', + allowedUsers: (rawConfig['allowedUsers'] as string[]) || [], + sessionScope: + (rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || 'user', + cwd: (rawConfig['cwd'] as string) || process.cwd(), + approvalMode: rawConfig['approvalMode'] as string | undefined, + instructions: rawConfig['instructions'] as string | undefined, + }; + + if (config.type !== 'telegram') { + writeStderrLine( + `Error: Channel type "${config.type}" is not yet supported. Only "telegram" is available.`, + ); + process.exit(1); + } + + const cliEntryPath = findCliEntryPath(); + writeStdoutLine(`[Channel] CLI entry: ${cliEntryPath}`); + writeStdoutLine(`[Channel] Starting "${name}" (type=${config.type})...`); + + const bridge = new AcpBridge({ cliEntryPath, cwd: config.cwd }); + await bridge.start(); + + const channel = new TelegramChannel(name, config, bridge); + await channel.connect(); + + writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`); + + // Keep process alive until interrupted + await new Promise((resolve) => { + process.on('SIGINT', () => { + writeStdoutLine('\n[Channel] Shutting down...'); + channel.disconnect(); + bridge.stop(); + resolve(); + }); + process.on('SIGTERM', () => { + channel.disconnect(); + bridge.stop(); + resolve(); + }); + }); + + process.exit(0); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 78ef3dde0..5b5436006 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -51,6 +51,7 @@ import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; +import { channelCommand } from '../commands/channel.js'; // UUID v4 regex pattern for validation const SESSION_ID_REGEX = @@ -590,7 +591,9 @@ export async function parseArguments(): Promise { // Register Auth subcommands .command(authCommand) // Register Hooks subcommands - .command(hooksCommand); + .command(hooksCommand) + // Register Channel subcommands + .command(channelCommand); yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json @@ -611,7 +614,8 @@ export async function parseArguments(): Promise { result._.length > 0 && (result._[0] === 'mcp' || result._[0] === 'extensions' || - result._[0] === 'hooks') + result._[0] === 'hooks' || + result._[0] === 'channel') ) { // MCP/Extensions/Hooks commands handle their own execution and process exit process.exit(0); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 379ea2168..b8b343685 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -189,6 +189,18 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, + // Channels configuration (Telegram, Discord, etc.) + channels: { + type: 'object', + label: 'Channels', + category: 'Advanced', + requiresRestart: true, + default: {} as Record>, + description: 'Configuration for messaging channels.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, + // Model providers configuration grouped by authType modelProviders: { type: 'object', From be838eea0180c9422db9d08f4afb904d6f211668 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 24 Mar 2026 05:17:31 +0000 Subject: [PATCH 02/51] feat(channels/telegram): format agent markdown as Telegram HTML Use telegram-markdown-formatter to convert agent markdown responses to Telegram HTML (bold, italic, code blocks, links). Falls back to plain text if HTML parsing fails. Also uses the package's built-in HTML-aware message splitting for long responses. --- package-lock.json | 15 ++++++- packages/channels/telegram/package.json | 3 +- .../channels/telegram/src/TelegramAdapter.ts | 43 +++++++------------ 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2a0fedcbf..5acd6df32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17029,6 +17029,18 @@ "node": "^12.20.0 || >=14.13.1" } }, + "node_modules/telegram-markdown-formatter": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/telegram-markdown-formatter/-/telegram-markdown-formatter-0.1.2.tgz", + "integrity": "sha512-GGkgawMLBhaO2epjx7YSncpCzoXciuB+zlmI1od7EqSCufWFls0qBKWZfjnON6RIENp1dQFsaoQdbP3tOCsJ5g==", + "license": "MIT", + "bin": { + "tg-md": "dist/cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -18909,7 +18921,8 @@ "version": "0.1.0", "dependencies": { "@qwen-code/channel-base": "file:../base", - "telegraf": "^4.16.0" + "telegraf": "^4.16.0", + "telegram-markdown-formatter": "^0.1.2" }, "devDependencies": { "typescript": "^5.0.0" diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json index 76bed3298..0154257d3 100644 --- a/packages/channels/telegram/package.json +++ b/packages/channels/telegram/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "@qwen-code/channel-base": "file:../base", - "telegraf": "^4.16.0" + "telegraf": "^4.16.0", + "telegram-markdown-formatter": "^0.1.2" }, "devDependencies": { "typescript": "^5.0.0" diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 5bb589759..e7209ae0c 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -1,10 +1,12 @@ import { Telegraf } from 'telegraf'; +import { + telegramFormat, + splitHtmlForTelegram, +} from 'telegram-markdown-formatter'; import { ChannelBase } from '@qwen-code/channel-base'; import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; import type { AcpBridge } from '@qwen-code/channel-base'; -const TELEGRAM_MSG_LIMIT = 4096; - export class TelegramChannel extends ChannelBase { private bot: Telegraf; @@ -40,21 +42,27 @@ export class TelegramChannel extends ChannelBase { } }); - console.log(`[Telegram:${this.name}] Launching bot (polling)...`); this.bot.launch({ dropPendingUpdates: true }).catch((err) => { console.error(`[Telegram:${this.name}] Bot launch error:`, err); }); - console.log(`[Telegram:${this.name}] Bot started (polling)`); process.once('SIGINT', () => this.bot.stop('SIGINT')); process.once('SIGTERM', () => this.bot.stop('SIGTERM')); } async sendMessage(chatId: string, text: string): Promise { - // Split long messages at Telegram's 4096 char limit - const chunks = splitMessage(text, TELEGRAM_MSG_LIMIT); + const html = telegramFormat(text); + const chunks = splitHtmlForTelegram(html); for (const chunk of chunks) { - await this.bot.telegram.sendMessage(chatId, chunk); + try { + await this.bot.telegram.sendMessage(chatId, chunk, { + parse_mode: 'HTML', + }); + } catch { + // Fallback to plain text if HTML parsing fails + await this.bot.telegram.sendMessage(chatId, text); + return; + } } } @@ -62,24 +70,3 @@ export class TelegramChannel extends ChannelBase { this.bot.stop(); } } - -function splitMessage(text: string, limit: number): string[] { - if (text.length <= limit) return [text]; - - const chunks: string[] = []; - let remaining = text; - while (remaining.length > 0) { - if (remaining.length <= limit) { - chunks.push(remaining); - break; - } - // Try to split at last newline within limit - let splitAt = remaining.lastIndexOf('\n', limit); - if (splitAt <= 0) { - splitAt = limit; - } - chunks.push(remaining.substring(0, splitAt)); - remaining = remaining.substring(splitAt).replace(/^\n/, ''); - } - return chunks; -} From 298520131795492fb7aa1ad895e7b133d10529fb Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 24 Mar 2026 06:08:15 +0000 Subject: [PATCH 03/51] feat(channels/telegram): add slash command support - Local commands: /start (welcome), /help (dynamic list), /reset (clear session) - Non-local slash commands forwarded to ACP agent as prompts - AcpBridge captures available_commands_update to populate /help dynamically - SessionRouter gains hasSession/removeSession for /reset support --- packages/channels/base/src/AcpBridge.ts | 20 +++++++ packages/channels/base/src/SessionRouter.ts | 13 +++++ packages/channels/base/src/index.ts | 2 +- .../channels/telegram/src/TelegramAdapter.ts | 55 ++++++++++++++++++- 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index 2860f1a7f..2a9143493 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -19,16 +19,26 @@ export interface AcpBridgeOptions { cwd: string; } +export interface AvailableCommand { + name: string; + description: string; +} + export class AcpBridge extends EventEmitter { private child: ChildProcess | null = null; private connection: ClientSideConnection | null = null; private options: AcpBridgeOptions; + private _availableCommands: AvailableCommand[] = []; constructor(options: AcpBridgeOptions) { super(); this.options = options; } + get availableCommands(): AvailableCommand[] { + return this._availableCommands; + } + async start(): Promise { const { cliEntryPath, cwd } = this.options; @@ -80,6 +90,16 @@ export class AcpBridge extends EventEmitter { ? JSON.stringify(update.content).substring(0, 200) : '', ); + + // Capture available commands from ACP + if ( + update?.sessionUpdate === 'available_commands_update' && + Array.isArray(update.availableCommands) + ) { + this._availableCommands = + update.availableCommands as AvailableCommand[]; + } + this.emit('sessionUpdate', params); return Promise.resolve(); }, diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts index 31e55ed24..4097a1870 100644 --- a/packages/channels/base/src/SessionRouter.ts +++ b/packages/channels/base/src/SessionRouter.ts @@ -34,4 +34,17 @@ export class SessionRouter { getTarget(sessionId: string): SessionTarget | undefined { return this.toTarget.get(sessionId); } + + hasSession(channelName: string, senderId: string): boolean { + return this.toSession.has(`${channelName}:${senderId}`); + } + + removeSession(channelName: string, senderId: string): boolean { + const key = `${channelName}:${senderId}`; + const sessionId = this.toSession.get(key); + if (!sessionId) return false; + this.toSession.delete(key); + this.toTarget.delete(sessionId); + return true; + } } diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 92f278d0d..9308d7f55 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -1,5 +1,5 @@ export { AcpBridge } from './AcpBridge.js'; -export type { AcpBridgeOptions } from './AcpBridge.js'; +export type { AcpBridgeOptions, AvailableCommand } from './AcpBridge.js'; export { ChannelBase } from './ChannelBase.js'; export { SenderGate } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index e7209ae0c..a32579c79 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -7,6 +7,9 @@ import { ChannelBase } from '@qwen-code/channel-base'; import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; import type { AcpBridge } from '@qwen-code/channel-base'; +// Commands handled locally by the Telegram adapter (not forwarded to ACP) +const LOCAL_COMMANDS = new Set(['start', 'help', 'reset']); + export class TelegramChannel extends ChannelBase { private bot: Telegraf; @@ -16,8 +19,58 @@ export class TelegramChannel extends ChannelBase { } async connect(): Promise { + // Register local-only commands + this.bot.command('start', async (ctx) => { + await ctx.reply( + `Hi ${ctx.from.first_name}! I'm a Qwen Code agent.\n\nSend any message to chat, or use slash commands like /compress, /summary.\n\nType /help for more info.`, + ); + }); + + this.bot.command('help', async (ctx) => { + const lines = [ + 'Local commands:', + '/start — Welcome message', + '/help — Show this help', + '/reset — Reset your session (start fresh)', + ]; + + const agentCommands = this.bridge.availableCommands; + if (agentCommands.length > 0) { + lines.push('', 'Agent commands (forwarded to Qwen Code):'); + for (const cmd of agentCommands) { + lines.push(`/${cmd.name} — ${cmd.description}`); + } + } + + lines.push('', 'Send any text to chat with the agent.'); + await ctx.reply(lines.join('\n')); + }); + + this.bot.command('reset', async (ctx) => { + const senderId = String(ctx.from.id); + const removed = this.router.removeSession(this.name, senderId); + if (removed) { + await ctx.reply( + 'Session reset. Your next message will start a fresh conversation.', + ); + } else { + await ctx.reply('No active session to reset.'); + } + }); + + // All other messages (including non-local slash commands) go through handleInbound this.bot.on('text', async (ctx) => { const msg = ctx.message; + const text = msg.text; + + // Skip if it's a local command (already handled above) + if (text.startsWith('/')) { + const command = text.slice(1).split(/[\s@]/)[0]?.toLowerCase(); + if (command && LOCAL_COMMANDS.has(command)) { + return; + } + } + const envelope: Envelope = { channelName: this.name, senderId: String(msg.from.id), @@ -25,7 +78,7 @@ export class TelegramChannel extends ChannelBase { msg.from.first_name + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), chatId: String(msg.chat.id), - text: msg.text, + text, }; try { From 615ccd08f2b9b8d3581769e90845e8dc5d9d1ee1 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 24 Mar 2026 06:20:16 +0000 Subject: [PATCH 04/51] feat(channels): add config validation, instructions, and sessionScope support - Validate required fields (type, token) with clear error messages - Prepend channel instructions to first prompt of each session - SessionRouter respects sessionScope (user/thread/single) for routing keys --- packages/channels/base/src/ChannelBase.ts | 12 +++++- packages/channels/base/src/SessionRouter.ts | 25 ++++++++++-- packages/cli/src/commands/channel/start.ts | 44 ++++++++++++++++----- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 2ffdb4c5f..89b875264 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -9,13 +9,14 @@ export abstract class ChannelBase { protected gate: SenderGate; protected router: SessionRouter; protected name: string; + private instructedSessions: Set = new Set(); constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { this.name = name; this.config = config; this.bridge = bridge; this.gate = new SenderGate(config.senderPolicy, config.allowedUsers); - this.router = new SessionRouter(bridge, config.cwd); + this.router = new SessionRouter(bridge, config.cwd, config.sessionScope); } abstract connect(): Promise; @@ -37,12 +38,19 @@ export abstract class ChannelBase { envelope.threadId, ); + // Prepend channel instructions on first message of a session + let promptText = envelope.text; + if (this.config.instructions && !this.instructedSessions.has(sessionId)) { + promptText = `${this.config.instructions}\n\n${envelope.text}`; + this.instructedSessions.add(sessionId); + } + console.log( `[Channel:${this.name}] Prompting session ${sessionId}: "${envelope.text.substring(0, 80)}"`, ); console.log(`[Channel:${this.name}] Waiting for prompt response...`); - const response = await this.bridge.prompt(sessionId, envelope.text); + const response = await this.bridge.prompt(sessionId, promptText); console.log( `[Channel:${this.name}] Got response (${response.length} chars): "${response.substring(0, 100)}"`, ); diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts index 4097a1870..302879f65 100644 --- a/packages/channels/base/src/SessionRouter.ts +++ b/packages/channels/base/src/SessionRouter.ts @@ -1,4 +1,4 @@ -import type { SessionTarget } from './types.js'; +import type { SessionScope, SessionTarget } from './types.js'; import type { AcpBridge } from './AcpBridge.js'; export class SessionRouter { @@ -7,10 +7,29 @@ export class SessionRouter { private bridge: AcpBridge; private cwd: string; + private scope: SessionScope; - constructor(bridge: AcpBridge, cwd: string) { + constructor(bridge: AcpBridge, cwd: string, scope: SessionScope = 'user') { this.bridge = bridge; this.cwd = cwd; + this.scope = scope; + } + + private routingKey( + channelName: string, + senderId: string, + chatId: string, + threadId?: string, + ): string { + switch (this.scope) { + case 'thread': + return `${channelName}:${threadId || chatId}`; + case 'single': + return `${channelName}:__single__`; + case 'user': + default: + return `${channelName}:${senderId}`; + } } async resolve( @@ -19,7 +38,7 @@ export class SessionRouter { chatId: string, threadId?: string, ): Promise { - const key = `${channelName}:${senderId}`; + const key = this.routingKey(channelName, senderId, chatId, threadId); const existing = this.toSession.get(key); if (existing) { return existing; diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index d1ac96a35..a26d07ff7 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -54,9 +54,42 @@ export const startCommand: CommandModule = { } const rawConfig = channels[name] as Record; + + // Validate required fields + if (!rawConfig['type']) { + writeStderrLine( + `Error: Channel "${name}" is missing required field "type".`, + ); + process.exit(1); + } + if (!rawConfig['token']) { + writeStderrLine( + `Error: Channel "${name}" is missing required field "token".`, + ); + process.exit(1); + } + + const channelType = rawConfig['type'] as string; + if (channelType !== 'telegram') { + writeStderrLine( + `Error: Channel type "${channelType}" is not yet supported. Only "telegram" is available.`, + ); + process.exit(1); + } + + let token: string; + try { + token = resolveEnvVars(rawConfig['token'] as string); + } catch (err) { + writeStderrLine( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + const config: ChannelConfig = { - type: rawConfig['type'] as ChannelConfig['type'], - token: resolveEnvVars(rawConfig['token'] as string), + type: channelType as ChannelConfig['type'], + token, senderPolicy: (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || 'allowlist', @@ -68,13 +101,6 @@ export const startCommand: CommandModule = { instructions: rawConfig['instructions'] as string | undefined, }; - if (config.type !== 'telegram') { - writeStderrLine( - `Error: Channel type "${config.type}" is not yet supported. Only "telegram" is available.`, - ); - process.exit(1); - } - const cliEntryPath = findCliEntryPath(); writeStdoutLine(`[Channel] CLI entry: ${cliEntryPath}`); writeStdoutLine(`[Channel] Starting "${name}" (type=${config.type})...`); From a5d2fafa3c971bf704cde5f2610210b58f50ec03 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 24 Mar 2026 06:45:39 +0000 Subject: [PATCH 05/51] fix(channels): fix TypeScript build errors - Use bracket notation for index signature properties - Add tsconfig.json for channels/base and channels/telegram packages Co-authored-by: Qwen-Coder --- packages/channels/base/src/AcpBridge.ts | 35 ++++++++++++++---------- packages/channels/base/tsconfig.json | 9 ++++++ packages/channels/telegram/tsconfig.json | 10 +++++++ 3 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 packages/channels/base/tsconfig.json create mode 100644 packages/channels/telegram/tsconfig.json diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index 2a9143493..ee6c6fe16 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -81,23 +81,25 @@ export class AcpBridge extends EventEmitter { this.connection = new ClientSideConnection( (): Client => ({ sessionUpdate: (params: SessionNotification): Promise => { - const update = (params as unknown as Record) - .update as Record | undefined; + const update = (params as unknown as Record)[ + 'update' + ] as Record | undefined; console.log( '[AcpBridge] sessionUpdate:', - update?.sessionUpdate, - update?.content - ? JSON.stringify(update.content).substring(0, 200) + update?.['sessionUpdate'], + update?.['content'] + ? JSON.stringify(update['content']).substring(0, 200) : '', ); // Capture available commands from ACP if ( - update?.sessionUpdate === 'available_commands_update' && - Array.isArray(update.availableCommands) + update?.['sessionUpdate'] === 'available_commands_update' && + Array.isArray(update['availableCommands']) ) { - this._availableCommands = - update.availableCommands as AvailableCommand[]; + this._availableCommands = update[ + 'availableCommands' + ] as AvailableCommand[]; } this.emit('sessionUpdate', params); @@ -113,10 +115,13 @@ export class AcpBridge extends EventEmitter { options.find((o) => o.optionId === 'proceed_once')?.optionId || options[0]?.optionId || 'proceed_once'; + const toolCall = params.toolCall as + | { name?: string; [key: string]: unknown } + | undefined; console.log( '[AcpBridge] Permission request auto-approved:', optionId, - params.toolCall?.name, + toolCall?.['name'], ); return { outcome: { outcome: 'selected', optionId } }; }, @@ -150,12 +155,12 @@ export class AcpBridge extends EventEmitter { const chunks: string[] = []; const onUpdate = (params: SessionNotification) => { if (params.sessionId !== sessionId) return; - const update = (params as unknown as Record).update as - | Record - | undefined; + const update = (params as unknown as Record)[ + 'update' + ] as Record | undefined; if (!update) return; - if (update.sessionUpdate !== 'agent_message_chunk') return; - const content = update.content as + if (update['sessionUpdate'] !== 'agent_message_chunk') return; + const content = update['content'] as | { type?: string; text?: string } | undefined; if (content?.type === 'text' && content.text) { diff --git a/packages/channels/base/tsconfig.json b/packages/channels/base/tsconfig.json new file mode 100644 index 000000000..b00960ec6 --- /dev/null +++ b/packages/channels/base/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/channels/telegram/tsconfig.json b/packages/channels/telegram/tsconfig.json new file mode 100644 index 000000000..8daf59408 --- /dev/null +++ b/packages/channels/telegram/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../base" }] +} From 2867e779b9f700b09580e1816e3245801501544e Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 24 Mar 2026 10:18:23 +0000 Subject: [PATCH 06/51] refactor(channels): simplify Telegram status tracking and improve event handling - Add ToolCallEvent interface and emit typed events from AcpBridge - Refactor sessionUpdate handling into dedicated method - Simplify TelegramAdapter to use simple 'Working...' message - Change to non-awaited handler to avoid Telegraf 90s timeout - Remove console.log statements for cleaner code Co-authored-by: Qwen-Coder --- packages/channels/base/src/AcpBridge.ts | 126 +++++++++--------- packages/channels/base/src/ChannelBase.ts | 25 ++-- packages/channels/base/src/index.ts | 6 +- .../channels/telegram/src/TelegramAdapter.ts | 43 ++++-- 4 files changed, 107 insertions(+), 93 deletions(-) diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index ee6c6fe16..348fe38a3 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -24,6 +24,15 @@ export interface AvailableCommand { description: string; } +export interface ToolCallEvent { + sessionId: string; + toolCallId: string; + kind: string; + title: string; + status: string; + rawInput?: Record; +} + export class AcpBridge extends EventEmitter { private child: ChildProcess | null = null; private connection: ClientSideConnection | null = null; @@ -81,48 +90,19 @@ export class AcpBridge extends EventEmitter { this.connection = new ClientSideConnection( (): Client => ({ sessionUpdate: (params: SessionNotification): Promise => { - const update = (params as unknown as Record)[ - 'update' - ] as Record | undefined; - console.log( - '[AcpBridge] sessionUpdate:', - update?.['sessionUpdate'], - update?.['content'] - ? JSON.stringify(update['content']).substring(0, 200) - : '', - ); - - // Capture available commands from ACP - if ( - update?.['sessionUpdate'] === 'available_commands_update' && - Array.isArray(update['availableCommands']) - ) { - this._availableCommands = update[ - 'availableCommands' - ] as AvailableCommand[]; - } - - this.emit('sessionUpdate', params); + this.handleSessionUpdate(params); return Promise.resolve(); }, requestPermission: async ( params: RequestPermissionRequest, ): Promise => { - // Phase 1: auto-approve everything so plain text works + // Auto-approve for now; Phase 5 will add interactive approval const options = Array.isArray(params.options) ? params.options : []; const optionId = options.find((o) => o.optionId === 'proceed_once')?.optionId || options[0]?.optionId || 'proceed_once'; - const toolCall = params.toolCall as - | { name?: string; [key: string]: unknown } - | undefined; - console.log( - '[AcpBridge] Permission request auto-approved:', - optionId, - toolCall?.['name'], - ); return { outcome: { outcome: 'selected', optionId } }; }, @@ -135,59 +115,33 @@ export class AcpBridge extends EventEmitter { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, }); - - console.log('[AcpBridge] Connected and initialized'); } async newSession(cwd: string): Promise { const conn = this.ensureConnection(); const response = await conn.newSession({ cwd, mcpServers: [] }); - const sessionId = response.sessionId; - console.log('[AcpBridge] New session:', sessionId); - return sessionId; + return response.sessionId; } async prompt(sessionId: string, text: string): Promise { const conn = this.ensureConnection(); - // Collect text from sessionUpdate events during this prompt - // SessionNotification shape: { sessionId, update: { sessionUpdate, content: { type, text } } } const chunks: string[] = []; - const onUpdate = (params: SessionNotification) => { - if (params.sessionId !== sessionId) return; - const update = (params as unknown as Record)[ - 'update' - ] as Record | undefined; - if (!update) return; - if (update['sessionUpdate'] !== 'agent_message_chunk') return; - const content = update['content'] as - | { type?: string; text?: string } - | undefined; - if (content?.type === 'text' && content.text) { - chunks.push(content.text); - } + const onChunk = (sid: string, chunk: string) => { + if (sid === sessionId) chunks.push(chunk); }; - this.on('sessionUpdate', onUpdate); + this.on('textChunk', onChunk); try { - console.log('[AcpBridge] Sending prompt...'); - const result = await conn.prompt({ + await conn.prompt({ sessionId, prompt: [{ type: 'text', text }], }); - console.log( - '[AcpBridge] Prompt resolved, stopReason:', - result?.stopReason, - ); } finally { - this.off('sessionUpdate', onUpdate); + this.off('textChunk', onChunk); } - const response = chunks.join(''); - console.log( - `[AcpBridge] Collected ${chunks.length} chunks, ${response.length} chars`, - ); - return response; + return chunks.join(''); } stop(): void { @@ -204,6 +158,50 @@ export class AcpBridge extends EventEmitter { ); } + private handleSessionUpdate(params: SessionNotification): void { + const { sessionId } = params; + const update = (params as unknown as Record)['update'] as + | Record + | undefined; + if (!update) return; + + const type = update['sessionUpdate'] as string; + + switch (type) { + case 'agent_message_chunk': { + const content = update['content'] as + | { type?: string; text?: string } + | undefined; + if (content?.type === 'text' && content.text) { + this.emit('textChunk', sessionId, content.text); + } + break; + } + case 'tool_call': { + const event: ToolCallEvent = { + sessionId, + toolCallId: update['toolCallId'] as string, + kind: (update['kind'] as string) || '', + title: (update['title'] as string) || '', + status: (update['status'] as string) || 'pending', + rawInput: update['rawInput'] as Record | undefined, + }; + this.emit('toolCall', event); + break; + } + case 'available_commands_update': { + if (Array.isArray(update['availableCommands'])) { + this._availableCommands = update[ + 'availableCommands' + ] as AvailableCommand[]; + } + break; + } + } + + this.emit('sessionUpdate', params); + } + private ensureConnection(): ClientSideConnection { if (!this.connection || !this.isConnected) { throw new Error('Not connected to ACP agent'); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 89b875264..be1bf45fc 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -1,7 +1,7 @@ import type { ChannelConfig, Envelope } from './types.js'; import { SenderGate } from './SenderGate.js'; import { SessionRouter } from './SessionRouter.js'; -import type { AcpBridge } from './AcpBridge.js'; +import type { AcpBridge, ToolCallEvent } from './AcpBridge.js'; export abstract class ChannelBase { protected config: ChannelConfig; @@ -17,17 +17,23 @@ export abstract class ChannelBase { this.bridge = bridge; this.gate = new SenderGate(config.senderPolicy, config.allowedUsers); this.router = new SessionRouter(bridge, config.cwd, config.sessionScope); + + bridge.on('toolCall', (event: ToolCallEvent) => { + const target = this.router.getTarget(event.sessionId); + if (target) { + this.onToolCall(target.chatId, event); + } + }); } abstract connect(): Promise; abstract sendMessage(chatId: string, text: string): Promise; abstract disconnect(): void; + onToolCall(_chatId: string, _event: ToolCallEvent): void {} + async handleInbound(envelope: Envelope): Promise { if (!this.gate.check(envelope.senderId)) { - console.log( - `[Channel:${this.name}] Sender ${envelope.senderId} denied by gate`, - ); return; } @@ -45,21 +51,10 @@ export abstract class ChannelBase { this.instructedSessions.add(sessionId); } - console.log( - `[Channel:${this.name}] Prompting session ${sessionId}: "${envelope.text.substring(0, 80)}"`, - ); - - console.log(`[Channel:${this.name}] Waiting for prompt response...`); const response = await this.bridge.prompt(sessionId, promptText); - console.log( - `[Channel:${this.name}] Got response (${response.length} chars): "${response.substring(0, 100)}"`, - ); if (response) { await this.sendMessage(envelope.chatId, response); - console.log( - `[Channel:${this.name}] Message sent to chat ${envelope.chatId}`, - ); } } } diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 9308d7f55..71dfaefa6 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -1,5 +1,9 @@ export { AcpBridge } from './AcpBridge.js'; -export type { AcpBridgeOptions, AvailableCommand } from './AcpBridge.js'; +export type { + AcpBridgeOptions, + AvailableCommand, + ToolCallEvent, +} from './AcpBridge.js'; export { ChannelBase } from './ChannelBase.js'; export { SenderGate } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index a32579c79..4b063c063 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -4,8 +4,11 @@ import { splitHtmlForTelegram, } from 'telegram-markdown-formatter'; import { ChannelBase } from '@qwen-code/channel-base'; -import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; -import type { AcpBridge } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; // Commands handled locally by the Telegram adapter (not forwarded to ACP) const LOCAL_COMMANDS = new Set(['start', 'help', 'reset']); @@ -81,18 +84,13 @@ export class TelegramChannel extends ChannelBase { text, }; - try { - await this.handleInbound(envelope); - } catch (err) { + // Don't await — Telegraf has a 90s handler timeout that would kill long prompts + this.handleInbound(envelope).catch((err) => { console.error(`[Telegram:${this.name}] Error handling message:`, err); - try { - await ctx.reply( - 'Sorry, something went wrong processing your message.', - ); - } catch { - // ignore send failure - } - } + ctx + .reply('Sorry, something went wrong processing your message.') + .catch(() => {}); + }); }); this.bot.launch({ dropPendingUpdates: true }).catch((err) => { @@ -103,6 +101,24 @@ export class TelegramChannel extends ChannelBase { process.once('SIGTERM', () => this.bot.stop('SIGTERM')); } + override async handleInbound(envelope: Envelope): Promise { + // Send "Working..." immediately for instant feedback + const workingMsg = await this.bot.telegram + .sendMessage(envelope.chatId, 'Working...') + .catch(() => null); + + try { + await super.handleInbound(envelope); + } finally { + // Always delete "Working..." — even on error/timeout + if (workingMsg) { + this.bot.telegram + .deleteMessage(envelope.chatId, workingMsg.message_id) + .catch(() => {}); + } + } + } + async sendMessage(chatId: string, text: string): Promise { const html = telegramFormat(text); const chunks = splitHtmlForTelegram(html); @@ -122,4 +138,5 @@ export class TelegramChannel extends ChannelBase { disconnect(): void { this.bot.stop(); } + } From 59ee49e0abdf5eba36de06e808379feb724e5957 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 24 Mar 2026 11:12:52 +0000 Subject: [PATCH 07/51] docs(channels): add Channels feature documentation - Add overview page explaining channels architecture and configuration - Add Telegram channel setup guide with bot creation steps - Add navigation entries for channels section This documents the new Channels feature that allows users to interact with Qwen Code agents from messaging platforms like Telegram. Co-authored-by: Qwen-Coder --- docs/users/features/_meta.ts | 1 + docs/users/features/channels/_meta.ts | 4 + docs/users/features/channels/overview.md | 98 ++++++++++++++++++++++++ docs/users/features/channels/telegram.md | 85 ++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 docs/users/features/channels/_meta.ts create mode 100644 docs/users/features/channels/overview.md create mode 100644 docs/users/features/channels/telegram.md diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index 9cf6d403f..0dda77989 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -13,4 +13,5 @@ export default { 'token-caching': 'Token Caching', sandbox: 'Sandboxing', language: 'i18n', + channels: 'Channels', }; diff --git a/docs/users/features/channels/_meta.ts b/docs/users/features/channels/_meta.ts new file mode 100644 index 000000000..70fadc28f --- /dev/null +++ b/docs/users/features/channels/_meta.ts @@ -0,0 +1,4 @@ +export default { + overview: 'Overview', + telegram: 'Telegram', +}; diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md new file mode 100644 index 000000000..71813981c --- /dev/null +++ b/docs/users/features/channels/overview.md @@ -0,0 +1,98 @@ +# Channels + +Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI. + +## How It Works + +When you run `qwen channel start `, Qwen Code: + +1. Reads the channel configuration from your `settings.json` +2. Spawns an agent process using the [Agent Client Protocol (ACP)](../../developers/architecture) +3. Connects to the messaging platform (e.g., Telegram) and starts listening for messages +4. Routes incoming messages to the agent and sends responses back to the chat + +Each channel runs as a long-lived process that bridges a messaging platform to a Qwen Code agent. + +## Quick Start + +1. Set up a bot on your messaging platform (see the channel-specific guide, e.g., [Telegram](./telegram)) +2. Add the channel configuration to `~/.qwen/settings.json` +3. Run `qwen channel start ` + +## Configuration + +Channels are configured under the `channels` key in `settings.json`. Each channel has a name and a set of options: + +```json +{ + "channels": { + "my-channel": { + "type": "telegram", + "token": "$MY_BOT_TOKEN", + "senderPolicy": "allowlist", + "allowedUsers": ["123456789"], + "sessionScope": "user", + "cwd": "/path/to/working/directory", + "instructions": "Optional system instructions for the agent." + } + } +} +``` + +### Options + +| Option | Required | Description | +| -------------- | -------- | ---------------------------------------------------------------------------- | +| `type` | Yes | Channel type: `telegram` (more coming soon) | +| `token` | Yes | Bot token. Supports `$ENV_VAR` syntax to read from environment variables | +| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | +| `allowedUsers` | No | List of user IDs allowed to use the bot (when `senderPolicy` is `allowlist`) | +| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | +| `cwd` | No | Working directory for the agent. Defaults to the current directory | +| `instructions` | No | Custom instructions prepended to the first message of each session | + +### Sender Policy + +Controls who can interact with the bot: + +- **`allowlist`** (default) — Only users listed in `allowedUsers` can send messages. Others are silently ignored. +- **`open`** — Anyone can send messages. Use with caution. +- **`pairing`** — (Coming soon) New users go through a pairing flow before they can chat. + +### Session Scope + +Controls how conversation sessions are managed: + +- **`user`** (default) — One session per user. All messages from the same user share a conversation. +- **`thread`** — One session per thread/topic. Useful for group chats with threads. +- **`single`** — One shared session for all users. Everyone shares the same conversation. + +### Token Security + +Bot tokens should not be stored directly in `settings.json`. Instead, use environment variable references: + +```json +{ + "token": "$TELEGRAM_BOT_TOKEN" +} +``` + +Set the actual token in your shell environment or in a `.env` file that gets loaded before running the channel. + +## Slash Commands + +Channels support slash commands. Some are handled locally by the adapter: + +- `/start` — Welcome message +- `/help` — List available commands +- `/reset` — Reset your session and start fresh + +All other slash commands (e.g., `/compress`, `/summary`) are forwarded to the agent. + +## Running + +```bash +qwen channel start my-channel +``` + +The bot runs in the foreground. Press `Ctrl+C` to stop. diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md new file mode 100644 index 000000000..236a597ff --- /dev/null +++ b/docs/users/features/channels/telegram.md @@ -0,0 +1,85 @@ +# Telegram + +This guide covers setting up a Qwen Code channel on Telegram. + +## Prerequisites + +- A Telegram account +- A Telegram bot token (see below) + +## Creating a Bot + +1. Open Telegram and search for [@BotFather](https://t.me/BotFather) +2. Send `/newbot` and follow the prompts to choose a name and username +3. BotFather will give you a bot token — save it securely + +## Finding Your User ID + +To use `senderPolicy: "allowlist"`, you need your Telegram user ID (a numeric ID, not your username). + +The easiest way to find it: + +1. Search for [@userinfobot](https://t.me/userinfobot) on Telegram +2. Send it any message — it will reply with your user ID + +## Configuration + +Add the channel to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-telegram": { + "type": "telegram", + "token": "$TELEGRAM_BOT_TOKEN", + "senderPolicy": "allowlist", + "allowedUsers": ["YOUR_USER_ID"], + "sessionScope": "user", + "cwd": "/path/to/your/project", + "instructions": "You are a concise coding assistant responding via Telegram. Keep responses short." + } + } +} +``` + +Set the bot token as an environment variable: + +```bash +export TELEGRAM_BOT_TOKEN= +``` + +Or add it to a `.env` file that gets sourced before running. + +## Running + +```bash +qwen channel start my-telegram +``` + +Then open your bot in Telegram and send a message. You should see "Working..." appear immediately, followed by the agent's response. + +## Tips + +- **Keep instructions concise-focused** — Telegram has a 4096-character message limit. Adding instructions like "keep responses short" helps the agent stay within bounds. +- **Use `sessionScope: "user"`** — This gives each user their own conversation. Use `/reset` to start fresh. +- **Restrict access** — Use `senderPolicy: "allowlist"` with your user ID to prevent unauthorized access. The bot silently ignores messages from users not on the list. + +## Message Formatting + +The agent's markdown responses are automatically converted to Telegram-compatible HTML. Code blocks, bold, italic, links, and lists are all supported. + +## Troubleshooting + +### Bot doesn't respond + +- Check that the bot token is correct and the environment variable is set +- Verify your user ID is in `allowedUsers` if using `senderPolicy: "allowlist"` +- Check the terminal output for errors + +### "Sorry, something went wrong processing your message" + +This usually means the agent encountered an error. Check the terminal output for details. + +### Bot takes a long time to respond + +The agent may be running multiple tool calls (reading files, searching, etc.). The "Working..." indicator shows while the agent is processing. Complex tasks can take a minute or more. From 8753245b5f6d63e5703bf5acea70cd7876629098 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 24 Mar 2026 11:37:16 +0000 Subject: [PATCH 08/51] feat(channels): add DM pairing flow for sender approval - Add PairingStore for managing pending requests and approved users - Update SenderGate to support pairing policy with code generation - Add CLI commands: `qwen channel pairing list/approve` - Document pairing flow with rules and usage examples This allows unknown senders to request access via a pairing code that the bot operator approves through the CLI. Co-authored-by: Qwen-Coder --- docs/users/features/channels/overview.md | 53 +++++-- docs/users/features/channels/telegram.md | 6 +- eslint.config.js | 2 +- packages/channels/base/src/ChannelBase.ts | 33 ++++- packages/channels/base/src/PairingStore.ts | 140 +++++++++++++++++++ packages/channels/base/src/SenderGate.ts | 41 +++++- packages/channels/base/src/index.ts | 3 + packages/cli/src/commands/channel.ts | 17 +++ packages/cli/src/commands/channel/pairing.ts | 66 +++++++++ 9 files changed, 338 insertions(+), 23 deletions(-) create mode 100644 packages/channels/base/src/PairingStore.ts create mode 100644 packages/cli/src/commands/channel/pairing.ts diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 71813981c..a4ea55d3d 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -41,23 +41,23 @@ Channels are configured under the `channels` key in `settings.json`. Each channe ### Options -| Option | Required | Description | -| -------------- | -------- | ---------------------------------------------------------------------------- | -| `type` | Yes | Channel type: `telegram` (more coming soon) | -| `token` | Yes | Bot token. Supports `$ENV_VAR` syntax to read from environment variables | -| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | -| `allowedUsers` | No | List of user IDs allowed to use the bot (when `senderPolicy` is `allowlist`) | -| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | -| `cwd` | No | Working directory for the agent. Defaults to the current directory | -| `instructions` | No | Custom instructions prepended to the first message of each session | +| Option | Required | Description | +| -------------- | -------- | ------------------------------------------------------------------------------------ | +| `type` | Yes | Channel type: `telegram` (more coming soon) | +| `token` | Yes | Bot token. Supports `$ENV_VAR` syntax to read from environment variables | +| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | +| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | +| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | +| `cwd` | No | Working directory for the agent. Defaults to the current directory | +| `instructions` | No | Custom instructions prepended to the first message of each session | ### Sender Policy Controls who can interact with the bot: - **`allowlist`** (default) — Only users listed in `allowedUsers` can send messages. Others are silently ignored. +- **`pairing`** — Unknown senders receive a pairing code. The bot operator approves them via CLI, and they're added to a persistent allowlist. Users in `allowedUsers` skip pairing entirely. See [DM Pairing](#dm-pairing) below. - **`open`** — Anyone can send messages. Use with caution. -- **`pairing`** — (Coming soon) New users go through a pairing flow before they can chat. ### Session Scope @@ -79,6 +79,39 @@ Bot tokens should not be stored directly in `settings.json`. Instead, use enviro Set the actual token in your shell environment or in a `.env` file that gets loaded before running the channel. +## DM Pairing + +When `senderPolicy` is set to `"pairing"`, unknown senders go through an approval flow: + +1. An unknown user sends a message to the bot +2. The bot replies with an 8-character pairing code (e.g., `VEQDDWXJ`) +3. The user shares the code with you (the bot operator) +4. You approve them via CLI: + +```bash +qwen channel pairing approve my-channel VEQDDWXJ +``` + +Once approved, the user's ID is saved to `~/.qwen/channels/-allowlist.json` and all future messages go through normally. + +### Pairing CLI Commands + +```bash +# List pending pairing requests +qwen channel pairing list my-channel + +# Approve a request by code +qwen channel pairing approve my-channel +``` + +### Pairing Rules + +- Codes are 8 characters, uppercase, using an unambiguous alphabet (no `0`/`O`/`1`/`I`) +- Codes expire after 1 hour +- Maximum 3 pending requests per channel at a time — additional requests are ignored until one expires or is approved +- Users listed in `allowedUsers` in `settings.json` always skip pairing +- Approved users are stored in `~/.qwen/channels/-allowlist.json` — treat this file as sensitive + ## Slash Commands Channels support slash commands. Some are handled locally by the adapter: diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md index 236a597ff..197fcdb5d 100644 --- a/docs/users/features/channels/telegram.md +++ b/docs/users/features/channels/telegram.md @@ -15,7 +15,7 @@ This guide covers setting up a Qwen Code channel on Telegram. ## Finding Your User ID -To use `senderPolicy: "allowlist"`, you need your Telegram user ID (a numeric ID, not your username). +To use `senderPolicy: "allowlist"` or `"pairing"`, you need your Telegram user ID (a numeric ID, not your username). The easiest way to find it: @@ -62,7 +62,7 @@ Then open your bot in Telegram and send a message. You should see "Working..." a - **Keep instructions concise-focused** — Telegram has a 4096-character message limit. Adding instructions like "keep responses short" helps the agent stay within bounds. - **Use `sessionScope: "user"`** — This gives each user their own conversation. Use `/reset` to start fresh. -- **Restrict access** — Use `senderPolicy: "allowlist"` with your user ID to prevent unauthorized access. The bot silently ignores messages from users not on the list. +- **Restrict access** — Use `senderPolicy: "allowlist"` for a fixed set of users, or `"pairing"` to let new users request access with a code you approve via CLI. See [DM Pairing](./overview#dm-pairing) for details. ## Message Formatting @@ -73,7 +73,7 @@ The agent's markdown responses are automatically converted to Telegram-compatibl ### Bot doesn't respond - Check that the bot token is correct and the environment variable is set -- Verify your user ID is in `allowedUsers` if using `senderPolicy: "allowlist"` +- Verify your user ID is in `allowedUsers` if using `senderPolicy: "allowlist"`, or that you've been approved if using `"pairing"` - Check the terminal output for errors ### "Sorry, something went wrong processing your message" diff --git a/eslint.config.js b/eslint.config.js index 7b54f58a8..c52b6b5c5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -64,7 +64,7 @@ export default tseslint.config( }, { // General overrides and rules for the project (TS/TSX files) - files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + files: ['packages/**/src/**/*.{ts,tsx}'], // Target TS/TSX in all packages (including nested) plugins: { import: importPlugin, }, diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index be1bf45fc..a48e96992 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -1,5 +1,6 @@ import type { ChannelConfig, Envelope } from './types.js'; import { SenderGate } from './SenderGate.js'; +import { PairingStore } from './PairingStore.js'; import { SessionRouter } from './SessionRouter.js'; import type { AcpBridge, ToolCallEvent } from './AcpBridge.js'; @@ -15,7 +16,14 @@ export abstract class ChannelBase { this.name = name; this.config = config; this.bridge = bridge; - this.gate = new SenderGate(config.senderPolicy, config.allowedUsers); + + const pairingStore = + config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined; + this.gate = new SenderGate( + config.senderPolicy, + config.allowedUsers, + pairingStore, + ); this.router = new SessionRouter(bridge, config.cwd, config.sessionScope); bridge.on('toolCall', (event: ToolCallEvent) => { @@ -33,7 +41,11 @@ export abstract class ChannelBase { onToolCall(_chatId: string, _event: ToolCallEvent): void {} async handleInbound(envelope: Envelope): Promise { - if (!this.gate.check(envelope.senderId)) { + const result = this.gate.check(envelope.senderId, envelope.senderName); + if (!result.allowed) { + if (result.pairingCode !== undefined) { + await this.onPairingRequired(envelope.chatId, result.pairingCode); + } return; } @@ -57,4 +69,21 @@ export abstract class ChannelBase { await this.sendMessage(envelope.chatId, response); } } + + protected async onPairingRequired( + chatId: string, + code: string | null, + ): Promise { + if (code) { + await this.sendMessage( + chatId, + `Your pairing code is: ${code}\n\nAsk the bot operator to approve you with:\n qwen channel pairing approve ${this.name} ${code}`, + ); + } else { + await this.sendMessage( + chatId, + 'Too many pending pairing requests. Please try again later.', + ); + } + } } diff --git a/packages/channels/base/src/PairingStore.ts b/packages/channels/base/src/PairingStore.ts new file mode 100644 index 000000000..c49eeb4c9 --- /dev/null +++ b/packages/channels/base/src/PairingStore.ts @@ -0,0 +1,140 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// Alphabet without ambiguous chars: 0/O, 1/I +const SAFE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const CODE_LENGTH = 8; +const EXPIRY_MS = 60 * 60 * 1000; // 1 hour +const MAX_PENDING = 3; + +export interface PairingRequest { + senderId: string; + senderName: string; + code: string; + createdAt: number; // epoch ms +} + +export class PairingStore { + private dir: string; + private pendingPath: string; + private allowlistPath: string; + + constructor(channelName: string) { + this.dir = path.join(os.homedir(), '.qwen', 'channels'); + this.pendingPath = path.join(this.dir, `${channelName}-pairing.json`); + this.allowlistPath = path.join(this.dir, `${channelName}-allowlist.json`); + } + + isApproved(senderId: string): boolean { + const list = this.readAllowlist(); + return list.includes(senderId); + } + + /** + * Create a pairing request for an unknown sender. + * Returns the code if created, or null if the pending cap is reached. + * If the sender already has a non-expired pending request, returns that code. + */ + createRequest(senderId: string, senderName: string): string | null { + const pending = this.readPending(); + + // Purge expired + const now = Date.now(); + const active = pending.filter((r) => now - r.createdAt < EXPIRY_MS); + + // Check if sender already has a pending request + const existing = active.find((r) => r.senderId === senderId); + if (existing) { + return existing.code; + } + + // Cap check + if (active.length >= MAX_PENDING) { + return null; + } + + const code = generateCode(); + active.push({ senderId, senderName, code, createdAt: now }); + this.writePending(active); + return code; + } + + /** + * Approve a pairing request by code. + * Returns the sender ID if found, or null if not found / expired. + */ + approve(code: string): PairingRequest | null { + const pending = this.readPending(); + const now = Date.now(); + const idx = pending.findIndex( + (r) => r.code === code.toUpperCase() && now - r.createdAt < EXPIRY_MS, + ); + if (idx === -1) return null; + + const request = pending[idx]!; + pending.splice(idx, 1); + this.writePending(pending); + + // Add to allowlist + const list = this.readAllowlist(); + if (!list.includes(request.senderId)) { + list.push(request.senderId); + this.writeAllowlist(list); + } + + return request; + } + + listPending(): PairingRequest[] { + const pending = this.readPending(); + const now = Date.now(); + return pending.filter((r) => now - r.createdAt < EXPIRY_MS); + } + + getAllowlist(): string[] { + return this.readAllowlist(); + } + + private ensureDir(): void { + if (!fs.existsSync(this.dir)) { + fs.mkdirSync(this.dir, { recursive: true }); + } + } + + private readPending(): PairingRequest[] { + try { + const data = fs.readFileSync(this.pendingPath, 'utf-8'); + return JSON.parse(data) as PairingRequest[]; + } catch { + return []; + } + } + + private writePending(requests: PairingRequest[]): void { + this.ensureDir(); + fs.writeFileSync(this.pendingPath, JSON.stringify(requests, null, 2)); + } + + private readAllowlist(): string[] { + try { + const data = fs.readFileSync(this.allowlistPath, 'utf-8'); + return JSON.parse(data) as string[]; + } catch { + return []; + } + } + + private writeAllowlist(list: string[]): void { + this.ensureDir(); + fs.writeFileSync(this.allowlistPath, JSON.stringify(list, null, 2)); + } +} + +function generateCode(): string { + let code = ''; + for (let i = 0; i < CODE_LENGTH; i++) { + code += SAFE_ALPHABET[Math.floor(Math.random() * SAFE_ALPHABET.length)]; + } + return code; +} diff --git a/packages/channels/base/src/SenderGate.ts b/packages/channels/base/src/SenderGate.ts index b6a338c86..ec9e4e586 100644 --- a/packages/channels/base/src/SenderGate.ts +++ b/packages/channels/base/src/SenderGate.ts @@ -1,23 +1,50 @@ import type { SenderPolicy } from './types.js'; +import type { PairingStore } from './PairingStore.js'; + +export interface SenderCheckResult { + allowed: boolean; + pairingCode?: string | null; // set when pairing policy returns a code (null = cap reached) +} export class SenderGate { private policy: SenderPolicy; private allowedUsers: Set; + private pairingStore: PairingStore | null; - constructor(policy: SenderPolicy, allowedUsers: string[] = []) { + constructor( + policy: SenderPolicy, + allowedUsers: string[] = [], + pairingStore?: PairingStore, + ) { this.policy = policy; this.allowedUsers = new Set(allowedUsers); + this.pairingStore = pairingStore || null; } - check(senderId: string): boolean { + check(senderId: string, senderName?: string): SenderCheckResult { switch (this.policy) { case 'open': - return true; + return { allowed: true }; case 'allowlist': - return this.allowedUsers.has(senderId); - case 'pairing': - // Pairing will be implemented later; for now, treat as allowlist - return this.allowedUsers.has(senderId); + return { allowed: this.allowedUsers.has(senderId) }; + case 'pairing': { + // Check static allowlist first + if (this.allowedUsers.has(senderId)) { + return { allowed: true }; + } + // Check dynamic approved list + if (this.pairingStore?.isApproved(senderId)) { + return { allowed: true }; + } + // Generate pairing code + const code = this.pairingStore?.createRequest( + senderId, + senderName || senderId, + ); + return { allowed: false, pairingCode: code ?? null }; + } + default: + throw new Error(`Unknown sender policy: ${this.policy}`); } } } diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 71dfaefa6..d33a06880 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -5,7 +5,10 @@ export type { ToolCallEvent, } from './AcpBridge.js'; export { ChannelBase } from './ChannelBase.js'; +export { PairingStore } from './PairingStore.js'; +export type { PairingRequest } from './PairingStore.js'; export { SenderGate } from './SenderGate.js'; +export type { SenderCheckResult } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; export type { ChannelConfig, diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 190923d20..2d259a78e 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -1,5 +1,21 @@ import type { CommandModule, Argv } from 'yargs'; import { startCommand } from './channel/start.js'; +import { + pairingListCommand, + pairingApproveCommand, +} from './channel/pairing.js'; + +const pairingCommand: CommandModule = { + command: 'pairing', + describe: 'Manage DM pairing requests', + builder: (yargs: Argv) => + yargs + .command(pairingListCommand) + .command(pairingApproveCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => {}, +}; export const channelCommand: CommandModule = { command: 'channel', @@ -7,6 +23,7 @@ export const channelCommand: CommandModule = { builder: (yargs: Argv) => yargs .command(startCommand) + .command(pairingCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => {}, diff --git a/packages/cli/src/commands/channel/pairing.ts b/packages/cli/src/commands/channel/pairing.ts new file mode 100644 index 000000000..b61b0622e --- /dev/null +++ b/packages/cli/src/commands/channel/pairing.ts @@ -0,0 +1,66 @@ +import type { CommandModule } from 'yargs'; +import { PairingStore } from '@qwen-code/channel-base'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; + +export const pairingListCommand: CommandModule = { + command: 'list ', + describe: 'List pending pairing requests for a channel', + builder: (yargs) => + yargs.positional('name', { + type: 'string', + describe: 'Channel name', + demandOption: true, + }), + handler: (argv) => { + const store = new PairingStore(argv.name); + const pending = store.listPending(); + + if (pending.length === 0) { + writeStdoutLine('No pending pairing requests.'); + return; + } + + writeStdoutLine(`Pending pairing requests for "${argv.name}":\n`); + for (const req of pending) { + const ago = Math.round((Date.now() - req.createdAt) / 60000); + writeStdoutLine( + ` Code: ${req.code} Sender: ${req.senderName} (${req.senderId}) ${ago}m ago`, + ); + } + }, +}; + +export const pairingApproveCommand: CommandModule< + object, + { name: string; code: string } +> = { + command: 'approve ', + describe: 'Approve a pending pairing request', + builder: (yargs) => + yargs + .positional('name', { + type: 'string', + describe: 'Channel name', + demandOption: true, + }) + .positional('code', { + type: 'string', + describe: 'Pairing code', + demandOption: true, + }), + handler: (argv) => { + const store = new PairingStore(argv.name); + const request = store.approve(argv.code); + + if (!request) { + writeStderrLine( + `No pending request found for code "${argv.code.toUpperCase()}". It may have expired.`, + ); + process.exit(1); + } + + writeStdoutLine( + `Approved: ${request.senderName} (${request.senderId}) can now use channel "${argv.name}".`, + ); + }, +}; From 90236465d385c5b02432b569a0fb0b301a0865e0 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 25 Mar 2026 02:04:27 +0000 Subject: [PATCH 09/51] feat(channels): add group chat support for Telegram - Add GroupGate class for group access control with three policies: disabled (default), allowlist, and open - Implement mention gating: bot only responds when @mentioned or replied to in groups (configurable per-group) - Extend Envelope type with isGroup, isMentioned, isReplyToBot fields - Update TelegramAdapter to detect group context and mentions - Add comprehensive documentation for group chat setup and troubleshooting This enables using Qwen Code bots in Telegram groups with fine-grained access control and mention-based activation to prevent noise. Co-authored-by: Qwen-Coder --- docs/users/features/channels/overview.md | 86 ++++++++++++++++--- docs/users/features/channels/telegram.md | 25 +++++- packages/channels/base/src/AcpBridge.ts | 9 +- packages/channels/base/src/ChannelBase.ts | 11 +++ packages/channels/base/src/GroupGate.ts | 57 ++++++++++++ packages/channels/base/src/index.ts | 4 + packages/channels/base/src/types.ts | 10 +++ .../channels/telegram/src/TelegramAdapter.ts | 39 ++++++++- packages/cli/src/commands/channel/start.ts | 4 + 9 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 packages/channels/base/src/GroupGate.ts diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index a4ea55d3d..351ecbeaf 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -33,7 +33,11 @@ Channels are configured under the `channels` key in `settings.json`. Each channe "allowedUsers": ["123456789"], "sessionScope": "user", "cwd": "/path/to/working/directory", - "instructions": "Optional system instructions for the agent." + "instructions": "Optional system instructions for the agent.", + "groupPolicy": "disabled", + "groups": { + "*": { "requireMention": true } + } } } } @@ -41,15 +45,17 @@ Channels are configured under the `channels` key in `settings.json`. Each channe ### Options -| Option | Required | Description | -| -------------- | -------- | ------------------------------------------------------------------------------------ | -| `type` | Yes | Channel type: `telegram` (more coming soon) | -| `token` | Yes | Bot token. Supports `$ENV_VAR` syntax to read from environment variables | -| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | -| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | -| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | -| `cwd` | No | Working directory for the agent. Defaults to the current directory | -| `instructions` | No | Custom instructions prepended to the first message of each session | +| Option | Required | Description | +| -------------- | -------- | -------------------------------------------------------------------------------------------------- | +| `type` | Yes | Channel type: `telegram` (more coming soon) | +| `token` | Yes | Bot token. Supports `$ENV_VAR` syntax to read from environment variables | +| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | +| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | +| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | +| `cwd` | No | Working directory for the agent. Defaults to the current directory | +| `instructions` | No | Custom instructions prepended to the first message of each session | +| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | +| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | ### Sender Policy @@ -112,6 +118,66 @@ qwen channel pairing approve my-channel - Users listed in `allowedUsers` in `settings.json` always skip pairing - Approved users are stored in `~/.qwen/channels/-allowlist.json` — treat this file as sensitive +## Group Chats + +By default, the bot only works in direct messages. To enable group chat support, set `groupPolicy` to `"allowlist"` or `"open"`. + +### Group Policy + +Controls whether the bot participates in group chats at all: + +- **`disabled`** (default) — The bot ignores all group messages. Safest option. +- **`allowlist`** — The bot only responds in groups explicitly listed in `groups` by chat ID. The `"*"` key provides default settings but does **not** act as a wildcard allow. +- **`open`** — The bot responds in all groups it's added to. Use with caution. + +### Mention Gating + +In groups, the bot requires an `@mention` or a reply to one of its messages by default. This prevents the bot from responding to every message in a group chat. + +Configure per-group with the `groups` setting: + +```json +{ + "groups": { + "*": { "requireMention": true }, + "-100123456": { "requireMention": false } + } +} +``` + +- **`"*"`** — Default settings for all groups. Only sets config defaults, not an allowlist entry. +- **Group chat ID** — Override settings for a specific group. Overrides `"*"` defaults. +- **`requireMention`** (default: `true`) — When `true`, the bot only responds to messages that @mention it or reply to one of its messages. When `false`, the bot responds to all messages (useful for dedicated task groups). + +### How group messages are evaluated + +``` +1. groupPolicy — is this group allowed? (no → ignore) +2. requireMention — was the bot mentioned/replied to? (no → ignore) +3. senderPolicy — is this sender approved? (no → pairing flow) +4. Route to session +``` + +### Telegram Setup for Groups + +1. Add the bot to a group +2. **Disable privacy mode** in BotFather (`/mybots` → Bot Settings → Group Privacy → Turn Off) — otherwise the bot won't see non-command messages +3. **Remove and re-add the bot** to the group after changing privacy mode (Telegram caches this setting) + +### Finding a Group Chat ID + +To find a group's chat ID for the `groups` allowlist: + +1. Stop the bot if it's running +2. Send a message mentioning the bot in the group +3. Use the Telegram Bot API to check queued updates: + +```bash +curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates" | python3 -m json.tool +``` + +Look for `message.chat.id` in the response — group IDs are negative numbers (e.g., `-5170296765`). + ## Slash Commands Channels support slash commands. Some are handled locally by the adapter: diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md index 197fcdb5d..c8ddba38c 100644 --- a/docs/users/features/channels/telegram.md +++ b/docs/users/features/channels/telegram.md @@ -36,7 +36,11 @@ Add the channel to `~/.qwen/settings.json`: "allowedUsers": ["YOUR_USER_ID"], "sessionScope": "user", "cwd": "/path/to/your/project", - "instructions": "You are a concise coding assistant responding via Telegram. Keep responses short." + "instructions": "You are a concise coding assistant responding via Telegram. Keep responses short.", + "groupPolicy": "disabled", + "groups": { + "*": { "requireMention": true } + } } } } @@ -58,6 +62,17 @@ qwen channel start my-telegram Then open your bot in Telegram and send a message. You should see "Working..." appear immediately, followed by the agent's response. +## Group Chats + +To use the bot in Telegram groups: + +1. Set `groupPolicy` to `"allowlist"` or `"open"` in your channel config +2. **Disable privacy mode** in BotFather: `/mybots` → select your bot → Bot Settings → Group Privacy → Turn Off +3. Add the bot to a group. If it was already in the group, **remove and re-add it** (Telegram caches privacy settings from when the bot joined) +4. If using `groupPolicy: "allowlist"`, add the group's chat ID to `groups` in your config + +By default, the bot requires an @mention or a reply to respond in groups. Set `"requireMention": false` for a specific group to make it respond to all messages (useful for dedicated task groups). See [Group Chats](./overview#group-chats) for full details. + ## Tips - **Keep instructions concise-focused** — Telegram has a 4096-character message limit. Adding instructions like "keep responses short" helps the agent stay within bounds. @@ -76,6 +91,14 @@ The agent's markdown responses are automatically converted to Telegram-compatibl - Verify your user ID is in `allowedUsers` if using `senderPolicy: "allowlist"`, or that you've been approved if using `"pairing"` - Check the terminal output for errors +### Bot doesn't respond in groups + +- Check that `groupPolicy` is set to `"allowlist"` or `"open"` (default is `"disabled"`) +- If using `"allowlist"`, verify the group's chat ID is in the `groups` config +- Make sure **Group Privacy is turned off** in BotFather — without this, the bot can't see non-command messages in groups +- If you changed privacy mode after adding the bot to a group, **remove and re-add the bot** to the group +- By default, the bot requires an @mention or a reply. Send `@yourbotname hello` to test + ### "Sorry, something went wrong processing your message" This usually means the agent encountered an error. Check the terminal output for details. diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index 348fe38a3..502f4955f 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -61,13 +61,13 @@ export class AcpBridge extends EventEmitter { this.child.stderr?.on('data', (data: Buffer) => { const msg = data.toString().trim(); if (msg) { - console.error('[AcpBridge]', msg); + process.stderr.write(`[AcpBridge] ${msg}\n`); } }); this.child.on('exit', (code, signal) => { - console.error( - `[AcpBridge] Process exited (code=${code}, signal=${signal})`, + process.stderr.write( + `[AcpBridge] Process exited (code=${code}, signal=${signal})\n`, ); this.connection = null; this.child = null; @@ -197,6 +197,9 @@ export class AcpBridge extends EventEmitter { } break; } + default: + // Ignore other session update types + break; } this.emit('sessionUpdate', params); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index a48e96992..fe59dbeaa 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -1,4 +1,5 @@ import type { ChannelConfig, Envelope } from './types.js'; +import { GroupGate } from './GroupGate.js'; import { SenderGate } from './SenderGate.js'; import { PairingStore } from './PairingStore.js'; import { SessionRouter } from './SessionRouter.js'; @@ -7,6 +8,7 @@ import type { AcpBridge, ToolCallEvent } from './AcpBridge.js'; export abstract class ChannelBase { protected config: ChannelConfig; protected bridge: AcpBridge; + protected groupGate: GroupGate; protected gate: SenderGate; protected router: SessionRouter; protected name: string; @@ -17,6 +19,8 @@ export abstract class ChannelBase { this.config = config; this.bridge = bridge; + this.groupGate = new GroupGate(config.groupPolicy, config.groups); + const pairingStore = config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined; this.gate = new SenderGate( @@ -41,6 +45,13 @@ export abstract class ChannelBase { onToolCall(_chatId: string, _event: ToolCallEvent): void {} async handleInbound(envelope: Envelope): Promise { + // 1. Group gate: policy + allowlist + mention gating + const groupResult = this.groupGate.check(envelope); + if (!groupResult.allowed) { + return; // silently drop — no pairing, no reply + } + + // 2. Sender gate: allowlist / pairing / open const result = this.gate.check(envelope.senderId, envelope.senderName); if (!result.allowed) { if (result.pairingCode !== undefined) { diff --git a/packages/channels/base/src/GroupGate.ts b/packages/channels/base/src/GroupGate.ts new file mode 100644 index 000000000..82b9754e6 --- /dev/null +++ b/packages/channels/base/src/GroupGate.ts @@ -0,0 +1,57 @@ +import type { GroupPolicy, GroupConfig, Envelope } from './types.js'; + +export interface GroupCheckResult { + allowed: boolean; + reason?: 'disabled' | 'not_allowlisted' | 'mention_required'; +} + +export class GroupGate { + private policy: GroupPolicy; + private groups: Record; + + constructor( + policy: GroupPolicy = 'disabled', + groups: Record = {}, + ) { + this.policy = policy; + this.groups = groups; + } + + /** + * Full group check: policy + allowlist + mention gating. + * Evaluation order: + * 1. groupPolicy (disabled → drop) + * 2. group allowlist (allowlist mode, no match → drop) + * 3. mention gating (requireMention + not mentioned → drop silently) + * + * Mention gating runs before sender gate so that unmentioned messages + * in groups don't trigger pairing flows. + */ + check(envelope: Envelope): GroupCheckResult { + if (!envelope.isGroup) { + return { allowed: true }; + } + + if (this.policy === 'disabled') { + return { allowed: false, reason: 'disabled' }; + } + + if (this.policy === 'allowlist') { + // In allowlist mode, "*" is only a default config — not a wildcard allow. + // The group must be explicitly listed by ID. + if (!this.groups[envelope.chatId]) { + return { allowed: false, reason: 'not_allowlisted' }; + } + } + + // Per-group config, falling back to "*" defaults, then built-in defaults + const groupConfig = this.groups[envelope.chatId] || this.groups['*'] || {}; + const requireMention = groupConfig.requireMention ?? true; + + if (requireMention && !envelope.isMentioned && !envelope.isReplyToBot) { + return { allowed: false, reason: 'mention_required' }; + } + + return { allowed: true }; + } +} diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index d33a06880..da085cf60 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -7,6 +7,8 @@ export type { export { ChannelBase } from './ChannelBase.js'; export { PairingStore } from './PairingStore.js'; export type { PairingRequest } from './PairingStore.js'; +export { GroupGate } from './GroupGate.js'; +export type { GroupCheckResult } from './GroupGate.js'; export { SenderGate } from './SenderGate.js'; export type { SenderCheckResult } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; @@ -14,6 +16,8 @@ export type { ChannelConfig, ChannelType, Envelope, + GroupConfig, + GroupPolicy, SenderPolicy, SessionScope, SessionTarget, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 5d57ef3ce..dd849ab3b 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -1,6 +1,11 @@ export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; export type SessionScope = 'user' | 'thread' | 'single'; export type ChannelType = 'telegram' | 'discord' | 'webhook'; +export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; + +export interface GroupConfig { + requireMention?: boolean; // default: true +} export interface ChannelConfig { type: ChannelType; @@ -11,6 +16,8 @@ export interface ChannelConfig { cwd: string; approvalMode?: string; instructions?: string; + groupPolicy: GroupPolicy; // default: "disabled" + groups: Record; // "*" for defaults, group IDs for overrides } export interface Envelope { @@ -20,6 +27,9 @@ export interface Envelope { chatId: string; text: string; threadId?: string; + isGroup: boolean; + isMentioned: boolean; + isReplyToBot: boolean; } export interface SessionTarget { diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 4b063c063..5c76a1088 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -15,6 +15,8 @@ const LOCAL_COMMANDS = new Set(['start', 'help', 'reset']); export class TelegramChannel extends ChannelBase { private bot: Telegraf; + private botId: number = 0; + private botUsername: string = ''; constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { super(name, config, bridge); @@ -22,6 +24,9 @@ export class TelegramChannel extends ChannelBase { } async connect(): Promise { + const botInfo = await this.bot.telegram.getMe(); + this.botId = botInfo.id; + this.botUsername = botInfo.username ?? ''; // Register local-only commands this.bot.command('start', async (ctx) => { await ctx.reply( @@ -74,6 +79,22 @@ export class TelegramChannel extends ChannelBase { } } + const isGroup = + msg.chat.type === 'group' || msg.chat.type === 'supergroup'; + + // Check if the bot is mentioned via @username in message entities + const isMentioned = + msg.entities?.some( + (e) => + e.type === 'mention' && + this.botUsername && + text.slice(e.offset, e.offset + e.length).toLowerCase() === + `@${this.botUsername.toLowerCase()}`, + ) ?? false; + + // Check if this is a reply to one of the bot's messages + const isReplyToBot = msg.reply_to_message?.from?.id === this.botId; + const envelope: Envelope = { channelName: this.name, senderId: String(msg.from.id), @@ -82,11 +103,16 @@ export class TelegramChannel extends ChannelBase { (msg.from.last_name ? ` ${msg.from.last_name}` : ''), chatId: String(msg.chat.id), text, + isGroup, + isMentioned, + isReplyToBot, }; // Don't await — Telegraf has a 90s handler timeout that would kill long prompts this.handleInbound(envelope).catch((err) => { - console.error(`[Telegram:${this.name}] Error handling message:`, err); + process.stderr.write( + `[Telegram:${this.name}] Error handling message: ${err}\n`, + ); ctx .reply('Sorry, something went wrong processing your message.') .catch(() => {}); @@ -94,7 +120,9 @@ export class TelegramChannel extends ChannelBase { }); this.bot.launch({ dropPendingUpdates: true }).catch((err) => { - console.error(`[Telegram:${this.name}] Bot launch error:`, err); + process.stderr.write( + `[Telegram:${this.name}] Bot launch error: ${err}\n`, + ); }); process.once('SIGINT', () => this.bot.stop('SIGINT')); @@ -102,6 +130,12 @@ export class TelegramChannel extends ChannelBase { } override async handleInbound(envelope: Envelope): Promise { + // Check group gate before showing "Working..." indicator + const groupResult = this.groupGate.check(envelope); + if (!groupResult.allowed) { + return; + } + // Send "Working..." immediately for instant feedback const workingMsg = await this.bot.telegram .sendMessage(envelope.chatId, 'Working...') @@ -138,5 +172,4 @@ export class TelegramChannel extends ChannelBase { disconnect(): void { this.bot.stop(); } - } diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index a26d07ff7..01443facb 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -99,6 +99,10 @@ export const startCommand: CommandModule = { cwd: (rawConfig['cwd'] as string) || process.cwd(), approvalMode: rawConfig['approvalMode'] as string | undefined, instructions: rawConfig['instructions'] as string | undefined, + groupPolicy: + (rawConfig['groupPolicy'] as ChannelConfig['groupPolicy']) || + 'disabled', + groups: (rawConfig['groups'] as ChannelConfig['groups']) || {}, }; const cliEntryPath = findCliEntryPath(); From 24c9b0f33357247e34090de74eee4ef56612b4b4 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 25 Mar 2026 06:54:51 +0000 Subject: [PATCH 10/51] feat(channels): add WeChat/Weixin channel support - Add WeixinAdapter with accounts, api, login, monitor, send modules - Add channel configure command for interactive setup - Update TelegramAdapter for consistency Co-authored-by: Qwen-Coder --- package-lock.json | 18 ++- package.json | 3 +- packages/channels/base/src/types.ts | 2 +- .../channels/telegram/src/TelegramAdapter.ts | 10 +- packages/channels/weixin/package.json | 19 +++ packages/channels/weixin/src/WeixinAdapter.ts | 141 ++++++++++++++++ packages/channels/weixin/src/accounts.ts | 61 +++++++ packages/channels/weixin/src/api.ts | 128 +++++++++++++++ packages/channels/weixin/src/index.ts | 1 + packages/channels/weixin/src/login.ts | 112 +++++++++++++ packages/channels/weixin/src/monitor.ts | 152 ++++++++++++++++++ packages/channels/weixin/src/send.ts | 52 ++++++ packages/channels/weixin/src/types.ts | 128 +++++++++++++++ packages/channels/weixin/tsconfig.json | 10 ++ packages/cli/package.json | 1 + packages/cli/src/commands/channel.ts | 2 + .../cli/src/commands/channel/configure.ts | 85 ++++++++++ packages/cli/src/commands/channel/start.ts | 50 ++++-- 18 files changed, 954 insertions(+), 21 deletions(-) create mode 100644 packages/channels/weixin/package.json create mode 100644 packages/channels/weixin/src/WeixinAdapter.ts create mode 100644 packages/channels/weixin/src/accounts.ts create mode 100644 packages/channels/weixin/src/api.ts create mode 100644 packages/channels/weixin/src/index.ts create mode 100644 packages/channels/weixin/src/login.ts create mode 100644 packages/channels/weixin/src/monitor.ts create mode 100644 packages/channels/weixin/src/send.ts create mode 100644 packages/channels/weixin/src/types.ts create mode 100644 packages/channels/weixin/tsconfig.json create mode 100644 packages/cli/src/commands/channel/configure.ts diff --git a/package-lock.json b/package-lock.json index 5acd6df32..b5ef4afbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "workspaces": [ "packages/*", "packages/channels/base", - "packages/channels/telegram" + "packages/channels/telegram", + "packages/channels/weixin" ], "dependencies": { "@testing-library/dom": "^10.4.1", @@ -3000,6 +3001,10 @@ "resolved": "packages/channels/telegram", "link": true }, + "node_modules/@qwen-code/channel-weixin": { + "resolved": "packages/channels/weixin", + "link": true + }, "node_modules/@qwen-code/qwen-code": { "resolved": "packages/cli", "link": true @@ -18928,6 +18933,16 @@ "typescript": "^5.0.0" } }, + "packages/channels/weixin": { + "name": "@qwen-code/channel-weixin", + "version": "0.1.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, "packages/cli": { "name": "@qwen-code/qwen-code", "version": "0.13.0", @@ -18938,6 +18953,7 @@ "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/channel-base": "file:../channels/base", "@qwen-code/channel-telegram": "file:../channels/telegram", + "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/package.json b/package.json index 5017b94bd..55d0ac11e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "workspaces": [ "packages/*", "packages/channels/base", - "packages/channels/telegram" + "packages/channels/telegram", + "packages/channels/weixin" ], "repository": { "type": "git", diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index dd849ab3b..aaa4a6508 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -1,6 +1,6 @@ export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; export type SessionScope = 'user' | 'thread' | 'single'; -export type ChannelType = 'telegram' | 'discord' | 'webhook'; +export type ChannelType = 'telegram' | 'weixin' | 'discord' | 'webhook'; export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; export interface GroupConfig { diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 5c76a1088..353e71363 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -95,6 +95,14 @@ export class TelegramChannel extends ChannelBase { // Check if this is a reply to one of the bot's messages const isReplyToBot = msg.reply_to_message?.from?.id === this.botId; + // Strip @botname from message text so the agent only sees the actual prompt + let cleanText = text; + if (isMentioned && this.botUsername) { + cleanText = text + .replace(new RegExp(`@${this.botUsername}`, 'gi'), '') + .trim(); + } + const envelope: Envelope = { channelName: this.name, senderId: String(msg.from.id), @@ -102,7 +110,7 @@ export class TelegramChannel extends ChannelBase { msg.from.first_name + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), chatId: String(msg.chat.id), - text, + text: cleanText, isGroup, isMentioned, isReplyToBot, diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json new file mode 100644 index 000000000..29adf6afe --- /dev/null +++ b/packages/channels/weixin/package.json @@ -0,0 +1,19 @@ +{ + "name": "@qwen-code/channel-weixin", + "version": "0.1.0", + "description": "WeChat (Weixin) channel adapter for Qwen Code", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./accounts": "./src/accounts.ts", + "./login": "./src/login.ts" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts new file mode 100644 index 000000000..9254a601c --- /dev/null +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -0,0 +1,141 @@ +/** + * WeChat channel adapter for Qwen Code. + * Extends ChannelBase with WeChat iLink Bot API integration. + */ + +import { ChannelBase } from '@qwen-code/channel-base'; +import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; +import type { AcpBridge } from '@qwen-code/channel-base'; +import { loadAccount, DEFAULT_BASE_URL } from './accounts.js'; +import { startPollLoop, getContextToken } from './monitor.js'; +import { sendText } from './send.js'; +import { getConfig, sendTyping } from './api.js'; +import { TypingStatus } from './types.js'; + +/** In-memory typing ticket cache: userId -> typingTicket */ +const typingTickets = new Map(); + +export class WeixinChannel extends ChannelBase { + private abortController: AbortController | null = null; + private baseUrl: string; + private token: string = ''; + + constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { + super(name, config, bridge); + this.baseUrl = + (config as ChannelConfig & { baseUrl?: string }).baseUrl || + DEFAULT_BASE_URL; + } + + async connect(): Promise { + const account = loadAccount(); + if (!account) { + throw new Error( + 'WeChat account not configured. Run "qwen channel configure-weixin" first.', + ); + } + this.token = account.token; + if (account.baseUrl) { + this.baseUrl = account.baseUrl; + } + + this.abortController = new AbortController(); + + startPollLoop({ + baseUrl: this.baseUrl, + token: this.token, + onMessage: async (msg) => { + const envelope: Envelope = { + channelName: this.name, + senderId: msg.fromUserId, + senderName: msg.fromUserId, + chatId: msg.fromUserId, + text: msg.text, + isGroup: false, + isMentioned: false, + isReplyToBot: false, + }; + + this.handleInbound(envelope).catch((err) => { + const errMsg = + err instanceof Error ? err.message : JSON.stringify(err, null, 2); + process.stderr.write( + `[Weixin:${this.name}] Error handling message: ${errMsg}\n`, + ); + }); + }, + abortSignal: this.abortController.signal, + }).catch((err) => { + if (!this.abortController?.signal.aborted) { + process.stderr.write(`[Weixin:${this.name}] Poll loop error: ${err}\n`); + } + }); + + process.stderr.write( + `[Weixin:${this.name}] Connected to WeChat (${this.baseUrl})\n`, + ); + } + + override async handleInbound(envelope: Envelope): Promise { + // Check group gate before showing typing + const groupResult = this.groupGate.check(envelope); + if (!groupResult.allowed) { + return; + } + + // Show typing indicator while agent processes + await this.setTyping(envelope.chatId, true); + + try { + await super.handleInbound(envelope); + } finally { + await this.setTyping(envelope.chatId, false); + } + } + + async sendMessage(chatId: string, text: string): Promise { + const contextToken = getContextToken(chatId) || ''; + await sendText({ + to: chatId, + text, + baseUrl: this.baseUrl, + token: this.token, + contextToken, + }); + } + + disconnect(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + private async setTyping(userId: string, typing: boolean): Promise { + try { + let ticket = typingTickets.get(userId); + if (!ticket) { + const contextToken = getContextToken(userId); + const config = await getConfig( + this.baseUrl, + this.token, + userId, + contextToken, + ); + if (config.typing_ticket) { + ticket = config.typing_ticket; + typingTickets.set(userId, ticket); + } + } + if (!ticket) return; + + await sendTyping(this.baseUrl, this.token, { + ilink_user_id: userId, + typing_ticket: ticket, + status: typing ? TypingStatus.TYPING : TypingStatus.CANCEL, + }); + } catch { + // Typing is best-effort — don't fail the message flow + } + } +} diff --git a/packages/channels/weixin/src/accounts.ts b/packages/channels/weixin/src/accounts.ts new file mode 100644 index 000000000..c505b06ce --- /dev/null +++ b/packages/channels/weixin/src/accounts.ts @@ -0,0 +1,61 @@ +/** + * Credential storage for WeChat account. + * Stores account data in ~/.qwen/channels/weixin/ + */ + +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, + chmodSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'; + +export interface AccountData { + token: string; + baseUrl: string; + userId?: string; + savedAt: string; +} + +export function getStateDir(): string { + const dir = + process.env['WEIXIN_STATE_DIR'] || + join(homedir(), '.qwen', 'channels', 'weixin'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; +} + +function accountPath(): string { + return join(getStateDir(), 'account.json'); +} + +export function loadAccount(): AccountData | null { + const p = accountPath(); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf-8')) as AccountData; + } catch { + return null; + } +} + +export function saveAccount(data: AccountData): void { + const p = accountPath(); + writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); + chmodSync(p, 0o600); +} + +export function clearAccount(): void { + const p = accountPath(); + if (existsSync(p)) { + unlinkSync(p); + } +} diff --git a/packages/channels/weixin/src/api.ts b/packages/channels/weixin/src/api.ts new file mode 100644 index 000000000..f45ccfb30 --- /dev/null +++ b/packages/channels/weixin/src/api.ts @@ -0,0 +1,128 @@ +/** + * HTTP API wrapper for WeChat iLink Bot API. + */ + +import type { + GetUpdatesReq, + GetUpdatesResp, + SendMessageReq, + GetConfigResp, + SendTypingReq, + SendTypingResp, + BaseInfo, +} from './types.js'; + +const CHANNEL_VERSION = '0.1.0'; + +function baseInfo(): BaseInfo { + return { channel_version: CHANNEL_VERSION }; +} + +function randomUin(): string { + const buf = new Uint8Array(4); + crypto.getRandomValues(buf); + return btoa(String.fromCharCode(...buf)); +} + +function buildHeaders(token?: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-WECHAT-UIN': randomUin(), + }; + if (token) { + headers['AuthorizationType'] = 'ilink_bot_token'; + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +async function post( + baseUrl: string, + path: string, + body: unknown, + token?: string, + timeoutMs = 40000, + signal?: AbortSignal, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + if (signal) { + signal.addEventListener('abort', () => controller.abort(), { once: true }); + } + + try { + const resp = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: buildHeaders(token), + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + return (await resp.json()) as T; + } finally { + clearTimeout(timeout); + } +} + +export async function getUpdates( + baseUrl: string, + token: string, + getUpdatesBuf: string, + timeoutMs = 40000, + signal?: AbortSignal, +): Promise { + const body: GetUpdatesReq = { + get_updates_buf: getUpdatesBuf, + base_info: baseInfo(), + }; + try { + return await post( + baseUrl, + '/ilink/bot/getupdates', + body, + token, + timeoutMs, + signal, + ); + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') { + return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf }; + } + throw err; + } +} + +export async function sendMessage( + baseUrl: string, + token: string, + msg: SendMessageReq['msg'], +): Promise { + const body: SendMessageReq = { msg, base_info: baseInfo() }; + await post(baseUrl, '/ilink/bot/sendmessage', body, token); +} + +export async function getConfig( + baseUrl: string, + token: string, + userId: string, + contextToken?: string, +): Promise { + const body = { + ilink_user_id: userId, + context_token: contextToken, + base_info: baseInfo(), + }; + return post(baseUrl, '/ilink/bot/getconfig', body, token); +} + +export async function sendTyping( + baseUrl: string, + token: string, + req: Omit, +): Promise { + const body: SendTypingReq = { ...req, base_info: baseInfo() }; + return post(baseUrl, '/ilink/bot/sendtyping', body, token); +} diff --git a/packages/channels/weixin/src/index.ts b/packages/channels/weixin/src/index.ts new file mode 100644 index 000000000..9eec24cc6 --- /dev/null +++ b/packages/channels/weixin/src/index.ts @@ -0,0 +1 @@ +export { WeixinChannel } from './WeixinAdapter.js'; diff --git a/packages/channels/weixin/src/login.ts b/packages/channels/weixin/src/login.ts new file mode 100644 index 000000000..8771b2373 --- /dev/null +++ b/packages/channels/weixin/src/login.ts @@ -0,0 +1,112 @@ +/** + * QR code login flow for WeChat iLink Bot. + */ + +export interface LoginResult { + connected: boolean; + token?: string; + baseUrl?: string; + userId?: string; + message: string; +} + +/** Step 1: Get QR code from server and display in terminal */ +export async function startLogin(apiBaseUrl: string): Promise { + const resp = await fetch(`${apiBaseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`); + if (!resp.ok) { + throw new Error(`Failed to get QR code: HTTP ${resp.status}`); + } + const data = (await resp.json()) as { + qrcode?: string; + qrcode_img_content?: string; + }; + + if (!data.qrcode) { + throw new Error('No qrcode in response'); + } + + if (data.qrcode_img_content) { + process.stderr.write( + `QR code URL: ${data.qrcode_img_content}\nScan this URL with WeChat.\n`, + ); + } + + process.stderr.write('Scan the QR code with WeChat to connect.\n'); + return data.qrcode; +} + +/** Step 2: Poll for scan result */ +export async function waitForLogin(params: { + qrcodeId: string; + apiBaseUrl: string; + timeoutMs?: number; +}): Promise { + const { apiBaseUrl, timeoutMs = 480000 } = params; + let currentQrcodeId = params.qrcodeId; + const deadline = Date.now() + timeoutMs; + let retryCount = 0; + + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60000); + + const resp = await fetch( + `${apiBaseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(currentQrcodeId)}`, + { + headers: { 'iLink-App-ClientVersion': '1' }, + signal: controller.signal, + }, + ); + clearTimeout(timeout); + + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + + const data = (await resp.json()) as { + status?: string; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + ilink_user_id?: string; + }; + + switch (data.status) { + case 'confirmed': + return { + connected: true, + token: data.bot_token, + baseUrl: data.baseurl, + userId: data.ilink_user_id, + message: 'Connected to WeChat successfully!', + }; + case 'scaned': + process.stderr.write( + 'QR code scanned, waiting for confirmation...\n', + ); + break; + case 'expired': + retryCount++; + if (retryCount >= 3) { + return { + connected: false, + message: 'QR code expired after maximum retries.', + }; + } + process.stderr.write('QR code expired, refreshing...\n'); + currentQrcodeId = await startLogin(apiBaseUrl); + break; + default: + break; + } + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') { + continue; + } + throw err; + } + + await new Promise((r) => setTimeout(r, 1000)); + } + + return { connected: false, message: 'Login timed out.' }; +} diff --git a/packages/channels/weixin/src/monitor.ts b/packages/channels/weixin/src/monitor.ts new file mode 100644 index 000000000..e4a61ab71 --- /dev/null +++ b/packages/channels/weixin/src/monitor.ts @@ -0,0 +1,152 @@ +/** + * Long-polling loop: getUpdates -> callback. + * Platform-agnostic: the onMessage callback handles delivery. + */ + +import { getUpdates } from './api.js'; +import { MessageType, MessageItemType } from './types.js'; +import type { WeixinMessage } from './types.js'; +import { getStateDir } from './accounts.js'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** In-memory context token cache: userId -> contextToken */ +const contextTokens = new Map(); + +export function getContextToken(userId: string): string | undefined { + return contextTokens.get(userId); +} + +function cursorPath(): string { + return join(getStateDir(), 'cursor.txt'); +} + +function loadCursor(): string { + const p = cursorPath(); + if (existsSync(p)) return readFileSync(p, 'utf-8').trim(); + return ''; +} + +function saveCursor(cursor: string): void { + writeFileSync(cursorPath(), cursor, 'utf-8'); +} + +export interface ParsedMessage { + fromUserId: string; + messageId: string; + text: string; +} + +export type OnMessageCallback = (msg: ParsedMessage) => Promise; + +export async function startPollLoop(params: { + baseUrl: string; + token: string; + onMessage: OnMessageCallback; + abortSignal: AbortSignal; +}): Promise { + const { baseUrl, token, onMessage, abortSignal } = params; + + let cursor = loadCursor(); + let consecutiveErrors = 0; + let pollTimeoutMs = 40000; + + process.stderr.write('[weixin] Starting message poll loop...\n'); + + while (!abortSignal.aborted) { + try { + const resp = await getUpdates( + baseUrl, + token, + cursor, + pollTimeoutMs, + abortSignal, + ); + + if (resp.errcode === -14) { + process.stderr.write( + '[weixin] Session expired (errcode -14). Pausing 30s...\n', + ); + await new Promise((r) => setTimeout(r, 30000)); + continue; + } + + if (resp.ret !== 0 && resp.ret !== undefined) { + throw new Error( + `getUpdates error: ret=${resp.ret} errcode=${resp.errcode} ${resp.errmsg}`, + ); + } + + consecutiveErrors = 0; + + if (resp.get_updates_buf) { + cursor = resp.get_updates_buf; + saveCursor(cursor); + } + + // Respect server-suggested poll timeout + if (resp.longpolling_timeout_ms && resp.longpolling_timeout_ms > 0) { + pollTimeoutMs = resp.longpolling_timeout_ms + 5000; // add buffer for network + } + + if (resp.msgs && resp.msgs.length > 0) { + for (const msg of resp.msgs) { + await processMessage(msg, onMessage); + } + } + } catch (err: unknown) { + if (abortSignal.aborted) break; + + consecutiveErrors++; + process.stderr.write( + `[weixin] Poll error (${consecutiveErrors}): ${err instanceof Error ? err.message : err}\n`, + ); + + if (consecutiveErrors >= 3) { + process.stderr.write( + '[weixin] Too many consecutive errors, backing off 30s...\n', + ); + await new Promise((r) => setTimeout(r, 30000)); + consecutiveErrors = 0; + } else { + await new Promise((r) => setTimeout(r, 2000)); + } + } + } + + process.stderr.write('[weixin] Poll loop stopped.\n'); +} + +async function processMessage( + msg: WeixinMessage, + onMessage: OnMessageCallback, +): Promise { + if (msg.message_type !== MessageType.USER) return; + + const fromUserId = msg.from_user_id; + if (!fromUserId) return; + + // Cache context token (required for replies) + if (msg.context_token) { + contextTokens.set(fromUserId, msg.context_token); + } + + // Extract text content + let textContent = ''; + if (msg.item_list) { + for (const item of msg.item_list) { + if (item.type === MessageItemType.TEXT && item.text_item?.text) { + textContent += (textContent ? '\n' : '') + item.text_item.text; + } + // MVP: skip media items, text only + } + } + + if (!textContent) return; + + await onMessage({ + fromUserId, + messageId: String(msg.message_id || ''), + text: textContent, + }); +} diff --git a/packages/channels/weixin/src/send.ts b/packages/channels/weixin/src/send.ts new file mode 100644 index 000000000..54ca8fa52 --- /dev/null +++ b/packages/channels/weixin/src/send.ts @@ -0,0 +1,52 @@ +/** + * Send messages to WeChat users. + */ + +import { randomUUID } from 'node:crypto'; +import { sendMessage } from './api.js'; +import { MessageType, MessageState, MessageItemType } from './types.js'; + +/** Convert markdown to plain text (WeChat doesn't support markdown) */ +export function markdownToPlainText(text: string): string { + return text + .replace(/```[\s\S]*?\n([\s\S]*?)```/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/___(.+?)___/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '[$1]') + .replace(/^>\s+/gm, '') + .replace(/^[-*_]{3,}$/gm, '---') + .replace(/^[\s]*[-*+]\s+/gm, '- ') + .replace(/^[\s]*(\d+)\.\s+/gm, '$1. ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Send a text message */ +export async function sendText(params: { + to: string; + text: string; + baseUrl: string; + token: string; + contextToken: string; +}): Promise { + const { to, text, baseUrl, token, contextToken } = params; + const plainText = markdownToPlainText(text); + + await sendMessage(baseUrl, token, { + to_user_id: to, + from_user_id: '', + client_id: randomUUID(), + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: contextToken, + item_list: [{ type: MessageItemType.TEXT, text_item: { text: plainText } }], + }); +} diff --git a/packages/channels/weixin/src/types.ts b/packages/channels/weixin/src/types.ts new file mode 100644 index 000000000..7f2191b16 --- /dev/null +++ b/packages/channels/weixin/src/types.ts @@ -0,0 +1,128 @@ +/** + * WeChat iLink Bot API protocol types. + */ + +export const MessageType = { + NONE: 0, + USER: 1, + BOT: 2, +} as const; + +export const MessageItemType = { + NONE: 0, + TEXT: 1, + IMAGE: 2, + VOICE: 3, + FILE: 4, + VIDEO: 5, +} as const; + +export const MessageState = { + NEW: 0, + GENERATING: 1, + FINISH: 2, +} as const; + +export interface BaseInfo { + channel_version?: string; +} + +export interface CDNMedia { + encrypt_query_param?: string; + aes_key?: string; + encrypt_type?: number; +} + +export interface TextItem { + text?: string; +} + +export interface ImageItem { + media?: CDNMedia; + thumb_media?: CDNMedia; + aeskey?: string; + url?: string; + mid_size?: number; +} + +export interface VoiceItem { + media?: CDNMedia; + text?: string; +} + +export interface FileItem { + media?: CDNMedia; + file_name?: string; + md5?: string; + len?: string; +} + +export interface VideoItem { + media?: CDNMedia; + video_size?: number; +} + +export interface MessageItem { + type?: number; + text_item?: TextItem; + image_item?: ImageItem; + voice_item?: VoiceItem; + file_item?: FileItem; + video_item?: VideoItem; +} + +export interface WeixinMessage { + seq?: number; + message_id?: number; + from_user_id?: string; + to_user_id?: string; + client_id?: string; + create_time_ms?: number; + session_id?: string; + message_type?: number; + message_state?: number; + item_list?: MessageItem[]; + context_token?: string; +} + +export interface GetUpdatesReq { + get_updates_buf?: string; + base_info?: BaseInfo; +} + +export interface GetUpdatesResp { + ret?: number; + errcode?: number; + errmsg?: string; + msgs?: WeixinMessage[]; + get_updates_buf?: string; + longpolling_timeout_ms?: number; +} + +export interface SendMessageReq { + msg?: WeixinMessage; + base_info?: BaseInfo; +} + +export const TypingStatus = { + TYPING: 1, + CANCEL: 2, +} as const; + +export interface GetConfigResp { + ret?: number; + errmsg?: string; + typing_ticket?: string; +} + +export interface SendTypingReq { + ilink_user_id?: string; + typing_ticket?: string; + status?: number; + base_info?: BaseInfo; +} + +export interface SendTypingResp { + ret?: number; + errmsg?: string; +} diff --git a/packages/channels/weixin/tsconfig.json b/packages/channels/weixin/tsconfig.json new file mode 100644 index 000000000..8daf59408 --- /dev/null +++ b/packages/channels/weixin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../base" }] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f4023813..ffdc4092e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,6 +42,7 @@ "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/channel-base": "file:../channels/base", "@qwen-code/channel-telegram": "file:../channels/telegram", + "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 2d259a78e..978c13d06 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -4,6 +4,7 @@ import { pairingListCommand, pairingApproveCommand, } from './channel/pairing.js'; +import { configureWeixinCommand } from './channel/configure.js'; const pairingCommand: CommandModule = { command: 'pairing', @@ -24,6 +25,7 @@ export const channelCommand: CommandModule = { yargs .command(startCommand) .command(pairingCommand) + .command(configureWeixinCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => {}, diff --git a/packages/cli/src/commands/channel/configure.ts b/packages/cli/src/commands/channel/configure.ts new file mode 100644 index 000000000..0da23152a --- /dev/null +++ b/packages/cli/src/commands/channel/configure.ts @@ -0,0 +1,85 @@ +import type { CommandModule } from 'yargs'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { + loadAccount, + saveAccount, + clearAccount, + DEFAULT_BASE_URL, +} from '@qwen-code/channel-weixin/accounts'; +import { startLogin, waitForLogin } from '@qwen-code/channel-weixin/login'; + +export const configureWeixinCommand: CommandModule< + object, + { action: string | undefined } +> = { + command: 'configure-weixin [action]', + describe: 'Configure WeChat channel (login via QR code)', + builder: (yargs) => + yargs.positional('action', { + type: 'string', + describe: '"clear" to remove stored credentials, omit to login', + }), + handler: async (argv) => { + const { action } = argv; + + if (action === 'clear') { + clearAccount(); + writeStdoutLine('WeChat credentials cleared.'); + return; + } + + if (action === 'status') { + const account = loadAccount(); + if (account) { + writeStdoutLine(`WeChat account configured (saved ${account.savedAt})`); + writeStdoutLine(` Base URL: ${account.baseUrl}`); + if (account.userId) { + writeStdoutLine(` User ID: ${account.userId}`); + } + } else { + writeStdoutLine('WeChat account not configured.'); + } + return; + } + + // Default action: login + const existing = loadAccount(); + if (existing) { + writeStdoutLine( + `Existing WeChat credentials found (saved ${existing.savedAt}).`, + ); + writeStdoutLine('Re-running login will overwrite them.\n'); + } + + const baseUrl = DEFAULT_BASE_URL; + + writeStdoutLine('Starting WeChat QR code login...\n'); + + try { + const qrcodeId = await startLogin(baseUrl); + const result = await waitForLogin({ qrcodeId, apiBaseUrl: baseUrl }); + + if (result.connected && result.token) { + saveAccount({ + token: result.token, + baseUrl: result.baseUrl || baseUrl, + userId: result.userId, + savedAt: new Date().toISOString(), + }); + writeStdoutLine('\n' + result.message); + writeStdoutLine( + 'Credentials saved. You can now start a weixin channel with:', + ); + writeStdoutLine(' qwen channel start '); + } else { + writeStderrLine('\n' + result.message); + process.exit(1); + } + } catch (err) { + writeStderrLine( + `Login failed: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }, +}; diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 01443facb..ef26d19ac 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -4,6 +4,7 @@ import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; import { AcpBridge } from '@qwen-code/channel-base'; import type { ChannelConfig } from '@qwen-code/channel-base'; import { TelegramChannel } from '@qwen-code/channel-telegram'; +import { WeixinChannel } from '@qwen-code/channel-weixin'; import * as path from 'node:path'; function resolveEnvVars(value: string): string { @@ -62,29 +63,33 @@ export const startCommand: CommandModule = { ); process.exit(1); } - if (!rawConfig['token']) { - writeStderrLine( - `Error: Channel "${name}" is missing required field "token".`, - ); - process.exit(1); - } const channelType = rawConfig['type'] as string; - if (channelType !== 'telegram') { + const supportedTypes = ['telegram', 'weixin']; + if (!supportedTypes.includes(channelType)) { writeStderrLine( - `Error: Channel type "${channelType}" is not yet supported. Only "telegram" is available.`, + `Error: Channel type "${channelType}" is not supported. Available: ${supportedTypes.join(', ')}`, ); process.exit(1); } - let token: string; - try { - token = resolveEnvVars(rawConfig['token'] as string); - } catch (err) { - writeStderrLine( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(1); + // Token is required for telegram, not for weixin (uses account.json) + let token = ''; + if (channelType !== 'weixin') { + if (!rawConfig['token']) { + writeStderrLine( + `Error: Channel "${name}" is missing required field "token".`, + ); + process.exit(1); + } + try { + token = resolveEnvVars(rawConfig['token'] as string); + } catch (err) { + writeStderrLine( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } } const config: ChannelConfig = { @@ -105,6 +110,12 @@ export const startCommand: CommandModule = { groups: (rawConfig['groups'] as ChannelConfig['groups']) || {}, }; + // Pass through weixin-specific config + const extendedConfig = { + ...config, + baseUrl: rawConfig['baseUrl'] as string | undefined, + }; + const cliEntryPath = findCliEntryPath(); writeStdoutLine(`[Channel] CLI entry: ${cliEntryPath}`); writeStdoutLine(`[Channel] Starting "${name}" (type=${config.type})...`); @@ -112,7 +123,12 @@ export const startCommand: CommandModule = { const bridge = new AcpBridge({ cliEntryPath, cwd: config.cwd }); await bridge.start(); - const channel = new TelegramChannel(name, config, bridge); + let channel: TelegramChannel | WeixinChannel; + if (channelType === 'weixin') { + channel = new WeixinChannel(name, extendedConfig, bridge); + } else { + channel = new TelegramChannel(name, config, bridge); + } await channel.connect(); writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`); From 4f2b9e9bd88a1bb87ef0f59cdef28c634f2ee792 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 25 Mar 2026 07:43:48 +0000 Subject: [PATCH 11/51] feat(channels): add multimodal support with image handling - Add model configuration option for channel-specific model selection - Support base64-encoded images in prompts via AcpBridge - Add media utilities for WeChat/Weixin channel - Update settings schema for model configuration Enables channels to process images and use custom models. Co-authored-by: Qwen-Coder --- packages/channels/base/src/AcpBridge.ts | 26 ++++++++- packages/channels/base/src/ChannelBase.ts | 5 +- packages/channels/base/src/types.ts | 5 ++ packages/channels/weixin/src/WeixinAdapter.ts | 46 ++++++++++++++- packages/channels/weixin/src/media.ts | 56 +++++++++++++++++++ packages/channels/weixin/src/monitor.ts | 26 +++++++-- packages/cli/src/commands/channel/start.ts | 7 ++- .../schemas/settings.schema.json | 5 ++ 8 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 packages/channels/weixin/src/media.ts diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index 502f4955f..2b6155dba 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -17,6 +17,7 @@ import type { export interface AcpBridgeOptions { cliEntryPath: string; cwd: string; + model?: string; } export interface AvailableCommand { @@ -51,7 +52,12 @@ export class AcpBridge extends EventEmitter { async start(): Promise { const { cliEntryPath, cwd } = this.options; - this.child = spawn(process.execPath, [cliEntryPath, '--acp'], { + const args = [cliEntryPath, '--acp']; + if (this.options.model) { + args.push('--model', this.options.model); + } + + this.child = spawn(process.execPath, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env }, @@ -123,7 +129,11 @@ export class AcpBridge extends EventEmitter { return response.sessionId; } - async prompt(sessionId: string, text: string): Promise { + async prompt( + sessionId: string, + text: string, + options?: { imageBase64?: string; imageMimeType?: string }, + ): Promise { const conn = this.ensureConnection(); const chunks: string[] = []; @@ -132,10 +142,20 @@ export class AcpBridge extends EventEmitter { }; this.on('textChunk', onChunk); + const prompt: Array> = []; + if (options?.imageBase64 && options.imageMimeType) { + prompt.push({ + type: 'image', + data: options.imageBase64, + mimeType: options.imageMimeType, + }); + } + prompt.push({ type: 'text', text }); + try { await conn.prompt({ sessionId, - prompt: [{ type: 'text', text }], + prompt: prompt as Array<{ type: 'text'; text: string }>, }); } finally { this.off('textChunk', onChunk); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index fe59dbeaa..caf46245e 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -74,7 +74,10 @@ export abstract class ChannelBase { this.instructedSessions.add(sessionId); } - const response = await this.bridge.prompt(sessionId, promptText); + const response = await this.bridge.prompt(sessionId, promptText, { + imageBase64: envelope.imageBase64, + imageMimeType: envelope.imageMimeType, + }); if (response) { await this.sendMessage(envelope.chatId, response); diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index aaa4a6508..1eb405dd3 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -16,6 +16,7 @@ export interface ChannelConfig { cwd: string; approvalMode?: string; instructions?: string; + model?: string; groupPolicy: GroupPolicy; // default: "disabled" groups: Record; // "*" for defaults, group IDs for overrides } @@ -30,6 +31,10 @@ export interface Envelope { isGroup: boolean; isMentioned: boolean; isReplyToBot: boolean; + /** Base64-encoded image data (e.g. from WeChat CDN download). */ + imageBase64?: string; + /** MIME type for the image (e.g. "image/jpeg", "image/png"). */ + imageMimeType?: string; } export interface SessionTarget { diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts index 9254a601c..5c631eda9 100644 --- a/packages/channels/weixin/src/WeixinAdapter.ts +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -8,7 +8,9 @@ import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; import type { AcpBridge } from '@qwen-code/channel-base'; import { loadAccount, DEFAULT_BASE_URL } from './accounts.js'; import { startPollLoop, getContextToken } from './monitor.js'; +import type { ImageCdnRef } from './monitor.js'; import { sendText } from './send.js'; +import { downloadAndDecrypt } from './media.js'; import { getConfig, sendTyping } from './api.js'; import { TypingStatus } from './types.js'; @@ -56,7 +58,7 @@ export class WeixinChannel extends ChannelBase { isReplyToBot: false, }; - this.handleInbound(envelope).catch((err) => { + this.handleInboundWithImage(envelope, msg.image).catch((err) => { const errMsg = err instanceof Error ? err.message : JSON.stringify(err, null, 2); process.stderr.write( @@ -76,17 +78,36 @@ export class WeixinChannel extends ChannelBase { ); } - override async handleInbound(envelope: Envelope): Promise { + private async handleInboundWithImage( + envelope: Envelope, + image?: ImageCdnRef, + ): Promise { // Check group gate before showing typing const groupResult = this.groupGate.check(envelope); if (!groupResult.allowed) { return; } - // Show typing indicator while agent processes + // Show typing indicator immediately — before image download await this.setTyping(envelope.chatId, true); try { + // Download image from CDN (after typing has started) + if (image) { + try { + const imageData = await downloadAndDecrypt( + image.encryptQueryParam, + image.aesKey, + ); + envelope.imageBase64 = imageData.toString('base64'); + envelope.imageMimeType = detectImageMime(imageData); + } catch (err) { + process.stderr.write( + `[Weixin:${this.name}] Failed to download image: ${err instanceof Error ? err.message : err}\n`, + ); + } + } + await super.handleInbound(envelope); } finally { await this.setTyping(envelope.chatId, false); @@ -139,3 +160,22 @@ export class WeixinChannel extends ChannelBase { } } } + +/** Detect image MIME type from magic bytes. */ +function detectImageMime(data: Buffer): string { + if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e) { + 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 + ) { + return 'image/webp'; + } + return 'image/jpeg'; +} diff --git a/packages/channels/weixin/src/media.ts b/packages/channels/weixin/src/media.ts new file mode 100644 index 000000000..8cd7fa9eb --- /dev/null +++ b/packages/channels/weixin/src/media.ts @@ -0,0 +1,56 @@ +/** + * CDN download with AES-128-ECB decryption. + * Ported from cc-weixin/plugins/weixin/src/media.ts (download path only). + */ + +import { createDecipheriv } from 'node:crypto'; + +const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'; + +function buildCdnDownloadUrl(encryptedQueryParam: string): string { + return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`; +} + +function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + const decipher = createDecipheriv('aes-128-ecb', key, null); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +/** + * Parse aes_key from CDNMedia into a raw 16-byte Buffer. + * Two encodings exist: + * - base64(raw 16 bytes) → images + * - base64(hex string of 16 bytes) → file/voice/video + */ +function parseAesKey(aesKeyBase64: string): Buffer { + const decoded = Buffer.from(aesKeyBase64, 'base64'); + if (decoded.length === 16) { + return decoded; + } + if ( + decoded.length === 32 && + /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii')) + ) { + return Buffer.from(decoded.toString('ascii'), 'hex'); + } + throw new Error( + `Invalid aes_key: expected 16 raw bytes or 32 hex chars, got ${decoded.length} bytes`, + ); +} + +/** Download encrypted media from CDN and decrypt it. */ +export async function downloadAndDecrypt( + encryptQueryParam: string, + aesKey: string, +): Promise { + const url = buildCdnDownloadUrl(encryptQueryParam); + + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`CDN download failed: HTTP ${resp.status}`); + } + + const ciphertext = Buffer.from(await resp.arrayBuffer()); + const keyBuf = parseAesKey(aesKey); + return decryptAesEcb(ciphertext, keyBuf); +} diff --git a/packages/channels/weixin/src/monitor.ts b/packages/channels/weixin/src/monitor.ts index e4a61ab71..42ab90b9d 100644 --- a/packages/channels/weixin/src/monitor.ts +++ b/packages/channels/weixin/src/monitor.ts @@ -31,10 +31,17 @@ function saveCursor(cursor: string): void { writeFileSync(cursorPath(), cursor, 'utf-8'); } +export interface ImageCdnRef { + encryptQueryParam: string; + aesKey: string; +} + export interface ParsedMessage { fromUserId: string; messageId: string; text: string; + /** CDN reference for deferred image download. */ + image?: ImageCdnRef; } export type OnMessageCallback = (msg: ParsedMessage) => Promise; @@ -131,22 +138,33 @@ async function processMessage( contextTokens.set(fromUserId, msg.context_token); } - // Extract text content + // Extract text and image CDN reference let textContent = ''; + let image: ImageCdnRef | undefined; + if (msg.item_list) { for (const item of msg.item_list) { if (item.type === MessageItemType.TEXT && item.text_item?.text) { textContent += (textContent ? '\n' : '') + item.text_item.text; + } else if (item.type === MessageItemType.IMAGE && item.image_item) { + const media = item.image_item.media; + if (media?.encrypt_query_param && media.aes_key) { + image = { + encryptQueryParam: media.encrypt_query_param, + aesKey: media.aes_key, + }; + } } - // MVP: skip media items, text only } } - if (!textContent) return; + // Need either text or image to proceed + if (!textContent && !image) return; await onMessage({ fromUserId, messageId: String(msg.message_id || ''), - text: textContent, + text: textContent || '(image)', + image, }); } diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index ef26d19ac..afcd6320a 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -104,6 +104,7 @@ export const startCommand: CommandModule = { cwd: (rawConfig['cwd'] as string) || process.cwd(), approvalMode: rawConfig['approvalMode'] as string | undefined, instructions: rawConfig['instructions'] as string | undefined, + model: rawConfig['model'] as string | undefined, groupPolicy: (rawConfig['groupPolicy'] as ChannelConfig['groupPolicy']) || 'disabled', @@ -120,7 +121,11 @@ export const startCommand: CommandModule = { writeStdoutLine(`[Channel] CLI entry: ${cliEntryPath}`); writeStdoutLine(`[Channel] Starting "${name}" (type=${config.type})...`); - const bridge = new AcpBridge({ cliEntryPath, cwd: config.cwd }); + const bridge = new AcpBridge({ + cliEntryPath, + cwd: config.cwd, + model: config.model, + }); await bridge.start(); let channel: TelegramChannel | WeixinChannel; diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 8e5725ae0..cf04eea4f 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -8,6 +8,11 @@ "type": "object", "additionalProperties": true }, + "channels": { + "description": "Configuration for messaging channels.", + "type": "object", + "additionalProperties": true + }, "modelProviders": { "description": "Model providers configuration grouped by authType. Each authType contains an array of model configurations.", "type": "object", From b37e2110f522e272aa57d07daae96ed95efd1c93 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 25 Mar 2026 08:09:21 +0000 Subject: [PATCH 12/51] feat(channels): add file and photo support for Telegram and WeChat Co-authored-by: Qwen-Coder - Add photo message handling in Telegram adapter with base64 encoding - Add document/file message handling with temp directory storage - Extend WeChat adapter to support file downloads from CDN - Refactor envelope building into reusable buildEnvelope method - Rename ImageCdnRef to CdnRef for generic media handling This enables users to send images and files through both Telegram and WeChat channels, with files saved to a temp directory for agent access. --- .../channels/telegram/src/TelegramAdapter.ts | 165 ++++++++++++++---- packages/channels/weixin/src/WeixinAdapter.ts | 48 +++-- packages/channels/weixin/src/monitor.ts | 31 +++- 3 files changed, 190 insertions(+), 54 deletions(-) diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 353e71363..303242c94 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { Telegraf } from 'telegraf'; import { telegramFormat, @@ -79,42 +82,7 @@ export class TelegramChannel extends ChannelBase { } } - const isGroup = - msg.chat.type === 'group' || msg.chat.type === 'supergroup'; - - // Check if the bot is mentioned via @username in message entities - const isMentioned = - msg.entities?.some( - (e) => - e.type === 'mention' && - this.botUsername && - text.slice(e.offset, e.offset + e.length).toLowerCase() === - `@${this.botUsername.toLowerCase()}`, - ) ?? false; - - // Check if this is a reply to one of the bot's messages - const isReplyToBot = msg.reply_to_message?.from?.id === this.botId; - - // Strip @botname from message text so the agent only sees the actual prompt - let cleanText = text; - if (isMentioned && this.botUsername) { - cleanText = text - .replace(new RegExp(`@${this.botUsername}`, 'gi'), '') - .trim(); - } - - const envelope: Envelope = { - channelName: this.name, - senderId: String(msg.from.id), - senderName: - msg.from.first_name + - (msg.from.last_name ? ` ${msg.from.last_name}` : ''), - chatId: String(msg.chat.id), - text: cleanText, - isGroup, - isMentioned, - isReplyToBot, - }; + const envelope = this.buildEnvelope(msg, text, msg.entities); // Don't await — Telegraf has a 90s handler timeout that would kill long prompts this.handleInbound(envelope).catch((err) => { @@ -127,6 +95,88 @@ export class TelegramChannel extends ChannelBase { }); }); + // Photo messages + this.bot.on('photo', async (ctx) => { + const msg = ctx.message; + const envelope = this.buildEnvelope( + msg, + msg.caption || '(image)', + msg.caption_entities, + ); + + // Pick the largest photo size (last in array) + const photo = msg.photo[msg.photo.length - 1]; + if (!photo) return; + + try { + const fileUrl = await ctx.telegram.getFileLink(photo.file_id); + const resp = await fetch(fileUrl.href); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const buf = Buffer.from(await resp.arrayBuffer()); + envelope.imageBase64 = buf.toString('base64'); + envelope.imageMimeType = 'image/jpeg'; // Telegram always converts photos to JPEG + } catch (err) { + process.stderr.write( + `[Telegram:${this.name}] Failed to download photo: ${err instanceof Error ? err.message : err}\n`, + ); + } + + this.handleInbound(envelope).catch((err) => { + process.stderr.write( + `[Telegram:${this.name}] Error handling message: ${err}\n`, + ); + ctx + .reply('Sorry, something went wrong processing your message.') + .catch(() => {}); + }); + }); + + // Document/file messages + this.bot.on('document', async (ctx) => { + const msg = ctx.message; + const doc = msg.document; + const fileName = doc.file_name || `file_${Date.now()}`; + + const envelope = this.buildEnvelope( + msg, + msg.caption || `(file: ${fileName})`, + msg.caption_entities, + ); + + try { + const fileUrl = await ctx.telegram.getFileLink(doc.file_id); + const resp = await fetch(fileUrl.href); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const buf = Buffer.from(await resp.arrayBuffer()); + + // Save to temp dir so the agent can read it via read-file tool + const dir = join(tmpdir(), 'channel-files'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const filePath = join(dir, fileName); + writeFileSync(filePath, buf); + + envelope.text = + (msg.caption ? msg.caption + '\n\n' : '') + + `User sent a file. It has been saved to: ${filePath}`; + } catch (err) { + process.stderr.write( + `[Telegram:${this.name}] Failed to download document: ${err instanceof Error ? err.message : err}\n`, + ); + envelope.text = + (msg.caption || '') + + `\n\n(User sent a file "${fileName}" but download failed)`; + } + + this.handleInbound(envelope).catch((err) => { + process.stderr.write( + `[Telegram:${this.name}] Error handling message: ${err}\n`, + ); + ctx + .reply('Sorry, something went wrong processing your message.') + .catch(() => {}); + }); + }); + this.bot.launch({ dropPendingUpdates: true }).catch((err) => { process.stderr.write( `[Telegram:${this.name}] Bot launch error: ${err}\n`, @@ -180,4 +230,47 @@ export class TelegramChannel extends ChannelBase { disconnect(): void { this.bot.stop(); } + + private buildEnvelope( + msg: { + from: { id: number; first_name: string; last_name?: string }; + chat: { id: number; type: string }; + reply_to_message?: { from?: { id: number } }; + }, + text: string, + entities?: Array<{ type: string; offset: number; length: number }>, + ): Envelope { + const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup'; + + const isMentioned = + entities?.some( + (e) => + e.type === 'mention' && + this.botUsername && + text.slice(e.offset, e.offset + e.length).toLowerCase() === + `@${this.botUsername.toLowerCase()}`, + ) ?? false; + + const isReplyToBot = msg.reply_to_message?.from?.id === this.botId; + + let cleanText = text; + if (isMentioned && this.botUsername) { + cleanText = text + .replace(new RegExp(`@${this.botUsername}`, 'gi'), '') + .trim(); + } + + return { + channelName: this.name, + senderId: String(msg.from.id), + senderName: + msg.from.first_name + + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), + chatId: String(msg.chat.id), + text: cleanText, + isGroup, + isMentioned, + isReplyToBot, + }; + } } diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts index 5c631eda9..11f21afb6 100644 --- a/packages/channels/weixin/src/WeixinAdapter.ts +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -3,12 +3,15 @@ * Extends ChannelBase with WeChat iLink Bot API integration. */ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { ChannelBase } from '@qwen-code/channel-base'; import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; import type { AcpBridge } from '@qwen-code/channel-base'; import { loadAccount, DEFAULT_BASE_URL } from './accounts.js'; import { startPollLoop, getContextToken } from './monitor.js'; -import type { ImageCdnRef } from './monitor.js'; +import type { CdnRef, FileCdnRef } from './monitor.js'; import { sendText } from './send.js'; import { downloadAndDecrypt } from './media.js'; import { getConfig, sendTyping } from './api.js'; @@ -58,13 +61,15 @@ export class WeixinChannel extends ChannelBase { isReplyToBot: false, }; - this.handleInboundWithImage(envelope, msg.image).catch((err) => { - const errMsg = - err instanceof Error ? err.message : JSON.stringify(err, null, 2); - process.stderr.write( - `[Weixin:${this.name}] Error handling message: ${errMsg}\n`, - ); - }); + this.handleInboundWithMedia(envelope, msg.image, msg.file).catch( + (err) => { + const errMsg = + err instanceof Error ? err.message : JSON.stringify(err, null, 2); + process.stderr.write( + `[Weixin:${this.name}] Error handling message: ${errMsg}\n`, + ); + }, + ); }, abortSignal: this.abortController.signal, }).catch((err) => { @@ -78,9 +83,10 @@ export class WeixinChannel extends ChannelBase { ); } - private async handleInboundWithImage( + private async handleInboundWithMedia( envelope: Envelope, - image?: ImageCdnRef, + image?: CdnRef, + file?: FileCdnRef, ): Promise { // Check group gate before showing typing const groupResult = this.groupGate.check(envelope); @@ -88,7 +94,7 @@ export class WeixinChannel extends ChannelBase { return; } - // Show typing indicator immediately — before image download + // Show typing indicator immediately — before CDN download await this.setTyping(envelope.chatId, true); try { @@ -108,6 +114,26 @@ export class WeixinChannel extends ChannelBase { } } + // Download file from CDN, save to temp dir + if (file) { + try { + const fileData = await downloadAndDecrypt( + file.encryptQueryParam, + file.aesKey, + ); + const dir = join(tmpdir(), 'channel-files'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const filePath = join(dir, file.fileName); + writeFileSync(filePath, fileData); + envelope.text = `User sent a file. It has been saved to: ${filePath}`; + } catch (err) { + process.stderr.write( + `[Weixin:${this.name}] Failed to download file: ${err instanceof Error ? err.message : err}\n`, + ); + envelope.text = `(User sent a file "${file.fileName}" but download failed)`; + } + } + await super.handleInbound(envelope); } finally { await this.setTyping(envelope.chatId, false); diff --git a/packages/channels/weixin/src/monitor.ts b/packages/channels/weixin/src/monitor.ts index 42ab90b9d..276bb7d14 100644 --- a/packages/channels/weixin/src/monitor.ts +++ b/packages/channels/weixin/src/monitor.ts @@ -31,17 +31,23 @@ function saveCursor(cursor: string): void { writeFileSync(cursorPath(), cursor, 'utf-8'); } -export interface ImageCdnRef { +export interface CdnRef { encryptQueryParam: string; aesKey: string; } +export interface FileCdnRef extends CdnRef { + fileName: string; +} + export interface ParsedMessage { fromUserId: string; messageId: string; text: string; /** CDN reference for deferred image download. */ - image?: ImageCdnRef; + image?: CdnRef; + /** CDN reference for deferred file download. */ + file?: FileCdnRef; } export type OnMessageCallback = (msg: ParsedMessage) => Promise; @@ -138,9 +144,10 @@ async function processMessage( contextTokens.set(fromUserId, msg.context_token); } - // Extract text and image CDN reference + // Extract text, image, and file CDN references let textContent = ''; - let image: ImageCdnRef | undefined; + let image: CdnRef | undefined; + let file: FileCdnRef | undefined; if (msg.item_list) { for (const item of msg.item_list) { @@ -154,17 +161,27 @@ async function processMessage( aesKey: media.aes_key, }; } + } else if (item.type === MessageItemType.FILE && item.file_item) { + const media = item.file_item.media; + if (media?.encrypt_query_param && media.aes_key) { + file = { + encryptQueryParam: media.encrypt_query_param, + aesKey: media.aes_key, + fileName: item.file_item.file_name || `file_${Date.now()}`, + }; + } } } } - // Need either text or image to proceed - if (!textContent && !image) return; + // Need either text, image, or file to proceed + if (!textContent && !image && !file) return; await onMessage({ fromUserId, messageId: String(msg.message_id || ''), - text: textContent || '(image)', + text: textContent || (file ? `(file: ${file.fileName})` : '(image)'), image, + file, }); } From f6ae769736e3bf2ff521dda8c4e18fc3ae5f7e21 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 25 Mar 2026 08:12:53 +0000 Subject: [PATCH 13/51] docs(channels): document media support and add WeChat guide Co-authored-by: Qwen-Coder - Add Media Support section to overview with images and files - Document model option for multimodal channel support - Add Images and Files section to Telegram guide - Add complete WeChat (Weixin) setup guide with QR auth This documents the new media handling capabilities added to both Telegram and WeChat channels. --- docs/users/features/channels/overview.md | 63 +++++++++++--- docs/users/features/channels/telegram.md | 8 ++ docs/users/features/channels/weixin.md | 102 +++++++++++++++++++++++ 3 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 docs/users/features/channels/weixin.md diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 351ecbeaf..1a35b4d43 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -1,6 +1,6 @@ # Channels -Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI. +Channels let you interact with a Qwen Code agent from messaging platforms like Telegram or WeChat, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI. ## How It Works @@ -15,7 +15,7 @@ Each channel runs as a long-lived process that bridges a messaging platform to a ## Quick Start -1. Set up a bot on your messaging platform (see the channel-specific guide, e.g., [Telegram](./telegram)) +1. Set up a bot on your messaging platform (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin)) 2. Add the channel configuration to `~/.qwen/settings.json` 3. Run `qwen channel start ` @@ -45,17 +45,18 @@ Channels are configured under the `channels` key in `settings.json`. Each channe ### Options -| Option | Required | Description | -| -------------- | -------- | -------------------------------------------------------------------------------------------------- | -| `type` | Yes | Channel type: `telegram` (more coming soon) | -| `token` | Yes | Bot token. Supports `$ENV_VAR` syntax to read from environment variables | -| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | -| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | -| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | -| `cwd` | No | Working directory for the agent. Defaults to the current directory | -| `instructions` | No | Custom instructions prepended to the first message of each session | -| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | -| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | +| Option | Required | Description | +| -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | Yes | Channel type: `telegram` or `weixin` | +| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat | +| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input | +| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | +| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | +| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | +| `cwd` | No | Working directory for the agent. Defaults to the current directory | +| `instructions` | No | Custom instructions prepended to the first message of each session | +| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | +| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | ### Sender Policy @@ -178,6 +179,42 @@ curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates" | python3 Look for `message.chat.id` in the response — group IDs are negative numbers (e.g., `-5170296765`). +## Media Support + +Channels support sending images and files to the agent, not just text. + +### Images + +Send a photo to the bot and the agent will see it — useful for sharing screenshots, error messages, or diagrams. The image is sent directly to the model as a vision input. + +To use image support, configure a multimodal model for the channel: + +```json +{ + "channels": { + "my-channel": { + "type": "telegram", + "model": "qwen3.5-plus", + ... + } + } +} +``` + +### Files + +Send a document (PDF, code file, text file, etc.) to the bot. The file is downloaded and saved to a temporary directory, and the agent is told the file path so it can read the contents using its file-reading tools. + +Files work with any model — no multimodal support required. + +### Platform differences + +| Feature | Telegram | WeChat | +| -------- | -------------------------------------------- | -------------------------------- | +| Images | Direct download via Bot API | CDN download with AES decryption | +| Files | Direct download via Bot API (20MB limit) | CDN download with AES decryption | +| Captions | Photo/file captions included as message text | Not applicable | + ## Slash Commands Channels support slash commands. Some are handled locally by the adapter: diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md index c8ddba38c..9a322805e 100644 --- a/docs/users/features/channels/telegram.md +++ b/docs/users/features/channels/telegram.md @@ -73,6 +73,14 @@ To use the bot in Telegram groups: By default, the bot requires an @mention or a reply to respond in groups. Set `"requireMention": false` for a specific group to make it respond to all messages (useful for dedicated task groups). See [Group Chats](./overview#group-chats) for full details. +## Images and Files + +You can send photos and documents to the bot, not just text. + +**Photos:** Send a photo and the agent will analyze it using its vision capabilities. This requires a multimodal model — add `"model": "qwen3.5-plus"` (or another vision-capable model) to your channel config. Photo captions are passed as the message text. + +**Documents:** Send a PDF, code file, or any document. The bot downloads it and saves it locally so the agent can read it with its file tools. This works with any model. Telegram's file size limit is 20MB. + ## Tips - **Keep instructions concise-focused** — Telegram has a 4096-character message limit. Adding instructions like "keep responses short" helps the agent stay within bounds. diff --git a/docs/users/features/channels/weixin.md b/docs/users/features/channels/weixin.md new file mode 100644 index 000000000..e9fd14c82 --- /dev/null +++ b/docs/users/features/channels/weixin.md @@ -0,0 +1,102 @@ +# WeChat (Weixin) + +This guide covers setting up a Qwen Code channel on WeChat via the official iLink Bot API. + +## Prerequisites + +- A WeChat account that can scan QR codes (mobile app) +- Access to the iLink Bot platform (WeChat's official bot API) + +## Setup + +### 1. Log in via QR code + +WeChat uses QR code authentication instead of a static bot token. Run the login command: + +```bash +qwen channel configure-weixin +``` + +This will display a QR code URL. Scan it with your WeChat mobile app to authenticate. Your credentials are saved to `~/.qwen/channels/weixin/account.json`. + +### 2. Configure the channel + +Add the channel to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-weixin": { + "type": "weixin", + "senderPolicy": "pairing", + "allowedUsers": [], + "sessionScope": "user", + "cwd": "/path/to/your/project", + "model": "qwen3.5-plus", + "instructions": "You are a concise coding assistant responding via WeChat. Keep responses under 500 characters. Use plain text only." + } + } +} +``` + +Note: WeChat channels do not use a `token` field — credentials come from the QR login step. + +### 3. Start the channel + +```bash +qwen channel start my-weixin +``` + +Open WeChat and send a message to the bot. You should see a typing indicator ("...") while the agent processes, followed by the response. + +## Images and Files + +You can send photos and documents to the bot, not just text. + +**Photos:** Send an image (screenshot, photo, etc.) and the agent will analyze it using its vision capabilities. This requires a multimodal model — add `"model": "qwen3.5-plus"` (or another vision-capable model) to your channel config. A typing indicator shows while the image is being downloaded and processed. + +**Files:** Send a PDF, code file, or any document. The bot downloads and decrypts it from WeChat's CDN, saves it locally, and the agent reads it with its file tools. This works with any model. + +## Configuration Options + +WeChat channels support all the standard channel options (see [Channel Overview](./overview#options)), plus: + +| Option | Description | +| --------- | ------------------------------------------------------------------------------ | +| `baseUrl` | Override the iLink Bot API base URL (default: `https://ilinkai.weixin.qq.com`) | + +## Key Differences from Telegram + +- **Authentication:** QR code login instead of a static bot token. Sessions can expire — the channel will pause and log a message if this happens. +- **Formatting:** WeChat only supports plain text. Markdown in agent responses is automatically stripped. +- **Typing indicator:** WeChat has a native "..." typing indicator instead of a "Working..." text message. +- **Groups:** WeChat iLink Bot is DM-only — group chats are not supported. +- **Media encryption:** Images and files are encrypted on WeChat's CDN with AES-128-ECB. The channel handles decryption transparently. + +## Tips + +- **Use plain text instructions** — Since WeChat strips all markdown, add instructions like "Use plain text only" to avoid the agent producing formatted responses that look messy. +- **Keep responses short** — WeChat message bubbles work best with concise text. Adding a character limit to your instructions helps (e.g., "Keep responses under 500 characters"). +- **Session expiry** — If you see "Session expired (errcode -14)" in the logs, your WeChat login has expired. Stop the channel and re-run `qwen channel configure-weixin` to log in again. +- **Restrict access** — Use `senderPolicy: "pairing"` or `"allowlist"` to control who can talk to the bot. See [DM Pairing](./overview#dm-pairing) for details. + +## Troubleshooting + +### "WeChat account not configured" + +Run `qwen channel configure-weixin` to log in via QR code first. + +### "Session expired (errcode -14)" + +Your WeChat login session has expired. Stop the channel and run `qwen channel configure-weixin` again. + +### Bot doesn't respond + +- Check the terminal output for errors +- Verify the channel is running (`qwen channel start my-weixin`) +- If using `senderPolicy: "allowlist"`, make sure your WeChat user ID is in `allowedUsers` + +### Images not working + +- Make sure your channel config has a `model` that supports vision (e.g., `qwen3.5-plus`) +- Check the terminal for CDN download errors — these may indicate a network issue From 1a605ec973cd6f894f84546057003a08a8a69589 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 02:55:18 +0000 Subject: [PATCH 14/51] feat(channels): add crash recovery and gateway mode support - Add session persistence to SessionRouter for crash recovery - Add loadSession method to AcpBridge for restoring sessions - Add ChannelBaseOptions to support external router injection - Refactor start.ts to support both standalone and gateway modes - Extract config utilities into separate module This enables channels to recover sessions after bridge crashes and supports running multiple channels under a gateway process. Co-authored-by: Qwen-Coder --- docs/users/features/channels/overview.md | 33 +- docs/users/features/channels/telegram.md | 4 + docs/users/features/channels/weixin.md | 4 + packages/channels/base/src/AcpBridge.ts | 10 + packages/channels/base/src/ChannelBase.ts | 37 +- packages/channels/base/src/SessionRouter.ts | 133 +++++- packages/channels/base/src/index.ts | 1 + .../channels/telegram/src/TelegramAdapter.ts | 10 +- packages/channels/weixin/src/WeixinAdapter.ts | 17 +- .../cli/src/commands/channel/config-utils.ts | 69 +++ packages/cli/src/commands/channel/start.ts | 410 ++++++++++++------ 11 files changed, 567 insertions(+), 161 deletions(-) create mode 100644 packages/cli/src/commands/channel/config-utils.ts diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 1a35b4d43..1644f4085 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -4,20 +4,20 @@ Channels let you interact with a Qwen Code agent from messaging platforms like T ## How It Works -When you run `qwen channel start `, Qwen Code: +When you run `qwen channel start`, Qwen Code: -1. Reads the channel configuration from your `settings.json` -2. Spawns an agent process using the [Agent Client Protocol (ACP)](../../developers/architecture) -3. Connects to the messaging platform (e.g., Telegram) and starts listening for messages -4. Routes incoming messages to the agent and sends responses back to the chat +1. Reads channel configurations from your `settings.json` +2. Spawns a single agent process using the [Agent Client Protocol (ACP)](../../developers/architecture) +3. Connects to each messaging platform and starts listening for messages +4. Routes incoming messages to the agent and sends responses back to the correct chat -Each channel runs as a long-lived process that bridges a messaging platform to a Qwen Code agent. +All channels share one agent process with isolated sessions per user. Each channel can have its own working directory, model, and instructions. ## Quick Start 1. Set up a bot on your messaging platform (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin)) 2. Add the channel configuration to `~/.qwen/settings.json` -3. Run `qwen channel start ` +3. Run `qwen channel start` to start all channels, or `qwen channel start ` for a single channel ## Configuration @@ -228,7 +228,26 @@ All other slash commands (e.g., `/compress`, `/summary`) are forwarded to the ag ## Running ```bash +# Start all configured channels (shared agent process) +qwen channel start + +# Start a single channel qwen channel start my-channel ``` The bot runs in the foreground. Press `Ctrl+C` to stop. + +### Multi-Channel Mode + +When you run `qwen channel start` without a name, all channels defined in `settings.json` start together sharing a single agent process. Each channel maintains its own sessions — a Telegram user and a WeChat user get separate conversations, even though they share the same agent. + +Each channel uses its own `cwd` from its config, so different channels can work on different projects simultaneously. + +### Crash Recovery + +If the agent process crashes unexpectedly, the channel service automatically restarts it and attempts to restore all active sessions. Users can continue their conversations without starting over. + +- Sessions are persisted to `~/.qwen/channels/sessions.json` while the service is running +- On crash: the agent restarts within 3 seconds and reloads saved sessions +- After 3 consecutive crashes, the service exits with an error +- On clean shutdown (Ctrl+C): session data is cleared — the next start is always fresh diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md index 9a322805e..5c5f1bddd 100644 --- a/docs/users/features/channels/telegram.md +++ b/docs/users/features/channels/telegram.md @@ -57,7 +57,11 @@ Or add it to a `.env` file that gets sourced before running. ## Running ```bash +# Start only the Telegram channel qwen channel start my-telegram + +# Or start all configured channels together +qwen channel start ``` Then open your bot in Telegram and send a message. You should see "Working..." appear immediately, followed by the agent's response. diff --git a/docs/users/features/channels/weixin.md b/docs/users/features/channels/weixin.md index e9fd14c82..5bcc18a13 100644 --- a/docs/users/features/channels/weixin.md +++ b/docs/users/features/channels/weixin.md @@ -44,7 +44,11 @@ Note: WeChat channels do not use a `token` field — credentials come from the Q ### 3. Start the channel ```bash +# Start only the WeChat channel qwen channel start my-weixin + +# Or start all configured channels together +qwen channel start ``` Open WeChat and send a message to the bot. You should see a typing indicator ("...") while the agent processes, followed by the response. diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index 2b6155dba..2b9c9c5e4 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -129,6 +129,16 @@ export class AcpBridge extends EventEmitter { return response.sessionId; } + async loadSession(sessionId: string, cwd: string): Promise { + const conn = this.ensureConnection(); + const response = await conn.loadSession({ + sessionId, + cwd, + mcpServers: [], + }); + return response.sessionId; + } + async prompt( sessionId: string, text: string, diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index caf46245e..c8e4e7f33 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -5,6 +5,10 @@ import { PairingStore } from './PairingStore.js'; import { SessionRouter } from './SessionRouter.js'; import type { AcpBridge, ToolCallEvent } from './AcpBridge.js'; +export interface ChannelBaseOptions { + router?: SessionRouter; +} + export abstract class ChannelBase { protected config: ChannelConfig; protected bridge: AcpBridge; @@ -14,7 +18,12 @@ export abstract class ChannelBase { protected name: string; private instructedSessions: Set = new Set(); - constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { this.name = name; this.config = config; this.bridge = bridge; @@ -28,20 +37,31 @@ export abstract class ChannelBase { config.allowedUsers, pairingStore, ); - this.router = new SessionRouter(bridge, config.cwd, config.sessionScope); + this.router = + options?.router || + new SessionRouter(bridge, config.cwd, config.sessionScope); - bridge.on('toolCall', (event: ToolCallEvent) => { - const target = this.router.getTarget(event.sessionId); - if (target) { - this.onToolCall(target.chatId, event); - } - }); + // When running standalone (no gateway), register toolCall listener directly. + // In gateway mode, the ChannelManager dispatches events instead. + if (!options?.router) { + bridge.on('toolCall', (event: ToolCallEvent) => { + const target = this.router.getTarget(event.sessionId); + if (target) { + this.onToolCall(target.chatId, event); + } + }); + } } abstract connect(): Promise; abstract sendMessage(chatId: string, text: string): Promise; abstract disconnect(): void; + /** Replace the bridge instance (used after crash recovery restart). */ + setBridge(bridge: AcpBridge): void { + this.bridge = bridge; + } + onToolCall(_chatId: string, _event: ToolCallEvent): void {} async handleInbound(envelope: Envelope): Promise { @@ -65,6 +85,7 @@ export abstract class ChannelBase { envelope.senderId, envelope.chatId, envelope.threadId, + this.config.cwd, ); // Prepend channel instructions on first message of a session diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts index 302879f65..34217861f 100644 --- a/packages/channels/base/src/SessionRouter.ts +++ b/packages/channels/base/src/SessionRouter.ts @@ -1,18 +1,38 @@ +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import type { SessionScope, SessionTarget } from './types.js'; import type { AcpBridge } from './AcpBridge.js'; +interface PersistedEntry { + sessionId: string; + target: SessionTarget; + cwd: string; +} + export class SessionRouter { private toSession: Map = new Map(); // routing key → session ID private toTarget: Map = new Map(); // session ID → target + private toCwd: Map = new Map(); // session ID → cwd private bridge: AcpBridge; - private cwd: string; + private defaultCwd: string; private scope: SessionScope; + private persistPath: string | undefined; - constructor(bridge: AcpBridge, cwd: string, scope: SessionScope = 'user') { + constructor( + bridge: AcpBridge, + defaultCwd: string, + scope: SessionScope = 'user', + persistPath?: string, + ) { this.bridge = bridge; - this.cwd = cwd; + this.defaultCwd = defaultCwd; this.scope = scope; + this.persistPath = persistPath; + } + + /** Replace the bridge instance (used after crash recovery restart). */ + setBridge(bridge: AcpBridge): void { + this.bridge = bridge; } private routingKey( @@ -37,6 +57,7 @@ export class SessionRouter { senderId: string, chatId: string, threadId?: string, + cwd?: string, ): Promise { const key = this.routingKey(channelName, senderId, chatId, threadId); const existing = this.toSession.get(key); @@ -44,9 +65,12 @@ export class SessionRouter { return existing; } - const sessionId = await this.bridge.newSession(this.cwd); + const sessionCwd = cwd || this.defaultCwd; + const sessionId = await this.bridge.newSession(sessionCwd); this.toSession.set(key, sessionId); this.toTarget.set(sessionId, { channelName, senderId, chatId, threadId }); + this.toCwd.set(sessionId, sessionCwd); + this.persist(); return sessionId; } @@ -64,6 +88,107 @@ export class SessionRouter { if (!sessionId) return false; this.toSession.delete(key); this.toTarget.delete(sessionId); + this.toCwd.delete(sessionId); + this.persist(); return true; } + + /** Get all session entries for crash recovery. */ + getAll(): Array<{ key: string; sessionId: string; target: SessionTarget }> { + const entries: Array<{ + key: string; + sessionId: string; + target: SessionTarget; + }> = []; + for (const [key, sessionId] of this.toSession) { + const target = this.toTarget.get(sessionId); + if (target) { + entries.push({ key, sessionId, target }); + } + } + return entries; + } + + /** + * Restore session mappings from a previous bridge. + * Called after bridge restart — attempts loadSession for each saved mapping. + * Failed loads are silently dropped (new session on next message). + */ + async restoreSessions(): Promise<{ + restored: number; + failed: number; + }> { + if (!this.persistPath || !existsSync(this.persistPath)) { + return { restored: 0, failed: 0 }; + } + + let entries: Record; + try { + entries = JSON.parse(readFileSync(this.persistPath, 'utf-8')); + } catch { + return { restored: 0, failed: 0 }; + } + + let restored = 0; + let failed = 0; + + for (const [key, entry] of Object.entries(entries)) { + try { + const sessionId = await this.bridge.loadSession( + entry.sessionId, + entry.cwd, + ); + this.toSession.set(key, sessionId); + this.toTarget.set(sessionId, entry.target); + this.toCwd.set(sessionId, entry.cwd); + restored++; + } catch { + // Session can't be loaded — will create fresh on next message + failed++; + } + } + + // Update persist file to only include successfully restored sessions + if (failed > 0) { + this.persist(); + } + + return { restored, failed }; + } + + /** Clear in-memory state and delete persist file. Used on clean shutdown. */ + clearAll(): void { + this.toSession.clear(); + this.toTarget.clear(); + this.toCwd.clear(); + if (this.persistPath && existsSync(this.persistPath)) { + try { + unlinkSync(this.persistPath); + } catch { + // best-effort + } + } + } + + private persist(): void { + if (!this.persistPath) return; + + const data: Record = {}; + for (const [key, sessionId] of this.toSession) { + const target = this.toTarget.get(sessionId); + if (target) { + data[key] = { + sessionId, + target, + cwd: this.toCwd.get(sessionId) || this.defaultCwd, + }; + } + } + + try { + writeFileSync(this.persistPath, JSON.stringify(data, null, 2), 'utf-8'); + } catch { + // best-effort — don't break message flow for persistence failure + } + } } diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index da085cf60..8ea5e5e51 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -5,6 +5,7 @@ export type { ToolCallEvent, } from './AcpBridge.js'; export { ChannelBase } from './ChannelBase.js'; +export type { ChannelBaseOptions } from './ChannelBase.js'; export { PairingStore } from './PairingStore.js'; export type { PairingRequest } from './PairingStore.js'; export { GroupGate } from './GroupGate.js'; diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 303242c94..14533f304 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -9,6 +9,7 @@ import { import { ChannelBase } from '@qwen-code/channel-base'; import type { ChannelConfig, + ChannelBaseOptions, Envelope, AcpBridge, } from '@qwen-code/channel-base'; @@ -21,8 +22,13 @@ export class TelegramChannel extends ChannelBase { private botId: number = 0; private botUsername: string = ''; - constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { - super(name, config, bridge); + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); this.bot = new Telegraf(config.token); } diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts index 11f21afb6..1999d1e0e 100644 --- a/packages/channels/weixin/src/WeixinAdapter.ts +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -7,8 +7,12 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { ChannelBase } from '@qwen-code/channel-base'; -import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; -import type { AcpBridge } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; import { loadAccount, DEFAULT_BASE_URL } from './accounts.js'; import { startPollLoop, getContextToken } from './monitor.js'; import type { CdnRef, FileCdnRef } from './monitor.js'; @@ -25,8 +29,13 @@ export class WeixinChannel extends ChannelBase { private baseUrl: string; private token: string = ''; - constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { - super(name, config, bridge); + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); this.baseUrl = (config as ChannelConfig & { baseUrl?: string }).baseUrl || DEFAULT_BASE_URL; diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts new file mode 100644 index 000000000..41af02d7a --- /dev/null +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -0,0 +1,69 @@ +import type { ChannelConfig } from '@qwen-code/channel-base'; +import * as path from 'node:path'; + +export function resolveEnvVars(value: string): string { + if (value.startsWith('$')) { + const envName = value.substring(1); + const envValue = process.env[envName]; + if (!envValue) { + throw new Error( + `Environment variable ${envName} is not set (referenced as ${value})`, + ); + } + return envValue; + } + return value; +} + +export function findCliEntryPath(): string { + const mainModule = process.argv[1]; + if (mainModule) { + return path.resolve(mainModule); + } + throw new Error('Cannot determine CLI entry path'); +} + +const SUPPORTED_TYPES = ['telegram', 'weixin']; + +export function parseChannelConfig( + name: string, + rawConfig: Record, +): ChannelConfig & { baseUrl?: string } { + if (!rawConfig['type']) { + throw new Error(`Channel "${name}" is missing required field "type".`); + } + + const channelType = rawConfig['type'] as string; + if (!SUPPORTED_TYPES.includes(channelType)) { + throw new Error( + `Channel type "${channelType}" is not supported. Available: ${SUPPORTED_TYPES.join(', ')}`, + ); + } + + let token = ''; + if (channelType !== 'weixin') { + if (!rawConfig['token']) { + throw new Error(`Channel "${name}" is missing required field "token".`); + } + token = resolveEnvVars(rawConfig['token'] as string); + } + + return { + type: channelType as ChannelConfig['type'], + token, + senderPolicy: + (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || + 'allowlist', + allowedUsers: (rawConfig['allowedUsers'] as string[]) || [], + sessionScope: + (rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || 'user', + cwd: (rawConfig['cwd'] as string) || process.cwd(), + approvalMode: rawConfig['approvalMode'] as string | undefined, + instructions: rawConfig['instructions'] as string | undefined, + model: rawConfig['model'] as string | undefined, + groupPolicy: + (rawConfig['groupPolicy'] as ChannelConfig['groupPolicy']) || 'disabled', + groups: (rawConfig['groups'] as ChannelConfig['groups']) || {}, + baseUrl: rawConfig['baseUrl'] as string | undefined, + }; +} diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index afcd6320a..77c239a37 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -1,158 +1,296 @@ +import * as path from 'node:path'; +import * as os from 'node:os'; import type { CommandModule } from 'yargs'; import { loadSettings } from '../../config/settings.js'; import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; -import { AcpBridge } from '@qwen-code/channel-base'; -import type { ChannelConfig } from '@qwen-code/channel-base'; +import { AcpBridge, SessionRouter } from '@qwen-code/channel-base'; +import type { ChannelBase, ToolCallEvent } from '@qwen-code/channel-base'; import { TelegramChannel } from '@qwen-code/channel-telegram'; import { WeixinChannel } from '@qwen-code/channel-weixin'; -import * as path from 'node:path'; +import { findCliEntryPath, parseChannelConfig } from './config-utils.js'; -function resolveEnvVars(value: string): string { - if (value.startsWith('$')) { - const envName = value.substring(1); - const envValue = process.env[envName]; - if (!envValue) { - throw new Error( - `Environment variable ${envName} is not set (referenced as ${value})`, +const MAX_CRASH_RESTARTS = 3; +const RESTART_DELAY_MS = 3000; + +function sessionsPath(): string { + return path.join(os.homedir(), '.qwen', 'channels', 'sessions.json'); +} + +function loadChannelsConfig(): Record { + const settings = loadSettings(process.cwd()); + const channels = ( + settings.merged as unknown as { channels?: Record } + ).channels; + return channels || {}; +} + +function createChannel( + name: string, + config: ReturnType, + bridge: AcpBridge, + options?: { router?: SessionRouter }, +): ChannelBase { + if (config.type === 'weixin') { + return new WeixinChannel(name, config, bridge, options); + } + return new TelegramChannel(name, config, bridge, options); +} + +function registerToolCallDispatch( + bridge: AcpBridge, + router: SessionRouter, + channels: Map, +): void { + bridge.on('toolCall', (event: ToolCallEvent) => { + const target = router.getTarget(event.sessionId); + if (target) { + const channel = channels.get(target.channelName); + if (channel) { + channel.onToolCall(target.chatId, event); + } + } + }); +} + +/** Start a single channel with its own bridge + crash recovery. */ +async function startSingle(name: string): Promise { + const channelsConfig = loadChannelsConfig(); + + if (!channelsConfig[name]) { + writeStderrLine( + `Error: Channel "${name}" not found in settings. Add it to channels.${name} in settings.json.`, + ); + process.exit(1); + } + + let config; + try { + config = parseChannelConfig( + name, + channelsConfig[name] as Record, + ); + } catch (err) { + writeStderrLine( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + const cliEntryPath = findCliEntryPath(); + let shuttingDown = false; + let crashCount = 0; + + const bridgeOpts = { cliEntryPath, cwd: config.cwd, model: config.model }; + let bridge = new AcpBridge(bridgeOpts); + await bridge.start(); + + const router = new SessionRouter(bridge, config.cwd, 'user', sessionsPath()); + const channels: Map = new Map(); + + const channel = createChannel(name, config, bridge, { router }); + channels.set(name, channel); + registerToolCallDispatch(bridge, router, channels); + await channel.connect(); + + writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`); + + bridge.on('disconnected', async () => { + if (shuttingDown) return; + + crashCount++; + if (crashCount > MAX_CRASH_RESTARTS) { + writeStderrLine( + `[Channel] Bridge crashed ${crashCount} times. Giving up.`, + ); + router.clearAll(); + process.exit(1); + } + + writeStderrLine( + `[Channel] Bridge crashed (${crashCount}/${MAX_CRASH_RESTARTS}). Restarting in ${RESTART_DELAY_MS / 1000}s...`, + ); + await new Promise((r) => setTimeout(r, RESTART_DELAY_MS)); + + try { + bridge = new AcpBridge(bridgeOpts); + await bridge.start(); + router.setBridge(bridge); + channel.setBridge(bridge); + registerToolCallDispatch(bridge, router, channels); + + const result = await router.restoreSessions(); + writeStdoutLine( + `[Channel] Bridge restarted. Sessions restored: ${result.restored}, failed: ${result.failed}`, + ); + crashCount = 0; + } catch (err) { + writeStderrLine( + `[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`, ); } - return envValue; - } - return value; + }); + + const shutdown = () => { + shuttingDown = true; + writeStdoutLine('\n[Channel] Shutting down...'); + channel.disconnect(); + bridge.stop(); + router.clearAll(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + await new Promise(() => {}); } -function findCliEntryPath(): string { - // When running from bundled dist/cli.js, use that same file for --acp - const mainModule = process.argv[1]; - if (mainModule) { - return path.resolve(mainModule); +/** Start all configured channels with a shared bridge + crash recovery. */ +async function startAll(): Promise { + const channelsConfig = loadChannelsConfig(); + + if (Object.keys(channelsConfig).length === 0) { + writeStderrLine( + 'Error: No channels configured in settings.json. Add entries under "channels".', + ); + process.exit(1); } - throw new Error('Cannot determine CLI entry path'); + + // Parse all configs upfront — fail fast on bad config + const parsed: Array<{ + name: string; + config: ReturnType; + }> = []; + for (const [name, raw] of Object.entries(channelsConfig)) { + try { + parsed.push({ + name, + config: parseChannelConfig(name, raw as Record), + }); + } catch (err) { + writeStderrLine( + `Error in channel "${name}": ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } + + const cliEntryPath = findCliEntryPath(); + const defaultCwd = process.cwd(); + let shuttingDown = false; + let crashCount = 0; + + const bridgeOpts = { cliEntryPath, cwd: defaultCwd }; + let bridge = new AcpBridge(bridgeOpts); + await bridge.start(); + + const router = new SessionRouter(bridge, defaultCwd, 'user', sessionsPath()); + const channels: Map = new Map(); + + writeStdoutLine( + `[Channel] Starting ${parsed.length} channel(s): ${parsed.map((p) => p.name).join(', ')}`, + ); + + for (const { name, config } of parsed) { + channels.set(name, createChannel(name, config, bridge, { router })); + } + registerToolCallDispatch(bridge, router, channels); + + // Connect all channels + let connectedCount = 0; + for (const [name, channel] of channels) { + try { + await channel.connect(); + connectedCount++; + writeStdoutLine(`[Channel] "${name}" connected.`); + } catch (err) { + writeStderrLine( + `[Channel] Failed to connect "${name}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (connectedCount === 0) { + writeStderrLine('[Channel] No channels connected. Exiting.'); + bridge.stop(); + process.exit(1); + } + + writeStdoutLine( + `[Channel] Running ${connectedCount} channel(s). Press Ctrl+C to stop.`, + ); + + bridge.on('disconnected', async () => { + if (shuttingDown) return; + + crashCount++; + if (crashCount > MAX_CRASH_RESTARTS) { + writeStderrLine( + `[Channel] Bridge crashed ${crashCount} times. Giving up.`, + ); + router.clearAll(); + process.exit(1); + } + + writeStderrLine( + `[Channel] Bridge crashed (${crashCount}/${MAX_CRASH_RESTARTS}). Restarting in ${RESTART_DELAY_MS / 1000}s...`, + ); + await new Promise((r) => setTimeout(r, RESTART_DELAY_MS)); + + try { + bridge = new AcpBridge(bridgeOpts); + await bridge.start(); + router.setBridge(bridge); + for (const channel of channels.values()) { + channel.setBridge(bridge); + } + registerToolCallDispatch(bridge, router, channels); + + const result = await router.restoreSessions(); + writeStdoutLine( + `[Channel] Bridge restarted. Sessions restored: ${result.restored}, failed: ${result.failed}`, + ); + crashCount = 0; + } catch (err) { + writeStderrLine( + `[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }); + + const shutdown = () => { + shuttingDown = true; + writeStdoutLine('\n[Channel] Shutting down...'); + for (const [name, channel] of channels) { + try { + channel.disconnect(); + writeStdoutLine(`[Channel] "${name}" disconnected.`); + } catch { + // best-effort + } + } + bridge.stop(); + router.clearAll(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + await new Promise(() => {}); } -export const startCommand: CommandModule = { - command: 'start ', - describe: 'Start a messaging channel', +export const startCommand: CommandModule = { + command: 'start [name]', + describe: 'Start channels (all if no name given, or a single named channel)', builder: (yargs) => yargs.positional('name', { type: 'string', - describe: 'Name of the channel (as configured in settings.json)', - demandOption: true, + describe: 'Channel name (omit to start all configured channels)', }), handler: async (argv) => { - const { name } = argv; - - const settings = loadSettings(process.cwd()); - const channels = ( - settings.merged as unknown as { channels?: Record } - ).channels; - - if (!channels || !channels[name]) { - writeStderrLine( - `Error: Channel "${name}" not found in settings. Add it to channels.${name} in settings.json.`, - ); - process.exit(1); - } - - const rawConfig = channels[name] as Record; - - // Validate required fields - if (!rawConfig['type']) { - writeStderrLine( - `Error: Channel "${name}" is missing required field "type".`, - ); - process.exit(1); - } - - const channelType = rawConfig['type'] as string; - const supportedTypes = ['telegram', 'weixin']; - if (!supportedTypes.includes(channelType)) { - writeStderrLine( - `Error: Channel type "${channelType}" is not supported. Available: ${supportedTypes.join(', ')}`, - ); - process.exit(1); - } - - // Token is required for telegram, not for weixin (uses account.json) - let token = ''; - if (channelType !== 'weixin') { - if (!rawConfig['token']) { - writeStderrLine( - `Error: Channel "${name}" is missing required field "token".`, - ); - process.exit(1); - } - try { - token = resolveEnvVars(rawConfig['token'] as string); - } catch (err) { - writeStderrLine( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(1); - } - } - - const config: ChannelConfig = { - type: channelType as ChannelConfig['type'], - token, - senderPolicy: - (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || - 'allowlist', - allowedUsers: (rawConfig['allowedUsers'] as string[]) || [], - sessionScope: - (rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || 'user', - cwd: (rawConfig['cwd'] as string) || process.cwd(), - approvalMode: rawConfig['approvalMode'] as string | undefined, - instructions: rawConfig['instructions'] as string | undefined, - model: rawConfig['model'] as string | undefined, - groupPolicy: - (rawConfig['groupPolicy'] as ChannelConfig['groupPolicy']) || - 'disabled', - groups: (rawConfig['groups'] as ChannelConfig['groups']) || {}, - }; - - // Pass through weixin-specific config - const extendedConfig = { - ...config, - baseUrl: rawConfig['baseUrl'] as string | undefined, - }; - - const cliEntryPath = findCliEntryPath(); - writeStdoutLine(`[Channel] CLI entry: ${cliEntryPath}`); - writeStdoutLine(`[Channel] Starting "${name}" (type=${config.type})...`); - - const bridge = new AcpBridge({ - cliEntryPath, - cwd: config.cwd, - model: config.model, - }); - await bridge.start(); - - let channel: TelegramChannel | WeixinChannel; - if (channelType === 'weixin') { - channel = new WeixinChannel(name, extendedConfig, bridge); + if (argv.name) { + await startSingle(argv.name); } else { - channel = new TelegramChannel(name, config, bridge); + await startAll(); } - await channel.connect(); - - writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`); - - // Keep process alive until interrupted - await new Promise((resolve) => { - process.on('SIGINT', () => { - writeStdoutLine('\n[Channel] Shutting down...'); - channel.disconnect(); - bridge.stop(); - resolve(); - }); - process.on('SIGTERM', () => { - channel.disconnect(); - bridge.stop(); - resolve(); - }); - }); - - process.exit(0); }, }; From 697898a9fb6e9bef7f907e0ab12e148920fc5b56 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 03:06:48 +0000 Subject: [PATCH 15/51] feat(channel): add status and stop commands for service management - Add `qwen channel status` to check running service info - Add `qwen channel stop` to gracefully stop the service - Add PID file tracking to prevent duplicate service instances - Update documentation with new commands and usage This enables users to manage the channel service from another terminal without needing to use Ctrl+C on the foreground process. Co-authored-by: Qwen-Coder --- docs/users/features/channels/overview.md | 18 ++- packages/cli/src/commands/channel.ts | 4 + packages/cli/src/commands/channel/pidfile.ts | 126 +++++++++++++++++++ packages/cli/src/commands/channel/start.ts | 25 ++++ packages/cli/src/commands/channel/status.ts | 78 ++++++++++++ packages/cli/src/commands/channel/stop.ts | 49 ++++++++ 6 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/channel/pidfile.ts create mode 100644 packages/cli/src/commands/channel/status.ts create mode 100644 packages/cli/src/commands/channel/stop.ts diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 1644f4085..a70c254a7 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -233,9 +233,15 @@ qwen channel start # Start a single channel qwen channel start my-channel + +# Check if the service is running +qwen channel status + +# Stop the running service +qwen channel stop ``` -The bot runs in the foreground. Press `Ctrl+C` to stop. +The bot runs in the foreground. Press `Ctrl+C` to stop, or use `qwen channel stop` from another terminal. ### Multi-Channel Mode @@ -243,6 +249,14 @@ When you run `qwen channel start` without a name, all channels defined in `setti Each channel uses its own `cwd` from its config, so different channels can work on different projects simultaneously. +### Service Management + +The channel service uses a PID file (`~/.qwen/channels/service.pid`) to track the running instance: + +- **Duplicate prevention**: Running `qwen channel start` while a service is already running will show an error instead of starting a second instance +- **`qwen channel stop`**: Gracefully stops the running service from another terminal +- **`qwen channel status`**: Shows whether the service is running, its uptime, and session counts per channel + ### Crash Recovery If the agent process crashes unexpectedly, the channel service automatically restarts it and attempts to restore all active sessions. Users can continue their conversations without starting over. @@ -250,4 +264,4 @@ If the agent process crashes unexpectedly, the channel service automatically res - Sessions are persisted to `~/.qwen/channels/sessions.json` while the service is running - On crash: the agent restarts within 3 seconds and reloads saved sessions - After 3 consecutive crashes, the service exits with an error -- On clean shutdown (Ctrl+C): session data is cleared — the next start is always fresh +- On clean shutdown (Ctrl+C or `qwen channel stop`): session data is cleared — the next start is always fresh diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 978c13d06..7b75da308 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -1,5 +1,7 @@ import type { CommandModule, Argv } from 'yargs'; import { startCommand } from './channel/start.js'; +import { stopCommand } from './channel/stop.js'; +import { statusCommand } from './channel/status.js'; import { pairingListCommand, pairingApproveCommand, @@ -24,6 +26,8 @@ export const channelCommand: CommandModule = { builder: (yargs: Argv) => yargs .command(startCommand) + .command(stopCommand) + .command(statusCommand) .command(pairingCommand) .command(configureWeixinCommand) .demandCommand(1, 'You need at least one command before continuing.') diff --git a/packages/cli/src/commands/channel/pidfile.ts b/packages/cli/src/commands/channel/pidfile.ts new file mode 100644 index 000000000..b1f04f730 --- /dev/null +++ b/packages/cli/src/commands/channel/pidfile.ts @@ -0,0 +1,126 @@ +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + unlinkSync, +} from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +export interface ServiceInfo { + pid: number; + startedAt: string; + channels: string[]; +} + +function pidFilePath(): string { + return path.join(os.homedir(), '.qwen', 'channels', 'service.pid'); +} + +/** Check if a process is alive. */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Read the PID file and return service info if the process is still alive. + * Returns null if no file, invalid file, or stale (dead process). + * Automatically cleans up stale PID files. + */ +export function readServiceInfo(): ServiceInfo | null { + const filePath = pidFilePath(); + if (!existsSync(filePath)) return null; + + let info: ServiceInfo; + try { + info = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch { + // Corrupt file — clean up + try { + unlinkSync(filePath); + } catch { + // best-effort + } + return null; + } + + if (!isProcessAlive(info.pid)) { + // Stale PID — process is dead, clean up + try { + unlinkSync(filePath); + } catch { + // best-effort + } + return null; + } + + return info; +} + +/** Write PID file with current process info. */ +export function writeServiceInfo(channels: string[]): void { + const filePath = pidFilePath(); + const dir = path.dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const info: ServiceInfo = { + pid: process.pid, + startedAt: new Date().toISOString(), + channels, + }; + + writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8'); +} + +/** Delete the PID file. */ +export function removeServiceInfo(): void { + const filePath = pidFilePath(); + if (existsSync(filePath)) { + try { + unlinkSync(filePath); + } catch { + // best-effort + } + } +} + +/** + * Send a signal to the running service. + * Returns true if signal was sent, false if process not found. + */ +export function signalService( + pid: number, + signal: NodeJS.Signals = 'SIGTERM', +): boolean { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +/** + * Wait for a process to exit, polling at intervals. + * Returns true if process exited, false if timeout. + */ +export async function waitForExit( + pid: number, + timeoutMs: number = 5000, + pollMs: number = 200, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!isProcessAlive(pid)) return true; + await new Promise((r) => setTimeout(r, pollMs)); + } + return !isProcessAlive(pid); +} diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 77c239a37..9ed68903d 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -8,6 +8,11 @@ import type { ChannelBase, ToolCallEvent } from '@qwen-code/channel-base'; import { TelegramChannel } from '@qwen-code/channel-telegram'; import { WeixinChannel } from '@qwen-code/channel-weixin'; import { findCliEntryPath, parseChannelConfig } from './config-utils.js'; +import { + readServiceInfo, + writeServiceInfo, + removeServiceInfo, +} from './pidfile.js'; const MAX_CRASH_RESTARTS = 3; const RESTART_DELAY_MS = 3000; @@ -52,8 +57,21 @@ function registerToolCallDispatch( }); } +/** Check for duplicate instance and abort if one is already running. */ +function checkDuplicateInstance(): void { + const existing = readServiceInfo(); + if (existing) { + writeStderrLine( + `Error: Channel service is already running (PID ${existing.pid}, started ${existing.startedAt}).`, + ); + writeStderrLine('Use "qwen channel stop" to stop it first.'); + process.exit(1); + } +} + /** Start a single channel with its own bridge + crash recovery. */ async function startSingle(name: string): Promise { + checkDuplicateInstance(); const channelsConfig = loadChannelsConfig(); if (!channelsConfig[name]) { @@ -92,6 +110,7 @@ async function startSingle(name: string): Promise { registerToolCallDispatch(bridge, router, channels); await channel.connect(); + writeServiceInfo([name]); writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`); bridge.on('disconnected', async () => { @@ -103,6 +122,7 @@ async function startSingle(name: string): Promise { `[Channel] Bridge crashed ${crashCount} times. Giving up.`, ); router.clearAll(); + removeServiceInfo(); process.exit(1); } @@ -136,6 +156,7 @@ async function startSingle(name: string): Promise { channel.disconnect(); bridge.stop(); router.clearAll(); + removeServiceInfo(); process.exit(0); }; process.on('SIGINT', shutdown); @@ -146,6 +167,7 @@ async function startSingle(name: string): Promise { /** Start all configured channels with a shared bridge + crash recovery. */ async function startAll(): Promise { + checkDuplicateInstance(); const channelsConfig = loadChannelsConfig(); if (Object.keys(channelsConfig).length === 0) { @@ -215,6 +237,7 @@ async function startAll(): Promise { process.exit(1); } + writeServiceInfo(parsed.map((p) => p.name)); writeStdoutLine( `[Channel] Running ${connectedCount} channel(s). Press Ctrl+C to stop.`, ); @@ -228,6 +251,7 @@ async function startAll(): Promise { `[Channel] Bridge crashed ${crashCount} times. Giving up.`, ); router.clearAll(); + removeServiceInfo(); process.exit(1); } @@ -270,6 +294,7 @@ async function startAll(): Promise { } bridge.stop(); router.clearAll(); + removeServiceInfo(); process.exit(0); }; process.on('SIGINT', shutdown); diff --git a/packages/cli/src/commands/channel/status.ts b/packages/cli/src/commands/channel/status.ts new file mode 100644 index 000000000..bbd5e4f35 --- /dev/null +++ b/packages/cli/src/commands/channel/status.ts @@ -0,0 +1,78 @@ +import { existsSync, readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { CommandModule } from 'yargs'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { readServiceInfo } from './pidfile.js'; +import type { SessionTarget } from '@qwen-code/channel-base'; + +interface PersistedEntry { + sessionId: string; + target: SessionTarget; + cwd: string; +} + +function formatUptime(startedAt: string): string { + const ms = Date.now() - new Date(startedAt).getTime(); + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} + +export const statusCommand: CommandModule = { + command: 'status', + describe: 'Show channel service status', + handler: async () => { + const info = readServiceInfo(); + + if (!info) { + writeStdoutLine('No channel service is running.'); + process.exit(0); + } + + writeStdoutLine(`Channel service: running (PID ${info.pid})`); + writeStdoutLine(`Uptime: ${formatUptime(info.startedAt)}`); + writeStdoutLine(''); + + // Read session data for per-channel counts + const sessionsPath = path.join( + os.homedir(), + '.qwen', + 'channels', + 'sessions.json', + ); + + const sessionCounts = new Map(); + if (existsSync(sessionsPath)) { + try { + const entries: Record = JSON.parse( + readFileSync(sessionsPath, 'utf-8'), + ); + for (const entry of Object.values(entries)) { + const name = entry.target.channelName; + sessionCounts.set(name, (sessionCounts.get(name) || 0) + 1); + } + } catch { + // best-effort + } + } + + // Table header + const nameWidth = Math.max(15, ...info.channels.map((c) => c.length + 2)); + writeStdoutLine(`${'Channel'.padEnd(nameWidth)}Sessions`); + writeStdoutLine(`${'-'.repeat(nameWidth)}--------`); + + for (const name of info.channels) { + const count = sessionCounts.get(name) || 0; + writeStdoutLine(`${name.padEnd(nameWidth)}${count}`); + } + + process.exit(0); + }, +}; diff --git a/packages/cli/src/commands/channel/stop.ts b/packages/cli/src/commands/channel/stop.ts new file mode 100644 index 000000000..e783581ec --- /dev/null +++ b/packages/cli/src/commands/channel/stop.ts @@ -0,0 +1,49 @@ +import type { CommandModule } from 'yargs'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { + readServiceInfo, + signalService, + waitForExit, + removeServiceInfo, +} from './pidfile.js'; + +export const stopCommand: CommandModule = { + command: 'stop', + describe: 'Stop the running channel service', + handler: async () => { + const info = readServiceInfo(); + + if (!info) { + writeStdoutLine('No channel service is running.'); + process.exit(0); + } + + writeStdoutLine(`Stopping channel service (PID ${info.pid})...`); + + if (!signalService(info.pid, 'SIGTERM')) { + writeStderrLine( + 'Failed to send signal — process may have already exited.', + ); + removeServiceInfo(); + process.exit(0); + } + + const exited = await waitForExit(info.pid, 5000); + + if (exited) { + // Clean up in case the process didn't delete its own PID file + removeServiceInfo(); + writeStdoutLine('Service stopped.'); + } else { + writeStderrLine( + 'Service did not exit within 5 seconds. Sending SIGKILL...', + ); + signalService(info.pid, 'SIGKILL'); + await waitForExit(info.pid, 2000); + removeServiceInfo(); + writeStdoutLine('Service killed.'); + } + + process.exit(0); + }, +}; From 9c001ba61e3eccb9187398538deebfb33e186e7d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 03:24:44 +0000 Subject: [PATCH 16/51] feat(channels): add shared slash command system - Add /help, /clear (aliases: /reset, /new), /status commands to ChannelBase - Commands are handled locally without agent round-trip - TelegramAdapter skips "Working..." indicator for local commands - Update docs to reflect new command structure This provides a consistent command interface across all channel types (Telegram, WeChat, etc.) with platform-specific extensibility. Co-authored-by: Qwen-Coder --- docs/users/features/channels/overview.md | 8 +- docs/users/features/channels/telegram.md | 2 +- packages/channels/base/src/ChannelBase.ts | 109 ++++++++++++++++++ .../channels/telegram/src/TelegramAdapter.ts | 64 +++------- 4 files changed, 130 insertions(+), 53 deletions(-) diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index a70c254a7..6ba103845 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -217,14 +217,16 @@ Files work with any model — no multimodal support required. ## Slash Commands -Channels support slash commands. Some are handled locally by the adapter: +Channels support slash commands. These are handled locally (no agent round-trip): -- `/start` — Welcome message - `/help` — List available commands -- `/reset` — Reset your session and start fresh +- `/clear` — Clear your session and start fresh (aliases: `/reset`, `/new`) +- `/status` — Show session info and access policy All other slash commands (e.g., `/compress`, `/summary`) are forwarded to the agent. +These commands work on all channel types (Telegram, WeChat, etc.). + ## Running ```bash diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md index 5c5f1bddd..3e62ebbce 100644 --- a/docs/users/features/channels/telegram.md +++ b/docs/users/features/channels/telegram.md @@ -88,7 +88,7 @@ You can send photos and documents to the bot, not just text. ## Tips - **Keep instructions concise-focused** — Telegram has a 4096-character message limit. Adding instructions like "keep responses short" helps the agent stay within bounds. -- **Use `sessionScope: "user"`** — This gives each user their own conversation. Use `/reset` to start fresh. +- **Use `sessionScope: "user"`** — This gives each user their own conversation. Use `/clear` to start fresh. - **Restrict access** — Use `senderPolicy: "allowlist"` for a fixed set of users, or `"pairing"` to let new users request access with a code you approve via CLI. See [DM Pairing](./overview#dm-pairing) for details. ## Message Formatting diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index c8e4e7f33..0ff0d36f3 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -9,6 +9,9 @@ export interface ChannelBaseOptions { router?: SessionRouter; } +/** Handler for a slash command. Return true if handled, false to forward to agent. */ +type CommandHandler = (envelope: Envelope, args: string) => Promise; + export abstract class ChannelBase { protected config: ChannelConfig; protected bridge: AcpBridge; @@ -17,6 +20,7 @@ export abstract class ChannelBase { protected router: SessionRouter; protected name: string; private instructedSessions: Set = new Set(); + private commands: Map = new Map(); constructor( name: string, @@ -41,6 +45,8 @@ export abstract class ChannelBase { options?.router || new SessionRouter(bridge, config.cwd, config.sessionScope); + this.registerSharedCommands(); + // When running standalone (no gateway), register toolCall listener directly. // In gateway mode, the ChannelManager dispatches events instead. if (!options?.router) { @@ -64,6 +70,98 @@ export abstract class ChannelBase { onToolCall(_chatId: string, _event: ToolCallEvent): void {} + /** + * Register a slash command handler. Subclasses can call this to add + * platform-specific commands (e.g., /start for Telegram). + * Overrides shared commands if the same name is registered. + */ + protected registerCommand(name: string, handler: CommandHandler): void { + this.commands.set(name.toLowerCase(), handler); + } + + /** Register shared slash commands. Called from constructor. */ + private registerSharedCommands(): void { + const clearHandler: CommandHandler = async (envelope) => { + const removed = this.router.removeSession(this.name, envelope.senderId); + if (removed) { + this.instructedSessions.clear(); + await this.sendMessage( + envelope.chatId, + 'Session cleared. Your next message will start a fresh conversation.', + ); + } else { + await this.sendMessage(envelope.chatId, 'No active session to clear.'); + } + return true; + }; + + this.registerCommand('clear', clearHandler); + this.registerCommand('reset', clearHandler); + this.registerCommand('new', clearHandler); + + this.registerCommand('help', async (envelope) => { + const lines = [ + 'Commands:', + '/help — Show this help', + '/clear — Clear your session (aliases: /reset, /new)', + '/status — Show session info', + ]; + + // Platform-specific commands (registered by adapters, not shared ones) + const sharedCmds = new Set(['help', 'clear', 'reset', 'new', 'status']); + const platformCmds = [...this.commands.keys()].filter( + (c) => !sharedCmds.has(c), + ); + if (platformCmds.length > 0) { + for (const cmd of platformCmds) { + lines.push(`/${cmd}`); + } + } + + const agentCommands = this.bridge.availableCommands; + if (agentCommands.length > 0) { + lines.push('', 'Agent commands (forwarded to Qwen Code):'); + for (const cmd of agentCommands) { + lines.push(`/${cmd.name} — ${cmd.description}`); + } + } + + lines.push('', 'Send any text to chat with the agent.'); + await this.sendMessage(envelope.chatId, lines.join('\n')); + return true; + }); + + this.registerCommand('status', async (envelope) => { + const hasSession = this.router.hasSession(this.name, envelope.senderId); + const policy = this.config.senderPolicy; + const lines = [ + `Session: ${hasSession ? 'active' : 'none'}`, + `Access: ${policy}`, + `Channel: ${this.name}`, + ]; + await this.sendMessage(envelope.chatId, lines.join('\n')); + return true; + }); + } + + /** Check if a message text matches a registered local command. */ + protected isLocalCommand(text: string): boolean { + const parsed = this.parseCommand(text); + return parsed !== null && this.commands.has(parsed.command); + } + + /** + * Parse a slash command from message text. + * Returns { command, args } or null if not a slash command. + */ + private parseCommand(text: string): { command: string; args: string } | null { + if (!text.startsWith('/')) return null; + // Handle /command@botname format (Telegram groups) + const match = text.match(/^\/([a-zA-Z0-9_]+)(?:@\S+)?\s*(.*)/s); + if (!match) return null; + return { command: match[1].toLowerCase(), args: match[2].trim() }; + } + async handleInbound(envelope: Envelope): Promise { // 1. Group gate: policy + allowlist + mention gating const groupResult = this.groupGate.check(envelope); @@ -80,6 +178,17 @@ export abstract class ChannelBase { return; } + // 3. Slash command handling — before session/agent routing + const parsed = this.parseCommand(envelope.text); + if (parsed) { + const handler = this.commands.get(parsed.command); + if (handler) { + const handled = await handler(envelope, parsed.args); + if (handled) return; + } + // Unrecognized commands fall through to the agent + } + const sessionId = await this.router.resolve( this.name, envelope.senderId, diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 14533f304..af75be9de 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -14,8 +14,8 @@ import type { AcpBridge, } from '@qwen-code/channel-base'; -// Commands handled locally by the Telegram adapter (not forwarded to ACP) -const LOCAL_COMMANDS = new Set(['start', 'help', 'reset']); +// Commands handled by Telegraf directly (before handleInbound) +const TELEGRAF_COMMANDS = new Set(); export class TelegramChannel extends ChannelBase { private bot: Telegraf; @@ -36,54 +36,16 @@ export class TelegramChannel extends ChannelBase { const botInfo = await this.bot.telegram.getMe(); this.botId = botInfo.id; this.botUsername = botInfo.username ?? ''; - // Register local-only commands - this.bot.command('start', async (ctx) => { - await ctx.reply( - `Hi ${ctx.from.first_name}! I'm a Qwen Code agent.\n\nSend any message to chat, or use slash commands like /compress, /summary.\n\nType /help for more info.`, - ); - }); - - this.bot.command('help', async (ctx) => { - const lines = [ - 'Local commands:', - '/start — Welcome message', - '/help — Show this help', - '/reset — Reset your session (start fresh)', - ]; - - const agentCommands = this.bridge.availableCommands; - if (agentCommands.length > 0) { - lines.push('', 'Agent commands (forwarded to Qwen Code):'); - for (const cmd of agentCommands) { - lines.push(`/${cmd.name} — ${cmd.description}`); - } - } - - lines.push('', 'Send any text to chat with the agent.'); - await ctx.reply(lines.join('\n')); - }); - - this.bot.command('reset', async (ctx) => { - const senderId = String(ctx.from.id); - const removed = this.router.removeSession(this.name, senderId); - if (removed) { - await ctx.reply( - 'Session reset. Your next message will start a fresh conversation.', - ); - } else { - await ctx.reply('No active session to reset.'); - } - }); - - // All other messages (including non-local slash commands) go through handleInbound + // All messages (including slash commands) go through handleInbound + // where ChannelBase dispatches shared commands (/help, /clear, /status, etc.) this.bot.on('text', async (ctx) => { const msg = ctx.message; const text = msg.text; - // Skip if it's a local command (already handled above) + // Skip Telegraf-handled commands if (text.startsWith('/')) { const command = text.slice(1).split(/[\s@]/)[0]?.toLowerCase(); - if (command && LOCAL_COMMANDS.has(command)) { + if (command && TELEGRAF_COMMANDS.has(command)) { return; } } @@ -200,15 +162,19 @@ export class TelegramChannel extends ChannelBase { return; } - // Send "Working..." immediately for instant feedback - const workingMsg = await this.bot.telegram - .sendMessage(envelope.chatId, 'Working...') - .catch(() => null); + // Skip "Working..." for local slash commands — they respond instantly + const isLocalCommand = + envelope.text.startsWith('/') && this.isLocalCommand(envelope.text); + + const workingMsg = isLocalCommand + ? null + : await this.bot.telegram + .sendMessage(envelope.chatId, 'Working...') + .catch(() => null); try { await super.handleInbound(envelope); } finally { - // Always delete "Working..." — even on error/timeout if (workingMsg) { this.bot.telegram .deleteMessage(envelope.chatId, workingMsg.message_id) From 1a272a12e92e1daaa5178af336ed65eca258cada Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 03:34:16 +0000 Subject: [PATCH 17/51] feat(channels): add reply context support for referenced messages - Add referencedText field to Envelope for quoted/replied-to messages - TelegramAdapter extracts text from reply_to_message - WeixinAdapter extracts text from ref_msg field - ChannelBase prepends referenced text to agent prompt This allows the agent to see what message a user is replying to, providing better context for conversations in both Telegram and WeChat. Co-authored-by: Qwen-Coder --- packages/channels/base/src/ChannelBase.ts | 9 +++++++-- packages/channels/base/src/types.ts | 2 ++ .../channels/telegram/src/TelegramAdapter.ts | 6 +++++- packages/channels/weixin/src/WeixinAdapter.ts | 1 + packages/channels/weixin/src/monitor.ts | 20 +++++++++++++++++-- packages/channels/weixin/src/types.ts | 6 ++++++ 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 0ff0d36f3..a5e966182 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -197,10 +197,15 @@ export abstract class ChannelBase { this.config.cwd, ); - // Prepend channel instructions on first message of a session + // Prepend referenced (quoted) message text for reply context let promptText = envelope.text; + if (envelope.referencedText) { + promptText = `[Replying to: "${envelope.referencedText}"]\n\n${promptText}`; + } + + // Prepend channel instructions on first message of a session if (this.config.instructions && !this.instructedSessions.has(sessionId)) { - promptText = `${this.config.instructions}\n\n${envelope.text}`; + promptText = `${this.config.instructions}\n\n${promptText}`; this.instructedSessions.add(sessionId); } diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 1eb405dd3..2f02cd510 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -31,6 +31,8 @@ export interface Envelope { isGroup: boolean; isMentioned: boolean; isReplyToBot: boolean; + /** Text of the message being replied to (quoted/referenced message). */ + referencedText?: string; /** Base64-encoded image data (e.g. from WeChat CDN download). */ imageBase64?: string; /** MIME type for the image (e.g. "image/jpeg", "image/png"). */ diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index af75be9de..3445f3986 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -207,7 +207,7 @@ export class TelegramChannel extends ChannelBase { msg: { from: { id: number; first_name: string; last_name?: string }; chat: { id: number; type: string }; - reply_to_message?: { from?: { id: number } }; + reply_to_message?: { from?: { id: number }; text?: string }; }, text: string, entities?: Array<{ type: string; offset: number; length: number }>, @@ -232,6 +232,9 @@ export class TelegramChannel extends ChannelBase { .trim(); } + // Extract referenced message text (when user replies to a message) + const referencedText = msg.reply_to_message?.text || undefined; + return { channelName: this.name, senderId: String(msg.from.id), @@ -243,6 +246,7 @@ export class TelegramChannel extends ChannelBase { isGroup, isMentioned, isReplyToBot, + referencedText, }; } } diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts index 1999d1e0e..3944916bc 100644 --- a/packages/channels/weixin/src/WeixinAdapter.ts +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -68,6 +68,7 @@ export class WeixinChannel extends ChannelBase { isGroup: false, isMentioned: false, isReplyToBot: false, + referencedText: msg.refText, }; this.handleInboundWithMedia(envelope, msg.image, msg.file).catch( diff --git a/packages/channels/weixin/src/monitor.ts b/packages/channels/weixin/src/monitor.ts index 276bb7d14..48c3097c9 100644 --- a/packages/channels/weixin/src/monitor.ts +++ b/packages/channels/weixin/src/monitor.ts @@ -48,6 +48,8 @@ export interface ParsedMessage { image?: CdnRef; /** CDN reference for deferred file download. */ file?: FileCdnRef; + /** Text of the referenced (replied-to) message. */ + refText?: string; } export type OnMessageCallback = (msg: ParsedMessage) => Promise; @@ -144,16 +146,29 @@ async function processMessage( contextTokens.set(fromUserId, msg.context_token); } - // Extract text, image, and file CDN references + // Extract text, image, file CDN references, and referenced message let textContent = ''; let image: CdnRef | undefined; let file: FileCdnRef | undefined; + let refText: string | undefined; if (msg.item_list) { for (const item of msg.item_list) { if (item.type === MessageItemType.TEXT && item.text_item?.text) { textContent += (textContent ? '\n' : '') + item.text_item.text; - } else if (item.type === MessageItemType.IMAGE && item.image_item) { + } + + // Extract referenced message text + if (item.ref_msg) { + const refItem = item.ref_msg.message_item; + if (refItem?.text_item?.text) { + refText = refItem.text_item.text; + } else if (item.ref_msg.title) { + refText = item.ref_msg.title; + } + } + + if (item.type === MessageItemType.IMAGE && item.image_item) { const media = item.image_item.media; if (media?.encrypt_query_param && media.aes_key) { image = { @@ -183,5 +198,6 @@ async function processMessage( text: textContent || (file ? `(file: ${file.fileName})` : '(image)'), image, file, + refText, }); } diff --git a/packages/channels/weixin/src/types.ts b/packages/channels/weixin/src/types.ts index 7f2191b16..702ec7be8 100644 --- a/packages/channels/weixin/src/types.ts +++ b/packages/channels/weixin/src/types.ts @@ -62,6 +62,11 @@ export interface VideoItem { video_size?: number; } +export interface RefMessage { + message_item?: MessageItem; + title?: string; +} + export interface MessageItem { type?: number; text_item?: TextItem; @@ -69,6 +74,7 @@ export interface MessageItem { voice_item?: VoiceItem; file_item?: FileItem; video_item?: VideoItem; + ref_msg?: RefMessage; } export interface WeixinMessage { From 92c54ff309ce4215ab6752670acb4cd40e536a77 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 08:03:43 +0000 Subject: [PATCH 18/51] feat(channels): add DingTalk channel adapter - Add @qwen-code/channel-dingtalk package with stream-based bot integration - Support clientId/clientSecret authentication for DingTalk - Add message deduplication and group chat mention handling - Update ChannelConfig type to include dingtalk channel type Co-authored-by: Qwen-Coder --- package-lock.json | 73 +++++++- package.json | 3 +- packages/channels/base/src/types.ts | 9 +- packages/channels/dingtalk/package.json | 18 ++ .../channels/dingtalk/src/DingtalkAdapter.ts | 168 ++++++++++++++++++ packages/channels/dingtalk/src/index.ts | 1 + packages/cli/package.json | 1 + .../cli/src/commands/channel/config-utils.ts | 19 +- packages/cli/src/commands/channel/start.ts | 4 + 9 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 packages/channels/dingtalk/package.json create mode 100644 packages/channels/dingtalk/src/DingtalkAdapter.ts create mode 100644 packages/channels/dingtalk/src/index.ts diff --git a/package-lock.json b/package-lock.json index b5ef4afbb..caf9f2156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "packages/*", "packages/channels/base", "packages/channels/telegram", - "packages/channels/weixin" + "packages/channels/weixin", + "packages/channels/dingtalk" ], "dependencies": { "@testing-library/dom": "^10.4.1", @@ -2997,6 +2998,10 @@ "resolved": "packages/channels/base", "link": true }, + "node_modules/@qwen-code/channel-dingtalk": { + "resolved": "packages/channels/dingtalk", + "link": true + }, "node_modules/@qwen-code/channel-telegram": { "resolved": "packages/channels/telegram", "link": true @@ -6442,6 +6447,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -8102,6 +8118,17 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dingtalk-stream-sdk-nodejs": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/dingtalk-stream-sdk-nodejs/-/dingtalk-stream-sdk-nodejs-2.0.4.tgz", + "integrity": "sha512-aVHQ72zAZ6upfuwQXhLvorDZY47uyOp8cvMFVrvLOws8tVCiM1YwFcKvcPthOt9c2gaGdv3BXHtnLeLeWFAv8Q==", + "license": "MIT", + "dependencies": { + "axios": "^1.4.0", + "debug": "^4.3.4", + "ws": "^8.13.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -9777,6 +9804,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9810,9 +9857,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -14707,6 +14754,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -18921,6 +18974,17 @@ "typescript": "^5.0.0" } }, + "packages/channels/dingtalk": { + "name": "@qwen-code/channel-dingtalk", + "version": "0.1.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "dingtalk-stream-sdk-nodejs": "^2.0.4" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, "packages/channels/telegram": { "name": "@qwen-code/channel-telegram", "version": "0.1.0", @@ -18952,6 +19016,7 @@ "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/channel-base": "file:../channels/base", + "@qwen-code/channel-dingtalk": "file:../channels/dingtalk", "@qwen-code/channel-telegram": "file:../channels/telegram", "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", diff --git a/package.json b/package.json index 55d0ac11e..cf339100a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "packages/*", "packages/channels/base", "packages/channels/telegram", - "packages/channels/weixin" + "packages/channels/weixin", + "packages/channels/dingtalk" ], "repository": { "type": "git", diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 2f02cd510..0bea8aa15 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -1,6 +1,11 @@ export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; export type SessionScope = 'user' | 'thread' | 'single'; -export type ChannelType = 'telegram' | 'weixin' | 'discord' | 'webhook'; +export type ChannelType = + | 'telegram' + | 'weixin' + | 'dingtalk' + | 'discord' + | 'webhook'; export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; export interface GroupConfig { @@ -10,6 +15,8 @@ export interface GroupConfig { export interface ChannelConfig { type: ChannelType; token: string; + clientId?: string; + clientSecret?: string; senderPolicy: SenderPolicy; allowedUsers: string[]; sessionScope: SessionScope; diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json new file mode 100644 index 000000000..facfd5894 --- /dev/null +++ b/packages/channels/dingtalk/package.json @@ -0,0 +1,18 @@ +{ + "name": "@qwen-code/channel-dingtalk", + "version": "0.1.0", + "description": "DingTalk channel adapter for Qwen Code", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "dingtalk-stream-sdk-nodejs": "^2.0.4" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts new file mode 100644 index 000000000..1959fb35d --- /dev/null +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -0,0 +1,168 @@ +import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs'; +import type { + DWClientDownStream, + RobotMessage, +} from 'dingtalk-stream-sdk-nodejs'; +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; + +/** Track seen msgIds to deduplicate retried callbacks. */ +const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes + +export class DingtalkChannel extends ChannelBase { + private client: DWClient; + private seenMessages: Map = new Map(); + private dedupTimer?: ReturnType; + + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + + if (!config.clientId || !config.clientSecret) { + throw new Error( + `Channel "${name}" requires clientId and clientSecret for DingTalk.`, + ); + } + + this.client = new DWClient({ + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + } + + async connect(): Promise { + this.client.registerCallbackListener( + TOPIC_ROBOT, + (msg: DWClientDownStream) => { + // ACK immediately so DingTalk doesn't retry + this.client.send(msg.headers.messageId, { + status: EventAck.SUCCESS, + message: 'ok', + }); + this.onMessage(msg); + }, + ); + + await this.client.connect(); + + // Periodically clean up dedup map + this.dedupTimer = setInterval(() => { + const now = Date.now(); + for (const [id, ts] of this.seenMessages) { + if (now - ts > DEDUP_TTL_MS) { + this.seenMessages.delete(id); + } + } + }, 60_000); + + process.stderr.write(`[DingTalk:${this.name}] Connected via stream.\n`); + } + + async sendMessage(chatId: string, text: string): Promise { + // chatId is the sessionWebhook URL for DingTalk + const body = { + msgtype: 'markdown', + markdown: { + title: 'Reply', + text, + }, + }; + + const resp = await fetch(chatId, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + process.stderr.write( + `[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`, + ); + } + } + + disconnect(): void { + if (this.dedupTimer) { + clearInterval(this.dedupTimer); + } + this.client.disconnect(); + process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`); + } + + private onMessage(downstream: DWClientDownStream): void { + try { + const data: RobotMessage = + typeof downstream.data === 'string' + ? JSON.parse(downstream.data) + : (downstream.data as unknown as RobotMessage); + const msgId = data.msgId || downstream.headers.messageId; + + // Dedup: DingTalk retries unACKed messages + if (msgId && this.seenMessages.has(msgId)) { + return; + } + if (msgId) { + this.seenMessages.set(msgId, Date.now()); + } + + const isGroup = data.conversationType === '2'; + const text = data.text?.content?.trim() || ''; + const sessionWebhook = data.sessionWebhook; + + if (!sessionWebhook) { + process.stderr.write( + `[DingTalk:${this.name}] No sessionWebhook in message, skipping.\n`, + ); + return; + } + + // In group chats, check isInAtList from the raw data + const rawData = JSON.parse(downstream.data); + const isMentioned = Boolean(rawData.isInAtList); + + // Strip @bot mention from text + let cleanText = text; + if (isMentioned && data.senderNick) { + // DingTalk prepends the @mention text; remove it + cleanText = text.replace(/@\S+/g, '').trim(); + } + + const envelope: Envelope = { + channelName: this.name, + senderId: data.senderId || data.senderStaffId, + senderName: data.senderNick || 'Unknown', + chatId: sessionWebhook, // Use webhook URL as chatId for sendMessage + text: cleanText || text, + isGroup, + isMentioned, + isReplyToBot: false, + }; + + // Don't await — stream callback should return quickly + this.handleInbound(envelope).catch((err) => { + process.stderr.write( + `[DingTalk:${this.name}] Error handling message: ${err}\n`, + ); + // Try to send error reply + this.sendMessage( + sessionWebhook, + 'Sorry, something went wrong processing your message.', + ).catch(() => {}); + }); + } catch (err) { + process.stderr.write( + `[DingTalk:${this.name}] Failed to parse message: ${err}\n`, + ); + } + } +} diff --git a/packages/channels/dingtalk/src/index.ts b/packages/channels/dingtalk/src/index.ts new file mode 100644 index 000000000..80f7b912f --- /dev/null +++ b/packages/channels/dingtalk/src/index.ts @@ -0,0 +1 @@ +export { DingtalkChannel } from './DingtalkAdapter.js'; diff --git a/packages/cli/package.json b/packages/cli/package.json index ffdc4092e..bee927ccb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,6 +43,7 @@ "@qwen-code/channel-base": "file:../channels/base", "@qwen-code/channel-telegram": "file:../channels/telegram", "@qwen-code/channel-weixin": "file:../channels/weixin", + "@qwen-code/channel-dingtalk": "file:../channels/dingtalk", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index 41af02d7a..e6da41cb5 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -23,7 +23,7 @@ export function findCliEntryPath(): string { throw new Error('Cannot determine CLI entry path'); } -const SUPPORTED_TYPES = ['telegram', 'weixin']; +const SUPPORTED_TYPES = ['telegram', 'weixin', 'dingtalk']; export function parseChannelConfig( name: string, @@ -41,16 +41,31 @@ export function parseChannelConfig( } let token = ''; - if (channelType !== 'weixin') { + if (channelType !== 'weixin' && channelType !== 'dingtalk') { if (!rawConfig['token']) { throw new Error(`Channel "${name}" is missing required field "token".`); } token = resolveEnvVars(rawConfig['token'] as string); } + // DingTalk uses clientId + clientSecret instead of token + let clientId: string | undefined; + let clientSecret: string | undefined; + if (channelType === 'dingtalk') { + if (!rawConfig['clientId'] || !rawConfig['clientSecret']) { + throw new Error( + `Channel "${name}" requires "clientId" and "clientSecret" for DingTalk.`, + ); + } + clientId = resolveEnvVars(rawConfig['clientId'] as string); + clientSecret = resolveEnvVars(rawConfig['clientSecret'] as string); + } + return { type: channelType as ChannelConfig['type'], token, + clientId, + clientSecret, senderPolicy: (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || 'allowlist', diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 9ed68903d..927eb56e8 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -7,6 +7,7 @@ import { AcpBridge, SessionRouter } from '@qwen-code/channel-base'; import type { ChannelBase, ToolCallEvent } from '@qwen-code/channel-base'; import { TelegramChannel } from '@qwen-code/channel-telegram'; import { WeixinChannel } from '@qwen-code/channel-weixin'; +import { DingtalkChannel } from '@qwen-code/channel-dingtalk'; import { findCliEntryPath, parseChannelConfig } from './config-utils.js'; import { readServiceInfo, @@ -38,6 +39,9 @@ function createChannel( if (config.type === 'weixin') { return new WeixinChannel(name, config, bridge, options); } + if (config.type === 'dingtalk') { + return new DingtalkChannel(name, config, bridge, options); + } return new TelegramChannel(name, config, bridge, options); } From 217964b849097d6ab236c8b9a90f0897c898de9a Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 08:29:27 +0000 Subject: [PATCH 19/51] feat(channels): add reaction feedback and webhook caching for DingTalk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache sessionWebhook by conversationId for reliable message routing - Show 👀 reaction while processing messages, then recall it - Use conversationId as chatId instead of webhook URL - Fix rawData parsing for already-parsed message data Co-authored-by: Qwen-Coder --- .../channels/dingtalk/src/DingtalkAdapter.ts | 120 ++++++++++++++++-- 1 file changed, 111 insertions(+), 9 deletions(-) diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 1959fb35d..52dc17c21 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -14,10 +14,17 @@ import type { /** Track seen msgIds to deduplicate retried callbacks. */ const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes +const ACK_REACTION_NAME = '👀'; +const ACK_EMOTION_ID = '2659900'; +const ACK_EMOTION_BG_ID = 'im_bg_1'; +const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion'; + export class DingtalkChannel extends ChannelBase { private client: DWClient; private seenMessages: Map = new Map(); private dedupTimer?: ReturnType; + /** Map conversationId → latest sessionWebhook URL for sending replies. */ + private webhooks: Map = new Map(); constructor( name: string, @@ -68,7 +75,15 @@ export class DingtalkChannel extends ChannelBase { } async sendMessage(chatId: string, text: string): Promise { - // chatId is the sessionWebhook URL for DingTalk + // chatId is a conversationId — resolve to the latest sessionWebhook + const webhook = this.webhooks.get(chatId); + if (!webhook) { + process.stderr.write( + `[DingTalk:${this.name}] No webhook for chatId ${chatId}, cannot send.\n`, + ); + return; + } + const body = { msgtype: 'markdown', markdown: { @@ -77,7 +92,7 @@ export class DingtalkChannel extends ChannelBase { }, }; - const resp = await fetch(chatId, { + const resp = await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -91,6 +106,67 @@ export class DingtalkChannel extends ChannelBase { } } + private getAccessToken(): string | undefined { + return this.client.getConfig().access_token; + } + + private async emotionApi( + endpoint: 'reply' | 'recall', + msgId: string, + conversationId: string, + ): Promise { + const token = this.getAccessToken(); + if (!token) return; + + const robotCode = this.config.clientId; + if (!robotCode || !msgId || !conversationId) return; + + try { + const resp = await fetch(`${EMOTION_API}/${endpoint}`, { + method: 'POST', + headers: { + 'x-acs-dingtalk-access-token': token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + robotCode, + openMsgId: msgId, + openConversationId: conversationId, + emotionType: 2, + emotionName: ACK_REACTION_NAME, + textEmotion: { + emotionId: ACK_EMOTION_ID, + emotionName: ACK_REACTION_NAME, + text: ACK_REACTION_NAME, + backgroundId: ACK_EMOTION_BG_ID, + }, + }), + }); + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + process.stderr.write( + `[DingTalk:${this.name}] emotion/${endpoint} failed: ${resp.status} ${detail}\n`, + ); + } + } catch { + // best-effort, don't break message flow + } + } + + private async attachReaction( + msgId: string, + conversationId: string, + ): Promise { + await this.emotionApi('reply', msgId, conversationId); + } + + private async recallReaction( + msgId: string, + conversationId: string, + ): Promise { + await this.emotionApi('recall', msgId, conversationId); + } + disconnect(): void { if (this.dedupTimer) { clearInterval(this.dedupTimer); @@ -118,6 +194,7 @@ export class DingtalkChannel extends ChannelBase { const isGroup = data.conversationType === '2'; const text = data.text?.content?.trim() || ''; const sessionWebhook = data.sessionWebhook; + const conversationId = data.conversationId; if (!sessionWebhook) { process.stderr.write( @@ -126,36 +203,61 @@ export class DingtalkChannel extends ChannelBase { return; } + // Cache webhook by conversationId so sendMessage can look it up + if (conversationId) { + this.webhooks.set(conversationId, sessionWebhook); + } + // In group chats, check isInAtList from the raw data - const rawData = JSON.parse(downstream.data); + const rawData = + typeof downstream.data === 'string' + ? JSON.parse(downstream.data) + : downstream.data; const isMentioned = Boolean(rawData.isInAtList); // Strip @bot mention from text let cleanText = text; - if (isMentioned && data.senderNick) { - // DingTalk prepends the @mention text; remove it + if (isMentioned) { cleanText = text.replace(/@\S+/g, '').trim(); } + const chatId = conversationId || sessionWebhook; + const envelope: Envelope = { channelName: this.name, senderId: data.senderId || data.senderStaffId, senderName: data.senderNick || 'Unknown', - chatId: sessionWebhook, // Use webhook URL as chatId for sendMessage + chatId, text: cleanText || text, isGroup, isMentioned, isReplyToBot: false, }; + // Attach 👀 reaction, process message, then recall reaction + const reactionMsgId = msgId; + const reactionConvId = conversationId; + + const processMessage = async () => { + if (reactionMsgId && reactionConvId) { + this.attachReaction(reactionMsgId, reactionConvId).catch(() => {}); + } + try { + await this.handleInbound(envelope); + } finally { + if (reactionMsgId && reactionConvId) { + this.recallReaction(reactionMsgId, reactionConvId).catch(() => {}); + } + } + }; + // Don't await — stream callback should return quickly - this.handleInbound(envelope).catch((err) => { + processMessage().catch((err) => { process.stderr.write( `[DingTalk:${this.name}] Error handling message: ${err}\n`, ); - // Try to send error reply this.sendMessage( - sessionWebhook, + chatId, 'Sorry, something went wrong processing your message.', ).catch(() => {}); }); From 6c6057cf9c404a55182db2f4f7ada07689788c16 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 08:34:34 +0000 Subject: [PATCH 20/51] feat(channels): add DingTalk markdown normalization - Convert tables to pipe-separated plain text (DingTalk doesn't render tables) - Split long messages into chunks respecting ~3800 char limit - Handle code fences across chunk boundaries - Extract title from first markdown line for webhook payload This ensures markdown content renders correctly in DingTalk's limited renderer. Co-authored-by: Qwen-Coder --- .../channels/dingtalk/src/DingtalkAdapter.ts | 40 +++--- packages/channels/dingtalk/src/markdown.ts | 130 ++++++++++++++++++ 2 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 packages/channels/dingtalk/src/markdown.ts diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 52dc17c21..6a248c824 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -4,6 +4,7 @@ import type { RobotMessage, } from 'dingtalk-stream-sdk-nodejs'; import { ChannelBase } from '@qwen-code/channel-base'; +import { normalizeDingTalkMarkdown, extractTitle } from './markdown.js'; import type { ChannelConfig, ChannelBaseOptions, @@ -84,25 +85,30 @@ export class DingtalkChannel extends ChannelBase { return; } - const body = { - msgtype: 'markdown', - markdown: { - title: 'Reply', - text, - }, - }; + const chunks = normalizeDingTalkMarkdown(text); + const title = extractTitle(text); - const resp = await fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + for (const chunk of chunks) { + const body = { + msgtype: 'markdown', + markdown: { + title: chunks.length > 1 ? `${title} (cont.)` : title, + text: chunk, + }, + }; - if (!resp.ok) { - const detail = await resp.text().catch(() => ''); - process.stderr.write( - `[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`, - ); + const resp = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + process.stderr.write( + `[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`, + ); + } } } diff --git a/packages/channels/dingtalk/src/markdown.ts b/packages/channels/dingtalk/src/markdown.ts new file mode 100644 index 000000000..751f5d2b8 --- /dev/null +++ b/packages/channels/dingtalk/src/markdown.ts @@ -0,0 +1,130 @@ +/** + * DingTalk markdown normalization. + * + * DingTalk's markdown renderer is a limited subset with quirks: + * - Tables don't render — convert to pipe-separated plain text + * - Max message length ~3800 chars — split into chunks + * - Code fences must be closed/reopened across chunk boundaries + */ + +const CHUNK_LIMIT = 3800; + +// --- Table conversion --- + +function isTableSeparator(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed.includes('-')) return false; + const cells = trimmed + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()); + return cells.length > 0 && cells.every((c) => /^:?-{3,}:?$/.test(c)); +} + +function isTableRow(line: string): boolean { + const trimmed = line.trim(); + return trimmed.includes('|') && !trimmed.startsWith('```'); +} + +function parseTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()); +} + +function renderTable(lines: string[]): string { + const rows = lines.map(parseTableRow).filter((cells) => cells.length > 0); + return rows.map((cells) => cells.join(' | ')).join(' \n'); +} + +export function convertTables(text: string): string { + const lines = text.split('\n'); + const output: string[] = []; + let i = 0; + let inCode = false; + + while (i < lines.length) { + const line = lines[i] || ''; + if (line.trim().startsWith('```')) { + inCode = !inCode; + output.push(line); + i++; + continue; + } + + if ( + !inCode && + i + 1 < lines.length && + isTableRow(line) && + isTableSeparator(lines[i + 1] || '') + ) { + const tableLines = [line]; + i += 2; // skip header + separator + while (i < lines.length && isTableRow(lines[i] || '')) { + tableLines.push(lines[i] || ''); + i++; + } + output.push(renderTable(tableLines)); + continue; + } + + output.push(line); + i++; + } + + return output.join('\n'); +} + +// --- Chunk splitting --- + +export function splitChunks(text: string): string[] { + if (!text || text.length <= CHUNK_LIMIT) { + return [text]; + } + + const chunks: string[] = []; + let buf = ''; + const lines = text.split('\n'); + let inCode = false; + + for (const line of lines) { + const fenceCount = (line.match(/```/g) || []).length; + + if (buf.length + line.length + 1 > CHUNK_LIMIT && buf.length > 0) { + if (inCode) { + buf += '\n```'; + } + chunks.push(buf); + buf = inCode ? '```\n' : ''; + } + + buf += (buf ? '\n' : '') + line; + + if (fenceCount % 2 === 1) { + inCode = !inCode; + } + } + + if (buf) { + chunks.push(buf); + } + + return chunks; +} + +/** Extract a short title from the first line of markdown for the webhook payload. */ +export function extractTitle(text: string): string { + const firstLine = text.split('\n')[0] || ''; + const cleaned = firstLine.replace(/^[#*\s\->]+/, '').slice(0, 20); + return cleaned || 'Reply'; +} + +/** Full normalization pipeline: tables → chunks. */ +export function normalizeDingTalkMarkdown(text: string): string[] { + const converted = convertTables(text); + return splitChunks(converted); +} From a61189b23204f6567e2295d484e8985354d48898 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 08:49:56 +0000 Subject: [PATCH 21/51] feat(channels): add DingTalk media download support - Handle richText, picture, file, audio, video message types - Download media via DingTalk API two-step flow - Attach images as base64, save other files to temp dir - Add DingTalkMessageData interface for richer payloads This enables the DingTalk channel to process media attachments in incoming messages. Co-authored-by: Qwen-Coder --- .../channels/dingtalk/src/DingtalkAdapter.ts | 193 ++++++++++++++++-- packages/channels/dingtalk/src/index.ts | 1 + packages/channels/dingtalk/src/media.ts | 85 ++++++++ packages/channels/dingtalk/tsconfig.json | 10 + 4 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 packages/channels/dingtalk/src/media.ts create mode 100644 packages/channels/dingtalk/tsconfig.json diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 6a248c824..3d73a3888 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -1,10 +1,11 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs'; -import type { - DWClientDownStream, - RobotMessage, -} from 'dingtalk-stream-sdk-nodejs'; +import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs'; import { ChannelBase } from '@qwen-code/channel-base'; import { normalizeDingTalkMarkdown, extractTitle } from './markdown.js'; +import { downloadMedia } from './media.js'; import type { ChannelConfig, ChannelBaseOptions, @@ -12,6 +13,34 @@ import type { AcpBridge, } from '@qwen-code/channel-base'; +/** + * Raw DingTalk message data — the SDK's RobotMessage type only covers text, + * but DingTalk sends richer payloads for richText, picture, file, etc. + */ + +interface DingTalkMessageData { + msgId?: string; + msgtype?: string; + conversationType?: string; + conversationId?: string; + sessionWebhook?: string; + senderId?: string; + senderStaffId?: string; + senderNick?: string; + isInAtList?: boolean; + text?: { content?: string }; + content?: { + richText?: Array<{ + type?: string; + text?: string; + downloadCode?: string; + }>; + downloadCode?: string; + fileName?: string; + recognition?: string; + }; +} + /** Track seen msgIds to deduplicate retried callbacks. */ const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes @@ -181,12 +210,136 @@ export class DingtalkChannel extends ChannelBase { process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`); } + /** + * Extract text and media download codes from an incoming DingTalk message. + * Handles text, richText, picture, file, audio, and video message types. + */ + private extractContent(data: DingTalkMessageData): { + text: string; + downloadCodes: string[]; + mediaType?: 'image' | 'file' | 'audio' | 'video'; + fileName?: string; + } { + const msgtype = data.msgtype || 'text'; + + if (msgtype === 'richText') { + const richText = data.content?.richText; + if (!Array.isArray(richText)) { + return { text: '', downloadCodes: [] }; + } + let text = ''; + const codes: string[] = []; + for (const part of richText) { + const partType = part.type || 'text'; + if (partType === 'text' && part.text) { + text += part.text; + } else if (partType === 'picture' && part.downloadCode) { + codes.push(part.downloadCode); + } + } + return { + text: text.trim() || (codes.length > 0 ? '(image)' : ''), + downloadCodes: codes, + mediaType: codes.length > 0 ? 'image' : undefined, + }; + } + + if (msgtype === 'picture') { + const code = data.content?.downloadCode; + return { + text: '(image)', + downloadCodes: code ? [code] : [], + mediaType: 'image', + }; + } + + if (msgtype === 'file') { + const code = data.content?.downloadCode; + const fileName = data.content?.fileName || undefined; + return { + text: `(file: ${fileName || 'file'})`, + downloadCodes: code ? [code] : [], + mediaType: 'file', + fileName, + }; + } + + if (msgtype === 'audio') { + const code = data.content?.downloadCode; + const recognition = data.content?.recognition; + return { + text: recognition || '(audio)', + downloadCodes: code ? [code] : [], + mediaType: 'audio', + }; + } + + if (msgtype === 'video') { + const code = data.content?.downloadCode; + return { + text: '(video)', + downloadCodes: code ? [code] : [], + mediaType: 'video', + }; + } + + // Default: text message + return { text: data.text?.content?.trim() || '', downloadCodes: [] }; + } + + /** + * Download a media file and attach it to the envelope. + * Images → base64 in envelope; files → saved to temp dir with path in text. + */ + private async attachMedia( + envelope: Envelope, + downloadCode: string, + mediaType: 'image' | 'file' | 'audio' | 'video', + fileName?: string, + ): Promise { + const token = this.getAccessToken(); + const robotCode = this.config.clientId; + if (!token || !robotCode) { + process.stderr.write( + `[DingTalk:${this.name}] Cannot download media: missing token or robotCode.\n`, + ); + return; + } + + const media = await downloadMedia(downloadCode, robotCode, token); + if (!media) return; + + if (mediaType === 'image') { + envelope.imageBase64 = media.buffer.toString('base64'); + envelope.imageMimeType = media.mimeType.startsWith('image/') + ? media.mimeType + : 'image/jpeg'; + } else { + // Save non-image files to temp dir so the agent can read them + const dir = join(tmpdir(), 'channel-files'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const safeName = fileName || `dingtalk_${mediaType}_${Date.now()}`; + const filePath = join(dir, safeName); + writeFileSync(filePath, media.buffer); + + const prefix = + envelope.text && + envelope.text !== `(file: ${fileName || 'file'})` && + envelope.text !== '(audio)' && + envelope.text !== '(video)' + ? envelope.text + '\n\n' + : ''; + envelope.text = + prefix + `User sent a ${mediaType}. It has been saved to: ${filePath}`; + } + } + private onMessage(downstream: DWClientDownStream): void { try { - const data: RobotMessage = + const data: DingTalkMessageData = typeof downstream.data === 'string' ? JSON.parse(downstream.data) - : (downstream.data as unknown as RobotMessage); + : (downstream.data as DingTalkMessageData); const msgId = data.msgId || downstream.headers.messageId; // Dedup: DingTalk retries unACKed messages @@ -198,7 +351,6 @@ export class DingtalkChannel extends ChannelBase { } const isGroup = data.conversationType === '2'; - const text = data.text?.content?.trim() || ''; const sessionWebhook = data.sessionWebhook; const conversationId = data.conversationId; @@ -214,27 +366,25 @@ export class DingtalkChannel extends ChannelBase { this.webhooks.set(conversationId, sessionWebhook); } - // In group chats, check isInAtList from the raw data - const rawData = - typeof downstream.data === 'string' - ? JSON.parse(downstream.data) - : downstream.data; - const isMentioned = Boolean(rawData.isInAtList); + const isMentioned = Boolean(data.isInAtList); + + // Extract text and media info from message + const content = this.extractContent(data); + let cleanText = content.text; // Strip @bot mention from text - let cleanText = text; if (isMentioned) { - cleanText = text.replace(/@\S+/g, '').trim(); + cleanText = cleanText.replace(/@\S+/g, '').trim(); } const chatId = conversationId || sessionWebhook; const envelope: Envelope = { channelName: this.name, - senderId: data.senderId || data.senderStaffId, + senderId: data.senderId || data.senderStaffId || '', senderName: data.senderNick || 'Unknown', chatId, - text: cleanText || text, + text: cleanText || content.text, isGroup, isMentioned, isReplyToBot: false, @@ -249,6 +399,15 @@ export class DingtalkChannel extends ChannelBase { this.attachReaction(reactionMsgId, reactionConvId).catch(() => {}); } try { + // Download media if present (first downloadCode only for images) + if (content.downloadCodes.length > 0 && content.mediaType) { + await this.attachMedia( + envelope, + content.downloadCodes[0]!, + content.mediaType, + content.fileName, + ); + } await this.handleInbound(envelope); } finally { if (reactionMsgId && reactionConvId) { diff --git a/packages/channels/dingtalk/src/index.ts b/packages/channels/dingtalk/src/index.ts index 80f7b912f..62baec7de 100644 --- a/packages/channels/dingtalk/src/index.ts +++ b/packages/channels/dingtalk/src/index.ts @@ -1 +1,2 @@ export { DingtalkChannel } from './DingtalkAdapter.js'; +export { downloadMedia } from './media.js'; diff --git a/packages/channels/dingtalk/src/media.ts b/packages/channels/dingtalk/src/media.ts new file mode 100644 index 000000000..19396a949 --- /dev/null +++ b/packages/channels/dingtalk/src/media.ts @@ -0,0 +1,85 @@ +/** + * DingTalk media download helpers. + * + * Two-step flow: + * 1. POST downloadCode to DingTalk API → get a temporary downloadUrl + * 2. GET the downloadUrl → arraybuffer + */ + +const DOWNLOAD_API = + 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'; + +export interface MediaFile { + buffer: Buffer; + mimeType: string; +} + +/** + * Download a media file from DingTalk using a downloadCode. + * + * @param downloadCode - The code from incoming message richText/content + * @param robotCode - The bot's clientId (appKey) + * @param accessToken - A valid DingTalk access token + * @returns MediaFile with buffer and mimeType, or null on failure + */ +export async function downloadMedia( + downloadCode: string, + robotCode: string, + accessToken: string, +): Promise { + if (!downloadCode || !robotCode || !accessToken) { + return null; + } + + try { + // Step 1: Get downloadUrl from DingTalk API + const apiResp = await fetch(DOWNLOAD_API, { + method: 'POST', + headers: { + 'x-acs-dingtalk-access-token': accessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ downloadCode, robotCode }), + }); + + if (!apiResp.ok) { + const detail = await apiResp.text().catch(() => ''); + process.stderr.write( + `[DingTalk] downloadMedia API failed: HTTP ${apiResp.status} ${detail}\n`, + ); + return null; + } + + const payload = (await apiResp.json()) as Record; + const downloadUrl = + (payload['downloadUrl'] as string) ?? + ((payload['data'] as Record)?.['downloadUrl'] as string); + + if (!downloadUrl) { + process.stderr.write( + `[DingTalk] downloadMedia: no downloadUrl in response\n`, + ); + return null; + } + + // Step 2: Download the actual file + const fileResp = await fetch(downloadUrl); + if (!fileResp.ok) { + process.stderr.write( + `[DingTalk] downloadMedia file fetch failed: HTTP ${fileResp.status}\n`, + ); + return null; + } + + const mimeType = + fileResp.headers.get('content-type') || 'application/octet-stream'; + const buffer = Buffer.from(await fileResp.arrayBuffer()); + + return { buffer, mimeType }; + } catch (err) { + process.stderr.write( + `[DingTalk] downloadMedia error: ${err instanceof Error ? err.message : err}\n`, + ); + return null; + } +} diff --git a/packages/channels/dingtalk/tsconfig.json b/packages/channels/dingtalk/tsconfig.json new file mode 100644 index 000000000..8daf59408 --- /dev/null +++ b/packages/channels/dingtalk/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../base" }] +} From 9f4dd53198fe2630994caa92d01e8e281fce78e2 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 09:08:36 +0000 Subject: [PATCH 22/51] feat(channels): add DingTalk reply/quote message context support - Add interfaces for DingTalkRepliedMsg and DingTalkRichTextPart types - Support both newer text.repliedMsg and legacy quoteMessage formats - Extract quoted message context with isReplyToBot detection - Summarize replied content (text, richText, media placeholders) This enables the bot to understand when users are replying to specific messages and provides context about what message is being referenced. Co-authored-by: Qwen-Coder --- .../channels/dingtalk/src/DingtalkAdapter.ts | 125 ++++++++++++++++-- 1 file changed, 117 insertions(+), 8 deletions(-) diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 3d73a3888..4aa468c38 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -17,7 +17,26 @@ import type { * Raw DingTalk message data — the SDK's RobotMessage type only covers text, * but DingTalk sends richer payloads for richText, picture, file, etc. */ - + +interface DingTalkRichTextPart { + type?: string; + text?: string; + downloadCode?: string; + atName?: string; +} + +interface DingTalkRepliedMsg { + msgId?: string; + msgType?: string; + senderId?: string; + content?: { + text?: string; + richText?: DingTalkRichTextPart[]; + downloadCode?: string; + fileName?: string; + }; +} + interface DingTalkMessageData { msgId?: string; msgtype?: string; @@ -27,14 +46,21 @@ interface DingTalkMessageData { senderId?: string; senderStaffId?: string; senderNick?: string; + chatbotUserId?: string; isInAtList?: boolean; - text?: { content?: string }; + text?: { + content?: string; + isReplyMsg?: boolean; + repliedMsg?: DingTalkRepliedMsg; + }; + quoteMessage?: { + msgId?: string; + senderId?: string; + text?: { content?: string }; + msgtype?: string; + }; content?: { - richText?: Array<{ - type?: string; - text?: string; - downloadCode?: string; - }>; + richText?: DingTalkRichTextPart[]; downloadCode?: string; fileName?: string; recognition?: string; @@ -210,6 +236,85 @@ export class DingtalkChannel extends ChannelBase { process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`); } + /** + * Extract quoted/referenced message context from a reply. + * DingTalk provides this via text.repliedMsg (newer) or quoteMessage (legacy). + */ + private extractQuotedContext(data: DingTalkMessageData): { + referencedText?: string; + isReplyToBot: boolean; + } { + // Newer format: text.repliedMsg + if (data.text?.isReplyMsg && data.text.repliedMsg) { + const replied = data.text.repliedMsg; + const isReplyToBot = + !!data.chatbotUserId && replied.senderId === data.chatbotUserId; + + // Note: DingTalk doesn't include content for interactiveCard replies + // (bot responses sent via webhook). Only user message quotes have text. + const text = this.summarizeRepliedContent(replied); + return { referencedText: text || undefined, isReplyToBot }; + } + + // Legacy format: quoteMessage + if (data.quoteMessage) { + const quote = data.quoteMessage; + const isReplyToBot = + !!data.chatbotUserId && quote.senderId === data.chatbotUserId; + const text = quote.text?.content?.trim(); + return { referencedText: text || undefined, isReplyToBot }; + } + + return { isReplyToBot: false }; + } + + /** + * Build a text summary from a repliedMsg, handling text, richText, and + * media message types with placeholders. + */ + private summarizeRepliedContent(replied: DingTalkRepliedMsg): string { + const msgType = replied.msgType; + const content = replied.content; + + // Direct text content + if (content?.text?.trim()) { + return content.text.trim(); + } + + // RichText: concatenate text parts, placeholder for images + if (content?.richText && Array.isArray(content.richText)) { + const parts: string[] = []; + for (const part of content.richText) { + const partType = part.type || 'text'; + if (partType === 'text' && part.text) { + parts.push(part.text); + } else if (partType === 'picture') { + parts.push('[image]'); + } else if (partType === 'at' && part.atName) { + parts.push(`@${part.atName}`); + } + } + const summary = parts.join('').trim(); + if (summary) return summary; + } + + // Media type placeholders + switch (msgType) { + case 'picture': + return '[image]'; + case 'file': + return `[file: ${content?.fileName || 'file'}]`; + case 'audio': + return '[audio]'; + case 'video': + return '[video]'; + default: + break; + } + + return ''; + } + /** * Extract text and media download codes from an incoming DingTalk message. * Handles text, richText, picture, file, audio, and video message types. @@ -377,6 +482,9 @@ export class DingtalkChannel extends ChannelBase { cleanText = cleanText.replace(/@\S+/g, '').trim(); } + // Extract quoted message context + const quoted = this.extractQuotedContext(data); + const chatId = conversationId || sessionWebhook; const envelope: Envelope = { @@ -387,7 +495,8 @@ export class DingtalkChannel extends ChannelBase { text: cleanText || content.text, isGroup, isMentioned, - isReplyToBot: false, + isReplyToBot: quoted.isReplyToBot, + referencedText: quoted.referencedText, }; // Attach 👀 reaction, process message, then recall reaction From 33901fb9cddc36c746aa4b55dceffd68f07de277 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 09:14:31 +0000 Subject: [PATCH 23/51] docs(channels): add DingTalk channel documentation - Add comprehensive DingTalk setup guide with prerequisites, configuration, and troubleshooting - Add WeChat and DingTalk entries to channels navigation This provides users with complete documentation for setting up and using DingTalk as a Qwen Code channel. Co-authored-by: Qwen-Coder --- docs/users/features/channels/_meta.ts | 2 + docs/users/features/channels/dingtalk.md | 134 +++++++++++++++++++++++ docs/users/features/channels/overview.md | 22 ++-- 3 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 docs/users/features/channels/dingtalk.md diff --git a/docs/users/features/channels/_meta.ts b/docs/users/features/channels/_meta.ts index 70fadc28f..a0fae7ec4 100644 --- a/docs/users/features/channels/_meta.ts +++ b/docs/users/features/channels/_meta.ts @@ -1,4 +1,6 @@ export default { overview: 'Overview', telegram: 'Telegram', + weixin: 'WeChat', + dingtalk: 'DingTalk', }; diff --git a/docs/users/features/channels/dingtalk.md b/docs/users/features/channels/dingtalk.md new file mode 100644 index 000000000..ed88caa4f --- /dev/null +++ b/docs/users/features/channels/dingtalk.md @@ -0,0 +1,134 @@ +# DingTalk (Dingtalk) + +This guide covers setting up a Qwen Code channel on DingTalk (钉钉). + +## Prerequisites + +- A DingTalk organization account +- A DingTalk bot application with AppKey and AppSecret (see below) + +## Creating a Bot + +1. Go to the [DingTalk Developer Portal](https://open-dev.dingtalk.com) +2. Create a new application (or use an existing one) +3. Under the application, enable the **Robot** capability +4. In Robot settings, enable **Stream Mode** (机器人协议 → Stream 模式) +5. Note the **AppKey** (Client ID) and **AppSecret** (Client Secret) from the application credentials page + +### Stream Mode + +DingTalk Stream mode uses an outbound WebSocket connection — no public URL or server is needed. The bot connects to DingTalk's servers, which push messages through the WebSocket. This is the simplest deployment model. + +## Configuration + +Add the channel to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-dingtalk": { + "type": "dingtalk", + "clientId": "$DINGTALK_CLIENT_ID", + "clientSecret": "$DINGTALK_CLIENT_SECRET", + "senderPolicy": "open", + "sessionScope": "user", + "cwd": "/path/to/your/project", + "instructions": "You are a concise coding assistant responding via DingTalk.", + "groupPolicy": "open", + "groups": { + "*": { "requireMention": true } + } + } + } +} +``` + +Set the credentials as environment variables: + +```bash +export DINGTALK_CLIENT_ID= +export DINGTALK_CLIENT_SECRET= +``` + +Or define them in the `env` section of `settings.json`: + +```json +{ + "env": { + "DINGTALK_CLIENT_ID": "your-app-key", + "DINGTALK_CLIENT_SECRET": "your-app-secret" + } +} +``` + +## Running + +```bash +# Start only the DingTalk channel +qwen channel start my-dingtalk + +# Or start all configured channels together +qwen channel start +``` + +Open DingTalk and send a message to the bot. You should see a 👀 emoji reaction appear while the agent processes, followed by the response. + +## Group Chats + +DingTalk bots work in both DM and group conversations. To enable group support: + +1. Set `groupPolicy` to `"allowlist"` or `"open"` in your channel config +2. Add the bot to a DingTalk group +3. @mention the bot in the group to trigger a response + +By default, the bot requires an @mention in group chats (`requireMention: true`). Set `"requireMention": false` for a specific group to make it respond to all messages. See [Group Chats](./overview#group-chats) for full details. + +### Finding a Group's Conversation ID + +DingTalk uses `conversationId` to identify groups. You can find it in the channel service logs when someone sends a message in the group — look for the `conversationId` field in the log output. + +## Images and Files + +You can send photos and documents to the bot, not just text. + +**Photos:** Send an image (screenshot, diagram, etc.) and the agent will analyze it using its vision capabilities. This requires a multimodal model — add `"model": "qwen3.5-plus"` (or another vision-capable model) to your channel config. DingTalk supports sending images directly or as part of rich text messages (mixed text + images). + +**Files:** Send a PDF, code file, or any document. The bot downloads it from DingTalk's servers and saves it locally so the agent can read it with its file tools. Audio and video files are also supported. This works with any model. + +## Key Differences from Telegram + +- **Authentication:** AppKey + AppSecret instead of a static bot token. The SDK manages access token refresh automatically. +- **Connection:** WebSocket stream instead of polling — no public IP or webhook URL needed. +- **Formatting:** Responses use DingTalk's markdown dialect (a limited subset). Tables are automatically converted to plain text since DingTalk doesn't render them. Long messages are split into chunks at ~3800 characters. +- **Working indicator:** A 👀 emoji reaction is added to the user's message while processing, then removed when the response is sent. +- **Media download:** Two-step process — a `downloadCode` from the message is exchanged for a temporary download URL via DingTalk's API. +- **Groups:** DingTalk uses `isInAtList` for @mention detection instead of parsing message entities. + +## Tips + +- **Use DingTalk markdown-aware instructions** — DingTalk supports a limited markdown subset (headers, bold, links, code blocks, but not tables). Adding instructions like "Use DingTalk markdown. Avoid tables." helps the agent format responses correctly. +- **Restrict access** — In an organization context, `senderPolicy: "open"` may be acceptable. For tighter control, use `"allowlist"` or `"pairing"`. See [DM Pairing](./overview#dm-pairing) for details. +- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. Quoting bot responses is not yet supported. + +## Troubleshooting + +### Bot doesn't connect + +- Verify your AppKey and AppSecret are correct +- Check that the environment variables are set before running `qwen channel start` +- Make sure **Stream Mode** is enabled in the bot's settings on the DingTalk Developer Portal +- Check the terminal output for connection errors + +### Bot doesn't respond in groups + +- Check that `groupPolicy` is set to `"allowlist"` or `"open"` (default is `"disabled"`) +- Make sure you @mention the bot in the group message +- Verify the bot has been added to the group + +### "No sessionWebhook in message" + +This means DingTalk didn't include a reply endpoint in the message callback. This can happen if the bot's permissions are misconfigured. Check the bot's settings in the Developer Portal. + +### "Sorry, something went wrong processing your message" + +This usually means the agent encountered an error. Check the terminal output for details. diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 6ba103845..0cae74aee 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -1,6 +1,6 @@ # Channels -Channels let you interact with a Qwen Code agent from messaging platforms like Telegram or WeChat, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI. +Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, WeChat, or DingTalk, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI. ## How It Works @@ -15,7 +15,7 @@ All channels share one agent process with isolated sessions per user. Each chann ## Quick Start -1. Set up a bot on your messaging platform (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin)) +1. Set up a bot on your messaging platform (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin), [DingTalk](./dingtalk)) 2. Add the channel configuration to `~/.qwen/settings.json` 3. Run `qwen channel start` to start all channels, or `qwen channel start ` for a single channel @@ -47,8 +47,10 @@ Channels are configured under the `channels` key in `settings.json`. Each channe | Option | Required | Description | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | Yes | Channel type: `telegram` or `weixin` | -| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat | +| `type` | Yes | Channel type: `telegram`, `weixin`, or `dingtalk` | +| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk | +| `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax | +| `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax | | `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input | | `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | | `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | @@ -209,11 +211,11 @@ Files work with any model — no multimodal support required. ### Platform differences -| Feature | Telegram | WeChat | -| -------- | -------------------------------------------- | -------------------------------- | -| Images | Direct download via Bot API | CDN download with AES decryption | -| Files | Direct download via Bot API (20MB limit) | CDN download with AES decryption | -| Captions | Photo/file captions included as message text | Not applicable | +| Feature | Telegram | WeChat | DingTalk | +| -------- | -------------------------------------------- | -------------------------------- | --------------------------------------------- | +| Images | Direct download via Bot API | CDN download with AES decryption | downloadCode API (two-step) | +| Files | Direct download via Bot API (20MB limit) | CDN download with AES decryption | downloadCode API (two-step) | +| Captions | Photo/file captions included as message text | Not applicable | Rich text: mixed text + images in one message | ## Slash Commands @@ -225,7 +227,7 @@ Channels support slash commands. These are handled locally (no agent round-trip) All other slash commands (e.g., `/compress`, `/summary`) are forwarded to the agent. -These commands work on all channel types (Telegram, WeChat, etc.). +These commands work on all channel types (Telegram, WeChat, DingTalk). ## Running From f3a03d0bdcfc991daf91d88169e17578fe408617 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 11:58:03 +0000 Subject: [PATCH 24/51] fix(channels): isolate sessions per chat and serialize prompts per session Two bugs caused cross-talk between DM and group conversations: 1. Session routing key only used senderId, so the same user in DM and group shared one ACP session (and conversation context). Now includes chatId: `channelName:senderId:chatId`. 2. Concurrent messages on the same session caused textChunk listener pollution in AcpBridge.prompt(), leaking response text across chats. Added per-session promise queue in ChannelBase to serialize prompts. --- packages/channels/base/src/ChannelBase.ts | 38 +++++++++++++++----- packages/channels/base/src/SessionRouter.ts | 40 +++++++++++++++++---- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index a5e966182..12b9b9ff9 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -21,6 +21,8 @@ export abstract class ChannelBase { protected name: string; private instructedSessions: Set = new Set(); private commands: Map = new Map(); + /** Per-session promise chain to serialize prompt + send. */ + private sessionQueues: Map> = new Map(); constructor( name: string, @@ -82,7 +84,11 @@ export abstract class ChannelBase { /** Register shared slash commands. Called from constructor. */ private registerSharedCommands(): void { const clearHandler: CommandHandler = async (envelope) => { - const removed = this.router.removeSession(this.name, envelope.senderId); + const removed = this.router.removeSession( + this.name, + envelope.senderId, + envelope.chatId, + ); if (removed) { this.instructedSessions.clear(); await this.sendMessage( @@ -132,7 +138,11 @@ export abstract class ChannelBase { }); this.registerCommand('status', async (envelope) => { - const hasSession = this.router.hasSession(this.name, envelope.senderId); + const hasSession = this.router.hasSession( + this.name, + envelope.senderId, + envelope.chatId, + ); const policy = this.config.senderPolicy; const lines = [ `Session: ${hasSession ? 'active' : 'none'}`, @@ -209,14 +219,24 @@ export abstract class ChannelBase { this.instructedSessions.add(sessionId); } - const response = await this.bridge.prompt(sessionId, promptText, { - imageBase64: envelope.imageBase64, - imageMimeType: envelope.imageMimeType, - }); + // Serialize prompt + send per session to prevent textChunk listener + // pollution when concurrent messages hit the same session. + const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve(); + const current = prev.then(async () => { + const response = await this.bridge.prompt(sessionId, promptText, { + imageBase64: envelope.imageBase64, + imageMimeType: envelope.imageMimeType, + }); - if (response) { - await this.sendMessage(envelope.chatId, response); - } + if (response) { + await this.sendMessage(envelope.chatId, response); + } + }); + this.sessionQueues.set( + sessionId, + current.catch(() => {}), + ); + await current; } protected async onPairingRequired( diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts index 34217861f..50a52319f 100644 --- a/packages/channels/base/src/SessionRouter.ts +++ b/packages/channels/base/src/SessionRouter.ts @@ -48,7 +48,7 @@ export class SessionRouter { return `${channelName}:__single__`; case 'user': default: - return `${channelName}:${senderId}`; + return `${channelName}:${senderId}:${chatId}`; } } @@ -78,18 +78,46 @@ export class SessionRouter { return this.toTarget.get(sessionId); } - hasSession(channelName: string, senderId: string): boolean { - return this.toSession.has(`${channelName}:${senderId}`); + hasSession(channelName: string, senderId: string, chatId?: string): boolean { + const key = chatId + ? this.routingKey(channelName, senderId, chatId) + : `${channelName}:${senderId}`; + // If chatId is provided, do exact lookup; otherwise prefix-scan for any match + if (chatId) return this.toSession.has(key); + for (const k of this.toSession.keys()) { + if (k.startsWith(`${channelName}:${senderId}`)) return true; + } + return false; } - removeSession(channelName: string, senderId: string): boolean { - const key = `${channelName}:${senderId}`; + removeSession( + channelName: string, + senderId: string, + chatId?: string, + ): boolean { + if (chatId) { + const key = this.routingKey(channelName, senderId, chatId); + return this.deleteByKey(key); + } + // No chatId: remove all sessions for this sender on this channel + let removed = false; + const prefix = `${channelName}:${senderId}`; + for (const k of [...this.toSession.keys()]) { + if (k.startsWith(prefix)) { + this.deleteByKey(k); + removed = true; + } + } + if (removed) this.persist(); + return removed; + } + + private deleteByKey(key: string): boolean { const sessionId = this.toSession.get(key); if (!sessionId) return false; this.toSession.delete(key); this.toTarget.delete(sessionId); this.toCwd.delete(sessionId); - this.persist(); return true; } From 8a6ed128eae08ae9efe9f2c30c29339debd049b1 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 12:34:31 +0000 Subject: [PATCH 25/51] feat(channels): add ChannelPlugin interface and registry-based factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor channel system to use a formal plugin interface: - Add ChannelPlugin interface to @qwen-code/channel-base (channelType, displayName, requiredConfigFields, createChannel factory) - Each built-in adapter (Telegram, WeChat, DingTalk) exports a plugin object - Replace hardcoded if/else factory with a Map-based channel registry - Config validation now uses plugin's requiredConfigFields dynamically - ChannelType changed from fixed union to string for extensibility - Multi-channel mode now picks model from first channel config This is the foundation for external plugin support — built-in channels go through the exact same code path that third-party plugins will use. --- packages/channels/base/src/index.ts | 1 + packages/channels/base/src/types.ts | 37 ++++++++++++--- packages/channels/dingtalk/src/index.ts | 11 +++++ packages/channels/telegram/src/index.ts | 11 +++++ packages/channels/weixin/src/index.ts | 10 +++++ .../src/commands/channel/channel-registry.ts | 28 ++++++++++++ .../cli/src/commands/channel/config-utils.ts | 45 +++++++++---------- packages/cli/src/commands/channel/start.ts | 30 ++++++++----- 8 files changed, 134 insertions(+), 39 deletions(-) create mode 100644 packages/cli/src/commands/channel/channel-registry.ts diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 8ea5e5e51..28fda02b9 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -15,6 +15,7 @@ export type { SenderCheckResult } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; export type { ChannelConfig, + ChannelPlugin, ChannelType, Envelope, GroupConfig, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 0bea8aa15..93e2b2aa5 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -1,11 +1,9 @@ +import type { AcpBridge } from './AcpBridge.js'; +import type { ChannelBase, ChannelBaseOptions } from './ChannelBase.js'; + export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; export type SessionScope = 'user' | 'thread' | 'single'; -export type ChannelType = - | 'telegram' - | 'weixin' - | 'dingtalk' - | 'discord' - | 'webhook'; +export type ChannelType = string; export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; export interface GroupConfig { @@ -52,3 +50,30 @@ export interface SessionTarget { chatId: string; threadId?: string; } + +/** + * A channel plugin registers a channel type and provides a factory + * to create adapter instances. Both built-in adapters and external + * plugins conform to this interface. + */ +export interface ChannelPlugin { + /** Unique channel type ID (e.g., "telegram", "tmcp-dingtalk"). */ + channelType: string; + + /** Human-readable name for CLI output. */ + displayName: string; + + /** + * Config fields required by this channel type, beyond the shared + * ChannelConfig fields. Validated at startup. + */ + requiredConfigFields?: string[]; + + /** Create a channel adapter instance. */ + createChannel( + name: string, + config: ChannelConfig & Record, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ): ChannelBase; +} diff --git a/packages/channels/dingtalk/src/index.ts b/packages/channels/dingtalk/src/index.ts index 62baec7de..4ea26fd7d 100644 --- a/packages/channels/dingtalk/src/index.ts +++ b/packages/channels/dingtalk/src/index.ts @@ -1,2 +1,13 @@ export { DingtalkChannel } from './DingtalkAdapter.js'; export { downloadMedia } from './media.js'; + +import { DingtalkChannel } from './DingtalkAdapter.js'; +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'dingtalk', + displayName: 'DingTalk', + requiredConfigFields: ['clientId', 'clientSecret'], + createChannel: (name, config, bridge, options) => + new DingtalkChannel(name, config, bridge, options), +}; diff --git a/packages/channels/telegram/src/index.ts b/packages/channels/telegram/src/index.ts index 976c4ab0d..97426548d 100644 --- a/packages/channels/telegram/src/index.ts +++ b/packages/channels/telegram/src/index.ts @@ -1 +1,12 @@ export { TelegramChannel } from './TelegramAdapter.js'; + +import { TelegramChannel } from './TelegramAdapter.js'; +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'telegram', + displayName: 'Telegram', + requiredConfigFields: ['token'], + createChannel: (name, config, bridge, options) => + new TelegramChannel(name, config, bridge, options), +}; diff --git a/packages/channels/weixin/src/index.ts b/packages/channels/weixin/src/index.ts index 9eec24cc6..440c5c0a6 100644 --- a/packages/channels/weixin/src/index.ts +++ b/packages/channels/weixin/src/index.ts @@ -1 +1,11 @@ export { WeixinChannel } from './WeixinAdapter.js'; + +import { WeixinChannel } from './WeixinAdapter.js'; +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'weixin', + displayName: 'WeChat', + createChannel: (name, config, bridge, options) => + new WeixinChannel(name, config, bridge, options), +}; diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts new file mode 100644 index 000000000..bc2f2997c --- /dev/null +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -0,0 +1,28 @@ +import type { ChannelPlugin } from '@qwen-code/channel-base'; +import { plugin as telegramPlugin } from '@qwen-code/channel-telegram'; +import { plugin as weixinPlugin } from '@qwen-code/channel-weixin'; +import { plugin as dingtalkPlugin } from '@qwen-code/channel-dingtalk'; + +const registry = new Map(); + +// Register built-in channel types +for (const p of [telegramPlugin, weixinPlugin, dingtalkPlugin]) { + registry.set(p.channelType, p); +} + +export function registerPlugin(plugin: ChannelPlugin): void { + if (registry.has(plugin.channelType)) { + throw new Error( + `Channel type "${plugin.channelType}" is already registered.`, + ); + } + registry.set(plugin.channelType, plugin); +} + +export function getPlugin(channelType: string): ChannelPlugin | undefined { + return registry.get(channelType); +} + +export function supportedTypes(): string[] { + return [...registry.keys()]; +} diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts index e6da41cb5..5e7401b1d 100644 --- a/packages/cli/src/commands/channel/config-utils.ts +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -1,5 +1,6 @@ import type { ChannelConfig } from '@qwen-code/channel-base'; import * as path from 'node:path'; +import { getPlugin, supportedTypes } from './channel-registry.js'; export function resolveEnvVars(value: string): string { if (value.startsWith('$')) { @@ -23,46 +24,45 @@ export function findCliEntryPath(): string { throw new Error('Cannot determine CLI entry path'); } -const SUPPORTED_TYPES = ['telegram', 'weixin', 'dingtalk']; - export function parseChannelConfig( name: string, rawConfig: Record, -): ChannelConfig & { baseUrl?: string } { +): ChannelConfig & Record { if (!rawConfig['type']) { throw new Error(`Channel "${name}" is missing required field "type".`); } const channelType = rawConfig['type'] as string; - if (!SUPPORTED_TYPES.includes(channelType)) { + const plugin = getPlugin(channelType); + if (!plugin) { throw new Error( - `Channel type "${channelType}" is not supported. Available: ${SUPPORTED_TYPES.join(', ')}`, + `Channel type "${channelType}" is not supported. Available: ${supportedTypes().join(', ')}`, ); } - let token = ''; - if (channelType !== 'weixin' && channelType !== 'dingtalk') { - if (!rawConfig['token']) { - throw new Error(`Channel "${name}" is missing required field "token".`); - } - token = resolveEnvVars(rawConfig['token'] as string); - } - - // DingTalk uses clientId + clientSecret instead of token - let clientId: string | undefined; - let clientSecret: string | undefined; - if (channelType === 'dingtalk') { - if (!rawConfig['clientId'] || !rawConfig['clientSecret']) { + // Validate plugin-required fields + for (const field of plugin.requiredConfigFields ?? []) { + if (!rawConfig[field]) { throw new Error( - `Channel "${name}" requires "clientId" and "clientSecret" for DingTalk.`, + `Channel "${name}" (${channelType}) requires "${field}".`, ); } - clientId = resolveEnvVars(rawConfig['clientId'] as string); - clientSecret = resolveEnvVars(rawConfig['clientSecret'] as string); } + // Resolve env vars for known credential fields + const token = rawConfig['token'] + ? resolveEnvVars(rawConfig['token'] as string) + : ''; + const clientId = rawConfig['clientId'] + ? resolveEnvVars(rawConfig['clientId'] as string) + : undefined; + const clientSecret = rawConfig['clientSecret'] + ? resolveEnvVars(rawConfig['clientSecret'] as string) + : undefined; + return { - type: channelType as ChannelConfig['type'], + ...rawConfig, + type: channelType, token, clientId, clientSecret, @@ -79,6 +79,5 @@ export function parseChannelConfig( groupPolicy: (rawConfig['groupPolicy'] as ChannelConfig['groupPolicy']) || 'disabled', groups: (rawConfig['groups'] as ChannelConfig['groups']) || {}, - baseUrl: rawConfig['baseUrl'] as string | undefined, }; } diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 927eb56e8..9c731d640 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -5,9 +5,7 @@ import { loadSettings } from '../../config/settings.js'; import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; import { AcpBridge, SessionRouter } from '@qwen-code/channel-base'; import type { ChannelBase, ToolCallEvent } from '@qwen-code/channel-base'; -import { TelegramChannel } from '@qwen-code/channel-telegram'; -import { WeixinChannel } from '@qwen-code/channel-weixin'; -import { DingtalkChannel } from '@qwen-code/channel-dingtalk'; +import { getPlugin } from './channel-registry.js'; import { findCliEntryPath, parseChannelConfig } from './config-utils.js'; import { readServiceInfo, @@ -36,13 +34,11 @@ function createChannel( bridge: AcpBridge, options?: { router?: SessionRouter }, ): ChannelBase { - if (config.type === 'weixin') { - return new WeixinChannel(name, config, bridge, options); + const channelPlugin = getPlugin(config.type); + if (!channelPlugin) { + throw new Error(`Unknown channel type: "${config.type}".`); } - if (config.type === 'dingtalk') { - return new DingtalkChannel(name, config, bridge, options); - } - return new TelegramChannel(name, config, bridge, options); + return channelPlugin.createChannel(name, config, bridge, options); } function registerToolCallDispatch( @@ -205,7 +201,21 @@ async function startAll(): Promise { let shuttingDown = false; let crashCount = 0; - const bridgeOpts = { cliEntryPath, cwd: defaultCwd }; + // All channels share one bridge process. Use the first channel's model. + const models = [ + ...new Set(parsed.map((p) => p.config.model).filter(Boolean)), + ]; + if (models.length > 1) { + writeStderrLine( + `[Channel] Warning: Multiple models configured (${models.join(', ')}). ` + + `Shared bridge will use "${models[0]}".`, + ); + } + const bridgeOpts = { + cliEntryPath, + cwd: defaultCwd, + model: models[0], + }; let bridge = new AcpBridge(bridgeOpts); await bridge.start(); From 06ccc80c48fdca22be569553ef7240aac98c5298 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 13:59:32 +0000 Subject: [PATCH 26/51] feat(channels): allow extensions to register channel plugins - Add ExtensionChannelConfig interface for declaring channels in extension manifests - Add loadChannelsFromExtensions() to discover and register channel plugins from active extensions - Integrate extension channel loading into channel start commands This enables extensions to contribute new channel types (e.g., Telegram, Slack) without modifying core code. Co-authored-by: Qwen-Coder --- packages/cli/src/commands/channel/start.ts | 75 ++++++++++++++++++- .../core/src/extension/extensionManager.ts | 15 ++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 9c731d640..f0b34eef4 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -4,14 +4,19 @@ import type { CommandModule } from 'yargs'; import { loadSettings } from '../../config/settings.js'; import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; import { AcpBridge, SessionRouter } from '@qwen-code/channel-base'; -import type { ChannelBase, ToolCallEvent } from '@qwen-code/channel-base'; -import { getPlugin } from './channel-registry.js'; +import type { + ChannelBase, + ChannelPlugin, + ToolCallEvent, +} from '@qwen-code/channel-base'; +import { getPlugin, registerPlugin } from './channel-registry.js'; import { findCliEntryPath, parseChannelConfig } from './config-utils.js'; import { readServiceInfo, writeServiceInfo, removeServiceInfo, } from './pidfile.js'; +import { getExtensionManager } from '../extensions/utils.js'; const MAX_CRASH_RESTARTS = 3; const RESTART_DELAY_MS = 3000; @@ -28,6 +33,68 @@ function loadChannelsConfig(): Record { return channels || {}; } +/** + * Load channel plugins from active extensions. + * Extensions declare channels in their qwen-extension.json manifest. + */ +async function loadChannelsFromExtensions(): Promise { + let loaded = 0; + try { + const extensionManager = await getExtensionManager(); + const extensions = extensionManager + .getLoadedExtensions() + .filter((e) => e.isActive && e.channels); + + for (const ext of extensions) { + for (const [channelType, channelDef] of Object.entries(ext.channels!)) { + if (getPlugin(channelType)) { + writeStderrLine( + `[Extensions] Skipping channel "${channelType}" from "${ext.name}": type already registered`, + ); + continue; + } + + const entryPath = path.join(ext.path, channelDef.entry); + try { + const module = (await import(entryPath)) as { + plugin?: ChannelPlugin; + }; + const plugin = module.plugin; + + if (!plugin || typeof plugin.createChannel !== 'function') { + writeStderrLine( + `[Extensions] "${ext.name}": channel entry point does not export a valid plugin object`, + ); + continue; + } + + if (plugin.channelType !== channelType) { + writeStderrLine( + `[Extensions] "${ext.name}": channelType mismatch — manifest says "${channelType}", plugin says "${plugin.channelType}"`, + ); + continue; + } + + registerPlugin(plugin); + loaded++; + writeStdoutLine( + `[Extensions] Loaded channel "${channelType}" from "${ext.name}"`, + ); + } catch (err) { + writeStderrLine( + `[Extensions] Failed to load channel "${channelType}" from "${ext.name}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + } catch (err) { + writeStderrLine( + `[Extensions] Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return loaded; +} + function createChannel( name: string, config: ReturnType, @@ -74,6 +141,8 @@ async function startSingle(name: string): Promise { checkDuplicateInstance(); const channelsConfig = loadChannelsConfig(); + await loadChannelsFromExtensions(); + if (!channelsConfig[name]) { writeStderrLine( `Error: Channel "${name}" not found in settings. Add it to channels.${name} in settings.json.`, @@ -170,6 +239,8 @@ async function startAll(): Promise { checkDuplicateInstance(); const channelsConfig = loadChannelsConfig(); + await loadChannelsFromExtensions(); + if (Object.keys(channelsConfig).length === 0) { writeStderrLine( 'Error: No channels configured in settings.json. Add entries under "channels".', diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 15ead552d..866c20232 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -87,6 +87,15 @@ export enum SettingScope { SystemDefaults = 'SystemDefaults', } +export interface ExtensionChannelConfig { + /** Relative path to JS entry point (must export `plugin: ChannelPlugin`) */ + entry: string; + /** Human-readable name for CLI output */ + displayName?: string; + /** Extra config fields required beyond the shared ChannelConfig fields */ + requiredConfigFields?: string[]; +} + export interface Extension { id: string; name: string; @@ -104,6 +113,7 @@ export interface Extension { skills?: SkillConfig[]; agents?: SubagentConfig[]; hooks?: { [K in HookEventName]?: HookDefinition[] }; + channels?: Record; } export interface ExtensionConfig { @@ -117,6 +127,7 @@ export interface ExtensionConfig { agents?: string | string[]; settings?: ExtensionSetting[]; hooks?: { [K in HookEventName]?: HookDefinition[] }; + channels?: Record; } export interface ExtensionUpdateInfo { @@ -650,6 +661,10 @@ export class ExtensionManager { ); } + if (config.channels) { + extension.channels = config.channels; + } + extension.commands = await loadCommandsFromDir( `${effectiveExtensionPath}/commands`, ); From 2b10a2dc5422651021b19ac58da71eb69d14ad17 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 14:15:56 +0000 Subject: [PATCH 27/51] test(channels): add loopback channel integration test - Uses in-process LoopbackChannel instead of 3-process mock architecture - Tests real agent responses through full channel pipeline - Verifies session state persistence across messages - Validates per-sender session routing This provides lightweight integration testing for the channel plugin system without external dependencies. Co-authored-by: Qwen-Coder --- integration-tests/channel-plugin.test.ts | 275 +++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 integration-tests/channel-plugin.test.ts diff --git a/integration-tests/channel-plugin.test.ts b/integration-tests/channel-plugin.test.ts new file mode 100644 index 000000000..9712581ea --- /dev/null +++ b/integration-tests/channel-plugin.test.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Channel Plugin Integration Test — "Loopback Channel" + * + * Creative approach: instead of the heavy 3-process architecture + * (mock server + channel service + mock client), we use an in-process + * "loopback channel" that acts as both sender and receiver. + * + * The LoopbackChannel extends ChannelBase and plugs directly into AcpBridge. + * When a message is sent, it flows through the REAL pipeline: + * + * test.send("What is 2+2?") + * → LoopbackChannel.handleInbound(envelope) + * → SenderGate (open policy) + * → SessionRouter (creates/reuses session) + * → AcpBridge.prompt(sessionId, text) + * → qwen-code --acp (REAL model request) + * → LoopbackChannel.sendMessage(chatId, response) + * → test receives response via promise + * + * No WebSocket, no HTTP, no separate processes. Just the real + * channel pipeline with a real agent backend. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdirSync } from 'node:fs'; + +// Import channel-base directly from compiled dist +import { + AcpBridge, + ChannelBase, + SessionRouter, +} from '../packages/channels/base/dist/index.js'; +import type { + ChannelConfig, + Envelope, + ChannelBaseOptions, +} from '../packages/channels/base/dist/index.js'; +import type { AcpBridge as AcpBridgeType } from '../packages/channels/base/dist/index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = join(__dirname, '..', 'dist', 'cli.js'); +const RESPONSE_TIMEOUT_MS = 120_000; + +// --------------------------------------------------------------------------- +// Loopback Channel — the creative core +// --------------------------------------------------------------------------- + +/** + * A channel that lives entirely in the test process. + * + * - connect() is a no-op (nothing external to connect to) + * - sendMessage() resolves a pending promise so the test gets the response + * - send() pushes a message through handleInbound and returns the agent reply + * + * Think of it as a "promise pipe" that wraps the full ChannelBase pipeline. + */ +class LoopbackChannel extends ChannelBase { + /** Map of chatId → resolver for the next sendMessage call */ + private responseResolvers = new Map void>(); + private responseChunks = new Map(); + + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridgeType, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + } + + async connect(): Promise { + // No external connection needed — we ARE the platform + } + + async sendMessage(chatId: string, text: string): Promise { + const resolver = this.responseResolvers.get(chatId); + if (resolver) { + resolver(text); + this.responseResolvers.delete(chatId); + } else { + // Buffer for cases where response arrives before await + const chunks = this.responseChunks.get(chatId) || []; + chunks.push(text); + this.responseChunks.set(chatId, chunks); + } + } + + disconnect(): void { + // Clean up any pending resolvers + for (const [, resolver] of this.responseResolvers) { + resolver('[channel disconnected]'); + } + this.responseResolvers.clear(); + } + + /** + * Send a message through the full channel pipeline and wait for the response. + * This is the test-facing API. + */ + async send( + text: string, + options?: { + senderId?: string; + senderName?: string; + chatId?: string; + timeoutMs?: number; + }, + ): Promise { + const chatId = options?.chatId || 'loopback-dm-1'; + const senderId = options?.senderId || 'test-user'; + const senderName = options?.senderName || 'Test User'; + const timeoutMs = options?.timeoutMs || RESPONSE_TIMEOUT_MS; + + // Create promise to capture the response from sendMessage + const responsePromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.responseResolvers.delete(chatId); + reject(new Error(`Loopback timeout: no response after ${timeoutMs}ms`)); + }, timeoutMs); + + this.responseResolvers.set(chatId, (text: string) => { + clearTimeout(timer); + resolve(text); + }); + }); + + // Build envelope and push through the pipeline + const envelope: Envelope = { + channelName: this.name, + senderId, + senderName, + chatId, + text, + isGroup: false, + isMentioned: false, + isReplyToBot: false, + }; + + // handleInbound → gates → session → bridge.prompt → sendMessage + await this.handleInbound(envelope); + + return responsePromise; + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function createTestConfig(cwd: string): ChannelConfig { + return { + type: 'loopback', + token: '', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'user', + cwd, + groupPolicy: 'disabled', + groups: {}, + } as ChannelConfig; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Channel Plugin (Loopback)', () => { + let bridge: InstanceType; + let channel: LoopbackChannel; + let testDir: string; + + // Set up once for all tests — reuse the bridge (expensive to start) + const setup = async () => { + const baseDir = + process.env['INTEGRATION_TEST_FILE_DIR'] || + join(__dirname, '..', '.integration-tests', `channel-${Date.now()}`); + testDir = join(baseDir, 'channel-plugin'); + mkdirSync(testDir, { recursive: true }); + + bridge = new AcpBridge({ + cliEntryPath: CLI_PATH, + cwd: testDir, + }); + await bridge.start(); + + const router = new SessionRouter(bridge, testDir, 'user'); + const config = createTestConfig(testDir); + channel = new LoopbackChannel('test-loopback', config, bridge, { router }); + await channel.connect(); + }; + + afterAll(() => { + try { + channel?.disconnect(); + } catch { + // ignore + } + try { + bridge?.stop(); + } catch { + // ignore + } + }); + + it( + 'should receive a real agent response through the full channel pipeline', + async () => { + await setup(); + + const response = await channel.send( + 'What is 2+2? Reply with ONLY the number, nothing else.', + ); + + // The real model should return something containing "4" + expect(response).toBeTruthy(); + expect(response).toContain('4'); + console.log(`[channel-plugin] Single turn response: "${response}"`); + }, + RESPONSE_TIMEOUT_MS, + ); + + it( + 'should maintain session state across multiple messages', + async () => { + // Use a dedicated chatId for this test's session + const chatId = 'session-test-dm'; + + const r1 = await channel.send( + 'My secret word is "pineapple". Remember it.', + { + chatId, + }, + ); + expect(r1).toBeTruthy(); + console.log(`[channel-plugin] Memory set response: "${r1}"`); + + const r2 = await channel.send( + 'What is my secret word? Reply with ONLY the word, nothing else.', + { chatId }, + ); + expect(r2).toBeTruthy(); + expect(r2.toLowerCase()).toContain('pineapple'); + console.log(`[channel-plugin] Memory recall response: "${r2}"`); + }, + RESPONSE_TIMEOUT_MS * 2, + ); + + it( + 'should handle a different sender through the same pipeline', + async () => { + // Use a different sender to verify per-sender session routing works + const response = await channel.send( + 'What is 10 * 5? Reply with ONLY the number, nothing else.', + { + senderId: 'different-user', + senderName: 'Another User', + chatId: 'different-user-dm', + }, + ); + + expect(response).toBeTruthy(); + expect(response).toContain('50'); + console.log(`[channel-plugin] Different sender response: "${response}"`); + }, + RESPONSE_TIMEOUT_MS, + ); +}); From 0f9e4409df6dffa5baaffc17aee27fd2271a21be Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 14:30:33 +0000 Subject: [PATCH 28/51] feat(channels): add mock channel package for E2E testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @qwen-code/channel-mock package with MockPluginChannel - Add createMockServer for programmatic test control via WebSocket - Refactor integration test to use real WebSocket E2E flow This enables testing the full channel pipeline (WebSocket → ChannelBase → AcpBridge → agent) instead of the previous in-process loopback approach. Co-authored-by: Qwen-Coder --- integration-tests/channel-plugin.test.ts | 243 +++++------------ package-lock.json | 18 +- package.json | 3 +- packages/channels/mock/package.json | 17 ++ .../channels/mock/src/MockPluginChannel.ts | 116 ++++++++ packages/channels/mock/src/index.ts | 16 ++ packages/channels/mock/src/mock-server.ts | 254 ++++++++++++++++++ packages/channels/mock/src/protocol.ts | 23 ++ packages/channels/mock/tsconfig.json | 10 + 9 files changed, 526 insertions(+), 174 deletions(-) create mode 100644 packages/channels/mock/package.json create mode 100644 packages/channels/mock/src/MockPluginChannel.ts create mode 100644 packages/channels/mock/src/index.ts create mode 100644 packages/channels/mock/src/mock-server.ts create mode 100644 packages/channels/mock/src/protocol.ts create mode 100644 packages/channels/mock/tsconfig.json diff --git a/integration-tests/channel-plugin.test.ts b/integration-tests/channel-plugin.test.ts index 9712581ea..605df871c 100644 --- a/integration-tests/channel-plugin.test.ts +++ b/integration-tests/channel-plugin.test.ts @@ -5,26 +5,24 @@ */ /** - * Channel Plugin Integration Test — "Loopback Channel" + * Channel Plugin Integration Test — Real E2E with WebSocket * - * Creative approach: instead of the heavy 3-process architecture - * (mock server + channel service + mock client), we use an in-process - * "loopback channel" that acts as both sender and receiver. + * Tests the actual MockPluginChannel (from @qwen-code/channel-mock) connected + * to an in-process mock server via WebSocket. The full message flow is: * - * The LoopbackChannel extends ChannelBase and plugs directly into AcpBridge. - * When a message is sent, it flows through the REAL pipeline: + * server.sendMessage("What is 2+2?") + * → WebSocket push to MockPluginChannel + * → ChannelBase.handleInbound(envelope) + * → SenderGate (open policy) + * → SessionRouter (creates/reuses session) + * → AcpBridge.prompt(sessionId, text) + * → qwen-code --acp (REAL model request) + * → MockPluginChannel.sendMessage(chatId, response) + * → WebSocket response to mock server + * → server resolves promise with agent text * - * test.send("What is 2+2?") - * → LoopbackChannel.handleInbound(envelope) - * → SenderGate (open policy) - * → SessionRouter (creates/reuses session) - * → AcpBridge.prompt(sessionId, text) - * → qwen-code --acp (REAL model request) - * → LoopbackChannel.sendMessage(chatId, response) - * → test receives response via promise - * - * No WebSocket, no HTTP, no separate processes. Just the real - * channel pipeline with a real agent backend. + * This exercises the real WebSocket protocol, real message serialization, + * real ChannelPlugin interface, and real model backend — all in one test process. */ import { describe, it, expect, afterAll } from 'vitest'; @@ -32,172 +30,71 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { mkdirSync } from 'node:fs'; -// Import channel-base directly from compiled dist +// Import from the monorepo channel packages import { AcpBridge, - ChannelBase, SessionRouter, } from '../packages/channels/base/dist/index.js'; -import type { - ChannelConfig, - Envelope, - ChannelBaseOptions, -} from '../packages/channels/base/dist/index.js'; -import type { AcpBridge as AcpBridgeType } from '../packages/channels/base/dist/index.js'; +import type { ChannelConfig } from '../packages/channels/base/dist/index.js'; +import { + MockPluginChannel, + createMockServer, +} from '../packages/channels/mock/src/index.js'; +import type { MockServerHandle } from '../packages/channels/mock/src/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CLI_PATH = join(__dirname, '..', 'dist', 'cli.js'); const RESPONSE_TIMEOUT_MS = 120_000; -// --------------------------------------------------------------------------- -// Loopback Channel — the creative core -// --------------------------------------------------------------------------- - -/** - * A channel that lives entirely in the test process. - * - * - connect() is a no-op (nothing external to connect to) - * - sendMessage() resolves a pending promise so the test gets the response - * - send() pushes a message through handleInbound and returns the agent reply - * - * Think of it as a "promise pipe" that wraps the full ChannelBase pipeline. - */ -class LoopbackChannel extends ChannelBase { - /** Map of chatId → resolver for the next sendMessage call */ - private responseResolvers = new Map void>(); - private responseChunks = new Map(); - - constructor( - name: string, - config: ChannelConfig, - bridge: AcpBridgeType, - options?: ChannelBaseOptions, - ) { - super(name, config, bridge, options); - } - - async connect(): Promise { - // No external connection needed — we ARE the platform - } - - async sendMessage(chatId: string, text: string): Promise { - const resolver = this.responseResolvers.get(chatId); - if (resolver) { - resolver(text); - this.responseResolvers.delete(chatId); - } else { - // Buffer for cases where response arrives before await - const chunks = this.responseChunks.get(chatId) || []; - chunks.push(text); - this.responseChunks.set(chatId, chunks); - } - } - - disconnect(): void { - // Clean up any pending resolvers - for (const [, resolver] of this.responseResolvers) { - resolver('[channel disconnected]'); - } - this.responseResolvers.clear(); - } - - /** - * Send a message through the full channel pipeline and wait for the response. - * This is the test-facing API. - */ - async send( - text: string, - options?: { - senderId?: string; - senderName?: string; - chatId?: string; - timeoutMs?: number; - }, - ): Promise { - const chatId = options?.chatId || 'loopback-dm-1'; - const senderId = options?.senderId || 'test-user'; - const senderName = options?.senderName || 'Test User'; - const timeoutMs = options?.timeoutMs || RESPONSE_TIMEOUT_MS; - - // Create promise to capture the response from sendMessage - const responsePromise = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.responseResolvers.delete(chatId); - reject(new Error(`Loopback timeout: no response after ${timeoutMs}ms`)); - }, timeoutMs); - - this.responseResolvers.set(chatId, (text: string) => { - clearTimeout(timer); - resolve(text); - }); - }); - - // Build envelope and push through the pipeline - const envelope: Envelope = { - channelName: this.name, - senderId, - senderName, - chatId, - text, - isGroup: false, - isMentioned: false, - isReplyToBot: false, - }; - - // handleInbound → gates → session → bridge.prompt → sendMessage - await this.handleInbound(envelope); - - return responsePromise; - } -} - -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - -function createTestConfig(cwd: string): ChannelConfig { - return { - type: 'loopback', - token: '', - senderPolicy: 'open', - allowedUsers: [], - sessionScope: 'user', - cwd, - groupPolicy: 'disabled', - groups: {}, - } as ChannelConfig; -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe('Channel Plugin (Loopback)', () => { +describe('Channel Plugin (Mock WebSocket E2E)', () => { let bridge: InstanceType; - let channel: LoopbackChannel; + let channel: MockPluginChannel; + let server: MockServerHandle; let testDir: string; - // Set up once for all tests — reuse the bridge (expensive to start) const setup = async () => { const baseDir = process.env['INTEGRATION_TEST_FILE_DIR'] || join(__dirname, '..', '.integration-tests', `channel-${Date.now()}`); - testDir = join(baseDir, 'channel-plugin'); + testDir = join(baseDir, 'channel-mock-e2e'); mkdirSync(testDir, { recursive: true }); + // 1. Start mock server on random ports (no port conflicts) + server = await createMockServer({ httpPort: 0, wsPort: 0 }); + + // 2. Start AcpBridge (spawns real qwen-code --acp) bridge = new AcpBridge({ cliEntryPath: CLI_PATH, cwd: testDir, }); await bridge.start(); + // 3. Create and connect MockPluginChannel via WebSocket + const config: ChannelConfig & Record = { + type: 'mock-plugin', + token: '', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'user', + cwd: testDir, + groupPolicy: 'disabled', + groups: {}, + serverWsUrl: server.wsUrl, + }; + const router = new SessionRouter(bridge, testDir, 'user'); - const config = createTestConfig(testDir); - channel = new LoopbackChannel('test-loopback', config, bridge, { router }); + channel = new MockPluginChannel('test-mock', config, bridge, { router }); await channel.connect(); + + // 4. Wait for the channel's WebSocket to be registered by the server + await server.waitForConnection(5_000); }; - afterAll(() => { + afterAll(async () => { try { channel?.disconnect(); } catch { @@ -208,67 +105,69 @@ describe('Channel Plugin (Loopback)', () => { } catch { // ignore } + try { + await server?.close(); + } catch { + // ignore + } }); it( - 'should receive a real agent response through the full channel pipeline', + 'should send a message through WebSocket and receive a real agent response', async () => { await setup(); - const response = await channel.send( + // This goes: server → WS → MockPluginChannel → ChannelBase → AcpBridge → agent → back + const response = await server.sendMessage( 'What is 2+2? Reply with ONLY the number, nothing else.', ); - // The real model should return something containing "4" expect(response).toBeTruthy(); expect(response).toContain('4'); - console.log(`[channel-plugin] Single turn response: "${response}"`); + console.log(`[mock-e2e] Single turn response: "${response}"`); }, RESPONSE_TIMEOUT_MS, ); it( - 'should maintain session state across multiple messages', + 'should maintain session state across multiple WebSocket messages', async () => { - // Use a dedicated chatId for this test's session - const chatId = 'session-test-dm'; + const chatId = 'ws-session-test'; + const opts = { chatId }; - const r1 = await channel.send( + const r1 = await server.sendMessage( 'My secret word is "pineapple". Remember it.', - { - chatId, - }, + opts, ); expect(r1).toBeTruthy(); - console.log(`[channel-plugin] Memory set response: "${r1}"`); + console.log(`[mock-e2e] Memory set response: "${r1}"`); - const r2 = await channel.send( + const r2 = await server.sendMessage( 'What is my secret word? Reply with ONLY the word, nothing else.', - { chatId }, + opts, ); expect(r2).toBeTruthy(); expect(r2.toLowerCase()).toContain('pineapple'); - console.log(`[channel-plugin] Memory recall response: "${r2}"`); + console.log(`[mock-e2e] Memory recall response: "${r2}"`); }, RESPONSE_TIMEOUT_MS * 2, ); it( - 'should handle a different sender through the same pipeline', + 'should handle a different sender through the same WebSocket pipeline', async () => { - // Use a different sender to verify per-sender session routing works - const response = await channel.send( + const response = await server.sendMessage( 'What is 10 * 5? Reply with ONLY the number, nothing else.', { - senderId: 'different-user', + senderId: 'another-user', senderName: 'Another User', - chatId: 'different-user-dm', + chatId: 'dm-another-user', }, ); expect(response).toBeTruthy(); expect(response).toContain('50'); - console.log(`[channel-plugin] Different sender response: "${response}"`); + console.log(`[mock-e2e] Different sender response: "${response}"`); }, RESPONSE_TIMEOUT_MS, ); diff --git a/package-lock.json b/package-lock.json index caf9f2156..aaba91193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "packages/channels/base", "packages/channels/telegram", "packages/channels/weixin", - "packages/channels/dingtalk" + "packages/channels/dingtalk", + "packages/channels/mock" ], "dependencies": { "@testing-library/dom": "^10.4.1", @@ -3002,6 +3003,10 @@ "resolved": "packages/channels/dingtalk", "link": true }, + "node_modules/@qwen-code/channel-mock": { + "resolved": "packages/channels/mock", + "link": true + }, "node_modules/@qwen-code/channel-telegram": { "resolved": "packages/channels/telegram", "link": true @@ -18985,6 +18990,17 @@ "typescript": "^5.0.0" } }, + "packages/channels/mock": { + "name": "@qwen-code/channel-mock", + "version": "0.1.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.0" + } + }, "packages/channels/telegram": { "name": "@qwen-code/channel-telegram", "version": "0.1.0", diff --git a/package.json b/package.json index cf339100a..c565eaf91 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "packages/channels/base", "packages/channels/telegram", "packages/channels/weixin", - "packages/channels/dingtalk" + "packages/channels/dingtalk", + "packages/channels/mock" ], "repository": { "type": "git", diff --git a/packages/channels/mock/package.json b/packages/channels/mock/package.json new file mode 100644 index 000000000..18e2d6738 --- /dev/null +++ b/packages/channels/mock/package.json @@ -0,0 +1,17 @@ +{ + "name": "@qwen-code/channel-mock", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.0" + } +} diff --git a/packages/channels/mock/src/MockPluginChannel.ts b/packages/channels/mock/src/MockPluginChannel.ts new file mode 100644 index 000000000..f75c518fb --- /dev/null +++ b/packages/channels/mock/src/MockPluginChannel.ts @@ -0,0 +1,116 @@ +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; +import WebSocket from 'ws'; +import type { InboundMessage, OutboundMessage } from './protocol.js'; + +export interface MockPluginConfig extends ChannelConfig { + serverWsUrl: string; +} + +export class MockPluginChannel extends ChannelBase { + private ws: WebSocket | null = null; + private serverWsUrl: string; + private pendingMessageId: string | undefined; + + constructor( + name: string, + config: MockPluginConfig & Record, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + this.serverWsUrl = config.serverWsUrl; + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this.ws = new WebSocket(this.serverWsUrl); + + this.ws.on('open', () => { + resolve(); + }); + + this.ws.on('message', (data: Buffer) => { + try { + const msg = JSON.parse(data.toString()) as InboundMessage; + if (msg.type === 'inbound') { + this.onInboundMessage(msg); + } + } catch { + // ignore parse errors + } + }); + + this.ws.on('close', () => { + this.ws = null; + }); + + this.ws.on('error', (err: Error) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(err); + } + }); + }); + } + + private onInboundMessage(msg: InboundMessage): void { + const envelope: Envelope = { + channelName: this.name, + senderId: msg.senderId, + senderName: msg.senderName, + chatId: msg.chatId, + text: msg.text, + isGroup: false, + isMentioned: false, + isReplyToBot: false, + }; + + // Store messageId for response correlation + (envelope as unknown as Record)['_messageId'] = + msg.messageId; + + this.handleInbound(envelope).catch(() => { + // errors handled internally by ChannelBase + }); + } + + async sendMessage(chatId: string, text: string): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return; + } + + const messageId = this.pendingMessageId || 'unknown'; + + const outbound: OutboundMessage = { + type: 'outbound', + messageId, + chatId, + text, + }; + + this.ws.send(JSON.stringify(outbound)); + } + + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + override async handleInbound(envelope: Envelope): Promise { + this.pendingMessageId = (envelope as unknown as Record)[ + '_messageId' + ] as string | undefined; + try { + await super.handleInbound(envelope); + } finally { + this.pendingMessageId = undefined; + } + } +} diff --git a/packages/channels/mock/src/index.ts b/packages/channels/mock/src/index.ts new file mode 100644 index 000000000..df9ddb184 --- /dev/null +++ b/packages/channels/mock/src/index.ts @@ -0,0 +1,16 @@ +import type { ChannelPlugin } from '@qwen-code/channel-base'; +import { MockPluginChannel } from './MockPluginChannel.js'; + +export { MockPluginChannel } from './MockPluginChannel.js'; +export type { MockPluginConfig } from './MockPluginChannel.js'; +export { createMockServer } from './mock-server.js'; +export type { MockServerHandle, MockServerOptions } from './mock-server.js'; +export type { InboundMessage, OutboundMessage, WsMessage } from './protocol.js'; + +export const plugin: ChannelPlugin = { + channelType: 'mock-plugin', + displayName: 'Mock Plugin', + requiredConfigFields: ['serverWsUrl'], + createChannel: (name, config, bridge, options) => + new MockPluginChannel(name, config as MockPluginConfig, bridge, options), +}; diff --git a/packages/channels/mock/src/mock-server.ts b/packages/channels/mock/src/mock-server.ts new file mode 100644 index 000000000..6b40290ed --- /dev/null +++ b/packages/channels/mock/src/mock-server.ts @@ -0,0 +1,254 @@ +/** + * Mock Platform Server — programmatic API for integration tests. + * + * Provides a createMockServer() function that starts HTTP + WebSocket servers + * and returns a handle for sending messages and cleaning up. + * + * Architecture: + * Test code calls server.sendMessage("Hello") + * → HTTP handler creates messageId, pushes via WebSocket to connected channel + * → Channel processes → responds via WebSocket + * → Server resolves the pending promise with agent response text + */ + +import http from 'node:http'; +import crypto from 'node:crypto'; +import { WebSocketServer, WebSocket } from 'ws'; + +export interface MockServerHandle { + /** Port the HTTP server is listening on */ + httpPort: number; + /** Port the WebSocket server is listening on */ + wsPort: number; + /** WebSocket URL for channels to connect to */ + wsUrl: string; + /** Send a message through the full pipeline and wait for the agent response */ + sendMessage( + text: string, + options?: { senderId?: string; senderName?: string; chatId?: string }, + ): Promise; + /** Wait for a plugin channel to connect */ + waitForConnection(timeoutMs?: number): Promise; + /** Shut down both servers and reject pending requests */ + close(): Promise; +} + +export interface MockServerOptions { + /** HTTP port (0 = random available port) */ + httpPort?: number; + /** WebSocket port (0 = random available port) */ + wsPort?: number; + /** Timeout for agent responses in ms (default: 120000) */ + responseTimeoutMs?: number; +} + +export function createMockServer( + options?: MockServerOptions, +): Promise { + const responseTimeoutMs = options?.responseTimeoutMs ?? 120_000; + + let pluginWs: WebSocket | null = null; + let connectionResolver: (() => void) | null = null; + + const pendingRequests = new Map< + string, + { + resolve: (text: string) => void; + reject: (err: Error) => void; + timer: ReturnType; + } + >(); + + // --- WebSocket server --- + const wss = new WebSocketServer({ port: options?.wsPort ?? 0 }); + + wss.on('connection', (ws) => { + pluginWs = ws; + + if (connectionResolver) { + connectionResolver(); + connectionResolver = null; + } + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'outbound' && msg.messageId) { + const pending = pendingRequests.get(msg.messageId); + if (pending) { + clearTimeout(pending.timer); + pendingRequests.delete(msg.messageId); + pending.resolve(msg.text); + } + } + } catch { + // ignore + } + }); + + ws.on('close', () => { + if (pluginWs === ws) pluginWs = null; + }); + }); + + // --- HTTP server --- + const httpServer = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + status: 'ok', + pluginConnected: + pluginWs !== null && pluginWs.readyState === WebSocket.OPEN, + }), + ); + return; + } + + if (req.method === 'POST' && req.url === '/message') { + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { senderId, senderName, chatId, text } = JSON.parse(body); + if (!senderId || !text) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ error: 'senderId and text are required' }), + ); + return; + } + if (!pluginWs || pluginWs.readyState !== WebSocket.OPEN) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Plugin channel not connected' })); + return; + } + + const messageId = crypto.randomUUID(); + pluginWs.send( + JSON.stringify({ + type: 'inbound', + messageId, + senderId, + senderName: senderName || senderId, + chatId: chatId || `dm-${senderId}`, + text, + }), + ); + + const responsePromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(messageId); + reject(new Error('Timeout waiting for agent response')); + }, responseTimeoutMs); + pendingRequests.set(messageId, { resolve, reject, timer }); + }); + + responsePromise + .then((responseText) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ messageId, text: responseText })); + }) + .catch((err: Error) => { + res.writeHead(504, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + }); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON body' })); + } + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + }); + + // Start both servers and return the handle + return new Promise((resolve, reject) => { + const wsAddress = wss.address(); + if (!wsAddress || typeof wsAddress === 'string') { + reject(new Error('WebSocket server failed to bind')); + return; + } + const wsPort = wsAddress.port; + + httpServer.listen(options?.httpPort ?? 0, () => { + const httpAddress = httpServer.address(); + if (!httpAddress || typeof httpAddress === 'string') { + reject(new Error('HTTP server failed to bind')); + return; + } + const httpPort = httpAddress.port; + + const handle: MockServerHandle = { + httpPort, + wsPort, + wsUrl: `ws://localhost:${wsPort}`, + + async sendMessage(text, opts) { + const senderId = opts?.senderId || 'test-user'; + const senderName = opts?.senderName || 'Test User'; + const chatId = opts?.chatId || `dm-${senderId}`; + + if (!pluginWs || pluginWs.readyState !== WebSocket.OPEN) { + throw new Error('Plugin channel not connected'); + } + + const messageId = crypto.randomUUID(); + pluginWs.send( + JSON.stringify({ + type: 'inbound', + messageId, + senderId, + senderName, + chatId, + text, + }), + ); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(messageId); + reject(new Error('Timeout waiting for agent response')); + }, responseTimeoutMs); + pendingRequests.set(messageId, { resolve, reject, timer }); + }); + }, + + async waitForConnection(timeoutMs = 10_000) { + if (pluginWs && pluginWs.readyState === WebSocket.OPEN) return; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timeout waiting for channel connection')); + }, timeoutMs); + connectionResolver = () => { + clearTimeout(timer); + resolve(); + }; + }); + }, + + async close() { + for (const [, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error('Server shutting down')); + } + pendingRequests.clear(); + + await new Promise((r) => { + wss.close(() => r()); + }); + await new Promise((r) => { + httpServer.close(() => r()); + }); + }, + }; + + resolve(handle); + }); + }); +} diff --git a/packages/channels/mock/src/protocol.ts b/packages/channels/mock/src/protocol.ts new file mode 100644 index 000000000..49c2e0fba --- /dev/null +++ b/packages/channels/mock/src/protocol.ts @@ -0,0 +1,23 @@ +/** + * Shared protocol types for mock channel WebSocket communication. + */ + +/** Server → Plugin Channel (WebSocket) */ +export interface InboundMessage { + type: 'inbound'; + messageId: string; + senderId: string; + senderName: string; + chatId: string; + text: string; +} + +/** Plugin Channel → Server (WebSocket) */ +export interface OutboundMessage { + type: 'outbound'; + messageId: string; + chatId: string; + text: string; +} + +export type WsMessage = InboundMessage | OutboundMessage; diff --git a/packages/channels/mock/tsconfig.json b/packages/channels/mock/tsconfig.json new file mode 100644 index 000000000..8daf59408 --- /dev/null +++ b/packages/channels/mock/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../base" }] +} From 01c2e5a373bf692cbf99cf9f2d5c0701a7cbf73d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 26 Mar 2026 14:41:20 +0000 Subject: [PATCH 29/51] docs(channels): add custom channel plugins documentation - Document channels config in extension manifest - Add guide for creating custom channel adapters - Explain ChannelPlugin interface and ChannelBase usage This enables users to extend the channel system with custom platform adapters. Co-authored-by: Qwen-Coder --- docs/users/extension/introduction.md | 7 + docs/users/features/channels/_meta.ts | 1 + .../features/channels/custom-channels.md | 242 ++++++++++++++++++ docs/users/features/channels/overview.md | 4 +- 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 docs/users/features/channels/custom-channels.md diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 0efb25b7c..38190b7d2 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -156,6 +156,12 @@ The `qwen-extension.json` file contains the configuration for the extension. The "command": "node my-server.js" } }, + "channels": { + "my-platform": { + "entry": "dist/index.js", + "displayName": "My Platform Channel" + } + }, "contextFileName": "QWEN.md", "commands": "commands", "skills": "skills", @@ -175,6 +181,7 @@ The `qwen-extension.json` file contains the configuration for the extension. The - `version`: The version of the extension. - `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - Note that all MCP server configuration options are supported except for `trust`. +- `channels`: A map of custom channel adapters. The key is the channel type name, and the value has an `entry` (path to compiled JS entry point) and optional `displayName`. The entry point must export a `plugin` object conforming to the `ChannelPlugin` interface. See [Custom Channel Plugins](../features/channels/custom-channels) for a full guide. - `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded. - `commands`: The directory containing custom commands (default: `commands`). Commands are `.md` files that define prompts. - `skills`: The directory containing custom skills (default: `skills`). Skills are discovered automatically and become available via the `/skills` command. diff --git a/docs/users/features/channels/_meta.ts b/docs/users/features/channels/_meta.ts index a0fae7ec4..872a21f0c 100644 --- a/docs/users/features/channels/_meta.ts +++ b/docs/users/features/channels/_meta.ts @@ -3,4 +3,5 @@ export default { telegram: 'Telegram', weixin: 'WeChat', dingtalk: 'DingTalk', + 'custom-channels': 'Custom Channel Plugins', }; diff --git a/docs/users/features/channels/custom-channels.md b/docs/users/features/channels/custom-channels.md new file mode 100644 index 000000000..c10d40619 --- /dev/null +++ b/docs/users/features/channels/custom-channels.md @@ -0,0 +1,242 @@ +# Custom Channel Plugins + +You can extend the channel system with custom platform adapters packaged as [extensions](../../extension/introduction). This lets you connect Qwen Code to any messaging platform, webhook, or custom transport. + +## How It Works + +Channel plugins are loaded at startup from active extensions. When `qwen channel start` runs, it: + +1. Scans all enabled extensions for `channels` entries in their `qwen-extension.json` +2. Dynamically imports each channel's entry point +3. Registers the channel type so it can be referenced in `settings.json` +4. Creates channel instances using the plugin's factory function + +The plugin provides a `ChannelPlugin` object that tells the channel system how to create your adapter. Your adapter extends `ChannelBase`, which gives you the full pipeline for free: sender gating, group policies, session routing, and the ACP bridge to the agent. + +## Creating a Channel Plugin + +### 1. Set up the project + +Create a new directory for your extension: + +```bash +mkdir my-channel-extension +cd my-channel-extension +npm init -y +``` + +Install the channel base package (adjust the path to your qwen-code checkout): + +```bash +npm install @qwen-code/channel-base +``` + +### 2. Write the channel adapter + +Create a class that extends `ChannelBase`. You need to implement three methods: + +- **`connect()`** — Connect to your platform (WebSocket, polling, webhook, etc.) +- **`sendMessage(chatId, text)`** — Send a response back to a specific chat +- **`disconnect()`** — Clean up connections on shutdown + +When your platform delivers an incoming message, build an `Envelope` and call `this.handleInbound(envelope)`. The base class handles everything else: access control, session routing, prompting the agent, and calling your `sendMessage()` with the response. + +```typescript +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; + +interface MyPlatformConfig extends ChannelConfig { + apiKey: string; + webhookUrl: string; +} + +export class MyPlatformChannel extends ChannelBase { + private client: any; + + constructor( + name: string, + config: MyPlatformConfig & Record, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + } + + async connect(): Promise { + // Connect to your platform + this.client = await createPlatformClient(this.config); + + // When a message arrives, push it through the pipeline + this.client.on('message', (msg) => { + const envelope: Envelope = { + channelName: this.name, + senderId: msg.userId, + senderName: msg.userName, + chatId: msg.chatId, + text: msg.text, + isGroup: msg.isGroup ?? false, + isMentioned: msg.isMentioned ?? false, + isReplyToBot: msg.isReplyToBot ?? false, + }; + this.handleInbound(envelope); + }); + } + + async sendMessage(chatId: string, text: string): Promise { + await this.client.send(chatId, text); + } + + disconnect(): void { + this.client?.close(); + } +} +``` + +### 3. Export the plugin + +Create an `index.ts` (or `index.js`) that exports a `plugin` object conforming to the `ChannelPlugin` interface: + +```typescript +import type { ChannelPlugin } from '@qwen-code/channel-base'; +import { MyPlatformChannel } from './MyPlatformChannel.js'; + +export const plugin: ChannelPlugin = { + channelType: 'my-platform', + displayName: 'My Platform', + requiredConfigFields: ['apiKey'], + createChannel: (name, config, bridge, options) => + new MyPlatformChannel(name, config as any, bridge, options), +}; +``` + +The fields are: + +| Field | Required | Description | +| ---------------------- | -------- | -------------------------------------------------------------------------- | +| `channelType` | Yes | Unique type identifier. Must match the key in `qwen-extension.json` | +| `displayName` | Yes | Human-readable name shown in CLI output | +| `requiredConfigFields` | No | Extra config fields your channel needs beyond the standard `ChannelConfig` | +| `createChannel` | Yes | Factory function that creates your channel adapter instance | + +### 4. Create the extension manifest + +Create `qwen-extension.json` in your project root: + +```json +{ + "name": "my-channel-extension", + "version": "1.0.0", + "channels": { + "my-platform": { + "entry": "dist/index.js", + "displayName": "My Platform Channel" + } + } +} +``` + +The `channels` field maps channel type names to their configuration: + +| Field | Required | Description | +| ---------------------- | -------- | ------------------------------------------------------------------- | +| `entry` | Yes | Relative path to the compiled JS entry point (must export `plugin`) | +| `displayName` | No | Human-readable name for CLI output | +| `requiredConfigFields` | No | Extra config fields the channel requires | + +> **Note:** The channel type key (e.g., `my-platform`) must match the `channelType` value in your exported plugin object. The system validates this at load time. + +### 5. Build the extension + +Compile your TypeScript to JavaScript. The entry point in `qwen-extension.json` must point to compiled JS, not TypeScript source: + +```bash +npx tsc +``` + +### 6. Install the extension + +You can install from a local path during development: + +```bash +qwen extensions install /path/to/my-channel-extension +``` + +Or link it for development (changes are reflected immediately): + +```bash +qwen extensions link /path/to/my-channel-extension +``` + +### 7. Configure the channel + +Add a channel entry to `~/.qwen/settings.json` using your custom type: + +```json +{ + "channels": { + "my-bot": { + "type": "my-platform", + "apiKey": "$MY_PLATFORM_API_KEY", + "senderPolicy": "open", + "cwd": "/path/to/project" + } + } +} +``` + +All standard channel options (`senderPolicy`, `allowedUsers`, `sessionScope`, `cwd`, `instructions`, `groupPolicy`, `groups`, `model`) work with custom channels. + +### 8. Start the channel + +```bash +qwen channel start my-bot +``` + +## The Envelope + +The `Envelope` is the message object you build from your platform's incoming data and pass to `handleInbound()`: + +| Field | Type | Required | Description | +| ---------------- | ------- | -------- | ------------------------------------------------- | +| `channelName` | string | Yes | The channel name (use `this.name`) | +| `senderId` | string | Yes | Platform-specific user identifier | +| `senderName` | string | Yes | Display name of the sender | +| `chatId` | string | Yes | Platform-specific chat/conversation identifier | +| `text` | string | Yes | The message text | +| `threadId` | string | No | Thread identifier (for `sessionScope: "thread"`) | +| `isGroup` | boolean | Yes | Whether the message is from a group chat | +| `isMentioned` | boolean | Yes | Whether the bot was @mentioned | +| `isReplyToBot` | boolean | Yes | Whether the message is a reply to the bot | +| `referencedText` | string | No | Text of a quoted/replied-to message (for context) | +| `imageBase64` | string | No | Base64-encoded image data (for multimodal models) | +| `imageMimeType` | string | No | MIME type of the image (e.g., `image/jpeg`) | + +## What You Get for Free + +By extending `ChannelBase`, your channel automatically supports: + +- **Sender policies** — `allowlist`, `pairing`, and `open` access control +- **Group policies** — Per-group settings with optional @mention gating +- **Session routing** — Per-user, per-thread, or single shared sessions +- **DM pairing** — Full pairing code flow for unknown users +- **Slash commands** — `/help`, `/clear`, `/status` work out of the box +- **Custom instructions** — Prepended to the first message in each session +- **Crash recovery** — Automatic restart with session preservation +- **Per-session serialization** — Messages are queued to prevent race conditions + +## Example: Mock Channel Plugin + +The `@qwen-code/channel-mock` package (in `packages/channels/mock/`) is a complete reference implementation. It connects to a WebSocket server and routes messages through the full pipeline: + +``` +Mock Client → HTTP → Mock Server → WebSocket → MockPluginChannel + → ChannelBase → AcpBridge → qwen-code agent + → response flows back the same path +``` + +See `packages/channels/mock/src/MockPluginChannel.ts` for a working example of a WebSocket-based channel adapter. diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 0cae74aee..6b6b9e972 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -19,6 +19,8 @@ All channels share one agent process with isolated sessions per user. Each chann 2. Add the channel configuration to `~/.qwen/settings.json` 3. Run `qwen channel start` to start all channels, or `qwen channel start ` for a single channel +Want to connect a platform that isn't built in? See [Custom Channel Plugins](./custom-channels) to build your own adapter as an extension. + ## Configuration Channels are configured under the `channels` key in `settings.json`. Each channel has a name and a set of options: @@ -47,7 +49,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe | Option | Required | Description | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | Yes | Channel type: `telegram`, `weixin`, or `dingtalk` | +| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Custom Channel Plugins](./custom-channels)) | | `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk | | `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax | | `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax | From 987eebd1c45a452ff622dc0599e925af5536c0cb Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 03:19:34 +0000 Subject: [PATCH 30/51] docs(channels): add plugin developer guide and rename mock to plugin-example - Add comprehensive developer guide for building channel plugins - Add user-facing docs for installing/configuring custom channel plugins - Replace custom-channels.md with new plugins.md - Rename @qwen-code/channel-mock to @qwen-code/channel-plugin-example - Add messageId field to Envelope type for response correlation This provides clear documentation for developers building custom channel adapters and renames the mock package to better reflect its purpose as a reference implementation example. Co-authored-by: Qwen-Coder --- docs/developers/_meta.ts | 1 + docs/developers/channel-plugins.md | 135 ++++++++++ docs/users/extension/introduction.md | 2 +- docs/users/features/channels/_meta.ts | 2 +- .../features/channels/custom-channels.md | 242 ------------------ docs/users/features/channels/overview.md | 4 +- docs/users/features/channels/plugins.md | 87 +++++++ integration-tests/channel-plugin.test.ts | 10 +- package-lock.json | 10 +- package.json | 2 +- packages/channels/base/src/types.ts | 2 + .../{mock => plugin-example}/package.json | 2 +- .../src/MockPluginChannel.ts | 13 +- .../{mock => plugin-example}/src/index.ts | 4 +- .../src/mock-server.ts | 0 .../{mock => plugin-example}/src/protocol.ts | 0 .../{mock => plugin-example}/tsconfig.json | 0 17 files changed, 246 insertions(+), 270 deletions(-) create mode 100644 docs/developers/channel-plugins.md delete mode 100644 docs/users/features/channels/custom-channels.md create mode 100644 docs/users/features/channels/plugins.md rename packages/channels/{mock => plugin-example}/package.json (86%) rename packages/channels/{mock => plugin-example}/src/MockPluginChannel.ts (88%) rename packages/channels/{mock => plugin-example}/src/index.ts (91%) rename packages/channels/{mock => plugin-example}/src/mock-server.ts (100%) rename packages/channels/{mock => plugin-example}/src/protocol.ts (100%) rename packages/channels/{mock => plugin-example}/tsconfig.json (100%) diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts index a7a316f77..42c938c9f 100644 --- a/docs/developers/_meta.ts +++ b/docs/developers/_meta.ts @@ -17,6 +17,7 @@ export default { type: 'separator', }, + 'channel-plugins': 'Channel Plugin Guide', tools: 'Tools', examples: { diff --git a/docs/developers/channel-plugins.md b/docs/developers/channel-plugins.md new file mode 100644 index 000000000..7837532b6 --- /dev/null +++ b/docs/developers/channel-plugins.md @@ -0,0 +1,135 @@ +# Channel Plugin Developer Guide + +A channel plugin connects Qwen Code to a messaging platform. It's packaged as an [extension](../users/extension/introduction) and loaded at startup. For user-facing docs on installing and configuring plugins, see [Plugins](../users/features/channels/plugins). + +## How It Fits Together + +Your plugin sits in the Platform Adapter layer. You handle platform-specific concerns (connecting, receiving messages, sending responses). `ChannelBase` handles everything else (access control, session routing, prompt queuing, slash commands, crash recovery). + +``` +Your Plugin → builds Envelope → handleInbound() +ChannelBase → gates → commands → routing → AcpBridge.prompt() +ChannelBase → calls your sendMessage() with the agent's response +``` + +## The Plugin Object + +Your extension entry point exports a `plugin` conforming to `ChannelPlugin`: + +```typescript +import type { ChannelPlugin } from '@qwen-code/channel-base'; +import { MyChannel } from './MyChannel.js'; + +export const plugin: ChannelPlugin = { + channelType: 'my-platform', // Unique ID, used in settings.json "type" field + displayName: 'My Platform', // Shown in CLI output + requiredConfigFields: ['apiKey'], // Validated at startup (beyond standard ChannelConfig) + createChannel: (name, config, bridge, options) => + new MyChannel(name, config, bridge, options), +}; +``` + +## The Channel Adapter + +Extend `ChannelBase` and implement three methods: + +```typescript +import { ChannelBase } from '@qwen-code/channel-base'; +import type { Envelope } from '@qwen-code/channel-base'; + +export class MyChannel extends ChannelBase { + async connect(): Promise { + // Connect to your platform, register message handlers + // When a message arrives: + const envelope: Envelope = { + channelName: this.name, + senderId: '...', // Stable, unique platform user ID + senderName: '...', // Display name + chatId: '...', // Chat/conversation ID (distinct for DMs vs groups) + text: '...', // Message text (strip @mentions) + isGroup: false, // Accurate — used by GroupGate + isMentioned: false, // Accurate — used by GroupGate + isReplyToBot: false, // Accurate — used by GroupGate + }; + this.handleInbound(envelope); + } + + async sendMessage(chatId: string, text: string): Promise { + // Format markdown → platform format, chunk if needed, deliver + } + + disconnect(): void { + // Clean up connections + } +} +``` + +## The Envelope + +The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate. + +| Field | Type | Required | Notes | +| ---------------- | ------- | -------- | -------------------------------------------------------------------------- | +| `channelName` | string | Yes | Use `this.name` | +| `senderId` | string | Yes | Must be stable across messages (used for session routing + access control) | +| `senderName` | string | Yes | Display name | +| `chatId` | string | Yes | Must distinguish DMs from groups | +| `text` | string | Yes | Strip bot @mentions | +| `threadId` | string | No | For `sessionScope: "thread"` | +| `messageId` | string | No | Platform message ID — useful for response correlation | +| `isGroup` | boolean | Yes | GroupGate relies on this | +| `isMentioned` | boolean | Yes | GroupGate relies on this | +| `isReplyToBot` | boolean | Yes | GroupGate relies on this | +| `referencedText` | string | No | Quoted message — prepended as context | +| `imageBase64` | string | No | Base64-encoded image for multimodal models | +| `imageMimeType` | string | No | e.g., `image/jpeg` | + +For **files**: download from your platform, save to a temp directory, include the file path in `text`. + +## Extension Manifest + +Your `qwen-extension.json` declares the channel type. The key must match `channelType` in your plugin object: + +```json +{ + "name": "my-channel-extension", + "version": "1.0.0", + "channels": { + "my-platform": { + "entry": "dist/index.js", + "displayName": "My Platform Channel" + } + } +} +``` + +## Optional Extension Points + +**Custom slash commands** — register in your constructor: + +```typescript +this.registerCommand('mycommand', async (envelope, args) => { + await this.sendMessage(envelope.chatId, 'Response'); + return true; // handled, don't forward to agent +}); +``` + +**Working indicators** — override `handleInbound()` to show platform-specific typing indicators: + +```typescript +override async handleInbound(envelope: Envelope): Promise { + await this.platformClient.sendTyping(envelope.chatId); // your platform API + try { await super.handleInbound(envelope); } + finally { await this.platformClient.stopTyping(envelope.chatId); } +} +``` + +**Tool call hooks** — override `onToolCall()` to display agent activity (e.g., "Running shell command..."). + +**Media** — download from your platform, set `imageBase64`/`imageMimeType` on the Envelope before calling `handleInbound()`. + +## Reference Implementations + +- **Plugin example** (`packages/channels/plugin-example/`) — minimal WebSocket-based adapter, good starting point +- **Telegram** (`packages/channels/telegram/`) — full-featured: images, files, formatting, typing indicators +- **DingTalk** (`packages/channels/dingtalk/`) — stream-based with rich text handling diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 38190b7d2..f8d5ce4c8 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -181,7 +181,7 @@ The `qwen-extension.json` file contains the configuration for the extension. The - `version`: The version of the extension. - `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence. - Note that all MCP server configuration options are supported except for `trust`. -- `channels`: A map of custom channel adapters. The key is the channel type name, and the value has an `entry` (path to compiled JS entry point) and optional `displayName`. The entry point must export a `plugin` object conforming to the `ChannelPlugin` interface. See [Custom Channel Plugins](../features/channels/custom-channels) for a full guide. +- `channels`: A map of custom channel adapters. The key is the channel type name, and the value has an `entry` (path to compiled JS entry point) and optional `displayName`. The entry point must export a `plugin` object conforming to the `ChannelPlugin` interface. See [Channel Plugins](../features/channels/plugins) for a full guide. - `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded. - `commands`: The directory containing custom commands (default: `commands`). Commands are `.md` files that define prompts. - `skills`: The directory containing custom skills (default: `skills`). Skills are discovered automatically and become available via the `/skills` command. diff --git a/docs/users/features/channels/_meta.ts b/docs/users/features/channels/_meta.ts index 872a21f0c..6ee996656 100644 --- a/docs/users/features/channels/_meta.ts +++ b/docs/users/features/channels/_meta.ts @@ -3,5 +3,5 @@ export default { telegram: 'Telegram', weixin: 'WeChat', dingtalk: 'DingTalk', - 'custom-channels': 'Custom Channel Plugins', + plugins: 'Plugins', }; diff --git a/docs/users/features/channels/custom-channels.md b/docs/users/features/channels/custom-channels.md deleted file mode 100644 index c10d40619..000000000 --- a/docs/users/features/channels/custom-channels.md +++ /dev/null @@ -1,242 +0,0 @@ -# Custom Channel Plugins - -You can extend the channel system with custom platform adapters packaged as [extensions](../../extension/introduction). This lets you connect Qwen Code to any messaging platform, webhook, or custom transport. - -## How It Works - -Channel plugins are loaded at startup from active extensions. When `qwen channel start` runs, it: - -1. Scans all enabled extensions for `channels` entries in their `qwen-extension.json` -2. Dynamically imports each channel's entry point -3. Registers the channel type so it can be referenced in `settings.json` -4. Creates channel instances using the plugin's factory function - -The plugin provides a `ChannelPlugin` object that tells the channel system how to create your adapter. Your adapter extends `ChannelBase`, which gives you the full pipeline for free: sender gating, group policies, session routing, and the ACP bridge to the agent. - -## Creating a Channel Plugin - -### 1. Set up the project - -Create a new directory for your extension: - -```bash -mkdir my-channel-extension -cd my-channel-extension -npm init -y -``` - -Install the channel base package (adjust the path to your qwen-code checkout): - -```bash -npm install @qwen-code/channel-base -``` - -### 2. Write the channel adapter - -Create a class that extends `ChannelBase`. You need to implement three methods: - -- **`connect()`** — Connect to your platform (WebSocket, polling, webhook, etc.) -- **`sendMessage(chatId, text)`** — Send a response back to a specific chat -- **`disconnect()`** — Clean up connections on shutdown - -When your platform delivers an incoming message, build an `Envelope` and call `this.handleInbound(envelope)`. The base class handles everything else: access control, session routing, prompting the agent, and calling your `sendMessage()` with the response. - -```typescript -import { ChannelBase } from '@qwen-code/channel-base'; -import type { - ChannelConfig, - ChannelBaseOptions, - Envelope, - AcpBridge, -} from '@qwen-code/channel-base'; - -interface MyPlatformConfig extends ChannelConfig { - apiKey: string; - webhookUrl: string; -} - -export class MyPlatformChannel extends ChannelBase { - private client: any; - - constructor( - name: string, - config: MyPlatformConfig & Record, - bridge: AcpBridge, - options?: ChannelBaseOptions, - ) { - super(name, config, bridge, options); - } - - async connect(): Promise { - // Connect to your platform - this.client = await createPlatformClient(this.config); - - // When a message arrives, push it through the pipeline - this.client.on('message', (msg) => { - const envelope: Envelope = { - channelName: this.name, - senderId: msg.userId, - senderName: msg.userName, - chatId: msg.chatId, - text: msg.text, - isGroup: msg.isGroup ?? false, - isMentioned: msg.isMentioned ?? false, - isReplyToBot: msg.isReplyToBot ?? false, - }; - this.handleInbound(envelope); - }); - } - - async sendMessage(chatId: string, text: string): Promise { - await this.client.send(chatId, text); - } - - disconnect(): void { - this.client?.close(); - } -} -``` - -### 3. Export the plugin - -Create an `index.ts` (or `index.js`) that exports a `plugin` object conforming to the `ChannelPlugin` interface: - -```typescript -import type { ChannelPlugin } from '@qwen-code/channel-base'; -import { MyPlatformChannel } from './MyPlatformChannel.js'; - -export const plugin: ChannelPlugin = { - channelType: 'my-platform', - displayName: 'My Platform', - requiredConfigFields: ['apiKey'], - createChannel: (name, config, bridge, options) => - new MyPlatformChannel(name, config as any, bridge, options), -}; -``` - -The fields are: - -| Field | Required | Description | -| ---------------------- | -------- | -------------------------------------------------------------------------- | -| `channelType` | Yes | Unique type identifier. Must match the key in `qwen-extension.json` | -| `displayName` | Yes | Human-readable name shown in CLI output | -| `requiredConfigFields` | No | Extra config fields your channel needs beyond the standard `ChannelConfig` | -| `createChannel` | Yes | Factory function that creates your channel adapter instance | - -### 4. Create the extension manifest - -Create `qwen-extension.json` in your project root: - -```json -{ - "name": "my-channel-extension", - "version": "1.0.0", - "channels": { - "my-platform": { - "entry": "dist/index.js", - "displayName": "My Platform Channel" - } - } -} -``` - -The `channels` field maps channel type names to their configuration: - -| Field | Required | Description | -| ---------------------- | -------- | ------------------------------------------------------------------- | -| `entry` | Yes | Relative path to the compiled JS entry point (must export `plugin`) | -| `displayName` | No | Human-readable name for CLI output | -| `requiredConfigFields` | No | Extra config fields the channel requires | - -> **Note:** The channel type key (e.g., `my-platform`) must match the `channelType` value in your exported plugin object. The system validates this at load time. - -### 5. Build the extension - -Compile your TypeScript to JavaScript. The entry point in `qwen-extension.json` must point to compiled JS, not TypeScript source: - -```bash -npx tsc -``` - -### 6. Install the extension - -You can install from a local path during development: - -```bash -qwen extensions install /path/to/my-channel-extension -``` - -Or link it for development (changes are reflected immediately): - -```bash -qwen extensions link /path/to/my-channel-extension -``` - -### 7. Configure the channel - -Add a channel entry to `~/.qwen/settings.json` using your custom type: - -```json -{ - "channels": { - "my-bot": { - "type": "my-platform", - "apiKey": "$MY_PLATFORM_API_KEY", - "senderPolicy": "open", - "cwd": "/path/to/project" - } - } -} -``` - -All standard channel options (`senderPolicy`, `allowedUsers`, `sessionScope`, `cwd`, `instructions`, `groupPolicy`, `groups`, `model`) work with custom channels. - -### 8. Start the channel - -```bash -qwen channel start my-bot -``` - -## The Envelope - -The `Envelope` is the message object you build from your platform's incoming data and pass to `handleInbound()`: - -| Field | Type | Required | Description | -| ---------------- | ------- | -------- | ------------------------------------------------- | -| `channelName` | string | Yes | The channel name (use `this.name`) | -| `senderId` | string | Yes | Platform-specific user identifier | -| `senderName` | string | Yes | Display name of the sender | -| `chatId` | string | Yes | Platform-specific chat/conversation identifier | -| `text` | string | Yes | The message text | -| `threadId` | string | No | Thread identifier (for `sessionScope: "thread"`) | -| `isGroup` | boolean | Yes | Whether the message is from a group chat | -| `isMentioned` | boolean | Yes | Whether the bot was @mentioned | -| `isReplyToBot` | boolean | Yes | Whether the message is a reply to the bot | -| `referencedText` | string | No | Text of a quoted/replied-to message (for context) | -| `imageBase64` | string | No | Base64-encoded image data (for multimodal models) | -| `imageMimeType` | string | No | MIME type of the image (e.g., `image/jpeg`) | - -## What You Get for Free - -By extending `ChannelBase`, your channel automatically supports: - -- **Sender policies** — `allowlist`, `pairing`, and `open` access control -- **Group policies** — Per-group settings with optional @mention gating -- **Session routing** — Per-user, per-thread, or single shared sessions -- **DM pairing** — Full pairing code flow for unknown users -- **Slash commands** — `/help`, `/clear`, `/status` work out of the box -- **Custom instructions** — Prepended to the first message in each session -- **Crash recovery** — Automatic restart with session preservation -- **Per-session serialization** — Messages are queued to prevent race conditions - -## Example: Mock Channel Plugin - -The `@qwen-code/channel-mock` package (in `packages/channels/mock/`) is a complete reference implementation. It connects to a WebSocket server and routes messages through the full pipeline: - -``` -Mock Client → HTTP → Mock Server → WebSocket → MockPluginChannel - → ChannelBase → AcpBridge → qwen-code agent - → response flows back the same path -``` - -See `packages/channels/mock/src/MockPluginChannel.ts` for a working example of a WebSocket-based channel adapter. diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 6b6b9e972..80bbe6f6a 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -19,7 +19,7 @@ All channels share one agent process with isolated sessions per user. Each chann 2. Add the channel configuration to `~/.qwen/settings.json` 3. Run `qwen channel start` to start all channels, or `qwen channel start ` for a single channel -Want to connect a platform that isn't built in? See [Custom Channel Plugins](./custom-channels) to build your own adapter as an extension. +Want to connect a platform that isn't built in? See [Plugins](./plugins) to add a custom adapter as an extension. ## Configuration @@ -49,7 +49,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe | Option | Required | Description | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Custom Channel Plugins](./custom-channels)) | +| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Plugins](./plugins)) | | `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk | | `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax | | `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax | diff --git a/docs/users/features/channels/plugins.md b/docs/users/features/channels/plugins.md new file mode 100644 index 000000000..0c9108913 --- /dev/null +++ b/docs/users/features/channels/plugins.md @@ -0,0 +1,87 @@ +# Custom Channel Plugins + +You can extend the channel system with custom platform adapters packaged as [extensions](../../extension/introduction). This lets you connect Qwen Code to any messaging platform, webhook, or custom transport. + +## How It Works + +Channel plugins are loaded at startup from active extensions. When `qwen channel start` runs, it: + +1. Scans all enabled extensions for `channels` entries in their `qwen-extension.json` +2. Dynamically imports each channel's entry point +3. Registers the channel type so it can be referenced in `settings.json` +4. Creates channel instances using the plugin's factory function + +Your custom channel gets the full shared pipeline for free: sender gating, group policies, session routing, slash commands, crash recovery, and the ACP bridge to the agent. + +## Installing a Custom Channel + +Install an extension that provides a channel plugin: + +```bash +# From a local path (for development or private plugins) +qwen extensions install /path/to/my-channel-extension + +# Or link it for development (changes are reflected immediately) +qwen extensions link /path/to/my-channel-extension +``` + +## Configuring a Custom Channel + +Add a channel entry to `~/.qwen/settings.json` using the custom type provided by the extension: + +```json +{ + "channels": { + "my-bot": { + "type": "my-platform", + "apiKey": "$MY_PLATFORM_API_KEY", + "senderPolicy": "open", + "cwd": "/path/to/project" + } + } +} +``` + +The `type` must match a channel type registered by an installed extension. Check the extension's documentation for which plugin-specific fields are required (e.g., `apiKey`, `webhookUrl`). + +All standard channel options work with custom channels: + +| Option | Description | +| -------------- | ---------------------------------------------- | +| `senderPolicy` | `allowlist`, `pairing`, or `open` | +| `allowedUsers` | Static allowlist of sender IDs | +| `sessionScope` | `user`, `thread`, or `single` | +| `cwd` | Working directory for the agent | +| `instructions` | Prepended to the first message of each session | +| `model` | Model override for the channel | +| `groupPolicy` | `disabled`, `allowlist`, or `open` | +| `groups` | Per-group settings | + +See [Overview](./overview) for details on each option. + +## Starting the Channel + +```bash +# Start all channels including custom ones +qwen channel start + +# Start just your custom channel +qwen channel start my-bot +``` + +## What You Get for Free + +Custom channels automatically support everything built-in channels do: + +- **Sender policies** — `allowlist`, `pairing`, and `open` access control +- **Group policies** — Per-group settings with optional @mention gating +- **Session routing** — Per-user, per-thread, or single shared sessions +- **DM pairing** — Full pairing code flow for unknown users +- **Slash commands** — `/help`, `/clear`, `/status` work out of the box +- **Custom instructions** — Prepended to the first message in each session +- **Crash recovery** — Automatic restart with session preservation +- **Per-session serialization** — Messages are queued to prevent race conditions + +## Building Your Own Channel Plugin + +Want to build a channel plugin for a new platform? See the [Channel Plugin Developer Guide](/developers/channel-plugins) for the `ChannelPlugin` interface, the `Envelope` format, and extension points. diff --git a/integration-tests/channel-plugin.test.ts b/integration-tests/channel-plugin.test.ts index 605df871c..10478c786 100644 --- a/integration-tests/channel-plugin.test.ts +++ b/integration-tests/channel-plugin.test.ts @@ -7,7 +7,7 @@ /** * Channel Plugin Integration Test — Real E2E with WebSocket * - * Tests the actual MockPluginChannel (from @qwen-code/channel-mock) connected + * Tests the actual MockPluginChannel (from @qwen-code/channel-plugin-example) connected * to an in-process mock server via WebSocket. The full message flow is: * * server.sendMessage("What is 2+2?") @@ -39,8 +39,8 @@ import type { ChannelConfig } from '../packages/channels/base/dist/index.js'; import { MockPluginChannel, createMockServer, -} from '../packages/channels/mock/src/index.js'; -import type { MockServerHandle } from '../packages/channels/mock/src/index.js'; +} from '../packages/channels/plugin-example/src/index.js'; +import type { MockServerHandle } from '../packages/channels/plugin-example/src/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CLI_PATH = join(__dirname, '..', 'dist', 'cli.js'); @@ -60,7 +60,7 @@ describe('Channel Plugin (Mock WebSocket E2E)', () => { const baseDir = process.env['INTEGRATION_TEST_FILE_DIR'] || join(__dirname, '..', '.integration-tests', `channel-${Date.now()}`); - testDir = join(baseDir, 'channel-mock-e2e'); + testDir = join(baseDir, 'channel-plugin-example-e2e'); mkdirSync(testDir, { recursive: true }); // 1. Start mock server on random ports (no port conflicts) @@ -75,7 +75,7 @@ describe('Channel Plugin (Mock WebSocket E2E)', () => { // 3. Create and connect MockPluginChannel via WebSocket const config: ChannelConfig & Record = { - type: 'mock-plugin', + type: 'plugin-example', token: '', senderPolicy: 'open', allowedUsers: [], diff --git a/package-lock.json b/package-lock.json index aaba91193..32aa31091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "packages/channels/telegram", "packages/channels/weixin", "packages/channels/dingtalk", - "packages/channels/mock" + "packages/channels/plugin-example" ], "dependencies": { "@testing-library/dom": "^10.4.1", @@ -3003,8 +3003,8 @@ "resolved": "packages/channels/dingtalk", "link": true }, - "node_modules/@qwen-code/channel-mock": { - "resolved": "packages/channels/mock", + "node_modules/@qwen-code/channel-plugin-example": { + "resolved": "packages/channels/plugin-example", "link": true }, "node_modules/@qwen-code/channel-telegram": { @@ -18990,8 +18990,8 @@ "typescript": "^5.0.0" } }, - "packages/channels/mock": { - "name": "@qwen-code/channel-mock", + "packages/channels/plugin-example": { + "name": "@qwen-code/channel-plugin-example", "version": "0.1.0", "dependencies": { "@qwen-code/channel-base": "file:../base", diff --git a/package.json b/package.json index c565eaf91..c5dc43258 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "packages/channels/telegram", "packages/channels/weixin", "packages/channels/dingtalk", - "packages/channels/mock" + "packages/channels/plugin-example" ], "repository": { "type": "git", diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 93e2b2aa5..4932ad469 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -33,6 +33,8 @@ export interface Envelope { chatId: string; text: string; threadId?: string; + /** Platform-specific message ID for response correlation. */ + messageId?: string; isGroup: boolean; isMentioned: boolean; isReplyToBot: boolean; diff --git a/packages/channels/mock/package.json b/packages/channels/plugin-example/package.json similarity index 86% rename from packages/channels/mock/package.json rename to packages/channels/plugin-example/package.json index 18e2d6738..d78bef775 100644 --- a/packages/channels/mock/package.json +++ b/packages/channels/plugin-example/package.json @@ -1,5 +1,5 @@ { - "name": "@qwen-code/channel-mock", + "name": "@qwen-code/channel-plugin-example", "version": "0.1.0", "type": "module", "main": "src/index.ts", diff --git a/packages/channels/mock/src/MockPluginChannel.ts b/packages/channels/plugin-example/src/MockPluginChannel.ts similarity index 88% rename from packages/channels/mock/src/MockPluginChannel.ts rename to packages/channels/plugin-example/src/MockPluginChannel.ts index f75c518fb..481c9e97f 100644 --- a/packages/channels/mock/src/MockPluginChannel.ts +++ b/packages/channels/plugin-example/src/MockPluginChannel.ts @@ -65,15 +65,12 @@ export class MockPluginChannel extends ChannelBase { senderName: msg.senderName, chatId: msg.chatId, text: msg.text, + messageId: msg.messageId, isGroup: false, isMentioned: false, isReplyToBot: false, }; - // Store messageId for response correlation - (envelope as unknown as Record)['_messageId'] = - msg.messageId; - this.handleInbound(envelope).catch(() => { // errors handled internally by ChannelBase }); @@ -84,11 +81,9 @@ export class MockPluginChannel extends ChannelBase { return; } - const messageId = this.pendingMessageId || 'unknown'; - const outbound: OutboundMessage = { type: 'outbound', - messageId, + messageId: this.pendingMessageId || 'unknown', chatId, text, }; @@ -104,9 +99,7 @@ export class MockPluginChannel extends ChannelBase { } override async handleInbound(envelope: Envelope): Promise { - this.pendingMessageId = (envelope as unknown as Record)[ - '_messageId' - ] as string | undefined; + this.pendingMessageId = envelope.messageId; try { await super.handleInbound(envelope); } finally { diff --git a/packages/channels/mock/src/index.ts b/packages/channels/plugin-example/src/index.ts similarity index 91% rename from packages/channels/mock/src/index.ts rename to packages/channels/plugin-example/src/index.ts index df9ddb184..239fb95d6 100644 --- a/packages/channels/mock/src/index.ts +++ b/packages/channels/plugin-example/src/index.ts @@ -8,8 +8,8 @@ export type { MockServerHandle, MockServerOptions } from './mock-server.js'; export type { InboundMessage, OutboundMessage, WsMessage } from './protocol.js'; export const plugin: ChannelPlugin = { - channelType: 'mock-plugin', - displayName: 'Mock Plugin', + channelType: 'plugin-example', + displayName: 'Plugin Example', requiredConfigFields: ['serverWsUrl'], createChannel: (name, config, bridge, options) => new MockPluginChannel(name, config as MockPluginConfig, bridge, options), diff --git a/packages/channels/mock/src/mock-server.ts b/packages/channels/plugin-example/src/mock-server.ts similarity index 100% rename from packages/channels/mock/src/mock-server.ts rename to packages/channels/plugin-example/src/mock-server.ts diff --git a/packages/channels/mock/src/protocol.ts b/packages/channels/plugin-example/src/protocol.ts similarity index 100% rename from packages/channels/mock/src/protocol.ts rename to packages/channels/plugin-example/src/protocol.ts diff --git a/packages/channels/mock/tsconfig.json b/packages/channels/plugin-example/tsconfig.json similarity index 100% rename from packages/channels/mock/tsconfig.json rename to packages/channels/plugin-example/tsconfig.json From c97c548acb3dcbc8af796e536f5bbf09d5e81b0e Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 04:21:56 +0000 Subject: [PATCH 31/51] feat(channels): make plugin-example package publishable - Update channel-base to use built dist/ output with proper exports - Add README with quick start guide and usage instructions - Add qwen-extension.json manifest for extension discovery - Add start-server CLI for running the mock WebSocket server - Update dependencies from local file: to npm version This enables the plugin-example package to be published and installed as a standalone extension for testing the channel plugin system. Co-authored-by: Qwen-Coder --- package-lock.json | 2 +- packages/channels/base/package.json | 15 ++- packages/channels/plugin-example/README.md | 97 +++++++++++++++++++ packages/channels/plugin-example/package.json | 22 ++++- .../plugin-example/qwen-extension.json | 10 ++ packages/channels/plugin-example/src/index.ts | 7 +- .../plugin-example/src/start-server.ts | 36 +++++++ 7 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 packages/channels/plugin-example/README.md create mode 100644 packages/channels/plugin-example/qwen-extension.json create mode 100644 packages/channels/plugin-example/src/start-server.ts diff --git a/package-lock.json b/package-lock.json index 32aa31091..18bc0e6f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18994,7 +18994,7 @@ "name": "@qwen-code/channel-plugin-example", "version": "0.1.0", "dependencies": { - "@qwen-code/channel-base": "file:../base", + "@qwen-code/channel-base": "^0.1.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/channels/base/package.json b/packages/channels/base/package.json index 7513c43b4..e32304c66 100644 --- a/packages/channels/base/package.json +++ b/packages/channels/base/package.json @@ -3,10 +3,19 @@ "version": "0.1.0", "description": "Base channel infrastructure for Qwen Code", "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build" }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1" diff --git a/packages/channels/plugin-example/README.md b/packages/channels/plugin-example/README.md new file mode 100644 index 000000000..a814fdd54 --- /dev/null +++ b/packages/channels/plugin-example/README.md @@ -0,0 +1,97 @@ +# @qwen-code/channel-plugin-example + +A reference channel plugin for Qwen Code. It connects to a WebSocket server and routes messages through the full channel pipeline (access control, session routing, agent bridge). + +Use this package to: + +- **Try out the channel plugin system** — install it as an extension and run it with the built-in mock server +- **Use it as a starting point** — fork the source to build your own channel adapter (see the [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md)) + +## Quick start + +### 1. Install the package + +```bash +npm install @qwen-code/channel-plugin-example +``` + +### 2. Link it as a Qwen Code extension + +The package ships a `qwen-extension.json` manifest, so it works as an extension out of the box: + +```bash +qwen extensions link ./node_modules/@qwen-code/channel-plugin-example +``` + +### 3. Configure the channel + +Add a channel entry to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-plugin-test": { + "type": "plugin-example", + "serverWsUrl": "ws://localhost:9201", + "senderPolicy": "open", + "sessionScope": "user", + "cwd": "/path/to/your/project" + } + } +} +``` + +### 4. Start the mock server + +```bash +npx qwen-channel-plugin-example-server +``` + +The server prints the HTTP and WebSocket URLs. You can customize ports with environment variables: + +```bash +HTTP_PORT=8080 WS_PORT=8081 npx qwen-channel-plugin-example-server +``` + +### 5. Start the channel + +In a separate terminal: + +```bash +qwen channel start my-plugin-test +``` + +### 6. Send a message + +```bash +curl -sX POST http://localhost:9200/message \ + -H 'Content-Type: application/json' \ + -d '{"senderId":"user1","senderName":"Tester","text":"What is 2+2?"}' +``` + +You should get a JSON response with the agent's reply. + +## How it works + +``` +Mock Server (HTTP + WS) + ↕ WebSocket +MockPluginChannel (this package) + → Envelope → ChannelBase.handleInbound() + → SenderGate → SessionRouter → AcpBridge.prompt() + → qwen-code agent → model API + ← response + ← sendMessage() → WebSocket → Mock Server + ← HTTP response +``` + +## Building your own channel + +See `src/MockPluginChannel.ts` for a working example. The key points: + +1. Extend `ChannelBase` and implement `connect()`, `sendMessage()`, `disconnect()` +2. Build an `Envelope` from incoming platform messages and call `this.handleInbound(envelope)` +3. Export a `plugin` object conforming to `ChannelPlugin` +4. Add a `qwen-extension.json` manifest + +Full guide: [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md) diff --git a/packages/channels/plugin-example/package.json b/packages/channels/plugin-example/package.json index d78bef775..9c59fd356 100644 --- a/packages/channels/plugin-example/package.json +++ b/packages/channels/plugin-example/package.json @@ -2,13 +2,27 @@ "name": "@qwen-code/channel-plugin-example", "version": "0.1.0", "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "qwen-extension.json" + ], + "bin": { + "qwen-channel-plugin-example-server": "dist/start-server.js" + }, + "scripts": { + "build": "tsc --build" }, "dependencies": { - "@qwen-code/channel-base": "file:../base", + "@qwen-code/channel-base": "^0.1.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/channels/plugin-example/qwen-extension.json b/packages/channels/plugin-example/qwen-extension.json new file mode 100644 index 000000000..fdfff08ff --- /dev/null +++ b/packages/channels/plugin-example/qwen-extension.json @@ -0,0 +1,10 @@ +{ + "name": "qwen-channel-plugin-example", + "version": "0.1.0", + "channels": { + "plugin-example": { + "entry": "dist/index.js", + "displayName": "Plugin Example Channel" + } + } +} diff --git a/packages/channels/plugin-example/src/index.ts b/packages/channels/plugin-example/src/index.ts index 239fb95d6..d5734298e 100644 --- a/packages/channels/plugin-example/src/index.ts +++ b/packages/channels/plugin-example/src/index.ts @@ -12,5 +12,10 @@ export const plugin: ChannelPlugin = { displayName: 'Plugin Example', requiredConfigFields: ['serverWsUrl'], createChannel: (name, config, bridge, options) => - new MockPluginChannel(name, config as MockPluginConfig, bridge, options), + new MockPluginChannel( + name, + config as typeof config & { serverWsUrl: string }, + bridge, + options, + ), }; diff --git a/packages/channels/plugin-example/src/start-server.ts b/packages/channels/plugin-example/src/start-server.ts new file mode 100644 index 000000000..c033a1343 --- /dev/null +++ b/packages/channels/plugin-example/src/start-server.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +/** + * Start the mock WebSocket server for testing the plugin-example channel. + * + * Usage: + * npx qwen-channel-plugin-example-server + * # or + * node node_modules/@qwen-code/channel-plugin-example/dist/start-server.js + * + * Environment variables: + * HTTP_PORT (default: 9200) + * WS_PORT (default: 9201) + */ +import { createMockServer } from './mock-server.js'; + +const httpPort = parseInt(process.env['HTTP_PORT'] || '9200', 10); +const wsPort = parseInt(process.env['WS_PORT'] || '9201', 10); + +const server = await createMockServer({ httpPort, wsPort }); + +console.log(`Mock server running:`); +console.log(` HTTP: http://localhost:${server.httpPort}`); +console.log(` WS: ws://localhost:${server.wsPort}`); +console.log(); +console.log(`Send a test message:`); +console.log(` curl -sX POST http://localhost:${server.httpPort}/message \\`); +console.log(` -H 'Content-Type: application/json' \\`); +console.log( + ` -d '{"senderId":"user1","senderName":"Tester","text":"Hello"}'`, +); + +process.on('SIGINT', async () => { + await server.close(); + process.exit(0); +}); From a700ce818664fdf51a0a9757bc2ec2a2dfaf15ea Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 06:16:22 +0000 Subject: [PATCH 32/51] chore(release): add channel packages to release workflow - Bump channel package versions to 0.13.0 - Add publish steps for @qwen-code/channel-base and @qwen-code/channel-plugin-example - Update version script to convert file: references to semver for published packages This enables proper npm publishing of channel packages during the release process. Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 16 ++++++++++++- packages/channels/base/package.json | 2 +- packages/channels/dingtalk/package.json | 2 +- packages/channels/plugin-example/package.json | 4 ++-- packages/channels/telegram/package.json | 2 +- packages/channels/weixin/package.json | 2 +- scripts/version.js | 23 +++++++++++++++++-- 7 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 617cf9553..5d323cdd9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,7 +166,7 @@ jobs: IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}' RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' run: |- - git add package.json package-lock.json packages/*/package.json + git add package.json package-lock.json packages/*/package.json packages/channels/*/package.json if git diff --staged --quiet; then echo "No version changes to commit" else @@ -198,6 +198,20 @@ jobs: env: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + - name: 'Publish @qwen-code/channel-base' + working-directory: 'packages/channels/base' + run: |- + npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + + - name: 'Publish @qwen-code/channel-plugin-example' + working-directory: 'packages/channels/plugin-example' + run: |- + npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + - name: 'Create GitHub Release and Tag' if: |- ${{ steps.vars.outputs.is_dry_run == 'false' }} diff --git a/packages/channels/base/package.json b/packages/channels/base/package.json index e32304c66..6d66675de 100644 --- a/packages/channels/base/package.json +++ b/packages/channels/base/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-base", - "version": "0.1.0", + "version": "0.13.0", "description": "Base channel infrastructure for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json index facfd5894..641fc9e29 100644 --- a/packages/channels/dingtalk/package.json +++ b/packages/channels/dingtalk/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-dingtalk", - "version": "0.1.0", + "version": "0.13.0", "description": "DingTalk channel adapter for Qwen Code", "type": "module", "main": "src/index.ts", diff --git a/packages/channels/plugin-example/package.json b/packages/channels/plugin-example/package.json index 9c59fd356..12816047d 100644 --- a/packages/channels/plugin-example/package.json +++ b/packages/channels/plugin-example/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-plugin-example", - "version": "0.1.0", + "version": "0.13.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -22,7 +22,7 @@ "build": "tsc --build" }, "dependencies": { - "@qwen-code/channel-base": "^0.1.0", + "@qwen-code/channel-base": "file:../base", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json index 0154257d3..adc4f1071 100644 --- a/packages/channels/telegram/package.json +++ b/packages/channels/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-telegram", - "version": "0.1.0", + "version": "0.13.0", "description": "Telegram channel adapter for Qwen Code", "type": "module", "main": "src/index.ts", diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json index 29adf6afe..f6a024733 100644 --- a/packages/channels/weixin/package.json +++ b/packages/channels/weixin/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-weixin", - "version": "0.1.0", + "version": "0.13.0", "description": "WeChat (Weixin) channel adapter for Qwen Code", "type": "module", "main": "src/index.ts", diff --git a/scripts/version.js b/scripts/version.js index d67ff71f6..cf74a8bd9 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -104,9 +104,28 @@ if (cliPackageJson.config?.sandboxImageUri) { writeJson(cliPackageJsonPath, cliPackageJson); } -// 7. Run `npm install` to update package-lock.json. +// 7. Rewrite file: references to semver for packages that will be published to npm. +// During development, channel packages use file: for monorepo linking. +// At release time, published packages need real semver references so they resolve from npm. +const publishedCrossRefs = [ + { + packagePath: 'packages/channels/plugin-example/package.json', + dep: '@qwen-code/channel-base', + }, +]; +for (const { packagePath, dep } of publishedCrossRefs) { + const pkgPath = resolve(process.cwd(), packagePath); + const pkgJson = readJson(pkgPath); + if (pkgJson.dependencies?.[dep]?.startsWith('file:')) { + pkgJson.dependencies[dep] = `^${newVersion}`; + console.log(`Updated ${dep} in ${packagePath} to ^${newVersion}`); + writeJson(pkgPath, pkgJson); + } +} + +// 8. Run `npm install` to update package-lock.json. run( - 'npm install --workspace packages/cli --workspace packages/core --package-lock-only', + 'npm install --workspace packages/cli --workspace packages/core --workspace packages/channels/base --workspace packages/channels/plugin-example --package-lock-only', ); console.log(`Successfully bumped versions to v${newVersion}.`); From 5dfcfd63c0692471b82972a5b10cfa96fdd5e881 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 08:17:00 +0000 Subject: [PATCH 33/51] feat(build): add channel-base package to build order This adds the new channel-base package to the build order, positioned before cli since cli depends on it. Co-authored-by: Qwen-Coder --- scripts/build.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/build.js b/scripts/build.js index 0ce010b3b..ed1e45916 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -37,14 +37,16 @@ execSync('npm run generate', { stdio: 'inherit', cwd: root }); // 1. test-utils (no internal dependencies) // 2. core (foundation package) // 3. web-templates (embeddable web templates - used by cli) -// 4. cli (depends on core, test-utils, web-templates) -// 5. webui (shared UI components - used by vscode companion) -// 6. sdk (no internal dependencies) -// 7. vscode-ide-companion (depends on webui) +// 4. channel-base (base channel infrastructure - used by channel adapters and cli) +// 5. cli (depends on core, test-utils, web-templates, channel-base) +// 6. webui (shared UI components - used by vscode companion) +// 7. sdk (no internal dependencies) +// 8. vscode-ide-companion (depends on webui) const buildOrder = [ 'packages/test-utils', 'packages/core', 'packages/web-templates', + 'packages/channels/base', 'packages/cli', 'packages/webui', 'packages/sdk-typescript', From fc0bb3c3db0287d63280722cdcf4c1f9256c0e19 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 08:29:21 +0000 Subject: [PATCH 34/51] chore(cli): add TypeScript project references for channels packages Add references to channels packages (base, telegram, weixin, dingtalk) to enable proper TypeScript project references for the CLI package. This enables better incremental builds and type checking across the monorepo's channel packages. Co-authored-by: Qwen-Coder --- packages/cli/tsconfig.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index cd546eeda..a62cd38e6 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -84,5 +84,11 @@ "src/services/prompt-processors/shellProcessor.test.ts", "src/commands/extensions/examples/**" ], - "references": [{ "path": "../core" }] + "references": [ + { "path": "../core" }, + { "path": "../channels/base" }, + { "path": "../channels/telegram" }, + { "path": "../channels/weixin" }, + { "path": "../channels/dingtalk" } + ] } From dea144918bc4315f719e73470cf8ad498e09c9c0 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 08:46:54 +0000 Subject: [PATCH 35/51] feat(channels): configure channel adapters for compiled distribution - Update package.json exports to point to dist directory - Add TypeScript build scripts to each channel adapter - Include channel adapters in build order This enables proper TypeScript compilation and distribution of channel adapter packages. Co-authored-by: Qwen-Coder --- packages/channels/dingtalk/package.json | 15 ++++++++++++--- packages/channels/telegram/package.json | 15 ++++++++++++--- packages/channels/weixin/package.json | 25 ++++++++++++++++++++----- scripts/build.js | 6 +++++- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json index 641fc9e29..73c915eab 100644 --- a/packages/channels/dingtalk/package.json +++ b/packages/channels/dingtalk/package.json @@ -3,10 +3,19 @@ "version": "0.13.0", "description": "DingTalk channel adapter for Qwen Code", "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build" }, "dependencies": { "@qwen-code/channel-base": "file:../base", diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json index adc4f1071..07ef9580d 100644 --- a/packages/channels/telegram/package.json +++ b/packages/channels/telegram/package.json @@ -3,10 +3,19 @@ "version": "0.13.0", "description": "Telegram channel adapter for Qwen Code", "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build" }, "dependencies": { "@qwen-code/channel-base": "file:../base", diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json index f6a024733..f0a35a853 100644 --- a/packages/channels/weixin/package.json +++ b/packages/channels/weixin/package.json @@ -3,12 +3,27 @@ "version": "0.13.0", "description": "WeChat (Weixin) channel adapter for Qwen Code", "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "exports": { - ".": "./src/index.ts", - "./accounts": "./src/accounts.ts", - "./login": "./src/login.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./accounts": { + "types": "./dist/accounts.d.ts", + "default": "./dist/accounts.js" + }, + "./login": { + "types": "./dist/login.d.ts", + "default": "./dist/login.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build" }, "dependencies": { "@qwen-code/channel-base": "file:../base" diff --git a/scripts/build.js b/scripts/build.js index ed1e45916..922f14b88 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -38,7 +38,8 @@ execSync('npm run generate', { stdio: 'inherit', cwd: root }); // 2. core (foundation package) // 3. web-templates (embeddable web templates - used by cli) // 4. channel-base (base channel infrastructure - used by channel adapters and cli) -// 5. cli (depends on core, test-utils, web-templates, channel-base) +// 5. channel adapters (depend on channel-base) +// 6. cli (depends on core, test-utils, web-templates, channel packages) // 6. webui (shared UI components - used by vscode companion) // 7. sdk (no internal dependencies) // 8. vscode-ide-companion (depends on webui) @@ -47,6 +48,9 @@ const buildOrder = [ 'packages/core', 'packages/web-templates', 'packages/channels/base', + 'packages/channels/telegram', + 'packages/channels/weixin', + 'packages/channels/dingtalk', 'packages/cli', 'packages/webui', 'packages/sdk-typescript', From af345a3924fb4fc117cbe7e4835cbda0890b9ead Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 09:26:07 +0000 Subject: [PATCH 36/51] chore(channels): bump package versions and improve clean script - Bump all channel packages from 0.1.0 to 0.13.0 - Fix plugin-example to use file reference for channel-base dependency - Add bin entry for plugin-example server - Clean tsconfig.tsbuildinfo files in clean script This aligns channel package versions with the main project and ensures proper cleanup of TypeScript build artifacts. Co-authored-by: Qwen-Coder --- package-lock.json | 15 +++++++++------ scripts/clean.js | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18bc0e6f0..57923129d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18971,7 +18971,7 @@ }, "packages/channels/base": { "name": "@qwen-code/channel-base", - "version": "0.1.0", + "version": "0.13.0", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1" }, @@ -18981,7 +18981,7 @@ }, "packages/channels/dingtalk": { "name": "@qwen-code/channel-dingtalk", - "version": "0.1.0", + "version": "0.13.0", "dependencies": { "@qwen-code/channel-base": "file:../base", "dingtalk-stream-sdk-nodejs": "^2.0.4" @@ -18992,18 +18992,21 @@ }, "packages/channels/plugin-example": { "name": "@qwen-code/channel-plugin-example", - "version": "0.1.0", + "version": "0.13.0", "dependencies": { - "@qwen-code/channel-base": "^0.1.0", + "@qwen-code/channel-base": "file:../base", "ws": "^8.18.0" }, + "bin": { + "qwen-channel-plugin-example-server": "dist/start-server.js" + }, "devDependencies": { "@types/ws": "^8.5.0" } }, "packages/channels/telegram": { "name": "@qwen-code/channel-telegram", - "version": "0.1.0", + "version": "0.13.0", "dependencies": { "@qwen-code/channel-base": "file:../base", "telegraf": "^4.16.0", @@ -19015,7 +19018,7 @@ }, "packages/channels/weixin": { "name": "@qwen-code/channel-weixin", - "version": "0.1.0", + "version": "0.13.0", "dependencies": { "@qwen-code/channel-base": "file:../base" }, diff --git a/scripts/clean.js b/scripts/clean.js index 864a2bec7..7de9ca0c3 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -43,6 +43,7 @@ for (const workspace of rootPackageJson.workspaces) { for (const pkgPath of packages) { const pkgDir = dirname(join(root, pkgPath)); rmSync(join(pkgDir, 'dist'), RMRF_OPTIONS); + rmSync(join(pkgDir, 'tsconfig.tsbuildinfo'), { force: true }); } } From a806c8aaf6516ebc3fe5844b8f54f0c0aa4525b1 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 10:11:17 +0000 Subject: [PATCH 37/51] chore(docker): ignore tsconfig.tsbuildinfo files Add tsconfig.tsbuildinfo pattern to .dockerignore to exclude TypeScript incremental compilation cache files from Docker builds. Co-authored-by: Qwen-Coder --- .dockerignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.dockerignore b/.dockerignore index 88c989193..f83243eb9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,8 @@ node_modules # Build artifacts (rebuilt from scratch inside the container) dist **/dist +**/tsconfig.tsbuildinfo +**/tsconfig.tsbuildinfo # Version control .git From cceac6093e6343423eb29f430a27a0becc23d5bd Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 11:47:01 +0000 Subject: [PATCH 38/51] fix(cli): skip stdin read for ACP mode - Extend stdin handling to skip reading for ACP mode, similar to stream-json - Remove duplicate line in .dockerignore ACP mode passes protocol data via stdin that should be forwarded to the sandbox intact, not consumed as a user prompt. Co-authored-by: Qwen-Coder --- .dockerignore | 1 - packages/cli/src/gemini.tsx | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index f83243eb9..f8700bbc7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,7 +6,6 @@ node_modules dist **/dist **/tsconfig.tsbuildinfo -**/tsconfig.tsbuildinfo # Version control .git diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b28ed2591..3d0e180db 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -282,11 +282,13 @@ export async function main() { process.exit(1); } } - // For stream-json mode, don't read stdin here - it should be forwarded to the sandbox - // and consumed by StreamJsonInputReader inside the container + // For stream-json and ACP modes, don't read stdin here — stdin carries + // protocol data (not a user prompt) and should be forwarded to the sandbox + // intact via stdio: 'inherit'. const inputFormat = argv.inputFormat as string | undefined; + const isAcpMode = argv.acp || argv.experimentalAcp; let stdinData = ''; - if (!process.stdin.isTTY && inputFormat !== 'stream-json') { + if (!process.stdin.isTTY && inputFormat !== 'stream-json' && !isAcpMode) { stdinData = await readStdin(); } From 0ca8cf86f688ca87a8bdc69825d2570b94df9b07 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 12:31:53 +0000 Subject: [PATCH 39/51] docs(channels): add README for channel-base package This provides documentation for the base infrastructure used to build Qwen Code channel adapters, including architecture overview, quick start guide, and API reference. Co-authored-by: Qwen-Coder --- packages/channels/base/README.md | 255 +++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/channels/base/README.md diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md new file mode 100644 index 000000000..65a46043a --- /dev/null +++ b/packages/channels/base/README.md @@ -0,0 +1,255 @@ +# @qwen-code/channel-base + +Base infrastructure for building Qwen Code channel adapters. Provides the abstract base class, access control, session routing, and the ACP bridge that communicates with the agent. + +If you're building a channel plugin, this is your only dependency. + +## Install + +```bash +npm install @qwen-code/channel-base +``` + +## Quick start + +Subclass `ChannelBase` and implement three methods: + +```typescript +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; + +class MyChannel extends ChannelBase { + async connect(): Promise { + // Connect to platform API, register message handlers. + // When a message arrives, build an Envelope and call: + // this.handleInbound(envelope) + } + + async sendMessage(chatId: string, text: string): Promise { + // Deliver the agent's response to the platform. + } + + disconnect(): void { + // Clean up connections on shutdown. + } +} +``` + +Export a `ChannelPlugin` object so the extension loader can discover it: + +```typescript +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'my-platform', + displayName: 'My Platform', + requiredConfigFields: ['apiKey'], + createChannel: (name, config, bridge, options) => + new MyChannel(name, config, bridge, options), +}; +``` + +For a complete working example, see [`@qwen-code/channel-plugin-example`](../plugin-example/). + +## Architecture + +``` +Inbound: Platform message + → Envelope + → GroupGate (group policy + mention gating) + → SenderGate (allowlist / pairing / open) + → Slash commands (/clear, /help, /status) + → SessionRouter (resolve or create ACP session) + → AcpBridge.prompt() → agent + +Outbound: Agent response + → ChannelBase + → sendMessage() → platform +``` + +Everything between `handleInbound()` and `sendMessage()` is handled by the base class — your adapter only deals with platform I/O. + +## Exports + +### Classes + +| Class | Purpose | +| --------------- | ---------------------------------------------------------------- | +| `ChannelBase` | Abstract base class — extend this to build a channel adapter | +| `AcpBridge` | Spawns and communicates with the `qwen-code --acp` agent process | +| `SessionRouter` | Maps senders to ACP sessions with configurable scoping | +| `SenderGate` | DM access control (allowlist / pairing / open) | +| `GroupGate` | Group chat policy and @mention gating | +| `PairingStore` | Pairing code generation, approval, and allowlist persistence | + +### Types + +| Type | Description | +| --------------- | ---------------------------------------------- | +| `ChannelConfig` | Channel configuration from `settings.json` | +| `ChannelPlugin` | Plugin factory interface (what you export) | +| `Envelope` | Normalized inbound message format | +| `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` | +| `GroupPolicy` | `'disabled' \| 'allowlist' \| 'open'` | +| `SessionScope` | `'user' \| 'thread' \| 'single'` | +| `GroupConfig` | Per-group settings (e.g. `requireMention`) | +| `SessionTarget` | Maps a session back to its channel/sender/chat | + +## API reference + +### ChannelBase + +```typescript +constructor(name: string, config: ChannelConfig, bridge: AcpBridge, options?: ChannelBaseOptions) +``` + +**Abstract methods** (you must implement): + +| Method | Signature | +| --------------- | ---------------------------------------------------------------------------- | +| `connect()` | `() => Promise` — Connect to the platform and start receiving messages | +| `sendMessage()` | `(chatId: string, text: string) => Promise` — Deliver agent response | +| `disconnect()` | `() => void` — Clean up on shutdown | + +**Provided methods:** + +| Method | Description | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `handleInbound(envelope)` | Route an inbound message through the full pipeline (gate checks, commands, session, prompt). Call this from your message handler. | +| `setBridge(bridge)` | Replace the ACP bridge after crash recovery | +| `registerCommand(name, handler)` | Register a custom slash command (e.g. `/mycommand`) | +| `onToolCall(chatId, event)` | Hook called on agent tool invocations — override to show indicators | + +**Built-in slash commands:** `/clear` (`/reset`, `/new`), `/help`, `/status` + +### AcpBridge + +Manages the `qwen-code --acp` child process and ACP sessions. + +```typescript +constructor(options: { cliEntryPath: string; cwd: string; model?: string }) +``` + +| Method | Description | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `start()` | Spawn the agent process | +| `stop()` | Kill the agent process | +| `newSession(cwd)` | Create a new ACP session, returns `sessionId` | +| `loadSession(sessionId, cwd)` | Restore an existing session | +| `prompt(sessionId, text, options?)` | Send a message to the agent, returns the full response text. Supports optional `imageBase64` and `imageMimeType`. | +| `isConnected` | Whether the agent process is alive | + +**Events** (EventEmitter): + +| Event | Payload | Description | +| -------------- | ------------------------ | ------------------------ | +| `textChunk` | `(sessionId, chunk)` | Streaming response chunk | +| `toolCall` | `(event: ToolCallEvent)` | Agent invoked a tool | +| `disconnected` | `(code, signal)` | Agent process exited | + +### SessionRouter + +Maps senders to ACP sessions based on the configured scope. + +```typescript +constructor(bridge: AcpBridge, defaultCwd: string, scope?: SessionScope, persistPath?: string) +``` + +**Routing keys by scope:** + +| Scope | Key format | Effect | +| ---------------- | ------------------------- | ----------------------------------------- | +| `user` (default) | `channel:senderId:chatId` | Each user gets their own session per chat | +| `thread` | `channel:threadId` | One session per thread | +| `single` | `channel:__single__` | One shared session for the entire channel | + +| Method | Description | +| --------------------------------------------------------- | ----------------------------------------------------------- | +| `resolve(channelName, senderId, chatId, threadId?, cwd?)` | Get or create a session for the given sender | +| `removeSession(channelName, senderId, chatId?)` | Remove session(s) — used by `/clear` | +| `restoreSessions()` | Reload sessions from disk after bridge restart | +| `clearAll()` | Clear all sessions and delete persist file (clean shutdown) | + +### SenderGate + +```typescript +constructor(policy: SenderPolicy, allowedUsers?: string[], pairingStore?: PairingStore) +``` + +| Method | Description | +| ------------------------------ | ------------------------------------------------------------ | +| `check(senderId, senderName?)` | Returns `{ allowed: boolean, pairingCode?: string \| null }` | + +**Policy behavior:** + +| Policy | Behavior | +| ----------- | --------------------------------------------------------------------------------------------------------- | +| `open` | Everyone allowed | +| `allowlist` | Only `allowedUsers` allowed | +| `pairing` | Check allowlist, then approved pairings, then generate a pairing code (8-char, 1hr expiry, max 3 pending) | + +### GroupGate + +```typescript +constructor(policy?: GroupPolicy, groups?: Record) +``` + +| Method | Description | +| ----------------- | ---------------------------------------------------------------------------------------------- | +| `check(envelope)` | Returns `{ allowed: boolean, reason?: 'disabled' \| 'not_allowlisted' \| 'mention_required' }` | + +**Policy behavior:** + +| Policy | Behavior | +| ----------- | ---------------------------------------- | +| `disabled` | All group messages rejected | +| `allowlist` | Only groups listed in config are allowed | +| `open` | All groups allowed | + +When `requireMention` is `true` (default), group messages are only processed if the bot is @mentioned or the message is a reply to the bot. + +### PairingStore + +```typescript +constructor(channelName: string) +``` + +Persists pairing state to `~/.qwen/channels/{channelName}-pairing.json` and `{channelName}-allowlist.json`. + +| Method | Description | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `createRequest(senderId, senderName)` | Generate an 8-char pairing code (or return existing). Returns `null` if 3 pending requests already exist. | +| `approve(code)` | Approve a pairing request, adds sender to allowlist. Returns the request or `null`. | +| `isApproved(senderId)` | Check if sender is in the approved allowlist | +| `listPending()` | Get active (non-expired) pending requests | + +## Envelope + +The normalized message format your adapter must construct: + +```typescript +interface Envelope { + channelName: string; // your channel instance name + senderId: string; // stable, unique sender ID + senderName: string; // display name + chatId: string; // distinguishes DMs from groups + text: string; // message text (@mentions stripped) + messageId?: string; // platform message ID + threadId?: string; // for thread-scoped sessions + isGroup: boolean; // true for group chats + isMentioned: boolean; // true if bot was @mentioned + isReplyToBot: boolean; // true if replying to bot's message + referencedText?: string; // quoted message text + imageBase64?: string; // base64-encoded image + imageMimeType?: string; // e.g. 'image/jpeg' +} +``` + +## Further reading + +- [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md) +- [`@qwen-code/channel-plugin-example`](../plugin-example/) — working reference implementation From f7979aa902b053f39fa73f5d1d8d84ec761009bd Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 14:08:09 +0000 Subject: [PATCH 40/51] feat(channels): add streaming response hooks to ChannelBase - Add onResponseChunk hook for progressive text display during streaming - Add onResponseComplete hook for customizing response delivery - Update mock plugin channel to support streaming chunks This enables channels to display AI responses progressively as they stream, improving user experience with real-time feedback. Co-authored-by: Qwen-Coder --- packages/channels/base/src/ChannelBase.ts | 48 ++++++++++++++++--- .../plugin-example/src/MockPluginChannel.ts | 30 +++++++++++- .../plugin-example/src/mock-server.ts | 44 +++++++++++++---- .../channels/plugin-example/src/protocol.ts | 12 ++++- 4 files changed, 117 insertions(+), 17 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 12b9b9ff9..d4c3814a7 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -72,6 +72,30 @@ export abstract class ChannelBase { onToolCall(_chatId: string, _event: ToolCallEvent): void {} + /** + * Called for each text chunk as the agent streams its response. + * Override to implement progressive display (e.g., updating an AI card in-place). + * Default: no-op (chunks are collected internally and delivered via onResponseComplete). + */ + protected onResponseChunk( + _chatId: string, + _chunk: string, + _sessionId: string, + ): void {} + + /** + * Called when the agent's full response is ready. + * Override to customize delivery (e.g., finalize an AI card). + * Default: calls sendMessage() with the full response text. + */ + protected async onResponseComplete( + chatId: string, + fullText: string, + _sessionId: string, + ): Promise { + await this.sendMessage(chatId, fullText); + } + /** * Register a slash command handler. Subclasses can call this to add * platform-specific commands (e.g., /start for Telegram). @@ -223,13 +247,25 @@ export abstract class ChannelBase { // pollution when concurrent messages hit the same session. const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve(); const current = prev.then(async () => { - const response = await this.bridge.prompt(sessionId, promptText, { - imageBase64: envelope.imageBase64, - imageMimeType: envelope.imageMimeType, - }); + // Forward streaming chunks to the subclass hook + const onChunk = (sid: string, chunk: string) => { + if (sid === sessionId) { + this.onResponseChunk(envelope.chatId, chunk, sessionId); + } + }; + this.bridge.on('textChunk', onChunk); - if (response) { - await this.sendMessage(envelope.chatId, response); + try { + const response = await this.bridge.prompt(sessionId, promptText, { + imageBase64: envelope.imageBase64, + imageMimeType: envelope.imageMimeType, + }); + + if (response) { + await this.onResponseComplete(envelope.chatId, response, sessionId); + } + } finally { + this.bridge.off('textChunk', onChunk); } }); this.sessionQueues.set( diff --git a/packages/channels/plugin-example/src/MockPluginChannel.ts b/packages/channels/plugin-example/src/MockPluginChannel.ts index 481c9e97f..a4f856350 100644 --- a/packages/channels/plugin-example/src/MockPluginChannel.ts +++ b/packages/channels/plugin-example/src/MockPluginChannel.ts @@ -6,7 +6,11 @@ import type { AcpBridge, } from '@qwen-code/channel-base'; import WebSocket from 'ws'; -import type { InboundMessage, OutboundMessage } from './protocol.js'; +import type { + InboundMessage, + OutboundMessage, + ChunkMessage, +} from './protocol.js'; export interface MockPluginConfig extends ChannelConfig { serverWsUrl: string; @@ -76,6 +80,30 @@ export class MockPluginChannel extends ChannelBase { }); } + protected override onResponseChunk( + chatId: string, + chunk: string, + _sessionId: string, + ): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + + const msg: ChunkMessage = { + type: 'chunk', + messageId: this.pendingMessageId || 'unknown', + chatId, + text: chunk, + }; + this.ws.send(JSON.stringify(msg)); + } + + protected override async onResponseComplete( + chatId: string, + fullText: string, + _sessionId: string, + ): Promise { + await this.sendMessage(chatId, fullText); + } + async sendMessage(chatId: string, text: string): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return; diff --git a/packages/channels/plugin-example/src/mock-server.ts b/packages/channels/plugin-example/src/mock-server.ts index 6b40290ed..450ded322 100644 --- a/packages/channels/plugin-example/src/mock-server.ts +++ b/packages/channels/plugin-example/src/mock-server.ts @@ -53,9 +53,10 @@ export function createMockServer( const pendingRequests = new Map< string, { - resolve: (text: string) => void; + resolve: (result: { text: string; chunks: string[] }) => void; reject: (err: Error) => void; timer: ReturnType; + chunks: string[]; } >(); @@ -73,12 +74,17 @@ export function createMockServer( ws.on('message', (data) => { try { const msg = JSON.parse(data.toString()); - if (msg.type === 'outbound' && msg.messageId) { + if (msg.type === 'chunk' && msg.messageId) { + const pending = pendingRequests.get(msg.messageId); + if (pending) { + pending.chunks.push(msg.text); + } + } else if (msg.type === 'outbound' && msg.messageId) { const pending = pendingRequests.get(msg.messageId); if (pending) { clearTimeout(pending.timer); pendingRequests.delete(msg.messageId); - pending.resolve(msg.text); + pending.resolve({ text: msg.text, chunks: pending.chunks }); } } } catch { @@ -138,18 +144,35 @@ export function createMockServer( }), ); - const responsePromise = new Promise((resolve, reject) => { + const responsePromise = new Promise<{ + text: string; + chunks: string[]; + }>((resolve, reject) => { const timer = setTimeout(() => { pendingRequests.delete(messageId); reject(new Error('Timeout waiting for agent response')); }, responseTimeoutMs); - pendingRequests.set(messageId, { resolve, reject, timer }); + pendingRequests.set(messageId, { + resolve, + reject, + timer, + chunks: [], + }); }); responsePromise - .then((responseText) => { + .then((result) => { res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ messageId, text: responseText })); + res.end( + JSON.stringify({ + messageId, + text: result.text, + streaming: { + chunks: result.chunks.length, + bytes: result.chunks.reduce((n, c) => n + c.length, 0), + }, + }), + ); }) .catch((err: Error) => { res.writeHead(504, { 'Content-Type': 'application/json' }); @@ -215,7 +238,12 @@ export function createMockServer( pendingRequests.delete(messageId); reject(new Error('Timeout waiting for agent response')); }, responseTimeoutMs); - pendingRequests.set(messageId, { resolve, reject, timer }); + pendingRequests.set(messageId, { + resolve: (result) => resolve(result.text), + reject, + timer, + chunks: [], + }); }); }, diff --git a/packages/channels/plugin-example/src/protocol.ts b/packages/channels/plugin-example/src/protocol.ts index 49c2e0fba..25eee9034 100644 --- a/packages/channels/plugin-example/src/protocol.ts +++ b/packages/channels/plugin-example/src/protocol.ts @@ -12,7 +12,15 @@ export interface InboundMessage { text: string; } -/** Plugin Channel → Server (WebSocket) */ +/** Plugin Channel → Server (WebSocket) — streaming chunk */ +export interface ChunkMessage { + type: 'chunk'; + messageId: string; + chatId: string; + text: string; +} + +/** Plugin Channel → Server (WebSocket) — final response */ export interface OutboundMessage { type: 'outbound'; messageId: string; @@ -20,4 +28,4 @@ export interface OutboundMessage { text: string; } -export type WsMessage = InboundMessage | OutboundMessage; +export type WsMessage = InboundMessage | ChunkMessage | OutboundMessage; From 3d24a9c3fe0bd9088994afe6170d9c074968878f Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 14:36:40 +0000 Subject: [PATCH 41/51] feat(channels): add BlockStreamer for progressive message delivery - Add BlockStreamer class to split streaming responses into multiple messages - Configure block streaming with min/max chars and idle coalescing - Integrate into ChannelBase when blockStreaming: 'on' - Add comprehensive test coverage (16 tests) This improves UX by delivering completed paragraphs as separate messages instead of waiting for the full response. Co-authored-by: Qwen-Coder Co-authored-by: Qwen-Coder --- .../channels/base/src/BlockStreamer.test.ts | 199 ++++++++++++++++++ packages/channels/base/src/BlockStreamer.ts | 134 ++++++++++++ packages/channels/base/src/ChannelBase.ts | 20 +- packages/channels/base/src/index.ts | 4 + packages/channels/base/src/types.ts | 19 ++ packages/channels/base/tsconfig.json | 2 +- 6 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 packages/channels/base/src/BlockStreamer.test.ts create mode 100644 packages/channels/base/src/BlockStreamer.ts diff --git a/packages/channels/base/src/BlockStreamer.test.ts b/packages/channels/base/src/BlockStreamer.test.ts new file mode 100644 index 000000000..598a9b2df --- /dev/null +++ b/packages/channels/base/src/BlockStreamer.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BlockStreamer } from './BlockStreamer.js'; + +describe('BlockStreamer', () => { + let sent: string[]; + let send: (text: string) => Promise; + + beforeEach(() => { + vi.useFakeTimers(); + sent = []; + send = async (text: string) => { + sent.push(text); + }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function createStreamer( + overrides: Partial<{ + minChars: number; + maxChars: number; + idleMs: number; + }> = {}, + ) { + return new BlockStreamer({ + minChars: overrides.minChars ?? 20, + maxChars: overrides.maxChars ?? 60, + idleMs: overrides.idleMs ?? 500, + send, + }); + } + + it('does not emit below minChars', () => { + const s = createStreamer(); + s.push('short'); + expect(sent).toEqual([]); + expect(s.blockCount).toBe(0); + }); + + it('emits at paragraph boundary when buffer >= minChars', async () => { + const s = createStreamer({ minChars: 10 }); + s.push('Hello world, this is a paragraph.\n\nSecond part'); + // Should have emitted the first paragraph + await s.flush(); + expect(sent).toEqual(['Hello world, this is a paragraph.', 'Second part']); + expect(s.blockCount).toBe(2); + }); + + it('does not split at paragraph boundary when text before it < minChars', async () => { + const s = createStreamer({ minChars: 100 }); + s.push('Short.\n\nAlso short.'); + // Neither section exceeds minChars, and total < maxChars + expect(sent).toEqual([]); + await s.flush(); + expect(sent).toEqual(['Short.\n\nAlso short.']); + }); + + it('force-splits at maxChars', async () => { + const s = createStreamer({ minChars: 10, maxChars: 30 }); + // 40 chars, no newlines — should force-split at space near 30 + s.push('aaaa bbbb cccc dddd eeee ffff gggg hhhh'); + await s.flush(); + // First block splits around 30 chars at a space boundary + expect(sent.length).toBe(2); + expect(sent[0]!.length).toBeLessThanOrEqual(30); + expect(sent[0]! + ' ' + sent[1]!).toBe( + 'aaaa bbbb cccc dddd eeee ffff gggg hhhh', + ); + }); + + it('force-splits at maxChars with no break points', async () => { + const s = createStreamer({ minChars: 5, maxChars: 10 }); + s.push('abcdefghijklmnop'); // 16 chars, no spaces + await s.flush(); + expect(sent).toEqual(['abcdefghij', 'klmnop']); + }); + + it('prefers paragraph break over newline when force-splitting', async () => { + const s = createStreamer({ minChars: 5, maxChars: 30 }); + s.push('line one\n\nline two\nline three xx'); + await s.flush(); + // Should split at \n\n (pos 10) since it's within maxChars + expect(sent[0]).toBe('line one'); + expect(sent.length).toBe(2); + }); + + it('emits on idle timer when buffer >= minChars', async () => { + const s = createStreamer({ minChars: 5, idleMs: 500 }); + s.push('Hello world'); // 11 chars, no boundary + expect(sent).toEqual([]); + + vi.advanceTimersByTime(500); + // idle timer should have fired + await s.flush(); + expect(sent).toEqual(['Hello world']); + }); + + it('does not emit on idle timer when buffer < minChars', async () => { + const s = createStreamer({ minChars: 100, idleMs: 500 }); + s.push('tiny'); + vi.advanceTimersByTime(500); + expect(sent).toEqual([]); + // flush still sends remaining + await s.flush(); + expect(sent).toEqual(['tiny']); + }); + + it('resets idle timer on each push', async () => { + const s = createStreamer({ minChars: 20, idleMs: 500 }); + s.push('Hello '); + vi.advanceTimersByTime(400); + s.push('world, how are you?'); // total 25 chars + vi.advanceTimersByTime(400); + // Only 400ms since last push, shouldn't fire yet + expect(sent).toEqual([]); + vi.advanceTimersByTime(100); + // Now 500ms since last push + await s.flush(); + expect(sent).toEqual(['Hello world, how are you?']); + }); + + it('flush sends everything remaining', async () => { + const s = createStreamer({ minChars: 1000 }); // very high min + s.push('some text that will never hit minChars'); + await s.flush(); + expect(sent).toEqual(['some text that will never hit minChars']); + }); + + it('flush with empty buffer is a no-op', async () => { + const s = createStreamer(); + await s.flush(); + expect(sent).toEqual([]); + expect(s.blockCount).toBe(0); + }); + + it('trims whitespace from emitted blocks', async () => { + const s = createStreamer({ minChars: 5 }); + s.push(' \n Hello world \n\n Next '); + await s.flush(); + // The first block includes leading whitespace up to \n\n, trimmed + expect(sent.every((t) => t === t.trim())).toBe(true); + }); + + it('does not emit empty blocks after trimming', async () => { + const s = createStreamer({ minChars: 1 }); + s.push('\n\n\n\n'); + await s.flush(); + // All whitespace — nothing to emit after trim + expect(sent).toEqual([]); + expect(s.blockCount).toBe(0); + }); + + it('serializes sends', async () => { + vi.useRealTimers(); + const order: string[] = []; + let callIndex = 0; + const slowSend = async (text: string) => { + const idx = callIndex++; + // Simulate async delay + await new Promise((r) => setTimeout(r, 10)); + order.push(`${idx}:${text}`); + }; + + const s = new BlockStreamer({ + minChars: 5, + maxChars: 20, + idleMs: 0, + send: slowSend, + }); + + s.push('aaaa bbbb cccc dddd eeee ffff'); + await s.flush(); + // All sends completed in order + expect(order.length).toBeGreaterThanOrEqual(2); + // Verify sequential ordering + for (let i = 0; i < order.length; i++) { + expect(order[i]).toMatch(new RegExp(`^${i}:`)); + } + }); + + it('handles multiple paragraph boundaries', async () => { + const s = createStreamer({ minChars: 5, maxChars: 200 }); + s.push('Para one.\n\nPara two.\n\nPara three.'); + await s.flush(); + // Should emit paras 1+2 as one block (last \n\n boundary), then para 3 + expect(sent).toEqual(['Para one.\n\nPara two.', 'Para three.']); + }); + + it('works with idleMs=0 (idle timer disabled)', async () => { + const s = createStreamer({ minChars: 10, idleMs: 0 }); + s.push('Hello world, no timer'); + vi.advanceTimersByTime(10000); + expect(sent).toEqual([]); + await s.flush(); + expect(sent).toEqual(['Hello world, no timer']); + }); +}); diff --git a/packages/channels/base/src/BlockStreamer.ts b/packages/channels/base/src/BlockStreamer.ts new file mode 100644 index 000000000..6938605d9 --- /dev/null +++ b/packages/channels/base/src/BlockStreamer.ts @@ -0,0 +1,134 @@ +/** + * BlockStreamer — progressive multi-message delivery for channels. + * + * Accumulates text chunks from the agent's streaming response and emits + * completed "blocks" (paragraphs / sections) as separate channel messages + * while the agent is still working. This gives users a natural conversation + * flow instead of waiting 30–120 seconds for a single wall of text. + * + * Emission triggers: + * 1. Buffer ≥ maxChars → force-split at best break point + * 2. Buffer ≥ minChars AND a paragraph boundary (\n\n) exists → emit up to boundary + * 3. Idle timer fires (no chunk for idleMs) AND buffer ≥ minChars → emit buffer + * 4. flush() called (response complete) → emit everything remaining + * + * All sends are serialized — the next block waits for the previous send to complete. + */ + +export interface BlockStreamerOptions { + /** Minimum characters before emitting a block. Default: 400. */ + minChars: number; + /** Force-emit when buffer exceeds this size. Default: 1000. */ + maxChars: number; + /** Emit buffered text after this many ms of inactivity. Default: 1500. */ + idleMs: number; + /** Callback to deliver a completed block. Called with trimmed text. */ + send: (text: string) => Promise; +} + +export class BlockStreamer { + private buffer = ''; + private idleTimer: ReturnType | null = null; + private sending: Promise = Promise.resolve(); + private opts: BlockStreamerOptions; + + /** Number of blocks emitted so far. */ + blockCount = 0; + + constructor(opts: BlockStreamerOptions) { + this.opts = opts; + } + + /** Feed a new text chunk from the agent stream. */ + push(chunk: string): void { + this.buffer += chunk; + this.clearIdleTimer(); + this.checkEmit(); + + if (this.buffer.length > 0 && this.opts.idleMs > 0) { + this.idleTimer = setTimeout(() => this.onIdle(), this.opts.idleMs); + } + } + + /** Flush all remaining buffered text. Awaits all pending sends. */ + async flush(): Promise { + this.clearIdleTimer(); + if (this.buffer.length > 0) { + this.emitBlock(this.buffer); + this.buffer = ''; + } + await this.sending; + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private checkEmit(): void { + // 1. Force-split if buffer exceeds maxChars + while (this.buffer.length >= this.opts.maxChars) { + const bp = this.findBreakPoint(this.buffer, this.opts.maxChars); + this.emitBlock(this.buffer.slice(0, bp)); + this.buffer = this.buffer.slice(bp); + } + + // 2. Emit at paragraph boundary if we have enough text + if (this.buffer.length >= this.opts.minChars) { + const bp = this.findBlockBoundary(this.buffer); + if (bp > 0) { + this.emitBlock(this.buffer.slice(0, bp)); + this.buffer = this.buffer.slice(bp); + } + } + } + + private onIdle(): void { + this.idleTimer = null; + if (this.buffer.length >= this.opts.minChars) { + this.emitBlock(this.buffer); + this.buffer = ''; + } + } + + private emitBlock(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + this.blockCount++; + this.sending = this.sending + .then(() => this.opts.send(trimmed)) + .catch(() => {}); + } + + /** + * Find the last paragraph boundary (\n\n) in the buffer. + * Returns the position after the boundary, or -1 if no suitable boundary + * exists at or after minChars. + */ + private findBlockBoundary(text: string): number { + const last = text.lastIndexOf('\n\n'); + if (last < 0 || last < this.opts.minChars) return -1; + return last + 2; + } + + /** + * Find the best break point at or before maxPos. + * Prefers paragraph break > newline > space > maxPos. + */ + private findBreakPoint(text: string, maxPos: number): number { + const sub = text.slice(0, maxPos); + const para = sub.lastIndexOf('\n\n'); + if (para > 0) return para + 2; + const nl = sub.lastIndexOf('\n'); + if (nl > 0) return nl + 1; + const sp = sub.lastIndexOf(' '); + if (sp > 0) return sp + 1; + return maxPos; + } + + private clearIdleTimer(): void { + if (this.idleTimer !== null) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + } +} diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index d4c3814a7..db958f52e 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -1,4 +1,5 @@ import type { ChannelConfig, Envelope } from './types.js'; +import { BlockStreamer } from './BlockStreamer.js'; import { GroupGate } from './GroupGate.js'; import { SenderGate } from './SenderGate.js'; import { PairingStore } from './PairingStore.js'; @@ -246,11 +247,22 @@ export abstract class ChannelBase { // Serialize prompt + send per session to prevent textChunk listener // pollution when concurrent messages hit the same session. const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve(); + const useBlockStreaming = this.config.blockStreaming === 'on'; const current = prev.then(async () => { - // Forward streaming chunks to the subclass hook + const streamer = useBlockStreaming + ? new BlockStreamer({ + minChars: this.config.blockStreamingChunk?.minChars ?? 400, + maxChars: this.config.blockStreamingChunk?.maxChars ?? 1000, + idleMs: this.config.blockStreamingCoalesce?.idleMs ?? 1500, + send: (text) => this.sendMessage(envelope.chatId, text), + }) + : null; + + // Forward streaming chunks to the subclass hook (and block streamer) const onChunk = (sid: string, chunk: string) => { if (sid === sessionId) { this.onResponseChunk(envelope.chatId, chunk, sessionId); + streamer?.push(chunk); } }; this.bridge.on('textChunk', onChunk); @@ -262,7 +274,11 @@ export abstract class ChannelBase { }); if (response) { - await this.onResponseComplete(envelope.chatId, response, sessionId); + if (streamer) { + await streamer.flush(); + } else { + await this.onResponseComplete(envelope.chatId, response, sessionId); + } } } finally { this.bridge.off('textChunk', onChunk); diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 28fda02b9..111ba2d51 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -4,6 +4,8 @@ export type { AvailableCommand, ToolCallEvent, } from './AcpBridge.js'; +export { BlockStreamer } from './BlockStreamer.js'; +export type { BlockStreamerOptions } from './BlockStreamer.js'; export { ChannelBase } from './ChannelBase.js'; export type { ChannelBaseOptions } from './ChannelBase.js'; export { PairingStore } from './PairingStore.js'; @@ -14,6 +16,8 @@ export { SenderGate } from './SenderGate.js'; export type { SenderCheckResult } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; export type { + BlockStreamingChunkConfig, + BlockStreamingCoalesceConfig, ChannelConfig, ChannelPlugin, ChannelType, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 4932ad469..d2424e05a 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -10,6 +10,18 @@ export interface GroupConfig { requireMention?: boolean; // default: true } +export interface BlockStreamingChunkConfig { + /** Minimum characters before emitting a block. Default: 400. */ + minChars?: number; + /** Force-emit when buffer exceeds this size. Default: 1000. */ + maxChars?: number; +} + +export interface BlockStreamingCoalesceConfig { + /** Emit buffered text after this many ms of inactivity. Default: 1500. */ + idleMs?: number; +} + export interface ChannelConfig { type: ChannelType; token: string; @@ -24,6 +36,13 @@ export interface ChannelConfig { model?: string; groupPolicy: GroupPolicy; // default: "disabled" groups: Record; // "*" for defaults, group IDs for overrides + + /** Enable block streaming — emit completed blocks as separate messages. */ + blockStreaming?: 'on' | 'off'; + /** Chunk size bounds for block streaming. */ + blockStreamingChunk?: BlockStreamingChunkConfig; + /** Idle coalescing for block streaming. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; } export interface Envelope { diff --git a/packages/channels/base/tsconfig.json b/packages/channels/base/tsconfig.json index b00960ec6..d2afb1929 100644 --- a/packages/channels/base/tsconfig.json +++ b/packages/channels/base/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } From 3e0f213ea3973419427dc32023fa717bbebbcf4b Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 14:50:24 +0000 Subject: [PATCH 42/51] feat(channels): add structured attachment support for file handling - Add Attachment interface with type, data, filePath, mimeType, fileName - Resolve attachments in ChannelBase before prompting bridge - Update DingTalk, Telegram, Weixin adapters to use structured attachments - Clean up placeholder text when files are received - Export Attachment type from base package index This enables proper handling of images and files across all channels, separating attachment metadata from message text. Co-authored-by: Qwen-Coder --- packages/channels/base/src/ChannelBase.ts | 26 ++++++++++++- packages/channels/base/src/index.ts | 1 + packages/channels/base/src/types.ts | 15 ++++++++ .../channels/dingtalk/src/DingtalkAdapter.ts | 38 +++++++++++++------ .../channels/telegram/src/TelegramAdapter.ts | 12 ++++-- packages/channels/weixin/src/WeixinAdapter.ts | 9 ++++- 6 files changed, 84 insertions(+), 17 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index db958f52e..e10e805ce 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -238,6 +238,28 @@ export abstract class ChannelBase { promptText = `[Replying to: "${envelope.referencedText}"]\n\n${promptText}`; } + // Resolve attachments: extract image for bridge, append file paths to text + let imageBase64 = envelope.imageBase64; + let imageMimeType = envelope.imageMimeType; + if (envelope.attachments?.length) { + const filePaths: string[] = []; + for (const att of envelope.attachments) { + if (att.type === 'image' && att.data && !imageBase64) { + imageBase64 = att.data; + imageMimeType = att.mimeType; + } else if (att.filePath) { + const label = att.type === 'file' ? 'file' : att.type; + const name = att.fileName ? ` "${att.fileName}"` : ''; + filePaths.push( + `User sent a ${label}${name}. It has been saved to: ${att.filePath}`, + ); + } + } + if (filePaths.length > 0) { + promptText = promptText + '\n\n' + filePaths.join('\n'); + } + } + // Prepend channel instructions on first message of a session if (this.config.instructions && !this.instructedSessions.has(sessionId)) { promptText = `${this.config.instructions}\n\n${promptText}`; @@ -269,8 +291,8 @@ export abstract class ChannelBase { try { const response = await this.bridge.prompt(sessionId, promptText, { - imageBase64: envelope.imageBase64, - imageMimeType: envelope.imageMimeType, + imageBase64, + imageMimeType, }); if (response) { diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 111ba2d51..532216478 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -16,6 +16,7 @@ export { SenderGate } from './SenderGate.js'; export type { SenderCheckResult } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; export type { + Attachment, BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, ChannelConfig, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index d2424e05a..0a1f91636 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -45,6 +45,19 @@ export interface ChannelConfig { blockStreamingCoalesce?: BlockStreamingCoalesceConfig; } +export interface Attachment { + /** Content category. */ + type: 'image' | 'file' | 'audio' | 'video'; + /** Base64-encoded data (for images or small files). */ + data?: string; + /** Absolute path to a local file (for large files saved to disk). */ + filePath?: string; + /** MIME type (e.g. "image/jpeg", "application/pdf"). */ + mimeType: string; + /** Original file name from the platform. */ + fileName?: string; +} + export interface Envelope { channelName: string; senderId: string; @@ -63,6 +76,8 @@ export interface Envelope { imageBase64?: string; /** MIME type for the image (e.g. "image/jpeg", "image/png"). */ imageMimeType?: string; + /** Structured attachments (images, files, audio, video). */ + attachments?: Attachment[]; } export interface SessionTarget { diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 4aa468c38..45e40c219 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -415,10 +415,17 @@ export class DingtalkChannel extends ChannelBase { if (!media) return; if (mediaType === 'image') { - envelope.imageBase64 = media.buffer.toString('base64'); - envelope.imageMimeType = media.mimeType.startsWith('image/') + const mimeType = media.mimeType.startsWith('image/') ? media.mimeType : 'image/jpeg'; + envelope.attachments = [ + ...(envelope.attachments || []), + { + type: 'image', + data: media.buffer.toString('base64'), + mimeType, + }, + ]; } else { // Save non-image files to temp dir so the agent can read them const dir = join(tmpdir(), 'channel-files'); @@ -427,15 +434,24 @@ export class DingtalkChannel extends ChannelBase { const filePath = join(dir, safeName); writeFileSync(filePath, media.buffer); - const prefix = - envelope.text && - envelope.text !== `(file: ${fileName || 'file'})` && - envelope.text !== '(audio)' && - envelope.text !== '(video)' - ? envelope.text + '\n\n' - : ''; - envelope.text = - prefix + `User sent a ${mediaType}. It has been saved to: ${filePath}`; + // Clean up placeholder text like "(audio)", "(video)", "(file: name)" + if ( + envelope.text === `(file: ${fileName || 'file'})` || + envelope.text === '(audio)' || + envelope.text === '(video)' + ) { + envelope.text = ''; + } + + envelope.attachments = [ + ...(envelope.attachments || []), + { + type: mediaType, + filePath, + mimeType: media.mimeType, + fileName: safeName, + }, + ]; } } diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 3445f3986..3912455a5 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -123,9 +123,15 @@ export class TelegramChannel extends ChannelBase { const filePath = join(dir, fileName); writeFileSync(filePath, buf); - envelope.text = - (msg.caption ? msg.caption + '\n\n' : '') + - `User sent a file. It has been saved to: ${filePath}`; + envelope.text = msg.caption || ''; + envelope.attachments = [ + { + type: 'file', + filePath, + mimeType: doc.mime_type || 'application/octet-stream', + fileName, + }, + ]; } catch (err) { process.stderr.write( `[Telegram:${this.name}] Failed to download document: ${err instanceof Error ? err.message : err}\n`, diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts index 3944916bc..cad61e64d 100644 --- a/packages/channels/weixin/src/WeixinAdapter.ts +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -135,7 +135,14 @@ export class WeixinChannel extends ChannelBase { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const filePath = join(dir, file.fileName); writeFileSync(filePath, fileData); - envelope.text = `User sent a file. It has been saved to: ${filePath}`; + envelope.attachments = [ + { + type: 'file', + filePath, + mimeType: 'application/octet-stream', + fileName: file.fileName, + }, + ]; } catch (err) { process.stderr.write( `[Weixin:${this.name}] Failed to download file: ${err instanceof Error ? err.message : err}\n`, From 39103eea5fe893a8211e46d11424a1e7b249318f Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 14:57:17 +0000 Subject: [PATCH 43/51] docs(channels): document attachments and block streaming features - Add Attachments interface docs with handling examples - Document block streaming configuration and behavior - Update architecture diagrams to show attachment resolution - Add Attachment type to exported types reference - Update plugin-example README Covers new structured attachment support and block streaming that delivers responses as multiple progressive messages. Co-authored-by: Qwen-Coder --- docs/developers/channel-plugins.md | 76 +++++++++++++++++----- docs/users/features/channels/overview.md | 59 +++++++++++++---- packages/channels/base/README.md | 60 ++++++++++++++--- packages/channels/plugin-example/README.md | 7 ++ 4 files changed, 161 insertions(+), 41 deletions(-) diff --git a/docs/developers/channel-plugins.md b/docs/developers/channel-plugins.md index 7837532b6..4dbcdadaf 100644 --- a/docs/developers/channel-plugins.md +++ b/docs/developers/channel-plugins.md @@ -68,23 +68,61 @@ export class MyChannel extends ChannelBase { The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate. -| Field | Type | Required | Notes | -| ---------------- | ------- | -------- | -------------------------------------------------------------------------- | -| `channelName` | string | Yes | Use `this.name` | -| `senderId` | string | Yes | Must be stable across messages (used for session routing + access control) | -| `senderName` | string | Yes | Display name | -| `chatId` | string | Yes | Must distinguish DMs from groups | -| `text` | string | Yes | Strip bot @mentions | -| `threadId` | string | No | For `sessionScope: "thread"` | -| `messageId` | string | No | Platform message ID — useful for response correlation | -| `isGroup` | boolean | Yes | GroupGate relies on this | -| `isMentioned` | boolean | Yes | GroupGate relies on this | -| `isReplyToBot` | boolean | Yes | GroupGate relies on this | -| `referencedText` | string | No | Quoted message — prepended as context | -| `imageBase64` | string | No | Base64-encoded image for multimodal models | -| `imageMimeType` | string | No | e.g., `image/jpeg` | +| Field | Type | Required | Notes | +| ---------------- | ------------ | -------- | -------------------------------------------------------------------------- | +| `channelName` | string | Yes | Use `this.name` | +| `senderId` | string | Yes | Must be stable across messages (used for session routing + access control) | +| `senderName` | string | Yes | Display name | +| `chatId` | string | Yes | Must distinguish DMs from groups | +| `text` | string | Yes | Strip bot @mentions | +| `threadId` | string | No | For `sessionScope: "thread"` | +| `messageId` | string | No | Platform message ID — useful for response correlation | +| `isGroup` | boolean | Yes | GroupGate relies on this | +| `isMentioned` | boolean | Yes | GroupGate relies on this | +| `isReplyToBot` | boolean | Yes | GroupGate relies on this | +| `referencedText` | string | No | Quoted message — prepended as context | +| `imageBase64` | string | No | Base64-encoded image (legacy — prefer `attachments`) | +| `imageMimeType` | string | No | e.g., `image/jpeg` (legacy — prefer `attachments`) | +| `attachments` | Attachment[] | No | Structured media attachments (see below) | -For **files**: download from your platform, save to a temp directory, include the file path in `text`. +### Attachments + +Use the `attachments` array for images, files, audio, and video. `handleInbound()` resolves them automatically: images with base64 `data` are sent to the model as vision input, files with a `filePath` get their path appended to the prompt so the agent can read them. + +```typescript +interface Attachment { + type: 'image' | 'file' | 'audio' | 'video'; + data?: string; // base64-encoded data (images, small files) + filePath?: string; // absolute path to local file (large files saved to disk) + mimeType: string; // e.g. 'application/pdf', 'image/jpeg' + fileName?: string; // original file name from the platform +} +``` + +Example — handling a file upload in your adapter: + +```typescript +import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const buf = await downloadFromPlatform(fileId); +const dir = join(tmpdir(), 'channel-files'); +if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); +const filePath = join(dir, fileName); +writeFileSync(filePath, buf); + +envelope.attachments = [ + { + type: 'file', + filePath, + mimeType: 'application/pdf', + fileName, + }, +]; +``` + +The legacy `imageBase64`/`imageMimeType` fields still work for backwards compatibility but `attachments` is preferred for new code. ## Extension Manifest @@ -126,7 +164,11 @@ override async handleInbound(envelope: Envelope): Promise { **Tool call hooks** — override `onToolCall()` to display agent activity (e.g., "Running shell command..."). -**Media** — download from your platform, set `imageBase64`/`imageMimeType` on the Envelope before calling `handleInbound()`. +**Streaming hooks** — override `onResponseChunk(chatId, chunk, sessionId)` for per-chunk progressive display (e.g., editing a message in-place). Override `onResponseComplete(chatId, fullText, sessionId)` to customize final delivery. + +**Block streaming** — set `blockStreaming: "on"` in the channel config. The base class automatically splits responses into multiple messages at paragraph boundaries. No plugin code needed — it works alongside `onResponseChunk`. + +**Media** — populate `envelope.attachments` with images/files. See [Attachments](#attachments) above. ## Reference Implementations diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 80bbe6f6a..471b63f0a 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -47,20 +47,23 @@ Channels are configured under the `channels` key in `settings.json`. Each channe ### Options -| Option | Required | Description | -| -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Plugins](./plugins)) | -| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk | -| `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax | -| `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax | -| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input | -| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | -| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | -| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | -| `cwd` | No | Working directory for the agent. Defaults to the current directory | -| `instructions` | No | Custom instructions prepended to the first message of each session | -| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | -| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | +| Option | Required | Description | +| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Plugins](./plugins)) | +| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk | +| `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax | +| `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax | +| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input | +| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | +| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | +| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | +| `cwd` | No | Working directory for the agent. Defaults to the current directory | +| `instructions` | No | Custom instructions prepended to the first message of each session | +| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | +| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | +| `blockStreaming` | No | Progressive response delivery: `on` or `off` (default). See [Block Streaming](#block-streaming) | +| `blockStreamingChunk` | No | Chunk size bounds: `{ "minChars": 400, "maxChars": 1000 }`. See [Block Streaming](#block-streaming) | +| `blockStreamingCoalesce` | No | Idle flush: `{ "idleMs": 1500 }`. See [Block Streaming](#block-streaming) | ### Sender Policy @@ -219,6 +222,34 @@ Files work with any model — no multimodal support required. | Files | Direct download via Bot API (20MB limit) | CDN download with AES decryption | downloadCode API (two-step) | | Captions | Photo/file captions included as message text | Not applicable | Rich text: mixed text + images in one message | +## Block Streaming + +By default, the agent works for a while and then sends one large response. With block streaming enabled, the response arrives as multiple shorter messages while the agent is still working — similar to how ChatGPT or Claude show progressive output. + +```json +{ + "channels": { + "my-channel": { + "type": "telegram", + "blockStreaming": "on", + "blockStreamingChunk": { "minChars": 400, "maxChars": 1000 }, + "blockStreamingCoalesce": { "idleMs": 1500 }, + ... + } + } +} +``` + +### How it works + +- The agent's response is split into blocks at paragraph boundaries and sent as separate messages +- `minChars` (default 400) — don't send a block until it's at least this long, to avoid spamming tiny messages +- `maxChars` (default 1000) — if a block gets this long without a natural break, send it anyway +- `idleMs` (default 1500) — if the agent pauses (e.g., running a tool), send what's buffered so far +- When the agent finishes, any remaining text is sent immediately + +Only `blockStreaming` is required. The chunk and coalesce settings are optional and have sensible defaults. + ## Slash Commands Channels support slash commands. These are handled locally (no agent round-trip): diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md index 65a46043a..ef89ca1f4 100644 --- a/packages/channels/base/README.md +++ b/packages/channels/base/README.md @@ -59,15 +59,16 @@ For a complete working example, see [`@qwen-code/channel-plugin-example`](../plu ``` Inbound: Platform message - → Envelope + → Envelope (with attachments) → GroupGate (group policy + mention gating) → SenderGate (allowlist / pairing / open) → Slash commands (/clear, /help, /status) → SessionRouter (resolve or create ACP session) + → Resolve attachments (images → bridge, files → prompt text) → AcpBridge.prompt() → agent Outbound: Agent response - → ChannelBase + → BlockStreamer (if enabled: split into blocks at paragraph boundaries) → sendMessage() → platform ``` @@ -81,6 +82,7 @@ Everything between `handleInbound()` and `sendMessage()` is handled by the base | --------------- | ---------------------------------------------------------------- | | `ChannelBase` | Abstract base class — extend this to build a channel adapter | | `AcpBridge` | Spawns and communicates with the `qwen-code --acp` agent process | +| `BlockStreamer` | Progressive multi-message delivery for block streaming | | `SessionRouter` | Maps senders to ACP sessions with configurable scoping | | `SenderGate` | DM access control (allowlist / pairing / open) | | `GroupGate` | Group chat policy and @mention gating | @@ -90,6 +92,7 @@ Everything between `handleInbound()` and `sendMessage()` is handled by the base | Type | Description | | --------------- | ---------------------------------------------- | +| `Attachment` | Structured file/image/audio/video attachment | | `ChannelConfig` | Channel configuration from `settings.json` | | `ChannelPlugin` | Plugin factory interface (what you export) | | `Envelope` | Normalized inbound message format | @@ -117,12 +120,16 @@ constructor(name: string, config: ChannelConfig, bridge: AcpBridge, options?: Ch **Provided methods:** -| Method | Description | -| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `handleInbound(envelope)` | Route an inbound message through the full pipeline (gate checks, commands, session, prompt). Call this from your message handler. | -| `setBridge(bridge)` | Replace the ACP bridge after crash recovery | -| `registerCommand(name, handler)` | Register a custom slash command (e.g. `/mycommand`) | -| `onToolCall(chatId, event)` | Hook called on agent tool invocations — override to show indicators | +| Method | Description | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `handleInbound(envelope)` | Route an inbound message through the full pipeline (gate checks, commands, session, prompt). Call this from your message handler. | +| `setBridge(bridge)` | Replace the ACP bridge after crash recovery | +| `registerCommand(name, handler)` | Register a custom slash command (e.g. `/mycommand`) | +| `onToolCall(chatId, event)` | Hook called on agent tool invocations — override to show indicators | +| `onResponseChunk(chatId, chunk, sessionId)` | Hook called per streaming text chunk — override for progressive display (default: no-op) | +| `onResponseComplete(chatId, fullText, sessionId)` | Hook called when full response is ready — override to customize delivery (default: `sendMessage()`) | + +**Block streaming:** When `blockStreaming: "on"` is set in the channel config, the base class automatically splits the agent's streaming response into multiple messages at paragraph boundaries. See [Block Streaming](#block-streaming) below. **Built-in slash commands:** `/clear` (`/reset`, `/new`), `/help`, `/status` @@ -244,11 +251,44 @@ interface Envelope { isMentioned: boolean; // true if bot was @mentioned isReplyToBot: boolean; // true if replying to bot's message referencedText?: string; // quoted message text - imageBase64?: string; // base64-encoded image - imageMimeType?: string; // e.g. 'image/jpeg' + imageBase64?: string; // base64-encoded image (legacy — prefer attachments) + imageMimeType?: string; // e.g. 'image/jpeg' (legacy — prefer attachments) + attachments?: Attachment[]; // structured file/image/audio/video attachments +} + +interface Attachment { + type: 'image' | 'file' | 'audio' | 'video'; + data?: string; // base64-encoded data (images, small files) + filePath?: string; // absolute path to local file (large files) + mimeType: string; // e.g. 'application/pdf', 'image/jpeg' + fileName?: string; // original file name from the platform } ``` +`handleInbound()` automatically resolves attachments: images with `data` are sent to the model as vision input, files with `filePath` get their path appended to the prompt text so the agent can read them with its tools. + +## Block Streaming + +When `blockStreaming: "on"` is set in a channel's config, the agent's response is delivered as multiple separate messages instead of one large wall of text. The `BlockStreamer` accumulates streaming chunks and emits completed blocks based on paragraph boundaries and size heuristics. + +**Config fields** (on `ChannelConfig`): + +| Field | Type | Default | Description | +| ------------------------ | ------------------------ | --------------- | --------------------------------------------------------------------------- | +| `blockStreaming` | `'on' \| 'off'` | `'off'` | Enable/disable block streaming | +| `blockStreamingChunk` | `{ minChars, maxChars }` | `{ 400, 1000 }` | `minChars`: don't emit until this size. `maxChars`: force-emit at this size | +| `blockStreamingCoalesce` | `{ idleMs }` | `{ 1500 }` | Emit buffered text after this many ms of silence from the agent | + +**How it works:** + +1. Text accumulates as the agent streams its response +2. When the buffer reaches `minChars` and hits a paragraph break (`\n\n`), that block is sent as a separate message +3. If the buffer reaches `maxChars` without a paragraph break, it force-splits at the best break point (newline > space) +4. If the agent goes quiet for `idleMs`, the buffer is flushed (as long as it's past `minChars`) +5. When the agent finishes, any remaining text is sent immediately regardless of `minChars` + +Block streaming and `onResponseChunk` work independently — plugins can override `onResponseChunk` for their own purposes while block streaming handles delivery. + ## Further reading - [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md) diff --git a/packages/channels/plugin-example/README.md b/packages/channels/plugin-example/README.md index a814fdd54..a17461080 100644 --- a/packages/channels/plugin-example/README.md +++ b/packages/channels/plugin-example/README.md @@ -94,4 +94,11 @@ See `src/MockPluginChannel.ts` for a working example. The key points: 3. Export a `plugin` object conforming to `ChannelPlugin` 4. Add a `qwen-extension.json` manifest +### Features you get for free + +- **Block streaming** — enable `blockStreaming: "on"` in config and the agent's response is automatically split into multiple messages at paragraph boundaries +- **Attachments** — populate `envelope.attachments` with images/files and `handleInbound()` routes them to the agent (images as vision input, files as paths in the prompt) +- **Streaming hooks** — override `onResponseChunk()` for progressive display (e.g., editing a message in-place) +- Access control (allowlist, pairing, open), session routing, slash commands, crash recovery + Full guide: [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md) From d84675e86f4fd6c1eea334a780b43b813d2f0eb2 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 15:17:55 +0000 Subject: [PATCH 44/51] test(channels): add comprehensive test suites for channel adapters - Add ChannelBase, GroupGate, SenderGate, SessionRouter tests - Add DingTalk markdown utility tests - Add Weixin media and send helper tests - Add CLI channel config-utils and pidfile tests - Configure vitest for all channel packages - Exclude test files from TypeScript build Tests cover attachment handling, block streaming, gating policies, session routing, markdown conversion, config parsing, and service management. Co-authored-by: Qwen-Coder --- .../channels/base/src/ChannelBase.test.ts | 397 ++++++++++++++++++ packages/channels/base/src/GroupGate.test.ts | 115 +++++ packages/channels/base/src/SenderGate.test.ts | 106 +++++ .../channels/base/src/SessionRouter.test.ts | 193 +++++++++ packages/channels/base/vitest.config.ts | 8 + .../channels/dingtalk/src/markdown.test.ts | 138 ++++++ packages/channels/dingtalk/tsconfig.json | 2 +- packages/channels/dingtalk/vitest.config.ts | 8 + packages/channels/telegram/tsconfig.json | 2 +- packages/channels/telegram/vitest.config.ts | 8 + packages/channels/weixin/src/media.test.ts | 91 ++++ packages/channels/weixin/src/send.test.ts | 82 ++++ packages/channels/weixin/tsconfig.json | 2 +- packages/channels/weixin/vitest.config.ts | 8 + .../src/commands/channel/config-utils.test.ts | 140 ++++++ .../cli/src/commands/channel/pidfile.test.ts | 178 ++++++++ vitest.config.ts | 4 + 17 files changed, 1479 insertions(+), 3 deletions(-) create mode 100644 packages/channels/base/src/ChannelBase.test.ts create mode 100644 packages/channels/base/src/GroupGate.test.ts create mode 100644 packages/channels/base/src/SenderGate.test.ts create mode 100644 packages/channels/base/src/SessionRouter.test.ts create mode 100644 packages/channels/base/vitest.config.ts create mode 100644 packages/channels/dingtalk/src/markdown.test.ts create mode 100644 packages/channels/dingtalk/vitest.config.ts create mode 100644 packages/channels/telegram/vitest.config.ts create mode 100644 packages/channels/weixin/src/media.test.ts create mode 100644 packages/channels/weixin/src/send.test.ts create mode 100644 packages/channels/weixin/vitest.config.ts create mode 100644 packages/cli/src/commands/channel/config-utils.test.ts create mode 100644 packages/cli/src/commands/channel/pidfile.test.ts diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts new file mode 100644 index 000000000..a7d6fd5bd --- /dev/null +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import type { ChannelConfig, Envelope } from './types.js'; +import type { AcpBridge } from './AcpBridge.js'; +import { ChannelBase } from './ChannelBase.js'; +import type { ChannelBaseOptions } from './ChannelBase.js'; + +// Concrete test implementation +class TestChannel extends ChannelBase { + sent: Array<{ chatId: string; text: string }> = []; + connected = false; + + async connect() { + this.connected = true; + } + async sendMessage(chatId: string, text: string) { + this.sent.push({ chatId, text }); + } + disconnect() { + this.connected = false; + } +} + +function createBridge(): AcpBridge { + const emitter = new EventEmitter(); + let sessionCounter = 0; + const bridge = Object.assign(emitter, { + newSession: vi.fn().mockImplementation(() => `s-${++sessionCounter}`), + loadSession: vi.fn(), + prompt: vi.fn().mockResolvedValue('agent response'), + stop: vi.fn(), + start: vi.fn(), + isConnected: true, + availableCommands: [], + setBridge: vi.fn(), + }); + return bridge as unknown as AcpBridge; +} + +function defaultConfig(overrides: Partial = {}): ChannelConfig { + return { + type: 'test', + token: 'tok', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'user', + cwd: '/tmp', + groupPolicy: 'disabled', + groups: {}, + ...overrides, + }; +} + +function envelope(overrides: Partial = {}): Envelope { + return { + channelName: 'test-chan', + senderId: 'user1', + senderName: 'User 1', + chatId: 'chat1', + text: 'hello', + isGroup: false, + isMentioned: false, + isReplyToBot: false, + ...overrides, + }; +} + +describe('ChannelBase', () => { + let bridge: AcpBridge; + + beforeEach(() => { + bridge = createBridge(); + }); + + function createChannel( + configOverrides: Partial = {}, + options?: ChannelBaseOptions, + ): TestChannel { + return new TestChannel( + 'test-chan', + defaultConfig(configOverrides), + bridge, + options, + ); + } + + describe('gate integration', () => { + it('silently drops group messages when groupPolicy=disabled', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ isGroup: true })); + expect(ch.sent).toEqual([]); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('allows DM messages through', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(bridge.prompt).toHaveBeenCalled(); + }); + + it('rejects sender with allowlist policy', async () => { + const ch = createChannel({ + senderPolicy: 'allowlist', + allowedUsers: ['admin'], + }); + await ch.handleInbound(envelope({ senderId: 'stranger' })); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('allows sender on allowlist', async () => { + const ch = createChannel({ + senderPolicy: 'allowlist', + allowedUsers: ['user1'], + }); + await ch.handleInbound(envelope()); + expect(bridge.prompt).toHaveBeenCalled(); + }); + }); + + describe('slash commands', () => { + it('/help sends command list', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/help' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('/help'); + expect(ch.sent[0]!.text).toContain('/clear'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('/clear removes session and confirms', async () => { + const ch = createChannel(); + // Create a session first + await ch.handleInbound(envelope()); + ch.sent = []; + // Now clear + await ch.handleInbound(envelope({ text: '/clear' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('Session cleared'); + }); + + it('/clear reports when no session exists', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/clear' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('No active session'); + }); + + it('/reset and /new are aliases for /clear', async () => { + for (const cmd of ['/reset', '/new']) { + const ch = createChannel(); + await ch.handleInbound(envelope()); + ch.sent = []; + await ch.handleInbound(envelope({ text: cmd })); + expect(ch.sent[0]!.text).toContain('Session cleared'); + } + }); + + it('/status shows session info', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/status' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('Session: none'); + expect(ch.sent[0]!.text).toContain('Access: open'); + expect(ch.sent[0]!.text).toContain('Channel: test-chan'); + }); + + it('/status shows active session', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: 'hi' })); + ch.sent = []; + await ch.handleInbound(envelope({ text: '/status' })); + expect(ch.sent[0]!.text).toContain('Session: active'); + }); + + it('handles /command@botname format', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/help@mybot' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('/help'); + }); + + it('forwards unrecognized commands to agent', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/unknown' })); + expect(bridge.prompt).toHaveBeenCalled(); + }); + }); + + describe('custom commands', () => { + it('subclass can register custom commands', async () => { + const ch = createChannel(); + // Access protected method via the test subclass + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ch as any).registerCommand('ping', async () => { + await ch.sendMessage('chat1', 'pong'); + return true; + }); + await ch.handleInbound(envelope({ text: '/ping' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toBe('pong'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('/help shows platform-specific commands', async () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ch as any).registerCommand('start', async () => true); + await ch.handleInbound(envelope({ text: '/help' })); + expect(ch.sent[0]!.text).toContain('/start'); + }); + }); + + describe('message enrichment', () => { + it('prepends referenced text', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ text: 'my reply', referencedText: 'original message' }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const promptText = (bridge.prompt as any).mock.calls[0][1] as string; + expect(promptText).toContain('[Replying to: "original message"]'); + expect(promptText).toContain('my reply'); + }); + + it('appends file paths from attachments', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'check this', + attachments: [ + { + type: 'file', + filePath: '/tmp/test.pdf', + mimeType: 'application/pdf', + fileName: 'test.pdf', + }, + ], + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const promptText = (bridge.prompt as any).mock.calls[0][1] as string; + expect(promptText).toContain('/tmp/test.pdf'); + expect(promptText).toContain('"test.pdf"'); + }); + + it('extracts image from attachments', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'see image', + attachments: [ + { + type: 'image', + data: 'base64data', + mimeType: 'image/png', + }, + ], + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = (bridge.prompt as any).mock.calls[0][2]; + expect(options.imageBase64).toBe('base64data'); + expect(options.imageMimeType).toBe('image/png'); + }); + + it('uses legacy imageBase64 when no attachment image', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'see image', + imageBase64: 'legacydata', + imageMimeType: 'image/jpeg', + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = (bridge.prompt as any).mock.calls[0][2]; + expect(options.imageBase64).toBe('legacydata'); + }); + + it('prepends instructions on first message only', async () => { + const ch = createChannel({ instructions: 'Be concise.' }); + await ch.handleInbound(envelope({ text: 'first' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstPrompt = (bridge.prompt as any).mock.calls[0][1] as string; + expect(firstPrompt).toContain('Be concise.'); + + await ch.handleInbound(envelope({ text: 'second' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const secondPrompt = (bridge.prompt as any).mock.calls[1][1] as string; + expect(secondPrompt).not.toContain('Be concise.'); + }); + }); + + describe('session routing', () => { + it('creates new session on first message', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('reuses session for same sender', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + await ch.handleInbound(envelope()); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('creates separate sessions for different senders', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ senderId: 'alice' })); + await ch.handleInbound(envelope({ senderId: 'bob' })); + expect(bridge.newSession).toHaveBeenCalledTimes(2); + }); + }); + + describe('response delivery', () => { + it('sends agent response via sendMessage', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toBe('agent response'); + }); + + it('does not send when agent returns empty response', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge.prompt as any).mockResolvedValue(''); + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(ch.sent).toEqual([]); + }); + }); + + describe('block streaming', () => { + it('uses block streamer when blockStreaming=on', async () => { + // The streamer sends blocks; onResponseComplete is NOT called + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge.prompt as any).mockImplementation( + (sid: string, _text: string) => { + // Simulate streaming chunks + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge as any).emit('textChunk', sid, 'Hello world! '); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge as any).emit('textChunk', sid, 'This is a test.'); + return Promise.resolve('Hello world! This is a test.'); + }, + ); + + const ch = createChannel({ + blockStreaming: 'on', + blockStreamingChunk: { minChars: 5, maxChars: 100 }, + blockStreamingCoalesce: { idleMs: 0 }, + }); + await ch.handleInbound(envelope()); + // BlockStreamer flush should have sent the accumulated text + expect(ch.sent.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('pairing flow', () => { + it('sends pairing code message when required', async () => { + const ch = createChannel({ senderPolicy: 'pairing', allowedUsers: [] }); + await ch.handleInbound(envelope({ senderId: 'stranger' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('pairing code'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + }); + + describe('setBridge', () => { + it('replaces the bridge instance', async () => { + const ch = createChannel(); + const newBridge = createBridge(); + ch.setBridge(newBridge); + // The channel should use the new bridge for future messages + // (this mainly ensures no crash) + expect(() => ch.setBridge(newBridge)).not.toThrow(); + }); + }); + + describe('isLocalCommand', () => { + it('returns true for registered commands', () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/help')).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/clear')).toBe(true); + }); + + it('returns false for non-commands', () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('hello')).toBe(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/unknown')).toBe(false); + }); + }); +}); diff --git a/packages/channels/base/src/GroupGate.test.ts b/packages/channels/base/src/GroupGate.test.ts new file mode 100644 index 000000000..4f5419523 --- /dev/null +++ b/packages/channels/base/src/GroupGate.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { GroupGate } from './GroupGate.js'; +import type { Envelope } from './types.js'; + +function envelope(overrides: Partial = {}): Envelope { + return { + channelName: 'test', + senderId: 'user1', + senderName: 'User', + chatId: 'chat1', + text: 'hello', + isGroup: false, + isMentioned: false, + isReplyToBot: false, + ...overrides, + }; +} + +describe('GroupGate', () => { + describe('non-group messages', () => { + it('always allows DM messages regardless of policy', () => { + for (const policy of ['disabled', 'allowlist', 'open'] as const) { + const gate = new GroupGate(policy); + expect(gate.check(envelope()).allowed).toBe(true); + } + }); + }); + + describe('disabled policy', () => { + it('rejects all group messages', () => { + const gate = new GroupGate('disabled'); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'disabled' }); + }); + }); + + describe('allowlist policy', () => { + it('rejects groups not in allowlist', () => { + const gate = new GroupGate('allowlist', { other: {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'not_allowlisted' }); + }); + + it('does not treat "*" as wildcard allow', () => { + const gate = new GroupGate('allowlist', { '*': {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'not_allowlisted' }); + }); + + it('allows explicitly listed group with mention', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check(envelope({ isGroup: true, isMentioned: true })); + expect(result.allowed).toBe(true); + }); + + it('requires mention by default for allowlisted group', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + + it('allows reply-to-bot as alternative to mention', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check( + envelope({ isGroup: true, isReplyToBot: true }), + ); + expect(result.allowed).toBe(true); + }); + + it('respects requireMention=false override', () => { + const gate = new GroupGate('allowlist', { + chat1: { requireMention: false }, + }); + const result = gate.check(envelope({ isGroup: true })); + expect(result.allowed).toBe(true); + }); + }); + + describe('open policy', () => { + it('allows any group with mention', () => { + const gate = new GroupGate('open'); + const result = gate.check(envelope({ isGroup: true, isMentioned: true })); + expect(result.allowed).toBe(true); + }); + + it('requires mention by default', () => { + const gate = new GroupGate('open'); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + + it('uses "*" as default config fallback', () => { + const gate = new GroupGate('open', { '*': { requireMention: false } }); + const result = gate.check(envelope({ isGroup: true })); + expect(result.allowed).toBe(true); + }); + + it('per-group config overrides "*" default', () => { + const gate = new GroupGate('open', { + '*': { requireMention: false }, + chat1: { requireMention: true }, + }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + }); + + describe('defaults', () => { + it('defaults to disabled policy', () => { + const gate = new GroupGate(); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'disabled' }); + }); + }); +}); diff --git a/packages/channels/base/src/SenderGate.test.ts b/packages/channels/base/src/SenderGate.test.ts new file mode 100644 index 000000000..05f74a83c --- /dev/null +++ b/packages/channels/base/src/SenderGate.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SenderGate } from './SenderGate.js'; +import type { PairingStore } from './PairingStore.js'; + +function mockPairingStore(overrides: Partial = {}): PairingStore { + return { + isApproved: vi.fn().mockReturnValue(false), + createRequest: vi.fn().mockReturnValue('ABCD1234'), + approve: vi.fn(), + listPending: vi.fn().mockReturnValue([]), + getAllowlist: vi.fn().mockReturnValue([]), + ...overrides, + } as unknown as PairingStore; +} + +describe('SenderGate', () => { + describe('open policy', () => { + it('allows any sender', () => { + const gate = new SenderGate('open'); + expect(gate.check('anyone').allowed).toBe(true); + }); + }); + + describe('allowlist policy', () => { + it('allows listed users', () => { + const gate = new SenderGate('allowlist', ['alice', 'bob']); + expect(gate.check('alice').allowed).toBe(true); + }); + + it('rejects unlisted users', () => { + const gate = new SenderGate('allowlist', ['alice']); + const result = gate.check('eve'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeUndefined(); + }); + + it('works with empty allowlist', () => { + const gate = new SenderGate('allowlist'); + expect(gate.check('anyone').allowed).toBe(false); + }); + }); + + describe('pairing policy', () => { + it('allows static allowlisted users without checking store', () => { + const store = mockPairingStore(); + const gate = new SenderGate('pairing', ['admin'], store); + const result = gate.check('admin'); + expect(result.allowed).toBe(true); + expect(store.isApproved).not.toHaveBeenCalled(); + }); + + it('allows dynamically approved users', () => { + const store = mockPairingStore({ + isApproved: vi.fn().mockReturnValue(true), + }); + const gate = new SenderGate('pairing', [], store); + expect(gate.check('user1').allowed).toBe(true); + }); + + it('generates pairing code for unknown sender', () => { + const store = mockPairingStore({ + createRequest: vi.fn().mockReturnValue('XYZW5678'), + }); + const gate = new SenderGate('pairing', [], store); + const result = gate.check('stranger', 'Stranger Name'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBe('XYZW5678'); + expect(store.createRequest).toHaveBeenCalledWith( + 'stranger', + 'Stranger Name', + ); + }); + + it('returns null pairingCode when cap reached', () => { + const store = mockPairingStore({ + createRequest: vi.fn().mockReturnValue(null), + }); + const gate = new SenderGate('pairing', [], store); + const result = gate.check('stranger'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeNull(); + }); + + it('uses senderId as senderName fallback', () => { + const store = mockPairingStore(); + const gate = new SenderGate('pairing', [], store); + gate.check('user42'); + expect(store.createRequest).toHaveBeenCalledWith('user42', 'user42'); + }); + + it('works without pairing store (no store provided)', () => { + const gate = new SenderGate('pairing'); + const result = gate.check('anyone'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeNull(); + }); + }); + + describe('unknown policy', () => { + it('throws on unknown policy', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gate = new SenderGate('unknown' as any); + expect(() => gate.check('user')).toThrow('Unknown sender policy'); + }); + }); +}); diff --git a/packages/channels/base/src/SessionRouter.test.ts b/packages/channels/base/src/SessionRouter.test.ts new file mode 100644 index 000000000..c8d1b5df1 --- /dev/null +++ b/packages/channels/base/src/SessionRouter.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SessionRouter } from './SessionRouter.js'; +import type { AcpBridge } from './AcpBridge.js'; + +let sessionCounter = 0; + +function mockBridge(): AcpBridge { + return { + newSession: vi.fn().mockImplementation(() => `session-${++sessionCounter}`), + loadSession: vi.fn().mockImplementation((id: string) => id), + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + availableCommands: [], + } as unknown as AcpBridge; +} + +describe('SessionRouter', () => { + let bridge: AcpBridge; + + beforeEach(() => { + sessionCounter = 0; + bridge = mockBridge(); + }); + + describe('routing key scopes', () => { + it('user scope: routes by channel + sender + chat', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'alice', 'chat2'); + const s3 = await router.resolve('ch', 'bob', 'chat1'); + expect(new Set([s1, s2, s3]).size).toBe(3); + }); + + it('user scope: same sender+chat reuses session', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'alice', 'chat1'); + expect(s1).toBe(s2); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('thread scope: routes by channel + threadId', async () => { + const router = new SessionRouter(bridge, '/tmp', 'thread'); + const s1 = await router.resolve('ch', 'alice', 'chat1', 'thread1'); + const s2 = await router.resolve('ch', 'bob', 'chat1', 'thread1'); + expect(s1).toBe(s2); // same thread = same session + }); + + it('thread scope: falls back to chatId when no threadId', async () => { + const router = new SessionRouter(bridge, '/tmp', 'thread'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'bob', 'chat1'); + expect(s1).toBe(s2); + }); + + it('single scope: all messages share one session per channel', async () => { + const router = new SessionRouter(bridge, '/tmp', 'single'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'bob', 'chat2'); + expect(s1).toBe(s2); + }); + + it('single scope: different channels get different sessions', async () => { + const router = new SessionRouter(bridge, '/tmp', 'single'); + const s1 = await router.resolve('ch1', 'alice', 'chat1'); + const s2 = await router.resolve('ch2', 'alice', 'chat1'); + expect(s1).not.toBe(s2); + }); + }); + + describe('resolve', () => { + it('passes cwd to bridge.newSession', async () => { + const router = new SessionRouter(bridge, '/default'); + await router.resolve('ch', 'alice', 'chat1', undefined, '/custom'); + expect(bridge.newSession).toHaveBeenCalledWith('/custom'); + }); + + it('uses defaultCwd when no cwd provided', async () => { + const router = new SessionRouter(bridge, '/default'); + await router.resolve('ch', 'alice', 'chat1'); + expect(bridge.newSession).toHaveBeenCalledWith('/default'); + }); + }); + + describe('getTarget', () => { + it('returns target for existing session', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const sid = await router.resolve('ch', 'alice', 'chat1', 'thread1'); + const target = router.getTarget(sid); + expect(target).toEqual({ + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + threadId: 'thread1', + }); + }); + + it('returns undefined for unknown session', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.getTarget('nonexistent')).toBeUndefined(); + }); + }); + + describe('hasSession', () => { + it('returns true for existing session with chatId', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(true); + }); + + it('returns false for non-existing session', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + }); + + it('prefix-scans when chatId omitted', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + expect(router.hasSession('ch', 'alice')).toBe(true); + expect(router.hasSession('ch', 'bob')).toBe(false); + }); + }); + + describe('removeSession', () => { + it('removes session by key and returns true', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + expect(router.removeSession('ch', 'alice', 'chat1')).toBe(true); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + }); + + it('returns false when nothing to remove', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.removeSession('ch', 'alice', 'chat1')).toBe(false); + }); + + it('removes all sender sessions when chatId omitted', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + await router.resolve('ch', 'alice', 'chat2'); + expect(router.removeSession('ch', 'alice')).toBe(true); + expect(router.hasSession('ch', 'alice')).toBe(false); + }); + + it('cleans up target mapping after removal', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const sid = await router.resolve('ch', 'alice', 'chat1'); + router.removeSession('ch', 'alice', 'chat1'); + expect(router.getTarget(sid)).toBeUndefined(); + }); + }); + + describe('getAll', () => { + it('returns all session entries', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + await router.resolve('ch', 'bob', 'chat2'); + const all = router.getAll(); + expect(all).toHaveLength(2); + expect(all.map((e) => e.target.senderId).sort()).toEqual([ + 'alice', + 'bob', + ]); + }); + + it('returns empty array when no sessions', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.getAll()).toEqual([]); + }); + }); + + describe('clearAll', () => { + it('clears all in-memory state', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + router.clearAll(); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + expect(router.getAll()).toEqual([]); + }); + }); + + describe('setBridge', () => { + it('replaces the bridge instance', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const newBridge = mockBridge(); + router.setBridge(newBridge); + await router.resolve('ch', 'alice', 'chat1'); + expect(newBridge.newSession).toHaveBeenCalled(); + expect(bridge.newSession).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/channels/base/vitest.config.ts b/packages/channels/base/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/base/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/dingtalk/src/markdown.test.ts b/packages/channels/dingtalk/src/markdown.test.ts new file mode 100644 index 000000000..b779a1951 --- /dev/null +++ b/packages/channels/dingtalk/src/markdown.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { + convertTables, + splitChunks, + extractTitle, + normalizeDingTalkMarkdown, +} from './markdown.js'; + +describe('DingTalk markdown utilities', () => { + describe('convertTables', () => { + it('converts a simple markdown table to pipe-separated text', () => { + const input = [ + '| Name | Age |', + '| --- | --- |', + '| Alice | 30 |', + '| Bob | 25 |', + ].join('\n'); + const result = convertTables(input); + expect(result).toContain('Name | Age'); + expect(result).toContain('Alice | 30'); + expect(result).not.toContain('---'); + }); + + it('preserves non-table content', () => { + const input = 'Hello world\n\nSome text'; + expect(convertTables(input)).toBe(input); + }); + + it('does not convert tables inside code fences', () => { + const input = [ + '```', + '| Name | Age |', + '| --- | --- |', + '| Alice | 30 |', + '```', + ].join('\n'); + const result = convertTables(input); + expect(result).toBe(input); + }); + + it('handles table with surrounding text', () => { + const input = [ + 'Before', + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + 'After', + ].join('\n'); + const result = convertTables(input); + expect(result).toContain('Before'); + expect(result).toContain('After'); + expect(result).toContain('A | B'); + }); + + it('handles table with alignment colons in separator', () => { + const input = [ + '| Left | Center | Right |', + '| :--- | :---: | ---: |', + '| a | b | c |', + ].join('\n'); + const result = convertTables(input); + expect(result).not.toContain(':---'); + }); + }); + + describe('splitChunks', () => { + it('returns single chunk for short text', () => { + expect(splitChunks('short text')).toEqual(['short text']); + }); + + it('returns single chunk for empty text', () => { + expect(splitChunks('')).toEqual(['']); + }); + + it('splits long text into chunks', () => { + const line = 'a'.repeat(100) + '\n'; + const text = line.repeat(50); // 5050 chars > 3800 + const chunks = splitChunks(text); + expect(chunks.length).toBeGreaterThan(1); + chunks.forEach((chunk) => { + expect(chunk.length).toBeLessThanOrEqual(3900); // allow small overhead + }); + }); + + it('closes and reopens code fences across boundaries', () => { + const longCode = '```\n' + 'x\n'.repeat(2000) + '```'; + const chunks = splitChunks(longCode); + expect(chunks.length).toBeGreaterThan(1); + // First chunk should end with closing fence + expect(chunks[0]).toContain('```'); + // Second chunk should start with opening fence + if (chunks.length > 1) { + expect(chunks[1]!.trimStart().startsWith('```')).toBe(true); + } + }); + }); + + describe('extractTitle', () => { + it('extracts title from first line', () => { + expect(extractTitle('Hello World\nmore text')).toBe('Hello World'); + }); + + it('strips markdown heading markers', () => { + expect(extractTitle('## My Title\ncontent')).toBe('My Title'); + }); + + it('strips bold/list markers', () => { + expect(extractTitle('* Item one')).toBe('Item one'); + expect(extractTitle('> Quote text')).toBe('Quote text'); + }); + + it('truncates to 20 chars', () => { + expect( + extractTitle('This is a very long title that should be truncated') + .length, + ).toBeLessThanOrEqual(20); + }); + + it('returns Reply for empty text', () => { + expect(extractTitle('')).toBe('Reply'); + expect(extractTitle('###')).toBe('Reply'); + }); + }); + + describe('normalizeDingTalkMarkdown', () => { + it('converts tables and splits into chunks', () => { + const input = ['| A | B |', '| --- | --- |', '| 1 | 2 |'].join('\n'); + const result = normalizeDingTalkMarkdown(input); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]).not.toContain('---'); + }); + + it('passes through plain text', () => { + const result = normalizeDingTalkMarkdown('simple text'); + expect(result).toEqual(['simple text']); + }); + }); +}); diff --git a/packages/channels/dingtalk/tsconfig.json b/packages/channels/dingtalk/tsconfig.json index 8daf59408..30e3324c8 100644 --- a/packages/channels/dingtalk/tsconfig.json +++ b/packages/channels/dingtalk/tsconfig.json @@ -5,6 +5,6 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], "references": [{ "path": "../base" }] } diff --git a/packages/channels/dingtalk/vitest.config.ts b/packages/channels/dingtalk/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/dingtalk/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/telegram/tsconfig.json b/packages/channels/telegram/tsconfig.json index 8daf59408..30e3324c8 100644 --- a/packages/channels/telegram/tsconfig.json +++ b/packages/channels/telegram/tsconfig.json @@ -5,6 +5,6 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], "references": [{ "path": "../base" }] } diff --git a/packages/channels/telegram/vitest.config.ts b/packages/channels/telegram/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/telegram/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/weixin/src/media.test.ts b/packages/channels/weixin/src/media.test.ts new file mode 100644 index 000000000..745c01554 --- /dev/null +++ b/packages/channels/weixin/src/media.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { createDecipheriv, createCipheriv } from 'node:crypto'; + +/** + * Test the AES key parsing and decryption logic used in media.ts. + * We test the pure crypto functions by reimplementing them here + * since they're not exported, but the behavior must match. + */ + +function parseAesKey(aesKeyBase64: string): Buffer { + const decoded = Buffer.from(aesKeyBase64, 'base64'); + if (decoded.length === 16) { + return decoded; + } + if ( + decoded.length === 32 && + /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii')) + ) { + return Buffer.from(decoded.toString('ascii'), 'hex'); + } + throw new Error( + `Invalid aes_key: expected 16 raw bytes or 32 hex chars, got ${decoded.length} bytes`, + ); +} + +function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + const decipher = createDecipheriv('aes-128-ecb', key, null); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +describe('Weixin media crypto', () => { + describe('parseAesKey', () => { + it('accepts 16-byte raw key encoded in base64', () => { + const raw = Buffer.alloc(16, 0xab); + const b64 = raw.toString('base64'); + const result = parseAesKey(b64); + expect(result).toEqual(raw); + expect(result.length).toBe(16); + }); + + it('accepts 32-char hex string encoded in base64', () => { + // 32 hex chars → 16 bytes when parsed as hex + const hexStr = 'aabbccdd11223344aabbccdd11223344'; + const b64 = Buffer.from(hexStr, 'ascii').toString('base64'); + const result = parseAesKey(b64); + expect(result.length).toBe(16); + expect(result.toString('hex')).toBe(hexStr); + }); + + it('throws for invalid key length', () => { + const bad = Buffer.alloc(20, 0x00).toString('base64'); + expect(() => parseAesKey(bad)).toThrow('Invalid aes_key'); + }); + + it('throws for 32-byte non-hex content', () => { + // 32 bytes but not valid hex characters + const nonHex = Buffer.from('zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', 'ascii'); + const b64 = nonHex.toString('base64'); + expect(() => parseAesKey(b64)).toThrow('Invalid aes_key'); + }); + }); + + describe('decryptAesEcb', () => { + it('encrypts then decrypts round-trip', () => { + const key = Buffer.alloc(16, 0x42); + const plaintext = Buffer.from('Hello, WeChat media decryption!'); + + // Encrypt + const cipher = createCipheriv('aes-128-ecb', key, null); + const ciphertext = Buffer.concat([ + cipher.update(plaintext), + cipher.final(), + ]); + + // Decrypt + const decrypted = decryptAesEcb(ciphertext, key); + expect(decrypted.toString()).toBe(plaintext.toString()); + }); + + it('handles empty plaintext', () => { + const key = Buffer.alloc(16, 0x01); + const cipher = createCipheriv('aes-128-ecb', key, null); + const ciphertext = Buffer.concat([ + cipher.update(Buffer.alloc(0)), + cipher.final(), + ]); + const decrypted = decryptAesEcb(ciphertext, key); + expect(decrypted.length).toBe(0); + }); + }); +}); diff --git a/packages/channels/weixin/src/send.test.ts b/packages/channels/weixin/src/send.test.ts new file mode 100644 index 000000000..95152672c --- /dev/null +++ b/packages/channels/weixin/src/send.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { markdownToPlainText } from './send.js'; + +describe('markdownToPlainText', () => { + it('strips code blocks', () => { + const input = '```js\nconst x = 1;\n```'; + expect(markdownToPlainText(input)).toBe('const x = 1;'); + }); + + it('strips inline code', () => { + expect(markdownToPlainText('use `npm install`')).toBe('use npm install'); + }); + + it('strips bold', () => { + expect(markdownToPlainText('**bold text**')).toBe('bold text'); + }); + + it('strips italic', () => { + expect(markdownToPlainText('*italic text*')).toBe('italic text'); + expect(markdownToPlainText('_italic text_')).toBe('italic text'); + }); + + it('strips bold+italic', () => { + expect(markdownToPlainText('***bold italic***')).toBe('bold italic'); + }); + + it('strips strikethrough', () => { + expect(markdownToPlainText('~~deleted~~')).toBe('deleted'); + }); + + it('strips headings', () => { + expect(markdownToPlainText('# Title\n## Subtitle')).toBe('Title\nSubtitle'); + }); + + it('converts links to text (url)', () => { + expect(markdownToPlainText('[click here](https://example.com)')).toBe( + 'click here (https://example.com)', + ); + }); + + it('converts image syntax (link regex fires before image regex)', () => { + // In the current implementation, the link regex fires before the image regex, + // so `![alt](url)` becomes `!alt (url)` rather than `[alt]` + const result = markdownToPlainText('![alt](https://img.png)'); + expect(result).toBe('!alt (https://img.png)'); + }); + + it('strips blockquote markers', () => { + expect(markdownToPlainText('> quoted text')).toBe('quoted text'); + }); + + it('normalizes list markers', () => { + expect(markdownToPlainText('* item 1\n- item 2')).toBe( + '- item 1\n- item 2', + ); + }); + + it('collapses triple+ newlines', () => { + expect(markdownToPlainText('a\n\n\n\nb')).toBe('a\n\nb'); + }); + + it('trims result', () => { + expect(markdownToPlainText(' \n hello \n ')).toBe('hello'); + }); + + it('handles double underscore bold', () => { + expect(markdownToPlainText('__bold__')).toBe('bold'); + }); + + it('handles complex markdown', () => { + const input = '# Title\n\n**Bold** and *italic* with `code`\n\n> quote'; + const result = markdownToPlainText(input); + expect(result).toContain('Title'); + expect(result).toContain('Bold'); + expect(result).toContain('italic'); + expect(result).toContain('code'); + expect(result).toContain('quote'); + expect(result).not.toContain('#'); + expect(result).not.toContain('**'); + expect(result).not.toContain('`'); + }); +}); diff --git a/packages/channels/weixin/tsconfig.json b/packages/channels/weixin/tsconfig.json index 8daf59408..30e3324c8 100644 --- a/packages/channels/weixin/tsconfig.json +++ b/packages/channels/weixin/tsconfig.json @@ -5,6 +5,6 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], "references": [{ "path": "../base" }] } diff --git a/packages/channels/weixin/vitest.config.ts b/packages/channels/weixin/vitest.config.ts new file mode 100644 index 000000000..bfaebe3ce --- /dev/null +++ b/packages/channels/weixin/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts new file mode 100644 index 000000000..d4002deee --- /dev/null +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resolveEnvVars, parseChannelConfig } from './config-utils.js'; + +// Mock the channel-registry so we don't pull in real plugins +vi.mock('./channel-registry.js', () => ({ + getPlugin: (type: string) => { + const plugins: Record< + string, + { channelType: string; requiredConfigFields?: string[] } + > = { + telegram: { channelType: 'telegram', requiredConfigFields: ['token'] }, + dingtalk: { + channelType: 'dingtalk', + requiredConfigFields: ['clientId', 'clientSecret'], + }, + bare: { channelType: 'bare' }, // no requiredConfigFields + }; + return plugins[type]; + }, + supportedTypes: () => ['telegram', 'dingtalk', 'bare'], +})); + +describe('resolveEnvVars', () => { + const ENV_KEY = 'TEST_RESOLVE_VAR_123'; + + afterEach(() => { + delete process.env[ENV_KEY]; + }); + + it('returns literal values unchanged', () => { + expect(resolveEnvVars('my-token')).toBe('my-token'); + }); + + it('resolves $ENV_VAR to its value', () => { + process.env[ENV_KEY] = 'secret'; + expect(resolveEnvVars(`$${ENV_KEY}`)).toBe('secret'); + }); + + it('throws when referenced env var is not set', () => { + expect(() => resolveEnvVars(`$${ENV_KEY}`)).toThrow( + `Environment variable ${ENV_KEY} is not set`, + ); + }); + + it('does not resolve vars that do not start with $', () => { + process.env[ENV_KEY] = 'val'; + expect(resolveEnvVars(`prefix$${ENV_KEY}`)).toBe(`prefix$${ENV_KEY}`); + }); +}); + +describe('parseChannelConfig', () => { + it('throws when type is missing', () => { + expect(() => parseChannelConfig('bot', {})).toThrow( + 'missing required field "type"', + ); + }); + + it('throws for unsupported channel type', () => { + expect(() => parseChannelConfig('bot', { type: 'slack' })).toThrow( + '"slack" is not supported', + ); + }); + + it('throws when plugin-required fields are missing', () => { + expect(() => parseChannelConfig('bot', { type: 'telegram' })).toThrow( + 'requires "token"', + ); + }); + + it('parses minimal valid config with defaults', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + }); + + expect(result.type).toBe('bare'); + expect(result.token).toBe(''); + expect(result.senderPolicy).toBe('allowlist'); + expect(result.allowedUsers).toEqual([]); + expect(result.sessionScope).toBe('user'); + expect(result.cwd).toBe(process.cwd()); + expect(result.groupPolicy).toBe('disabled'); + expect(result.groups).toEqual({}); + }); + + it('resolves env vars in token, clientId, clientSecret', () => { + process.env.TEST_TOKEN = 'tok123'; + process.env.TEST_CID = 'cid456'; + process.env.TEST_SEC = 'sec789'; + + const result = parseChannelConfig('bot', { + type: 'bare', + token: '$TEST_TOKEN', + clientId: '$TEST_CID', + clientSecret: '$TEST_SEC', + }); + + expect(result.token).toBe('tok123'); + expect(result.clientId).toBe('cid456'); + expect(result.clientSecret).toBe('sec789'); + + delete process.env.TEST_TOKEN; + delete process.env.TEST_CID; + delete process.env.TEST_SEC; + }); + + it('preserves explicit config values over defaults', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + token: 'literal-tok', + senderPolicy: 'open', + allowedUsers: ['alice'], + sessionScope: 'thread', + cwd: '/custom', + approvalMode: 'auto', + instructions: 'Be helpful', + model: 'qwen-coder', + groupPolicy: 'open', + groups: { g1: { mentionKeywords: ['@bot'] } }, + }); + + expect(result.token).toBe('literal-tok'); + expect(result.senderPolicy).toBe('open'); + expect(result.allowedUsers).toEqual(['alice']); + expect(result.sessionScope).toBe('thread'); + expect(result.cwd).toBe('/custom'); + expect(result.approvalMode).toBe('auto'); + expect(result.instructions).toBe('Be helpful'); + expect(result.model).toBe('qwen-coder'); + expect(result.groupPolicy).toBe('open'); + expect(result.groups).toEqual({ g1: { mentionKeywords: ['@bot'] } }); + }); + + it('spreads extra fields from raw config', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + customField: 42, + }); + expect((result as Record)['customField']).toBe(42); + }); +}); diff --git a/packages/cli/src/commands/channel/pidfile.test.ts b/packages/cli/src/commands/channel/pidfile.test.ts new file mode 100644 index 000000000..6e0d0398e --- /dev/null +++ b/packages/cli/src/commands/channel/pidfile.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// vi.hoisted runs before vi.mock hoisting, so fsStore is available in the factory +const fsStore = vi.hoisted(() => { + const store: Record = {}; + return store; +}); + +vi.mock('node:fs', () => { + const mock = { + existsSync: (p: string) => p in fsStore, + readFileSync: (p: string) => { + if (!(p in fsStore)) throw new Error('ENOENT'); + return fsStore[p]; + }, + writeFileSync: (p: string, data: string) => { + fsStore[p] = data; + }, + mkdirSync: () => {}, + unlinkSync: (p: string) => { + delete fsStore[p]; + }, + }; + return { ...mock, default: mock }; +}); + +import { + readServiceInfo, + writeServiceInfo, + removeServiceInfo, + signalService, + waitForExit, +} from './pidfile.js'; + +// We need to mock process.kill for isProcessAlive / signalService +const originalKill = process.kill; + +function getPidFilePath() { + return join(homedir(), '.qwen', 'channels', 'service.pid'); +} + +beforeEach(() => { + for (const k of Object.keys(fsStore)) delete fsStore[k]; +}); + +afterEach(() => { + process.kill = originalKill; +}); + +describe('writeServiceInfo + readServiceInfo', () => { + it('writes and reads back service info for a live process', () => { + // Mock process.kill(pid, 0) to indicate alive + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + + writeServiceInfo(['telegram', 'dingtalk']); + const info = readServiceInfo(); + + expect(info).not.toBeNull(); + expect(info!.pid).toBe(process.pid); + expect(info!.channels).toEqual(['telegram', 'dingtalk']); + expect(info!.startedAt).toBeTruthy(); + }); + + it('returns null when no PID file exists', () => { + const info = readServiceInfo(); + expect(info).toBeNull(); + }); + + it('cleans up and returns null for corrupt PID file', () => { + const filePath = getPidFilePath(); + fsStore[filePath] = 'not-json!!!'; + + const info = readServiceInfo(); + expect(info).toBeNull(); + // File should be cleaned up + expect(filePath in fsStore).toBe(false); + }); + + it('cleans up and returns null for stale PID (dead process)', () => { + // First write with alive process + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + writeServiceInfo(['telegram']); + + // Now simulate dead process + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + const info = readServiceInfo(); + expect(info).toBeNull(); + }); +}); + +describe('removeServiceInfo', () => { + it('removes existing PID file', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + writeServiceInfo(['test']); + removeServiceInfo(); + + const info = readServiceInfo(); + expect(info).toBeNull(); + }); + + it('is a no-op when no PID file exists', () => { + expect(() => removeServiceInfo()).not.toThrow(); + }); +}); + +describe('signalService', () => { + it('returns true when signal is delivered', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + expect(signalService(1234, 'SIGTERM')).toBe(true); + expect(process.kill).toHaveBeenCalledWith(1234, 'SIGTERM'); + }); + + it('returns false when process is not found', () => { + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + expect(signalService(9999)).toBe(false); + }); + + it('defaults to SIGTERM', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + signalService(1234); + expect(process.kill).toHaveBeenCalledWith(1234, 'SIGTERM'); + }); +}); + +describe('waitForExit', () => { + it('returns true immediately if process is already dead', async () => { + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + const result = await waitForExit(9999, 1000, 50); + expect(result).toBe(true); + }); + + it('returns true when process dies within timeout', async () => { + let alive = true; + + process.kill = vi.fn(() => { + if (!alive) throw new Error('ESRCH'); + return true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + // Kill after 100ms + setTimeout(() => { + alive = false; + }, 100); + + const result = await waitForExit(1234, 2000, 50); + expect(result).toBe(true); + }); + + it('returns false on timeout when process stays alive', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + + const result = await waitForExit(1234, 150, 50); + expect(result).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 88cded8b8..339420a56 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ 'packages/core', 'packages/vscode-ide-companion', 'packages/sdk-typescript', + 'packages/channels/base', + 'packages/channels/dingtalk', + 'packages/channels/telegram', + 'packages/channels/weixin', 'integration-tests', 'scripts', ], From 9fc2abbed2754df47672d2248c82c323ab7ff526 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sat, 28 Mar 2026 04:12:48 +0000 Subject: [PATCH 45/51] style(test): use bracket notation for process.env access This satisfies ESLint no-dot-notation rule for consistent property access. Co-authored-by: Qwen-Coder --- .../cli/src/commands/channel/config-utils.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts index d4002deee..6835f8880 100644 --- a/packages/cli/src/commands/channel/config-utils.test.ts +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -83,9 +83,9 @@ describe('parseChannelConfig', () => { }); it('resolves env vars in token, clientId, clientSecret', () => { - process.env.TEST_TOKEN = 'tok123'; - process.env.TEST_CID = 'cid456'; - process.env.TEST_SEC = 'sec789'; + process.env['TEST_TOKEN'] = 'tok123'; + process.env['TEST_CID'] = 'cid456'; + process.env['TEST_SEC'] = 'sec789'; const result = parseChannelConfig('bot', { type: 'bare', @@ -98,9 +98,9 @@ describe('parseChannelConfig', () => { expect(result.clientId).toBe('cid456'); expect(result.clientSecret).toBe('sec789'); - delete process.env.TEST_TOKEN; - delete process.env.TEST_CID; - delete process.env.TEST_SEC; + delete process.env['TEST_TOKEN']; + delete process.env['TEST_CID']; + delete process.env['TEST_SEC']; }); it('preserves explicit config values over defaults', () => { From 7251da015250adffe2c5abe322dd8b80da0027b5 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sat, 28 Mar 2026 06:19:02 +0000 Subject: [PATCH 46/51] feat(channels): add dispatch modes and prompt lifecycle hooks Add three dispatch modes for handling concurrent messages: - steer (default): cancel current prompt and start new one - collect: buffer messages and coalesce into follow-up prompt - followup: queue messages for sequential processing Introduce onPromptStart/onPromptEnd lifecycle hooks for working indicators. These fire only when a prompt actually begins processing, not for buffered (collect mode) or gated/blocked messages. Refactor Telegram, WeChat, and DingTalk adapters to use the new hooks instead of overriding handleInbound, simplifying the working indicator pattern and ensuring correct behavior with dispatch modes. This enables better UX for async workflows and prevents indicator leaks when messages are buffered or cancelled. --- docs/developers/channel-plugins.md | 12 +- docs/users/features/channels/overview.md | 66 +++- packages/channels/base/src/AcpBridge.ts | 5 + .../channels/base/src/ChannelBase.test.ts | 359 ++++++++++++++++++ packages/channels/base/src/ChannelBase.ts | 121 +++++- packages/channels/base/src/index.ts | 1 + packages/channels/base/src/types.ts | 5 + .../channels/dingtalk/src/DingtalkAdapter.ts | 65 +++- .../channels/telegram/src/TelegramAdapter.ts | 38 +- packages/channels/weixin/src/WeixinAdapter.ts | 101 +++-- 10 files changed, 649 insertions(+), 124 deletions(-) diff --git a/docs/developers/channel-plugins.md b/docs/developers/channel-plugins.md index 4dbcdadaf..7dffa78dc 100644 --- a/docs/developers/channel-plugins.md +++ b/docs/developers/channel-plugins.md @@ -152,13 +152,15 @@ this.registerCommand('mycommand', async (envelope, args) => { }); ``` -**Working indicators** — override `handleInbound()` to show platform-specific typing indicators: +**Working indicators** — override `onPromptStart()` and `onPromptEnd()` to show platform-specific typing indicators. These hooks fire only when a prompt actually begins processing — not for buffered messages (collect mode) or gated/blocked messages: ```typescript -override async handleInbound(envelope: Envelope): Promise { - await this.platformClient.sendTyping(envelope.chatId); // your platform API - try { await super.handleInbound(envelope); } - finally { await this.platformClient.stopTyping(envelope.chatId); } +protected override onPromptStart(chatId: string, sessionId: string, messageId?: string): void { + this.platformClient.sendTyping(chatId); // your platform API +} + +protected override onPromptEnd(chatId: string, sessionId: string, messageId?: string): void { + this.platformClient.stopTyping(chatId); } ``` diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 471b63f0a..3b6e74e9b 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -47,23 +47,24 @@ Channels are configured under the `channels` key in `settings.json`. Each channe ### Options -| Option | Required | Description | -| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Plugins](./plugins)) | -| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk | -| `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax | -| `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax | -| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input | -| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | -| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | -| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | -| `cwd` | No | Working directory for the agent. Defaults to the current directory | -| `instructions` | No | Custom instructions prepended to the first message of each session | -| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | -| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | -| `blockStreaming` | No | Progressive response delivery: `on` or `off` (default). See [Block Streaming](#block-streaming) | -| `blockStreamingChunk` | No | Chunk size bounds: `{ "minChars": 400, "maxChars": 1000 }`. See [Block Streaming](#block-streaming) | -| `blockStreamingCoalesce` | No | Idle flush: `{ "idleMs": 1500 }`. See [Block Streaming](#block-streaming) | +| Option | Required | Description | +| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Plugins](./plugins)) | +| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk | +| `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax | +| `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax | +| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input | +| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | +| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | +| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | +| `cwd` | No | Working directory for the agent. Defaults to the current directory | +| `instructions` | No | Custom instructions prepended to the first message of each session | +| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | +| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | +| `dispatchMode` | No | What happens when you send a message while the bot is busy: `steer` (default), `collect`, or `followup`. See [Dispatch Modes](#dispatch-modes) | +| `blockStreaming` | No | Progressive response delivery: `on` or `off` (default). See [Block Streaming](#block-streaming) | +| `blockStreamingChunk` | No | Chunk size bounds: `{ "minChars": 400, "maxChars": 1000 }`. See [Block Streaming](#block-streaming) | +| `blockStreamingCoalesce` | No | Idle flush: `{ "idleMs": 1500 }`. See [Block Streaming](#block-streaming) | ### Sender Policy @@ -222,6 +223,37 @@ Files work with any model — no multimodal support required. | Files | Direct download via Bot API (20MB limit) | CDN download with AES decryption | downloadCode API (two-step) | | Captions | Photo/file captions included as message text | Not applicable | Rich text: mixed text + images in one message | +## Dispatch Modes + +Controls what happens when you send a new message while the bot is still processing a previous one. + +- **`steer`** (default) — The bot cancels the current request and starts working on your new message. Best for normal chat, where a follow-up usually means you want to correct or redirect the bot. +- **`collect`** — Your new messages are buffered. When the current request finishes, all buffered messages are combined into a single follow-up prompt. Good for async workflows where you want to queue up thoughts. +- **`followup`** — Each message is queued and processed as its own separate turn, in order. Useful for batch workflows where each message is independent. + +```json +{ + "channels": { + "my-channel": { + "type": "telegram", + "dispatchMode": "steer", + ... + } + } +} +``` + +You can also set dispatch mode per group, overriding the channel default: + +```json +{ + "groups": { + "*": { "requireMention": true, "dispatchMode": "steer" }, + "-100123456": { "dispatchMode": "collect" } + } +} +``` + ## Block Streaming By default, the agent works for a while and then sends one large response. With block streaming enabled, the response arrives as multiple shorter messages while the agent is still working — similar to how ChatGPT or Claude show progressive output. diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index 2b9c9c5e4..346a1617a 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -174,6 +174,11 @@ export class AcpBridge extends EventEmitter { return chunks.join(''); } + async cancelSession(sessionId: string): Promise { + const conn = this.ensureConnection(); + await conn.cancel({ sessionId }); + } + stop(): void { if (this.child) { this.child.kill(); diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index a7d6fd5bd..34628a031 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -9,6 +9,13 @@ import type { ChannelBaseOptions } from './ChannelBase.js'; class TestChannel extends ChannelBase { sent: Array<{ chatId: string; text: string }> = []; connected = false; + promptStarts: Array<{ + chatId: string; + sessionId: string; + messageId?: string; + }> = []; + promptEnds: Array<{ chatId: string; sessionId: string; messageId?: string }> = + []; async connect() { this.connected = true; @@ -19,6 +26,22 @@ class TestChannel extends ChannelBase { disconnect() { this.connected = false; } + + protected override onPromptStart( + chatId: string, + sessionId: string, + messageId?: string, + ): void { + this.promptStarts.push({ chatId, sessionId, messageId }); + } + + protected override onPromptEnd( + chatId: string, + sessionId: string, + messageId?: string, + ): void { + this.promptEnds.push({ chatId, sessionId, messageId }); + } } function createBridge(): AcpBridge { @@ -377,6 +400,342 @@ describe('ChannelBase', () => { }); }); + describe('dispatch modes', () => { + it('collect: buffers messages and coalesces into one followup prompt', async () => { + // Make the first prompt "slow" — we control when it resolves + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('coalesced response'); + }); + + const ch = createChannel({ dispatchMode: 'collect' }); + + // Send first message — starts processing + const p1 = ch.handleInbound(envelope({ text: 'first' })); + + // Wait a tick for the prompt to be registered as active + 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' })); + + // p2 and p3 should resolve immediately (buffered, not queued) + await p2; + await p3; + + // First prompt is still running, bridge.prompt called only once + expect(callCount).toBe(1); + + // Resolve the first prompt + resolveFirst('first response'); + await p1; + + // Wait for the coalesced followup to process + await new Promise((r) => setTimeout(r, 50)); + + // bridge.prompt should have been called twice: original + coalesced + expect(callCount).toBe(2); + + // The second call should contain both buffered messages coalesced + const secondCallText = (bridge.prompt as ReturnType).mock + .calls[1][1] as string; + expect(secondCallText).toContain('second'); + expect(secondCallText).toContain('third'); + + // Both responses should have been sent + expect(ch.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'first response' }), + expect.objectContaining({ text: 'coalesced response' }), + ]), + ); + }); + + it('collect: no followup if no messages buffered', async () => { + const ch = createChannel({ dispatchMode: 'collect' }); + await ch.handleInbound(envelope({ text: 'only message' })); + expect(bridge.prompt).toHaveBeenCalledTimes(1); + expect(ch.sent).toHaveLength(1); + }); + + it('steer: cancels running prompt and re-prompts with cancellation note', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('steered response'); + }); + + // Add cancelSession mock + (bridge as unknown as Record).cancelSession = vi + .fn() + .mockImplementation(() => { + // Simulate cancellation — resolve the first prompt + resolveFirst('cancelled partial'); + return Promise.resolve(); + }); + + const ch = createChannel({ dispatchMode: 'steer' }); + + // Send first message — starts processing + const p1 = ch.handleInbound(envelope({ text: 'refactor auth' })); + + // Wait for prompt to register as active + await new Promise((r) => setTimeout(r, 10)); + + // Send correction while first is busy + const p2 = ch.handleInbound( + envelope({ text: 'actually refactor billing' }), + ); + + // Both should resolve + await p1; + await p2; + + // cancelSession should have been called + expect( + (bridge as unknown as Record unknown>).cancelSession, + ).toHaveBeenCalledTimes(1); + + // First prompt's response should NOT have been sent (it was cancelled) + expect(ch.sent).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'cancelled partial' }), + ]), + ); + + // Second prompt should include the cancellation note + const secondCallText = (bridge.prompt as ReturnType).mock + .calls[1][1] as string; + expect(secondCallText).toContain('previous request has been cancelled'); + expect(secondCallText).toContain('actually refactor billing'); + + // Steered response should have been sent + expect(ch.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'steered response' }), + ]), + ); + }); + + it('followup: queues messages sequentially', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve(`response-${callCount}`); + }); + + const ch = createChannel({ dispatchMode: 'followup' }); + + // Send first message + const p1 = ch.handleInbound(envelope({ text: 'task one' })); + + // Wait for prompt to start + await new Promise((r) => setTimeout(r, 10)); + + // Send second message — should queue (not buffer) + const p2 = ch.handleInbound(envelope({ text: 'task two' })); + + // Only first prompt should be running + expect(callCount).toBe(1); + + // Resolve first + resolveFirst('response-1'); + await p1; + await p2; + + // Both prompts ran sequentially + expect(callCount).toBe(2); + + // Both got their own response + expect(ch.sent).toEqual([ + expect.objectContaining({ text: 'response-1' }), + expect.objectContaining({ text: 'response-2' }), + ]); + }); + + it('steer is the default mode when dispatchMode not set', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('steered response'); + }); + + // Add cancelSession mock + (bridge as unknown as Record).cancelSession = vi + .fn() + .mockImplementation(() => { + resolveFirst('cancelled'); + return Promise.resolve(); + }); + + // No dispatchMode set — should default to steer + const ch = createChannel(); + + const p1 = ch.handleInbound(envelope({ text: 'first' })); + await new Promise((r) => setTimeout(r, 10)); + + // Second message should cancel the first (steer behavior) + const p2 = ch.handleInbound(envelope({ text: 'second' })); + + await p1; + await p2; + + // cancelSession should have been called (steer behavior) + expect( + (bridge as unknown as Record unknown>).cancelSession, + ).toHaveBeenCalledTimes(1); + + // Both prompts ran + expect(callCount).toBe(2); + }); + + it('per-group dispatchMode overrides channel-level', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve(`response-${callCount}`); + }); + + // Channel default is collect, but group overrides to followup + const ch = createChannel({ + dispatchMode: 'collect', + groupPolicy: 'open', + groups: { 'group-1': { dispatchMode: 'followup' } }, + }); + + const groupEnv = envelope({ + isGroup: true, + isMentioned: true, + chatId: 'group-1', + }); + + const p1 = ch.handleInbound({ ...groupEnv, text: 'first' }); + await new Promise((r) => setTimeout(r, 10)); + + // In followup mode, second message queues (doesn't buffer and return) + const p2Promise = ch.handleInbound({ ...groupEnv, text: 'second' }); + + expect(callCount).toBe(1); + + resolveFirst('response-1'); + await p1; + await p2Promise; + + // Both ran sequentially — followup behavior + expect(callCount).toBe(2); + expect(ch.sent).toEqual([ + expect.objectContaining({ text: 'response-1' }), + expect.objectContaining({ text: 'response-2' }), + ]); + }); + }); + + describe('prompt lifecycle hooks', () => { + it('calls onPromptStart and onPromptEnd for each prompt', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: 'hello' })); + + expect(ch.promptStarts).toHaveLength(1); + expect(ch.promptStarts[0]!.chatId).toBe('chat1'); + expect(ch.promptEnds).toHaveLength(1); + expect(ch.promptEnds[0]!.chatId).toBe('chat1'); + }); + + it('passes messageId to hooks', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: 'hello', messageId: 'msg-42' })); + + expect(ch.promptStarts[0]!.messageId).toBe('msg-42'); + expect(ch.promptEnds[0]!.messageId).toBe('msg-42'); + }); + + it('does not call hooks for gated messages', async () => { + const ch = createChannel({ + senderPolicy: 'allowlist', + allowedUsers: ['admin'], + }); + await ch.handleInbound(envelope({ senderId: 'stranger' })); + + expect(ch.promptStarts).toHaveLength(0); + expect(ch.promptEnds).toHaveLength(0); + }); + + it('does not call hooks for buffered messages in collect mode', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('ok'); + }); + + const ch = createChannel({ dispatchMode: 'collect' }); + + const p1 = ch.handleInbound( + envelope({ text: 'first', messageId: 'msg-1' }), + ); + await new Promise((r) => setTimeout(r, 10)); + + // This message gets buffered — should NOT trigger hooks + await ch.handleInbound(envelope({ text: 'second', messageId: 'msg-2' })); + + // Only one prompt start so far (for the first message) + expect(ch.promptStarts).toHaveLength(1); + expect(ch.promptStarts[0]!.messageId).toBe('msg-1'); + + resolveFirst('done'); + await p1; + await new Promise((r) => setTimeout(r, 50)); + + // After coalesced prompt runs, we should have 2 start/end pairs + expect(ch.promptStarts).toHaveLength(2); + expect(ch.promptEnds).toHaveLength(2); + }); + + it('calls onPromptEnd even when prompt throws', async () => { + (bridge.prompt as ReturnType).mockRejectedValue( + new Error('agent error'), + ); + + const ch = createChannel(); + // handleInbound catches the error internally + await ch.handleInbound(envelope({ text: 'hello' })).catch(() => {}); + + expect(ch.promptStarts).toHaveLength(1); + expect(ch.promptEnds).toHaveLength(1); + }); + }); + describe('isLocalCommand', () => { it('returns true for registered commands', () => { const ch = createChannel(); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index e10e805ce..0c533125f 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -1,4 +1,4 @@ -import type { ChannelConfig, Envelope } from './types.js'; +import type { ChannelConfig, DispatchMode, Envelope } from './types.js'; import { BlockStreamer } from './BlockStreamer.js'; import { GroupGate } from './GroupGate.js'; import { SenderGate } from './SenderGate.js'; @@ -22,9 +22,20 @@ export abstract class ChannelBase { protected name: string; private instructedSessions: Set = new Set(); private commands: Map = new Map(); - /** Per-session promise chain to serialize prompt + send. */ + /** Per-session promise chain to serialize prompt + send (followup mode). */ private sessionQueues: Map> = new Map(); + /** Per-session active prompt tracking for dispatch modes. */ + private activePrompts: Map< + string, + { cancelled: boolean; done: Promise; resolve: () => void } + > = new Map(); + /** Per-session message buffer for collect mode. */ + private collectBuffers: Map< + string, + Array<{ text: string; envelope: Envelope }> + > = new Map(); + constructor( name: string, config: ChannelConfig, @@ -73,6 +84,27 @@ export abstract class ChannelBase { onToolCall(_chatId: string, _event: ToolCallEvent): void {} + /** + * Called when a prompt actually begins processing (inside the session queue). + * Override to show a platform-specific working indicator (e.g., typing, reaction). + * Not called for buffered messages (collect mode) or gated/blocked messages. + */ + protected onPromptStart( + _chatId: string, + _sessionId: string, + _messageId?: string, + ): void {} + + /** + * Called when a prompt finishes (response sent or cancelled). + * Override to hide the working indicator. + */ + protected onPromptEnd( + _chatId: string, + _sessionId: string, + _messageId?: string, + ): void {} + /** * Called for each text chunk as the agent streams its response. * Override to implement progressive display (e.g., updating an AI card in-place). @@ -266,11 +298,64 @@ export abstract class ChannelBase { this.instructedSessions.add(sessionId); } - // Serialize prompt + send per session to prevent textChunk listener - // pollution when concurrent messages hit the same session. + // Resolve dispatch mode: per-group override → channel config → default + const groupCfg = envelope.isGroup + ? this.config.groups[envelope.chatId] || this.config.groups['*'] + : undefined; + const mode: DispatchMode = + groupCfg?.dispatchMode || this.config.dispatchMode || 'steer'; + + const active = this.activePrompts.get(sessionId); + + if (active) { + // A prompt is already running for this session + switch (mode) { + case 'collect': { + // Buffer the message; it will be coalesced when the active prompt finishes + let buffer = this.collectBuffers.get(sessionId); + if (!buffer) { + buffer = []; + this.collectBuffers.set(sessionId, buffer); + } + buffer.push({ text: promptText, envelope }); + return; + } + case 'steer': { + // Cancel the running prompt, then fall through to send a new one + active.cancelled = true; + await this.bridge.cancelSession(sessionId).catch(() => {}); + // Wait for the active prompt to finish winding down + await active.done; + // Prepend a cancellation note so the agent understands context + promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`; + break; + } + case 'followup': { + // Chain onto the session queue (existing sequential behavior) + break; + } + default: { + // Exhaustive check — should never happen + const _exhaustive: never = mode; + throw new Error(`Unknown dispatch mode: ${_exhaustive}`); + } + } + } + + // Run the prompt (with followup-mode serialization for safety) const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve(); const useBlockStreaming = this.config.blockStreaming === 'on'; const current = prev.then(async () => { + // Register this prompt as active + let doneResolve: () => void = () => {}; + const done = new Promise((r) => { + doneResolve = r; + }); + const promptState = { cancelled: false, done, resolve: doneResolve }; + this.activePrompts.set(sessionId, promptState); + + this.onPromptStart(envelope.chatId, sessionId, envelope.messageId); + const streamer = useBlockStreaming ? new BlockStreamer({ minChars: this.config.blockStreamingChunk?.minChars ?? 400, @@ -280,7 +365,6 @@ export abstract class ChannelBase { }) : null; - // Forward streaming chunks to the subclass hook (and block streamer) const onChunk = (sid: string, chunk: string) => { if (sid === sessionId) { this.onResponseChunk(envelope.chatId, chunk, sessionId); @@ -295,7 +379,8 @@ export abstract class ChannelBase { imageMimeType, }); - if (response) { + // If cancelled (steer mode), skip sending the response + if (!promptState.cancelled && response) { if (streamer) { await streamer.flush(); } else { @@ -304,6 +389,30 @@ export abstract class ChannelBase { } } finally { this.bridge.off('textChunk', onChunk); + this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId); + this.activePrompts.delete(sessionId); + // Signal any steer waiter that we're done + promptState.resolve(); + + // Drain collect buffer if any messages accumulated + const buffer = this.collectBuffers.get(sessionId); + if (buffer && buffer.length > 0) { + this.collectBuffers.delete(sessionId); + const coalesced = buffer.map((b) => b.text).join('\n\n'); + const lastEnvelope = buffer[buffer.length - 1]!.envelope; + // Re-enter handleInbound with the coalesced message + const syntheticEnvelope: Envelope = { + ...lastEnvelope, + text: coalesced, + // Clear attachments/references — already resolved in original text + referencedText: undefined, + attachments: undefined, + imageBase64: undefined, + imageMimeType: undefined, + }; + // Queue the coalesced prompt (don't await to avoid deadlock on the queue) + this.handleInbound(syntheticEnvelope).catch(() => {}); + } } }); this.sessionQueues.set( diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 532216478..9361644a2 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -22,6 +22,7 @@ export type { ChannelConfig, ChannelPlugin, ChannelType, + DispatchMode, Envelope, GroupConfig, GroupPolicy, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 0a1f91636..4ff2828f3 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -5,9 +5,11 @@ export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; export type SessionScope = 'user' | 'thread' | 'single'; export type ChannelType = string; export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; +export type DispatchMode = 'collect' | 'steer' | 'followup'; export interface GroupConfig { requireMention?: boolean; // default: true + dispatchMode?: DispatchMode; } export interface BlockStreamingChunkConfig { @@ -37,6 +39,9 @@ export interface ChannelConfig { groupPolicy: GroupPolicy; // default: "disabled" groups: Record; // "*" for defaults, group IDs for overrides + /** Dispatch mode for concurrent messages. Default: 'collect'. */ + dispatchMode?: DispatchMode; + /** Enable block streaming — emit completed blocks as separate messages. */ blockStreaming?: 'on' | 'off'; /** Chunk size bounds for block streaming. */ diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 45e40c219..b3d2bbca1 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -81,6 +81,8 @@ export class DingtalkChannel extends ChannelBase { private dedupTimer?: ReturnType; /** Map conversationId → latest sessionWebhook URL for sending replies. */ private webhooks: Map = new Map(); + /** Map messageId → conversationId for reaction attach/recall in hooks. */ + private reactionContext: Map = new Map(); constructor( name: string, @@ -236,6 +238,31 @@ export class DingtalkChannel extends ChannelBase { process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`); } + protected override onPromptStart( + _chatId: string, + _sessionId: string, + messageId?: string, + ): void { + if (!messageId) return; + const convId = this.reactionContext.get(messageId); + if (convId) { + this.attachReaction(messageId, convId).catch(() => {}); + } + } + + protected override onPromptEnd( + _chatId: string, + _sessionId: string, + messageId?: string, + ): void { + if (!messageId) return; + const convId = this.reactionContext.get(messageId); + if (convId) { + this.recallReaction(messageId, convId).catch(() => {}); + this.reactionContext.delete(messageId); + } + } + /** * Extract quoted/referenced message context from a reply. * DingTalk provides this via text.repliedMsg (newer) or quoteMessage (legacy). @@ -515,30 +542,26 @@ export class DingtalkChannel extends ChannelBase { referencedText: quoted.referencedText, }; - // Attach 👀 reaction, process message, then recall reaction - const reactionMsgId = msgId; - const reactionConvId = conversationId; + // Store messageId + conversationId for reaction hooks + envelope.messageId = msgId; + if (msgId && conversationId) { + this.reactionContext.set(msgId, conversationId); + } const processMessage = async () => { - if (reactionMsgId && reactionConvId) { - this.attachReaction(reactionMsgId, reactionConvId).catch(() => {}); - } - try { - // Download media if present (first downloadCode only for images) - if (content.downloadCodes.length > 0 && content.mediaType) { - await this.attachMedia( - envelope, - content.downloadCodes[0]!, - content.mediaType, - content.fileName, - ); - } - await this.handleInbound(envelope); - } finally { - if (reactionMsgId && reactionConvId) { - this.recallReaction(reactionMsgId, reactionConvId).catch(() => {}); - } + // Download media if present (first downloadCode only for images) + if (content.downloadCodes.length > 0 && content.mediaType) { + await this.attachMedia( + envelope, + content.downloadCodes[0]!, + content.mediaType, + content.fileName, + ); } + // reactionContext cleanup is handled by onPromptEnd (not here), + // because in collect mode handleInbound returns immediately after + // buffering — the context must survive until the prompt actually runs. + await this.handleInbound(envelope); }; // Don't await — stream callback should return quickly diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 3912455a5..a3485c3b1 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -161,31 +161,25 @@ export class TelegramChannel extends ChannelBase { process.once('SIGTERM', () => this.bot.stop('SIGTERM')); } - override async handleInbound(envelope: Envelope): Promise { - // Check group gate before showing "Working..." indicator - const groupResult = this.groupGate.check(envelope); - if (!groupResult.allowed) { - return; - } + /** Per-chat typing interval — repeats every 4s since Telegram expires it after 5s. */ + private typingIntervals = new Map>(); - // Skip "Working..." for local slash commands — they respond instantly - const isLocalCommand = - envelope.text.startsWith('/') && this.isLocalCommand(envelope.text); + protected override onPromptStart(chatId: string): void { + // Clear any stale interval (shouldn't happen, but safe) + const existing = this.typingIntervals.get(chatId); + if (existing) clearInterval(existing); - const workingMsg = isLocalCommand - ? null - : await this.bot.telegram - .sendMessage(envelope.chatId, 'Working...') - .catch(() => null); + const sendTyping = () => + this.bot.telegram.sendChatAction(chatId, 'typing').catch(() => {}); + sendTyping(); + this.typingIntervals.set(chatId, setInterval(sendTyping, 4000)); + } - try { - await super.handleInbound(envelope); - } finally { - if (workingMsg) { - this.bot.telegram - .deleteMessage(envelope.chatId, workingMsg.message_id) - .catch(() => {}); - } + protected override onPromptEnd(chatId: string): void { + const interval = this.typingIntervals.get(chatId); + if (interval) { + clearInterval(interval); + this.typingIntervals.delete(chatId); } } diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts index cad61e64d..6548953b7 100644 --- a/packages/channels/weixin/src/WeixinAdapter.ts +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -93,68 +93,63 @@ export class WeixinChannel extends ChannelBase { ); } + protected override onPromptStart(chatId: string): void { + this.setTyping(chatId, true).catch(() => {}); + } + + protected override onPromptEnd(chatId: string): void { + this.setTyping(chatId, false).catch(() => {}); + } + private async handleInboundWithMedia( envelope: Envelope, image?: CdnRef, file?: FileCdnRef, ): Promise { - // Check group gate before showing typing - const groupResult = this.groupGate.check(envelope); - if (!groupResult.allowed) { - return; + // Download image from CDN + if (image) { + try { + const imageData = await downloadAndDecrypt( + image.encryptQueryParam, + image.aesKey, + ); + envelope.imageBase64 = imageData.toString('base64'); + envelope.imageMimeType = detectImageMime(imageData); + } catch (err) { + process.stderr.write( + `[Weixin:${this.name}] Failed to download image: ${err instanceof Error ? err.message : err}\n`, + ); + } } - // Show typing indicator immediately — before CDN download - await this.setTyping(envelope.chatId, true); - - try { - // Download image from CDN (after typing has started) - if (image) { - try { - const imageData = await downloadAndDecrypt( - image.encryptQueryParam, - image.aesKey, - ); - envelope.imageBase64 = imageData.toString('base64'); - envelope.imageMimeType = detectImageMime(imageData); - } catch (err) { - process.stderr.write( - `[Weixin:${this.name}] Failed to download image: ${err instanceof Error ? err.message : err}\n`, - ); - } + // Download file from CDN, save to temp dir + if (file) { + try { + const fileData = await downloadAndDecrypt( + file.encryptQueryParam, + file.aesKey, + ); + const dir = join(tmpdir(), 'channel-files'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const filePath = join(dir, file.fileName); + writeFileSync(filePath, fileData); + envelope.attachments = [ + { + type: 'file', + filePath, + mimeType: 'application/octet-stream', + fileName: file.fileName, + }, + ]; + } catch (err) { + process.stderr.write( + `[Weixin:${this.name}] Failed to download file: ${err instanceof Error ? err.message : err}\n`, + ); + envelope.text = `(User sent a file "${file.fileName}" but download failed)`; } - - // Download file from CDN, save to temp dir - if (file) { - try { - const fileData = await downloadAndDecrypt( - file.encryptQueryParam, - file.aesKey, - ); - const dir = join(tmpdir(), 'channel-files'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const filePath = join(dir, file.fileName); - writeFileSync(filePath, fileData); - envelope.attachments = [ - { - type: 'file', - filePath, - mimeType: 'application/octet-stream', - fileName: file.fileName, - }, - ]; - } catch (err) { - process.stderr.write( - `[Weixin:${this.name}] Failed to download file: ${err instanceof Error ? err.message : err}\n`, - ); - envelope.text = `(User sent a file "${file.fileName}" but download failed)`; - } - } - - await super.handleInbound(envelope); - } finally { - await this.setTyping(envelope.chatId, false); } + + await super.handleInbound(envelope); } async sendMessage(chatId: string, text: string): Promise { From bac0ba0cc212c32539c2f2ece15625f9c24876f5 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 30 Mar 2026 12:46:52 +0000 Subject: [PATCH 47/51] docs(channels): add design documentation for channels feature - Architecture overview with platform adapters and ACP bridge - Plugin system contract and extension loading - Implementation guides for Telegram, WeChat, DingTalk - Testing guide with mock servers and E2E scenarios - Feature roadmap and known limitations These docs provide the foundation for the external messaging integrations. Co-authored-by: Qwen-Coder --- docs/design/channels/channels-design.md | 190 ++++++++++++++++++ .../channels/channels-implementation.md | 107 ++++++++++ docs/design/channels/channels-roadmap.md | 51 +++++ .../design/channels/channels-testing-guide.md | 156 ++++++++++++++ 4 files changed, 504 insertions(+) create mode 100644 docs/design/channels/channels-design.md create mode 100644 docs/design/channels/channels-implementation.md create mode 100644 docs/design/channels/channels-roadmap.md create mode 100644 docs/design/channels/channels-testing-guide.md diff --git a/docs/design/channels/channels-design.md b/docs/design/channels/channels-design.md new file mode 100644 index 000000000..e4ff1c2ec --- /dev/null +++ b/docs/design/channels/channels-design.md @@ -0,0 +1,190 @@ +# Channels Design + +> External messaging integrations for Qwen Code — interact with an agent from Telegram, WeChat, and more. +> +> Channel-implementation status: `channels-implementation.md`. Testing: `channels-testing-guide.md`. + +## Overview + +A **channel** connects an external messaging platform to a Qwen Code agent. Configured in `settings.json`, managed via `qwen channel` subcommands, multi-user (each user gets an isolated ACP session). + +## Architecture + +``` +┌──────────┐ ┌─────────────────────────────────────┐ +│ Telegram │ Platform API │ Channel Service │ +│ User A │◄──────────────────────►│ │ +├──────────┤ (WebSocket/polling) │ ┌───────────┐ ┌──────────────┐ │ +│ WeChat │◄──────────────────────►│ │ Platform │ │ ACP Bridge │ │ +│ User B │ │ │ Adapter │ │ (shared) │ │ +└──────────┘ │ │ │ │ │ │ + │ │ - connect │ │ - spawns │ │ + │ │ - receive │ │ qwen-code │ │ + │ │ - send │ │ - manages │ │ + │ │ │ │ sessions │ │ + │ └─────┬──────┘ └──────┬───────┘ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌─────────────────────────────────┐ │ + │ │ SenderGate · GroupGate │ │ + │ │ SessionRouter · ChannelBase │ │ + │ └─────────────────────────────────┘ │ + └─────────────────────────────────────┘ + │ + │ stdio (ACP ndjson) + ▼ + ┌─────────────────────────────────────┐ + │ qwen-code --acp │ + │ Session A (user andy, id: "abc") │ + │ Session B (user bob, id: "def") │ + └─────────────────────────────────────┘ +``` + +**Platform Adapter** — connects to external API, translates messages to/from Envelopes. **ACP Bridge** — spawns `qwen-code --acp`, manages sessions, emits `textChunk`/`toolCall`/`disconnected` events. **Session Router** — maps senders to ACP sessions via namespaced keys (`:`). **Sender Gate** / **Group Gate** — access control (allowlist / pairing / open) and mention gating. **Channel Base** — abstract base with Template Method pattern: plugins override `connect`, `sendMessage`, `disconnect`. **Channel Registry** — `Map` with collision detection. + +### Envelope + +Normalized message format all platforms convert to: + +- **Identity**: `senderId`, `senderName`, `chatId`, `channelName` +- **Content**: `text`, optional `imageBase64`/`imageMimeType`, optional `referencedText` +- **Context**: `isGroup`, `isMentioned`, `isReplyToBot`, optional `threadId` + +Plugin responsibilities: `senderId` must be stable/unique; `chatId` must distinguish DMs from groups; boolean flags must be accurate for gate logic; @mentions stripped from `text`. + +### Message Flow + +``` +Inbound: User message → Adapter → GroupGate → SenderGate → Slash commands → SessionRouter → AcpBridge → Agent +Outbound: Agent response → AcpBridge → SessionRouter → Adapter → User +``` + +Slash commands (`/clear`, `/help`, `/status`) are handled in ChannelBase before reaching the agent. + +### Sessions + +One `qwen-code --acp` process with multiple ACP sessions. Scope per channel: **`user`** (default), **`thread`**, or **`single`**. Routing keys namespaced as `:`. + +### Error Handling + +- **Connection failures** — logged; service continues if at least one channel connects +- **Bridge crashes** — exponential backoff (max 3 retries), `setBridge()` on all channels, session restore +- **Session serialization** — per-session promise chains prevent concurrent prompt collisions + +## Plugin System + +The architecture is extensible — new adapters (including third-party) can be added without modifying core. Built-in channels use the same plugin interface (dogfooding). + +### Plugin Contract + +A `ChannelPlugin` declares `channelType`, `displayName`, `requiredConfigFields`, and a `createChannel()` factory. Plugins implement three methods: + +| Method | Responsibility | +| --------------------------- | ------------------------------------------------- | +| `connect()` | Connect to platform and register message handlers | +| `sendMessage(chatId, text)` | Format and deliver agent response | +| `disconnect()` | Clean up on shutdown | + +On inbound messages, plugins build an `Envelope` and call `this.handleInbound(envelope)` — the base class handles the rest: access control, group gating, pairing, session routing, prompt serialization, slash commands, instructions injection, reply context, and crash recovery. + +### Extension Points + +- Custom slash commands via `registerCommand()` +- Working indicators by wrapping `handleInbound()` with typing/reaction display +- Tool call hooks via `onToolCall()` +- Media handling by attaching to Envelope before `handleInbound()` + +### Discovery & Loading + +External plugins are **extensions** managed by `ExtensionManager`, declared in `qwen-extension.json`: + +```json +{ + "name": "my-channel-extension", + "version": "1.0.0", + "channels": { + "my-platform": { + "entry": "dist/index.js", + "displayName": "My Platform Channel" + } + } +} +``` + +Loading sequence at `qwen channel start`: load settings → register built-ins → scan extensions → dynamic import + validate → register (reject collisions) → validate config → `createChannel()` → `connect()`. + +Plugins run in-process (no sandbox), same trust model as npm dependencies. + +## Configuration + +```jsonc +{ + "channels": { + "my-telegram": { + "type": "telegram", + "token": "$TELEGRAM_BOT_TOKEN", // env var reference + "senderPolicy": "allowlist", // allowlist | pairing | open + "allowedUsers": ["123456"], + "sessionScope": "user", // user | thread | single + "cwd": "/path/to/project", + "model": "qwen3.5-plus", + "instructions": "Keep responses short.", + "groupPolicy": "disabled", // disabled | allowlist | open + "groups": { "*": { "requireMention": true } }, + }, + }, +} +``` + +Auth is plugin-specific: static token (Telegram), app credentials (DingTalk), QR code login (WeChat), proxy token (TMCP). + +## CLI Commands + +```bash +# Channels +qwen channel start [name] # start all or one channel +qwen channel stop # stop running service +qwen channel status # show channels, sessions, uptime +qwen channel pairing list # pending pairing requests +qwen channel pairing approve # approve a request + +# Extensions +qwen extensions install # install +qwen extensions link # symlink for dev +qwen extensions list # show installed +qwen extensions remove # uninstall +``` + +## Package Structure + +``` +packages/channels/ +├── base/ # @qwen-code/channel-base +│ └── src/ +│ ├── AcpBridge.ts # ACP process lifecycle, session management +│ ├── SessionRouter.ts # sender ↔ session mapping, persistence +│ ├── SenderGate.ts # allowlist / pairing / open +│ ├── GroupGate.ts # group chat policy + mention gating +│ ├── PairingStore.ts # pairing code generation + approval +│ ├── ChannelBase.ts # abstract base: routing, slash commands +│ └── types.ts # Envelope, ChannelConfig, etc. +├── telegram/ # @qwen-code/channel-telegram +├── weixin/ # @qwen-code/channel-weixin +└── dingtalk/ # @qwen-code/channel-dingtalk +``` + +## What's Next + +- **DingTalk: quoted bot responses** — persist outbound text keyed by `processQueryKey` (see `channels-dingtalk.md`) +- **Streaming responses** — edit messages in-place as chunks arrive +- **Structured logging** — pino; JSON by default, human-readable on TTY +- **E2E tests** — mock servers for platform APIs + mock ACP agent +- **Daemon mode** — background operation, systemd/launchd unit generation + +## Known Limitations + +- **Shared workspace conflicts** — multiple users editing the same `cwd` may cause file conflicts +- **Crash-recovery sessions only** — sessions persist for bridge restarts but cleared on clean shutdown +- **Sequential prompts per session** — messages queue within a session; different sessions run independently +- **Single instance** — PID file prevents duplicates; `qwen channel stop` first +- **Shared bridge model** — all channels share one ACP bridge process; if channels configure different models, only the first is used (warning shown) diff --git a/docs/design/channels/channels-implementation.md b/docs/design/channels/channels-implementation.md new file mode 100644 index 000000000..186be6719 --- /dev/null +++ b/docs/design/channels/channels-implementation.md @@ -0,0 +1,107 @@ +# Channels + +Qwen Code supports three messaging channels — Telegram, WeChat, and DingTalk. All adapters extend the shared channel architecture (`ChannelBase`, `AcpBridge`, `SessionRouter`) in `packages/channels/base/src/`. Each channel can be started individually or all together with `node dist/cli.js channel start`. + +--- + +## Telegram + +Source: `packages/channels/telegram/src/TelegramAdapter.ts`, built on the Telegraf library. + +The adapter supports plain text messaging, slash commands, a working indicator ("typing" chat action), DM pairing, and group chat (supergroups with @mention gating). Image receiving works via `bot.on('photo')` → `getFileLink` → download → base64, with captions passed as envelope text. File/document receiving saves downloaded files to `/tmp/channel-files/` and includes the path in the envelope so the agent can read them via `read-file` (works with any model, no multimodal required). Referenced messages include the quoted text as context in the prompt. Output is formatted as Telegram HTML (converted from markdown). Authentication uses a static bot token. Session persistence and pairing state are stored under `~/.qwen/channels/`. + +```jsonc +// ~/.qwen/settings.json +{ + "channels": { + "my-telegram": { + "type": "telegram", + "token": "$TELEGRAM_BOT_TOKEN", + "senderPolicy": "pairing", + "allowedUsers": [], + "sessionScope": "user", + "instructions": "Keep responses concise.", + }, + }, +} +``` + +```bash +source /home/andy/projects/telegram/.env +npm run bundle && node dist/cli.js channel start my-telegram +``` + +**Future work:** Streaming responses via in-place `editMessageText` (throttled at ~2s to respect rate limits, best-effort fallback to single message). Slash command polish — register with BotFather via `setMyCommands()`, fix `/help` timing, add `/status` command. + +--- + +## WeChat (Weixin) + +Source: `packages/channels/weixin/src/`, ported from the cc-weixin project. Uses the iLink Bot API at `ilinkai.weixin.qq.com`. + +The adapter supports plain text messaging via a custom long-poll loop (`/ilink/bot/getupdates`, cursor-based), with `context_token` caching per user for reply context. Authentication uses QR code login (`qwen channel configure-weixin`), producing a bearer token stored in `~/.qwen/channels/weixin/account.json`. A typing indicator fires before each ACP prompt using the `sendTyping` API (ticket obtained from `getConfig`). Image and file/PDF receiving works through CDN download with AES-128-ECB decryption — images are forwarded as base64 content blocks, files are saved to `/tmp/channel-files/` and referenced by path. Referenced messages (user replies) include quoted text as context in the prompt. Formatting is plain text only (all markdown is stripped). The adapter handles session expiry (`errcode -14`) with automatic reconnection, uses backoff after consecutive errors, and persists the polling cursor to `~/.qwen/channels/weixin/cursor.txt` for crash recovery. + +```jsonc +// ~/.qwen/settings.json +{ + "channels": { + "my-weixin": { + "type": "weixin", + "senderPolicy": "pairing", + "allowedUsers": [], + "sessionScope": "user", + "instructions": "Keep responses concise, plain text only.", + "baseUrl": "https://ilinkai.weixin.qq.com", // optional override + }, + }, +} +``` + +Credentials are stored separately in `~/.qwen/channels/weixin/account.json`, created by `qwen channel configure-weixin`. + +```bash +# First time: login via QR code +node dist/cli.js channel configure-weixin + +# Start +npm run bundle && node dist/cli.js channel start my-weixin +``` + +**Future work:** Media send (upload to WeChat CDN with AES encryption). Voice/video receive. Streaming responses via `message_state: GENERATING` → `FINISH` (pending client-side investigation). Multi-account support. Message chunking for long responses. + +--- + +## DingTalk (钉钉) + +Source: `packages/channels/dingtalk/src/`, using Stream mode (WebSocket, no public IP required). Referenced from openclaw-channel-dingtalk. + +The adapter connects via the `dingtalk-stream` SDK, which handles WebSocket connection, reconnection, heartbeats, and callback ACKs (DingTalk retries unACKed messages). Authentication reuses the SDK's built-in token (`client.getConfig().access_token`) from AppKey + AppSecret. Responses are sent back through a per-message `sessionWebhook` URL — a temporary, conversation-scoped endpoint that supports text, markdown, images, and files. Both DM and group chat are supported, with group messages gated by `@mention` detection (`isInAtList`). A 👀 emoji reaction serves as a working indicator while the agent processes (posted via the emotion API and recalled on completion). Output is formatted as DingTalk markdown, with tables converted to plain text, messages split at ~3800 characters, and code fences maintained across chunks. Image, file, audio, and video receiving works through a two-step download flow (`downloadCode` → `downloadUrl` → buffer); images are forwarded as base64, files saved to `/tmp/channel-files/`. Quoted message context is extracted from `text.repliedMsg` and `quoteMessage`, with bot-reply detection via `chatbotUserId`. + +```jsonc +// ~/.qwen/settings.json +{ + "channels": { + "my-dingtalk": { + "type": "dingtalk", + "clientId": "$DINGTALK_CLIENT_ID", + "clientSecret": "$DINGTALK_CLIENT_SECRET", + "senderPolicy": "open", + "sessionScope": "user", + "cwd": "/path/to/project", + "instructions": "Keep responses concise. Use DingTalk markdown.", + "groupPolicy": "open", + "groups": { + "*": { "requireMention": true }, + }, + }, + }, +} +``` + +```bash +export DINGTALK_CLIENT_ID= +export DINGTALK_CLIENT_SECRET= +npm run bundle && node dist/cli.js channel start my-dingtalk +``` + +**Future work:** Quoted bot responses (persisting outbound messages keyed by `processQueryKey` for lookup on reply). AI Card streaming via `/v1.0/card/instances` and `/v1.0/card/streaming` with graceful markdown fallback. diff --git a/docs/design/channels/channels-roadmap.md b/docs/design/channels/channels-roadmap.md new file mode 100644 index 000000000..8f0583744 --- /dev/null +++ b/docs/design/channels/channels-roadmap.md @@ -0,0 +1,51 @@ +# Channels Roadmap + +## Implemented (MVP) + +- **3 built-in channels** — Telegram, WeChat, DingTalk +- **Plugin system** — `ChannelBase` SDK with `connect`/`sendMessage`/`disconnect`, extension manifest, compiled JS + `.d.ts` +- **Access control** — `allowlist`, `pairing` (8-char codes, CLI approval), `open` policies +- **Group chat** — `open`/`disabled`/`allowlist` group policy, `requireMention` per group, reply-as-mention +- **Session routing** — `user`, `thread`, `single` scopes with per-channel `cwd`, `model`, `instructions` +- **Dispatch modes** — `steer` (default: cancel + re-prompt), `collect` (buffer + coalesce), `followup` (sequential queue). Per-channel and per-group config. +- **Working indicators** — centralized `onPromptStart`/`onPromptEnd` hooks. Telegram: typing bar. WeChat: typing API. DingTalk: 👀 emoji reaction. +- **Block streaming** — progressive multi-message delivery with paragraph-aware chunking +- **Streaming hooks** — `onResponseChunk`/`onResponseComplete` for plugins to implement progressive display +- **Media support** — images (vision input), files/audio/video (saved to temp, path in prompt), `Attachment` interface on `Envelope` +- **Slash commands** — `/help`, `/clear` (`/reset`, `/new`), `/status`, custom via `registerCommand()` +- **Service management** — `qwen channel start/stop/status`, PID tracking, crash recovery (auto-restart, session persistence) +- **Token security** — `$ENV_VAR` syntax in config + +## Future Work + +### Safety & Group Chat + +- **Per-group tool restrictions** — `tools`/`toolsBySender` deny/allow lists per group +- **Group context history** — ring buffer of recent skipped messages, prepended on @mention +- **Regex mention patterns** — fallback `mentionPatterns` for unreliable @mention metadata +- **Per-group instructions** — `instructions` field on `GroupConfig` for per-group personas +- **`/activation` command** — runtime toggle for `requireMention`, persisted to disk + +### Operational Tooling + +- **`qwen channel doctor`** — config validation, env vars, bot tokens, network checks +- **`qwen channel status --probe`** — real connectivity checks per channel + +### Platform Expansion + +- **Discord** — Bot API + Gateway, servers/channels/DMs/threads +- **Slack** — Bolt SDK, Socket Mode, workspaces/channels/DMs/threads + +### Multi-Agent + +- **Multi-agent routing** — multiple agents with bindings per channel/group/user +- **Broadcast groups** — multiple agents respond to the same message + +### Plugin Ecosystem + +- **Community plugin template** — `create-qwen-channel` scaffolding tool +- **Plugin registry/discovery** — `qwen extensions search`, version compatibility + +## Reference: OpenClaw Comparison + +See [channels-comparison.md](channels-comparison.md) for the detailed feature comparison between OpenClaw and Qwen-Code channels. diff --git a/docs/design/channels/channels-testing-guide.md b/docs/design/channels/channels-testing-guide.md new file mode 100644 index 000000000..f488ae485 --- /dev/null +++ b/docs/design/channels/channels-testing-guide.md @@ -0,0 +1,156 @@ +# Channels Testing Guide + +How to test channel integrations end-to-end. + +## Credentials + +- Telegram bot: `@qwencod_test_1_bot` (远弟) +- Bot token env var: `TELEGRAM_BOT_TOKEN` +- Bot token file: `/home/andy/projects/telegram/.env` +- Andy's Telegram user ID: `8513463076` +- WeChat credentials: `~/.qwen/channels/weixin/account.json` + +## Before testing + +**Important:** Stop any running service first. Duplicate instances cause duplicate responses. + +```bash +# Stop the service if running +qwen channel stop + +# Or check status first +qwen channel status + +# If processes are stuck (e.g. from manual kill -9), clean up manually +pkill -9 -f "cli.js --acp" +pkill -9 -f "channel start" +rm -f ~/.qwen/channels/service.pid ~/.qwen/channels/sessions.json +``` + +## Sending messages via Bot API (no bot process needed) + +```bash +# Source the token +export TELEGRAM_BOT_TOKEN=$(grep TELEGRAM_BOT_TOKEN /home/andy/projects/telegram/.env | cut -d= -f2) + +# Send a message to Andy +curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d '{"chat_id": "8513463076", "text": "Hello from the bot!"}' +``` + +## Starting channels + +```bash +export TELEGRAM_BOT_TOKEN=$(grep TELEGRAM_BOT_TOKEN /home/andy/projects/telegram/.env | cut -d= -f2) +cd /home/andy/projects/qwen-code +npm run bundle + +# Single channel +node dist/cli.js channel start my-telegram + +# All channels (shared bridge) +node dist/cli.js channel start +``` + +Settings config: `~/.qwen/settings.json` under `channels.*`. + +## Checking registered commands + +```bash +curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMyCommands" | python3 -m json.tool +``` + +## Test scenarios + +### 1. Slash commands (shared across all channels) + +Start the service, then send on Telegram or WeChat: + +| Command | Expected | +| --------- | --------------------------------------------------------------- | +| `/help` | List of all commands | +| `/status` | "Session: none, Access: ..." | +| `/clear` | "No active session to clear." (or "Session cleared." if active) | +| `/reset` | Same as `/clear` (alias) | +| `/new` | Same as `/clear` (alias) | + +### 2. Basic text round-trip + +1. Start the bot +2. Send any text (e.g. "hello") +3. Bot should respond via the agent +4. `/status` should now show "Session: active" + +### 3. Multi-turn conversation + +1. Send "my name is Andy" +2. Send "what is my name?" +3. Agent should remember "Andy" from same session + +### 4. Session clear + +1. Have an active session (send a message first) +2. Send `/clear` (or `/reset` or `/new`) +3. Send "what is my name?" +4. Agent should NOT remember — fresh session + +### 5. Tool calls (internal) + +1. Send "list the files in /home/andy/projects/qwen-code" +2. Agent should use shell/ls internally and return file listing +3. Verify response contains actual file names + +### 6. Markdown formatting + +1. Send "write me a hello world in python with explanation" +2. Response should render with proper Telegram HTML formatting (bold, code blocks, etc.) + +### 7. Multi-channel mode + +1. Ensure both `my-telegram` and `my-weixin` are configured in `~/.qwen/settings.json` +2. For WeChat: run `node dist/cli.js channel configure-weixin` if token expired +3. Start all: `node dist/cli.js channel start` +4. Should show: `Starting 2 channel(s): my-weixin, my-telegram` +5. Send messages on both platforms — each should get exactly one response +6. Check `~/.qwen/channels/sessions.json` — each channel should have its own cwd + +### 8. Crash recovery + +1. Start multi-channel mode and send a message to create sessions +2. Find the ACP bridge PID: `ps --ppid -o pid,args | grep acp` +3. Kill it: `kill -9 ` +4. Log should show: `Bridge crashed (1/3). Restarting in 3s...` then `Sessions restored: 2, failed: 0` +5. Send a message — should work, and session context (e.g. "what is my name?") should be preserved + +### 9. Clean shutdown + +1. Start channels, send a message to create sessions +2. Press Ctrl+C (or `qwen channel stop` from another terminal) +3. `~/.qwen/channels/sessions.json` should be deleted +4. `~/.qwen/channels/service.pid` should be deleted + +### 10. Service management + +1. Start service: `qwen channel start` +2. Check status from another terminal: `qwen channel status` — should show running, uptime, channels +3. Try starting again: `qwen channel start` — should fail with "already running" error +4. Stop from another terminal: `qwen channel stop` — should stop gracefully +5. Confirm stopped: `qwen channel status` — should show "No channel service is running." + +### 11. Referenced messages (quoted replies) + +1. Send a message and get a bot response +2. Reply to (quote) the bot's response with a follow-up question (e.g. "summarize that") +3. Agent should see the quoted text as context and respond accordingly +4. Test on both Telegram and WeChat + +## Useful debug commands + +```bash +# Check recent updates the bot received +curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?limit=5" | python3 -m json.tool + +# Get bot info +curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" | python3 -m json.tool +``` From 2ca45b72f5979c01e87610c015fe6a76fde53385 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 30 Mar 2026 12:55:50 +0000 Subject: [PATCH 48/51] docs(channels): remove personal info from design docs Replace personal paths, user IDs, and names with generic placeholders. Co-authored-by: Qwen-Coder --- docs/design/channels/channels-design.md | 4 ++-- .../channels/channels-implementation.md | 2 +- .../design/channels/channels-testing-guide.md | 20 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/design/channels/channels-design.md b/docs/design/channels/channels-design.md index e4ff1c2ec..7acbdf5ed 100644 --- a/docs/design/channels/channels-design.md +++ b/docs/design/channels/channels-design.md @@ -35,8 +35,8 @@ A **channel** connects an external messaging platform to a Qwen Code agent. Conf ▼ ┌─────────────────────────────────────┐ │ qwen-code --acp │ - │ Session A (user andy, id: "abc") │ - │ Session B (user bob, id: "def") │ + │ Session A (user alice, id: "abc") │ + │ Session B (user bob, id: "def") │ └─────────────────────────────────────┘ ``` diff --git a/docs/design/channels/channels-implementation.md b/docs/design/channels/channels-implementation.md index 186be6719..35e936b4f 100644 --- a/docs/design/channels/channels-implementation.md +++ b/docs/design/channels/channels-implementation.md @@ -27,7 +27,7 @@ The adapter supports plain text messaging, slash commands, a working indicator ( ``` ```bash -source /home/andy/projects/telegram/.env +source /path/to/telegram/.env npm run bundle && node dist/cli.js channel start my-telegram ``` diff --git a/docs/design/channels/channels-testing-guide.md b/docs/design/channels/channels-testing-guide.md index f488ae485..cbb4bcdcd 100644 --- a/docs/design/channels/channels-testing-guide.md +++ b/docs/design/channels/channels-testing-guide.md @@ -6,8 +6,8 @@ How to test channel integrations end-to-end. - Telegram bot: `@qwencod_test_1_bot` (远弟) - Bot token env var: `TELEGRAM_BOT_TOKEN` -- Bot token file: `/home/andy/projects/telegram/.env` -- Andy's Telegram user ID: `8513463076` +- Bot token file: `/path/to/telegram/.env` +- Telegram user ID: `` - WeChat credentials: `~/.qwen/channels/weixin/account.json` ## Before testing @@ -31,19 +31,19 @@ rm -f ~/.qwen/channels/service.pid ~/.qwen/channels/sessions.json ```bash # Source the token -export TELEGRAM_BOT_TOKEN=$(grep TELEGRAM_BOT_TOKEN /home/andy/projects/telegram/.env | cut -d= -f2) +export TELEGRAM_BOT_TOKEN=$(grep TELEGRAM_BOT_TOKEN /path/to/telegram/.env | cut -d= -f2) -# Send a message to Andy +# Send a message (replace YOUR_CHAT_ID with your Telegram user ID) curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -H "Content-Type: application/json" \ - -d '{"chat_id": "8513463076", "text": "Hello from the bot!"}' + -d '{"chat_id": "YOUR_CHAT_ID", "text": "Hello from the bot!"}' ``` ## Starting channels ```bash -export TELEGRAM_BOT_TOKEN=$(grep TELEGRAM_BOT_TOKEN /home/andy/projects/telegram/.env | cut -d= -f2) -cd /home/andy/projects/qwen-code +export TELEGRAM_BOT_TOKEN=$(grep TELEGRAM_BOT_TOKEN /path/to/telegram/.env | cut -d= -f2) +cd /path/to/qwen-code npm run bundle # Single channel @@ -84,9 +84,9 @@ Start the service, then send on Telegram or WeChat: ### 3. Multi-turn conversation -1. Send "my name is Andy" +1. Send "my name is Alice" 2. Send "what is my name?" -3. Agent should remember "Andy" from same session +3. Agent should remember "Alice" from same session ### 4. Session clear @@ -97,7 +97,7 @@ Start the service, then send on Telegram or WeChat: ### 5. Tool calls (internal) -1. Send "list the files in /home/andy/projects/qwen-code" +1. Send "list the files in /path/to/project" 2. Agent should use shell/ls internally and return file listing 3. Verify response contains actual file names From 7bbd5e64712c40d80cf760f71baf6fe89d1d5085 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 31 Mar 2026 00:57:59 +0000 Subject: [PATCH 49/51] =?UTF-8?q?fix(channels):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20security,=20bugs,=20and=20reliability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: sanitize remote filenames with basename() and isolate uploads in UUID subdirs to prevent path traversal and collision (#2-4, #27) - fix: use crypto.randomInt() for pairing codes instead of Math.random() (#5) - fix: pass config.sessionScope instead of hardcoded 'user' (#6); add per-channel scope overrides via setChannelScope() for startAll (#7) - fix: removeSession now returns removed session IDs and persists when chatId is provided (#8) - fix: /clear only removes the cleared session from instructedSessions, not all sessions (#9) - fix: DingTalk @mention stripping now removes only the first mention instead of all mentions (#10) - fix: remove dead TELEGRAF_COMMANDS Set and its guard (#13) - fix: WeChat cursor saved after message processing, not before (#14) - fix: crash recovery uses time-window counting instead of resettable counter to prevent infinite restart loops (#17) - fix: call channel.disconnect() before exit on crash exhaustion (#18) --- packages/channels/base/src/ChannelBase.ts | 8 +-- packages/channels/base/src/PairingStore.ts | 3 +- .../channels/base/src/SessionRouter.test.ts | 51 +++++++++++++++--- packages/channels/base/src/SessionRouter.ts | 48 ++++++++++------- .../channels/dingtalk/src/DingtalkAdapter.ts | 16 +++--- .../channels/telegram/src/TelegramAdapter.ts | 22 +++----- packages/channels/weixin/src/WeixinAdapter.ts | 14 +++-- packages/channels/weixin/src/monitor.ts | 11 ++-- packages/cli/src/commands/channel/start.ts | 53 ++++++++++++++----- 9 files changed, 152 insertions(+), 74 deletions(-) diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 0c533125f..cc118e15e 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -141,13 +141,15 @@ export abstract class ChannelBase { /** Register shared slash commands. Called from constructor. */ private registerSharedCommands(): void { const clearHandler: CommandHandler = async (envelope) => { - const removed = this.router.removeSession( + const removedIds = this.router.removeSession( this.name, envelope.senderId, envelope.chatId, ); - if (removed) { - this.instructedSessions.clear(); + if (removedIds.length > 0) { + for (const id of removedIds) { + this.instructedSessions.delete(id); + } await this.sendMessage( envelope.chatId, 'Session cleared. Your next message will start a fresh conversation.', diff --git a/packages/channels/base/src/PairingStore.ts b/packages/channels/base/src/PairingStore.ts index c49eeb4c9..ebe23f1d5 100644 --- a/packages/channels/base/src/PairingStore.ts +++ b/packages/channels/base/src/PairingStore.ts @@ -1,3 +1,4 @@ +import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -134,7 +135,7 @@ export class PairingStore { function generateCode(): string { let code = ''; for (let i = 0; i < CODE_LENGTH; i++) { - code += SAFE_ALPHABET[Math.floor(Math.random() * SAFE_ALPHABET.length)]; + code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)]; } return code; } diff --git a/packages/channels/base/src/SessionRouter.test.ts b/packages/channels/base/src/SessionRouter.test.ts index c8d1b5df1..d33f67fd7 100644 --- a/packages/channels/base/src/SessionRouter.test.ts +++ b/packages/channels/base/src/SessionRouter.test.ts @@ -67,6 +67,43 @@ describe('SessionRouter', () => { const s2 = await router.resolve('ch2', 'alice', 'chat1'); expect(s1).not.toBe(s2); }); + + it('per-channel scope overrides default scope', async () => { + const router = new SessionRouter(bridge, '/tmp', 'user'); + router.setChannelScope('telegram', 'single'); + + // 'telegram' uses single scope: same session for different users + const t1 = await router.resolve('telegram', 'alice', 'chat1'); + const t2 = await router.resolve('telegram', 'bob', 'chat2'); + expect(t1).toBe(t2); + + // other channel still uses default 'user' scope + const d1 = await router.resolve('dingtalk', 'alice', 'chat1'); + const d2 = await router.resolve('dingtalk', 'bob', 'chat1'); + expect(d1).not.toBe(d2); + }); + + it('mixed per-channel scopes work independently', async () => { + const router = new SessionRouter(bridge, '/tmp'); + router.setChannelScope('ch-thread', 'thread'); + router.setChannelScope('ch-single', 'single'); + router.setChannelScope('ch-user', 'user'); + + // thread scope: same thread = same session + const t1 = await router.resolve('ch-thread', 'alice', 'c1', 'thread1'); + const t2 = await router.resolve('ch-thread', 'bob', 'c1', 'thread1'); + expect(t1).toBe(t2); + + // single scope: one session for all + const s1 = await router.resolve('ch-single', 'alice', 'c1'); + const s2 = await router.resolve('ch-single', 'bob', 'c2'); + expect(s1).toBe(s2); + + // user scope: per-sender-per-chat + const u1 = await router.resolve('ch-user', 'alice', 'c1'); + const u2 = await router.resolve('ch-user', 'alice', 'c2'); + expect(u1).not.toBe(u2); + }); }); describe('resolve', () => { @@ -123,23 +160,25 @@ describe('SessionRouter', () => { }); describe('removeSession', () => { - it('removes session by key and returns true', async () => { + it('removes session by key and returns session IDs', async () => { const router = new SessionRouter(bridge, '/tmp'); - await router.resolve('ch', 'alice', 'chat1'); - expect(router.removeSession('ch', 'alice', 'chat1')).toBe(true); + const sid = await router.resolve('ch', 'alice', 'chat1'); + const removed = router.removeSession('ch', 'alice', 'chat1'); + expect(removed).toEqual([sid]); expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); }); - it('returns false when nothing to remove', () => { + it('returns empty array when nothing to remove', () => { const router = new SessionRouter(bridge, '/tmp'); - expect(router.removeSession('ch', 'alice', 'chat1')).toBe(false); + expect(router.removeSession('ch', 'alice', 'chat1')).toEqual([]); }); it('removes all sender sessions when chatId omitted', async () => { const router = new SessionRouter(bridge, '/tmp'); await router.resolve('ch', 'alice', 'chat1'); await router.resolve('ch', 'alice', 'chat2'); - expect(router.removeSession('ch', 'alice')).toBe(true); + const removed = router.removeSession('ch', 'alice'); + expect(removed).toHaveLength(2); expect(router.hasSession('ch', 'alice')).toBe(false); }); diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts index 50a52319f..bf08301bc 100644 --- a/packages/channels/base/src/SessionRouter.ts +++ b/packages/channels/base/src/SessionRouter.ts @@ -15,7 +15,8 @@ export class SessionRouter { private bridge: AcpBridge; private defaultCwd: string; - private scope: SessionScope; + private defaultScope: SessionScope; + private channelScopes: Map = new Map(); private persistPath: string | undefined; constructor( @@ -26,7 +27,7 @@ export class SessionRouter { ) { this.bridge = bridge; this.defaultCwd = defaultCwd; - this.scope = scope; + this.defaultScope = scope; this.persistPath = persistPath; } @@ -35,13 +36,19 @@ export class SessionRouter { this.bridge = bridge; } + /** Set scope override for a specific channel. */ + setChannelScope(channelName: string, scope: SessionScope): void { + this.channelScopes.set(channelName, scope); + } + private routingKey( channelName: string, senderId: string, chatId: string, threadId?: string, ): string { - switch (this.scope) { + const scope = this.channelScopes.get(channelName) || this.defaultScope; + switch (scope) { case 'thread': return `${channelName}:${threadId || chatId}`; case 'single': @@ -90,35 +97,40 @@ export class SessionRouter { return false; } + /** + * Remove session(s) for the given sender. Returns the removed session IDs. + */ removeSession( channelName: string, senderId: string, chatId?: string, - ): boolean { + ): string[] { + const removedIds: string[] = []; if (chatId) { const key = this.routingKey(channelName, senderId, chatId); - return this.deleteByKey(key); - } - // No chatId: remove all sessions for this sender on this channel - let removed = false; - const prefix = `${channelName}:${senderId}`; - for (const k of [...this.toSession.keys()]) { - if (k.startsWith(prefix)) { - this.deleteByKey(k); - removed = true; + const sessionId = this.deleteByKey(key); + if (sessionId) removedIds.push(sessionId); + } else { + // No chatId: remove all sessions for this sender on this channel + const prefix = `${channelName}:${senderId}`; + for (const k of [...this.toSession.keys()]) { + if (k.startsWith(prefix)) { + const sessionId = this.deleteByKey(k); + if (sessionId) removedIds.push(sessionId); + } } } - if (removed) this.persist(); - return removed; + if (removedIds.length > 0) this.persist(); + return removedIds; } - private deleteByKey(key: string): boolean { + private deleteByKey(key: string): string | null { const sessionId = this.toSession.get(key); - if (!sessionId) return false; + if (!sessionId) return null; this.toSession.delete(key); this.toTarget.delete(sessionId); this.toCwd.delete(sessionId); - return true; + return sessionId; } /** Get all session entries for crash recovery. */ diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index b3d2bbca1..d927c4583 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -1,5 +1,6 @@ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs'; import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs'; @@ -455,9 +456,10 @@ export class DingtalkChannel extends ChannelBase { ]; } else { // Save non-image files to temp dir so the agent can read them - const dir = join(tmpdir(), 'channel-files'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const safeName = fileName || `dingtalk_${mediaType}_${Date.now()}`; + const dir = join(tmpdir(), 'channel-files', randomUUID()); + mkdirSync(dir, { recursive: true }); + const safeName = + basename(fileName || '') || `dingtalk_${mediaType}_${Date.now()}`; const filePath = join(dir, safeName); writeFileSync(filePath, media.buffer); @@ -520,9 +522,9 @@ export class DingtalkChannel extends ChannelBase { const content = this.extractContent(data); let cleanText = content.text; - // Strip @bot mention from text + // Strip first @mention (the bot) from text, keep other @mentions intact if (isMentioned) { - cleanText = cleanText.replace(/@\S+/g, '').trim(); + cleanText = cleanText.replace(/@\S+/, '').trim(); } // Extract quoted message context diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index a3485c3b1..141b8368a 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -1,5 +1,6 @@ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; import { Telegraf } from 'telegraf'; import { @@ -14,9 +15,6 @@ import type { AcpBridge, } from '@qwen-code/channel-base'; -// Commands handled by Telegraf directly (before handleInbound) -const TELEGRAF_COMMANDS = new Set(); - export class TelegramChannel extends ChannelBase { private bot: Telegraf; private botId: number = 0; @@ -42,14 +40,6 @@ export class TelegramChannel extends ChannelBase { const msg = ctx.message; const text = msg.text; - // Skip Telegraf-handled commands - if (text.startsWith('/')) { - const command = text.slice(1).split(/[\s@]/)[0]?.toLowerCase(); - if (command && TELEGRAF_COMMANDS.has(command)) { - return; - } - } - const envelope = this.buildEnvelope(msg, text, msg.entities); // Don't await — Telegraf has a 90s handler timeout that would kill long prompts @@ -118,9 +108,9 @@ export class TelegramChannel extends ChannelBase { const buf = Buffer.from(await resp.arrayBuffer()); // Save to temp dir so the agent can read it via read-file tool - const dir = join(tmpdir(), 'channel-files'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const filePath = join(dir, fileName); + const dir = join(tmpdir(), 'channel-files', randomUUID()); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, basename(fileName) || `file_${Date.now()}`); writeFileSync(filePath, buf); envelope.text = msg.caption || ''; diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts index 6548953b7..7a5b36b97 100644 --- a/packages/channels/weixin/src/WeixinAdapter.ts +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -3,8 +3,9 @@ * Extends ChannelBase with WeChat iLink Bot API integration. */ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; import { ChannelBase } from '@qwen-code/channel-base'; import type { @@ -129,9 +130,12 @@ export class WeixinChannel extends ChannelBase { file.encryptQueryParam, file.aesKey, ); - const dir = join(tmpdir(), 'channel-files'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const filePath = join(dir, file.fileName); + const dir = join(tmpdir(), 'channel-files', randomUUID()); + mkdirSync(dir, { recursive: true }); + const filePath = join( + dir, + basename(file.fileName) || `file_${Date.now()}`, + ); writeFileSync(filePath, fileData); envelope.attachments = [ { diff --git a/packages/channels/weixin/src/monitor.ts b/packages/channels/weixin/src/monitor.ts index 48c3097c9..73ac6a32e 100644 --- a/packages/channels/weixin/src/monitor.ts +++ b/packages/channels/weixin/src/monitor.ts @@ -94,11 +94,6 @@ export async function startPollLoop(params: { consecutiveErrors = 0; - if (resp.get_updates_buf) { - cursor = resp.get_updates_buf; - saveCursor(cursor); - } - // Respect server-suggested poll timeout if (resp.longpolling_timeout_ms && resp.longpolling_timeout_ms > 0) { pollTimeoutMs = resp.longpolling_timeout_ms + 5000; // add buffer for network @@ -109,6 +104,12 @@ export async function startPollLoop(params: { await processMessage(msg, onMessage); } } + + // Persist cursor after messages are processed to avoid losing messages on crash + if (resp.get_updates_buf) { + cursor = resp.get_updates_buf; + saveCursor(cursor); + } } catch (err: unknown) { if (abortSignal.aborted) break; diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index f0b34eef4..2b7022622 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -19,6 +19,7 @@ import { import { getExtensionManager } from '../extensions/utils.js'; const MAX_CRASH_RESTARTS = 3; +const CRASH_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for counting crashes const RESTART_DELAY_MS = 3000; function sessionsPath(): string { @@ -165,13 +166,18 @@ async function startSingle(name: string): Promise { const cliEntryPath = findCliEntryPath(); let shuttingDown = false; - let crashCount = 0; + const crashTimestamps: number[] = []; const bridgeOpts = { cliEntryPath, cwd: config.cwd, model: config.model }; let bridge = new AcpBridge(bridgeOpts); await bridge.start(); - const router = new SessionRouter(bridge, config.cwd, 'user', sessionsPath()); + const router = new SessionRouter( + bridge, + config.cwd, + config.sessionScope, + sessionsPath(), + ); const channels: Map = new Map(); const channel = createChannel(name, config, bridge, { router }); @@ -185,18 +191,25 @@ async function startSingle(name: string): Promise { bridge.on('disconnected', async () => { if (shuttingDown) return; - crashCount++; - if (crashCount > MAX_CRASH_RESTARTS) { + const now = Date.now(); + crashTimestamps.push(now); + // Only count crashes within the recent window + const recentCrashes = crashTimestamps.filter( + (ts) => now - ts < CRASH_WINDOW_MS, + ); + + if (recentCrashes.length > MAX_CRASH_RESTARTS) { writeStderrLine( - `[Channel] Bridge crashed ${crashCount} times. Giving up.`, + `[Channel] Bridge crashed ${recentCrashes.length} times in ${CRASH_WINDOW_MS / 1000}s. Giving up.`, ); + channel.disconnect(); router.clearAll(); removeServiceInfo(); process.exit(1); } writeStderrLine( - `[Channel] Bridge crashed (${crashCount}/${MAX_CRASH_RESTARTS}). Restarting in ${RESTART_DELAY_MS / 1000}s...`, + `[Channel] Bridge crashed (${recentCrashes.length}/${MAX_CRASH_RESTARTS} in window). Restarting in ${RESTART_DELAY_MS / 1000}s...`, ); await new Promise((r) => setTimeout(r, RESTART_DELAY_MS)); @@ -211,7 +224,6 @@ async function startSingle(name: string): Promise { writeStdoutLine( `[Channel] Bridge restarted. Sessions restored: ${result.restored}, failed: ${result.failed}`, ); - crashCount = 0; } catch (err) { writeStderrLine( `[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`, @@ -270,7 +282,7 @@ async function startAll(): Promise { const cliEntryPath = findCliEntryPath(); const defaultCwd = process.cwd(); let shuttingDown = false; - let crashCount = 0; + const crashTimestamps: number[] = []; // All channels share one bridge process. Use the first channel's model. const models = [ @@ -291,6 +303,10 @@ async function startAll(): Promise { await bridge.start(); const router = new SessionRouter(bridge, defaultCwd, 'user', sessionsPath()); + // Register per-channel scope overrides so each channel uses its own sessionScope + for (const { name, config } of parsed) { + router.setChannelScope(name, config.sessionScope); + } const channels: Map = new Map(); writeStdoutLine( @@ -330,18 +346,30 @@ async function startAll(): Promise { bridge.on('disconnected', async () => { if (shuttingDown) return; - crashCount++; - if (crashCount > MAX_CRASH_RESTARTS) { + const now = Date.now(); + crashTimestamps.push(now); + const recentCrashes = crashTimestamps.filter( + (ts) => now - ts < CRASH_WINDOW_MS, + ); + + if (recentCrashes.length > MAX_CRASH_RESTARTS) { writeStderrLine( - `[Channel] Bridge crashed ${crashCount} times. Giving up.`, + `[Channel] Bridge crashed ${recentCrashes.length} times in ${CRASH_WINDOW_MS / 1000}s. Giving up.`, ); + for (const channel of channels.values()) { + try { + channel.disconnect(); + } catch { + // best-effort + } + } router.clearAll(); removeServiceInfo(); process.exit(1); } writeStderrLine( - `[Channel] Bridge crashed (${crashCount}/${MAX_CRASH_RESTARTS}). Restarting in ${RESTART_DELAY_MS / 1000}s...`, + `[Channel] Bridge crashed (${recentCrashes.length}/${MAX_CRASH_RESTARTS} in window). Restarting in ${RESTART_DELAY_MS / 1000}s...`, ); await new Promise((r) => setTimeout(r, RESTART_DELAY_MS)); @@ -358,7 +386,6 @@ async function startAll(): Promise { writeStdoutLine( `[Channel] Bridge restarted. Sessions restored: ${result.restored}, failed: ${result.failed}`, ); - crashCount = 0; } catch (err) { writeStderrLine( `[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`, From f61517c40c5dd57ec1bad8cd055042766f43f319 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 1 Apr 2026 12:22:33 +0800 Subject: [PATCH 50/51] chore(channels): add plugin-example to build pipeline and prepublish script - Add plugin-example to build order in scripts/build.js - Add prepublishOnly script to auto-build before npm publish This ensures the plugin-example package is built during the main build process and automatically compiled before publishing to npm. Co-authored-by: Qwen-Coder --- packages/channels/plugin-example/package.json | 3 ++- scripts/build.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/channels/plugin-example/package.json b/packages/channels/plugin-example/package.json index 12816047d..c973892e5 100644 --- a/packages/channels/plugin-example/package.json +++ b/packages/channels/plugin-example/package.json @@ -19,7 +19,8 @@ "qwen-channel-plugin-example-server": "dist/start-server.js" }, "scripts": { - "build": "tsc --build" + "build": "tsc --build", + "prepublishOnly": "npm run build" }, "dependencies": { "@qwen-code/channel-base": "file:../base", diff --git a/scripts/build.js b/scripts/build.js index 922f14b88..9864a80e4 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -51,6 +51,7 @@ const buildOrder = [ 'packages/channels/telegram', 'packages/channels/weixin', 'packages/channels/dingtalk', + 'packages/channels/plugin-example', 'packages/cli', 'packages/webui', 'packages/sdk-typescript', From 46bd05eaf1143cb10ed527be142d510c61ea1af8 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 1 Apr 2026 04:28:02 +0000 Subject: [PATCH 51/51] fix(channels/telegram): migrate from telegraf to grammy Replace Telegraf with Grammy as the Telegram Bot framework. - Replace @telegraf/types with @grammyjs/types in package-lock.json - Swap telegraf dependency for grammy ^1.41.1 in package.json - Update TelegramAdapter.ts: Bot instead of Telegraf, .api.* instead of .telegram.* calls, .start() instead of .launch(), adjusted event subscription syntax (message:text, message:photo, message:document) Grammy is a more modern and actively maintained Telegram bot framework for Node.js, improving reliability and reduce legacy dependencies. Co-authored-by: Qwen-Coder --- package-lock.json | 109 ++++-------------- packages/channels/telegram/package.json | 2 +- .../channels/telegram/src/TelegramAdapter.ts | 42 ++++--- 3 files changed, 47 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45a70b18b..30de66476 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1545,6 +1545,12 @@ "resolved": "packages/test-utils", "link": true }, + "node_modules/@grammyjs/types": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", + "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", + "license": "MIT" + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -3986,12 +3992,6 @@ "node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" } }, - "node_modules/@telegraf/types": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", - "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==", - "license": "MIT" - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -6781,22 +6781,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "license": "MIT", - "dependencies": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "node_modules/buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "license": "MIT" - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -6812,12 +6796,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", - "license": "MIT" - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -10418,6 +10396,21 @@ "node": ">=10" } }, + "node_modules/grammy": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", + "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.25.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -13049,15 +13042,6 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -13993,15 +13977,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-timeout": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", - "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -15728,15 +15703,6 @@ ], "license": "MIT" }, - "node_modules/safe-compare": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz", - "integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==", - "license": "MIT", - "dependencies": { - "buffer-alloc": "^1.2.0" - } - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -15778,15 +15744,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sandwich-stream": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz", - "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -17070,28 +17027,6 @@ "node": ">=6" } }, - "node_modules/telegraf": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz", - "integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==", - "license": "MIT", - "dependencies": { - "@telegraf/types": "^7.1.0", - "abort-controller": "^3.0.0", - "debug": "^4.3.4", - "mri": "^1.2.0", - "node-fetch": "^2.7.0", - "p-timeout": "^4.1.0", - "safe-compare": "^1.1.4", - "sandwich-stream": "^2.0.2" - }, - "bin": { - "telegraf": "lib/cli.mjs" - }, - "engines": { - "node": "^12.20.0 || >=14.13.1" - } - }, "node_modules/telegram-markdown-formatter": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/telegram-markdown-formatter/-/telegram-markdown-formatter-0.1.2.tgz", @@ -19009,7 +18944,7 @@ "version": "0.13.0", "dependencies": { "@qwen-code/channel-base": "file:../base", - "telegraf": "^4.16.0", + "grammy": "^1.41.1", "telegram-markdown-formatter": "^0.1.2" }, "devDependencies": { diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json index 07ef9580d..1d18623fa 100644 --- a/packages/channels/telegram/package.json +++ b/packages/channels/telegram/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@qwen-code/channel-base": "file:../base", - "telegraf": "^4.16.0", + "grammy": "^1.41.1", "telegram-markdown-formatter": "^0.1.2" }, "devDependencies": { diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 141b8368a..2de28b08c 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; -import { Telegraf } from 'telegraf'; +import { Bot } from 'grammy'; import { telegramFormat, splitHtmlForTelegram, @@ -16,7 +16,7 @@ import type { } from '@qwen-code/channel-base'; export class TelegramChannel extends ChannelBase { - private bot: Telegraf; + private bot: Bot; private botId: number = 0; private botUsername: string = ''; @@ -27,22 +27,26 @@ export class TelegramChannel extends ChannelBase { options?: ChannelBaseOptions, ) { super(name, config, bridge, options); - this.bot = new Telegraf(config.token); + this.bot = new Bot(config.token); + } + + private getFileUrl(filePath: string): string { + return `https://api.telegram.org/file/bot${this.bot.token}/${filePath}`; } async connect(): Promise { - const botInfo = await this.bot.telegram.getMe(); + const botInfo = await this.bot.api.getMe(); this.botId = botInfo.id; this.botUsername = botInfo.username ?? ''; // All messages (including slash commands) go through handleInbound // where ChannelBase dispatches shared commands (/help, /clear, /status, etc.) - this.bot.on('text', async (ctx) => { + this.bot.on('message:text', async (ctx) => { const msg = ctx.message; const text = msg.text; const envelope = this.buildEnvelope(msg, text, msg.entities); - // Don't await — Telegraf has a 90s handler timeout that would kill long prompts + // Don't await — long prompts would block the update loop this.handleInbound(envelope).catch((err) => { process.stderr.write( `[Telegram:${this.name}] Error handling message: ${err}\n`, @@ -54,7 +58,7 @@ export class TelegramChannel extends ChannelBase { }); // Photo messages - this.bot.on('photo', async (ctx) => { + this.bot.on('message:photo', async (ctx) => { const msg = ctx.message; const envelope = this.buildEnvelope( msg, @@ -67,8 +71,9 @@ export class TelegramChannel extends ChannelBase { if (!photo) return; try { - const fileUrl = await ctx.telegram.getFileLink(photo.file_id); - const resp = await fetch(fileUrl.href); + const file = await ctx.api.getFile(photo.file_id); + const fileUrl = this.getFileUrl(file.file_path!); + const resp = await fetch(fileUrl); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const buf = Buffer.from(await resp.arrayBuffer()); envelope.imageBase64 = buf.toString('base64'); @@ -90,7 +95,7 @@ export class TelegramChannel extends ChannelBase { }); // Document/file messages - this.bot.on('document', async (ctx) => { + this.bot.on('message:document', async (ctx) => { const msg = ctx.message; const doc = msg.document; const fileName = doc.file_name || `file_${Date.now()}`; @@ -102,8 +107,9 @@ export class TelegramChannel extends ChannelBase { ); try { - const fileUrl = await ctx.telegram.getFileLink(doc.file_id); - const resp = await fetch(fileUrl.href); + const file = await ctx.api.getFile(doc.file_id); + const fileUrl = this.getFileUrl(file.file_path!); + const resp = await fetch(fileUrl); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const buf = Buffer.from(await resp.arrayBuffer()); @@ -141,14 +147,14 @@ export class TelegramChannel extends ChannelBase { }); }); - this.bot.launch({ dropPendingUpdates: true }).catch((err) => { + this.bot.start({ drop_pending_updates: true }).catch((err) => { process.stderr.write( `[Telegram:${this.name}] Bot launch error: ${err}\n`, ); }); - process.once('SIGINT', () => this.bot.stop('SIGINT')); - process.once('SIGTERM', () => this.bot.stop('SIGTERM')); + process.once('SIGINT', () => this.bot.stop()); + process.once('SIGTERM', () => this.bot.stop()); } /** Per-chat typing interval — repeats every 4s since Telegram expires it after 5s. */ @@ -160,7 +166,7 @@ export class TelegramChannel extends ChannelBase { if (existing) clearInterval(existing); const sendTyping = () => - this.bot.telegram.sendChatAction(chatId, 'typing').catch(() => {}); + this.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); sendTyping(); this.typingIntervals.set(chatId, setInterval(sendTyping, 4000)); } @@ -178,12 +184,12 @@ export class TelegramChannel extends ChannelBase { const chunks = splitHtmlForTelegram(html); for (const chunk of chunks) { try { - await this.bot.telegram.sendMessage(chatId, chunk, { + await this.bot.api.sendMessage(chatId, chunk, { parse_mode: 'HTML', }); } catch { // Fallback to plain text if HTML parsing fails - await this.bot.telegram.sendMessage(chatId, text); + await this.bot.api.sendMessage(chatId, text); return; } }