feat(channels): add WeChat/Weixin channel support

- Add WeixinAdapter with accounts, api, login, monitor, send modules
- Add channel configure command for interactive setup
- Update TelegramAdapter for consistency

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-03-25 06:54:51 +00:00
parent 90236465d3
commit 24c9b0f333
18 changed files with 954 additions and 21 deletions

View file

@ -4,6 +4,7 @@ import {
pairingListCommand,
pairingApproveCommand,
} from './channel/pairing.js';
import { configureWeixinCommand } from './channel/configure.js';
const pairingCommand: CommandModule = {
command: 'pairing',
@ -24,6 +25,7 @@ export const channelCommand: CommandModule = {
yargs
.command(startCommand)
.command(pairingCommand)
.command(configureWeixinCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {},

View file

@ -0,0 +1,85 @@
import type { CommandModule } from 'yargs';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
import {
loadAccount,
saveAccount,
clearAccount,
DEFAULT_BASE_URL,
} from '@qwen-code/channel-weixin/accounts';
import { startLogin, waitForLogin } from '@qwen-code/channel-weixin/login';
export const configureWeixinCommand: CommandModule<
object,
{ action: string | undefined }
> = {
command: 'configure-weixin [action]',
describe: 'Configure WeChat channel (login via QR code)',
builder: (yargs) =>
yargs.positional('action', {
type: 'string',
describe: '"clear" to remove stored credentials, omit to login',
}),
handler: async (argv) => {
const { action } = argv;
if (action === 'clear') {
clearAccount();
writeStdoutLine('WeChat credentials cleared.');
return;
}
if (action === 'status') {
const account = loadAccount();
if (account) {
writeStdoutLine(`WeChat account configured (saved ${account.savedAt})`);
writeStdoutLine(` Base URL: ${account.baseUrl}`);
if (account.userId) {
writeStdoutLine(` User ID: ${account.userId}`);
}
} else {
writeStdoutLine('WeChat account not configured.');
}
return;
}
// Default action: login
const existing = loadAccount();
if (existing) {
writeStdoutLine(
`Existing WeChat credentials found (saved ${existing.savedAt}).`,
);
writeStdoutLine('Re-running login will overwrite them.\n');
}
const baseUrl = DEFAULT_BASE_URL;
writeStdoutLine('Starting WeChat QR code login...\n');
try {
const qrcodeId = await startLogin(baseUrl);
const result = await waitForLogin({ qrcodeId, apiBaseUrl: baseUrl });
if (result.connected && result.token) {
saveAccount({
token: result.token,
baseUrl: result.baseUrl || baseUrl,
userId: result.userId,
savedAt: new Date().toISOString(),
});
writeStdoutLine('\n' + result.message);
writeStdoutLine(
'Credentials saved. You can now start a weixin channel with:',
);
writeStdoutLine(' qwen channel start <name>');
} else {
writeStderrLine('\n' + result.message);
process.exit(1);
}
} catch (err) {
writeStderrLine(
`Login failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
},
};

View file

@ -4,6 +4,7 @@ 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 { WeixinChannel } from '@qwen-code/channel-weixin';
import * as path from 'node:path';
function resolveEnvVars(value: string): string {
@ -62,29 +63,33 @@ export const startCommand: CommandModule<object, { name: string }> = {
);
process.exit(1);
}
if (!rawConfig['token']) {
writeStderrLine(
`Error: Channel "${name}" is missing required field "token".`,
);
process.exit(1);
}
const channelType = rawConfig['type'] as string;
if (channelType !== 'telegram') {
const supportedTypes = ['telegram', 'weixin'];
if (!supportedTypes.includes(channelType)) {
writeStderrLine(
`Error: Channel type "${channelType}" is not yet supported. Only "telegram" is available.`,
`Error: Channel type "${channelType}" is not supported. Available: ${supportedTypes.join(', ')}`,
);
process.exit(1);
}
let token: string;
try {
token = resolveEnvVars(rawConfig['token'] as string);
} catch (err) {
writeStderrLine(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
// Token is required for telegram, not for weixin (uses account.json)
let token = '';
if (channelType !== 'weixin') {
if (!rawConfig['token']) {
writeStderrLine(
`Error: Channel "${name}" is missing required field "token".`,
);
process.exit(1);
}
try {
token = resolveEnvVars(rawConfig['token'] as string);
} catch (err) {
writeStderrLine(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
}
const config: ChannelConfig = {
@ -105,6 +110,12 @@ export const startCommand: CommandModule<object, { name: string }> = {
groups: (rawConfig['groups'] as ChannelConfig['groups']) || {},
};
// Pass through weixin-specific config
const extendedConfig = {
...config,
baseUrl: rawConfig['baseUrl'] as string | undefined,
};
const cliEntryPath = findCliEntryPath();
writeStdoutLine(`[Channel] CLI entry: ${cliEntryPath}`);
writeStdoutLine(`[Channel] Starting "${name}" (type=${config.type})...`);
@ -112,7 +123,12 @@ export const startCommand: CommandModule<object, { name: string }> = {
const bridge = new AcpBridge({ cliEntryPath, cwd: config.cwd });
await bridge.start();
const channel = new TelegramChannel(name, config, bridge);
let channel: TelegramChannel | WeixinChannel;
if (channelType === 'weixin') {
channel = new WeixinChannel(name, extendedConfig, bridge);
} else {
channel = new TelegramChannel(name, config, bridge);
}
await channel.connect();
writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`);