mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 13:40:46 +00:00
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:
parent
f3a03d0bdc
commit
8a6ed128ea
8 changed files with 134 additions and 39 deletions
28
packages/cli/src/commands/channel/channel-registry.ts
Normal file
28
packages/cli/src/commands/channel/channel-registry.ts
Normal 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()];
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue