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();