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

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

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 { 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<CliArgs> {
// 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<CliArgs> {
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);

View file

@ -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<string, Record<string, unknown>>,
description: 'Configuration for messaging channels.',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
// Model providers configuration grouped by authType
modelProviders: {
type: 'object',