feat(channels): add ChannelPlugin interface and registry-based factory

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.
This commit is contained in:
tanzhenxin 2026-03-26 12:34:31 +00:00
parent f3a03d0bdc
commit 8a6ed128ea
8 changed files with 134 additions and 39 deletions

View file

@ -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<string, ChannelPlugin>();
// 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()];
}

View file

@ -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<string, unknown>,
): ChannelConfig & { baseUrl?: string } {
): ChannelConfig & Record<string, unknown> {
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,
};
}

View file

@ -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<void> {
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();