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 <name>` reads from settings.json channels config,
spawns ACP agent, connects to Telegram via polling.
This commit is contained in:
tanzhenxin 2026-03-24 04:49:01 +00:00
parent aebe889b31
commit 3eedc43238
18 changed files with 736 additions and 4 deletions

View file

@ -62,6 +62,10 @@ esbuild
__dirname, __dirname,
'packages/cli/src/patches/is-in-ci.ts', 'packages/cli/src/patches/is-in-ci.ts',
), ),
'@qwen-code/web-templates': path.resolve(
__dirname,
'packages/web-templates/src/index.ts',
),
}, },
define: { define: {
'process.env.CLI_VERSION': JSON.stringify(pkg.version), 'process.env.CLI_VERSION': JSON.stringify(pkg.version),

121
package-lock.json generated
View file

@ -8,7 +8,9 @@
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.13.0", "version": "0.13.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*",
"packages/channels/base",
"packages/channels/telegram"
], ],
"dependencies": { "dependencies": {
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
@ -2990,6 +2992,14 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause" "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": { "node_modules/@qwen-code/qwen-code": {
"resolved": "packages/cli", "resolved": "packages/cli",
"link": true "link": true
@ -3961,6 +3971,12 @@
"node": ">= 10.16.0 < 11 || >= 11.8.0 < 12 || >= 12.0.0" "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": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -6739,6 +6755,22 @@
"ieee754": "^1.1.13" "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": { "node_modules/buffer-crc32": {
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@ -6754,6 +6786,12 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "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": { "node_modules/bundle-name": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -12954,6 +12992,15 @@
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
"license": "MIT" "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": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@ -13889,6 +13936,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/package-json": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz",
@ -15609,6 +15665,15 @@
], ],
"license": "MIT" "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": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@ -15650,6 +15715,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/sax": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
@ -16933,6 +17007,28 @@
"node": ">=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/terminal-link": { "node_modules/terminal-link": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz",
@ -18798,6 +18894,27 @@
"url": "https://github.com/sponsors/colinhacks" "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": { "packages/cli": {
"name": "@qwen-code/qwen-code", "name": "@qwen-code/qwen-code",
"version": "0.13.0", "version": "0.13.0",
@ -18806,6 +18923,8 @@
"@google/genai": "1.30.0", "@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1", "@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/qwen-code-core": "file:../core",
"@qwen-code/web-templates": "file:../web-templates", "@qwen-code/web-templates": "file:../web-templates",
"@types/update-notifier": "^6.0.8", "@types/update-notifier": "^6.0.8",

View file

@ -6,7 +6,9 @@
}, },
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"packages/*" "packages/*",
"packages/channels/base",
"packages/channels/telegram"
], ],
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -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"
}
}

View file

@ -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<void> {
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<Uint8Array>;
const stdin = Writable.toWeb(this.child.stdin!) as WritableStream;
const stream = ndJsonStream(stdin, stdout);
this.connection = new ClientSideConnection(
(): Client => ({
sessionUpdate: (params: SessionNotification): Promise<void> => {
const update = (params as unknown as Record<string, unknown>)
.update as Record<string, unknown> | 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<RequestPermissionResponse> => {
// 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<void> => {},
}),
stream,
);
await this.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
});
console.log('[AcpBridge] Connected and initialized');
}
async newSession(cwd: string): Promise<string> {
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<string> {
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<string, unknown>).update as
| Record<string, unknown>
| 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;
}
}

View file

@ -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<void>;
abstract sendMessage(chatId: string, text: string): Promise<void>;
abstract disconnect(): void;
async handleInbound(envelope: Envelope): Promise<void> {
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}`,
);
}
}
}

View file

@ -0,0 +1,23 @@
import type { SenderPolicy } from './types.js';
export class SenderGate {
private policy: SenderPolicy;
private allowedUsers: Set<string>;
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);
}
}
}

View file

@ -0,0 +1,37 @@
import type { SessionTarget } from './types.js';
import type { AcpBridge } from './AcpBridge.js';
export class SessionRouter {
private toSession: Map<string, string> = new Map(); // routing key → session ID
private toTarget: Map<string, SessionTarget> = 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<string> {
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);
}
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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"
}
}

View file

@ -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<void> {
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<void> {
// 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;
}

View file

@ -0,0 +1 @@
export { TelegramChannel } from './TelegramAdapter.js';

View file

@ -40,6 +40,8 @@
"@google/genai": "1.30.0", "@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1", "@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/qwen-code-core": "file:../core",
"@qwen-code/web-templates": "file:../web-templates", "@qwen-code/web-templates": "file:../web-templates",
"@types/update-notifier": "^6.0.8", "@types/update-notifier": "^6.0.8",

View file

@ -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: () => {},
};

View file

@ -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<object, { name: string }> = {
command: 'start <name>',
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<string, unknown> }
).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<string, unknown>;
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<void>((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);
},
};

View file

@ -51,6 +51,7 @@ import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js'; import { loadSandboxConfig } from './sandboxConfig.js';
import { appEvents } from '../utils/events.js'; import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js'; import { mcpCommand } from '../commands/mcp.js';
import { channelCommand } from '../commands/channel.js';
// UUID v4 regex pattern for validation // UUID v4 regex pattern for validation
const SESSION_ID_REGEX = const SESSION_ID_REGEX =
@ -590,7 +591,9 @@ export async function parseArguments(): Promise<CliArgs> {
// Register Auth subcommands // Register Auth subcommands
.command(authCommand) .command(authCommand)
// Register Hooks subcommands // Register Hooks subcommands
.command(hooksCommand); .command(hooksCommand)
// Register Channel subcommands
.command(channelCommand);
yargsInstance yargsInstance
.version(await getCliVersion()) // This will enable the --version flag based on package.json .version(await getCliVersion()) // This will enable the --version flag based on package.json
@ -611,7 +614,8 @@ export async function parseArguments(): Promise<CliArgs> {
result._.length > 0 && result._.length > 0 &&
(result._[0] === 'mcp' || (result._[0] === 'mcp' ||
result._[0] === 'extensions' || result._[0] === 'extensions' ||
result._[0] === 'hooks') result._[0] === 'hooks' ||
result._[0] === 'channel')
) { ) {
// MCP/Extensions/Hooks commands handle their own execution and process exit // MCP/Extensions/Hooks commands handle their own execution and process exit
process.exit(0); process.exit(0);

View file

@ -189,6 +189,18 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.SHALLOW_MERGE, mergeStrategy: MergeStrategy.SHALLOW_MERGE,
}, },
// Channels configuration (Telegram, Discord, etc.)
channels: {
type: 'object',
label: 'Channels',
category: 'Advanced',
requiresRestart: true,
default: {} as Record<string, Record<string, unknown>>,
description: 'Configuration for messaging channels.',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
// Model providers configuration grouped by authType // Model providers configuration grouped by authType
modelProviders: { modelProviders: {
type: 'object', type: 'object',