diff --git a/package-lock.json b/package-lock.json index 5acd6df32..b5ef4afbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "workspaces": [ "packages/*", "packages/channels/base", - "packages/channels/telegram" + "packages/channels/telegram", + "packages/channels/weixin" ], "dependencies": { "@testing-library/dom": "^10.4.1", @@ -3000,6 +3001,10 @@ "resolved": "packages/channels/telegram", "link": true }, + "node_modules/@qwen-code/channel-weixin": { + "resolved": "packages/channels/weixin", + "link": true + }, "node_modules/@qwen-code/qwen-code": { "resolved": "packages/cli", "link": true @@ -18928,6 +18933,16 @@ "typescript": "^5.0.0" } }, + "packages/channels/weixin": { + "name": "@qwen-code/channel-weixin", + "version": "0.1.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, "packages/cli": { "name": "@qwen-code/qwen-code", "version": "0.13.0", @@ -18938,6 +18953,7 @@ "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/channel-base": "file:../channels/base", "@qwen-code/channel-telegram": "file:../channels/telegram", + "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/package.json b/package.json index 5017b94bd..55d0ac11e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "workspaces": [ "packages/*", "packages/channels/base", - "packages/channels/telegram" + "packages/channels/telegram", + "packages/channels/weixin" ], "repository": { "type": "git", diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index dd849ab3b..aaa4a6508 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -1,6 +1,6 @@ export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; export type SessionScope = 'user' | 'thread' | 'single'; -export type ChannelType = 'telegram' | 'discord' | 'webhook'; +export type ChannelType = 'telegram' | 'weixin' | 'discord' | 'webhook'; export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; export interface GroupConfig { diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 5c76a1088..353e71363 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -95,6 +95,14 @@ export class TelegramChannel extends ChannelBase { // Check if this is a reply to one of the bot's messages const isReplyToBot = msg.reply_to_message?.from?.id === this.botId; + // Strip @botname from message text so the agent only sees the actual prompt + let cleanText = text; + if (isMentioned && this.botUsername) { + cleanText = text + .replace(new RegExp(`@${this.botUsername}`, 'gi'), '') + .trim(); + } + const envelope: Envelope = { channelName: this.name, senderId: String(msg.from.id), @@ -102,7 +110,7 @@ export class TelegramChannel extends ChannelBase { msg.from.first_name + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), chatId: String(msg.chat.id), - text, + text: cleanText, isGroup, isMentioned, isReplyToBot, diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json new file mode 100644 index 000000000..29adf6afe --- /dev/null +++ b/packages/channels/weixin/package.json @@ -0,0 +1,19 @@ +{ + "name": "@qwen-code/channel-weixin", + "version": "0.1.0", + "description": "WeChat (Weixin) channel adapter for Qwen Code", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./accounts": "./src/accounts.ts", + "./login": "./src/login.ts" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts new file mode 100644 index 000000000..9254a601c --- /dev/null +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -0,0 +1,141 @@ +/** + * WeChat channel adapter for Qwen Code. + * Extends ChannelBase with WeChat iLink Bot API integration. + */ + +import { ChannelBase } from '@qwen-code/channel-base'; +import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; +import type { AcpBridge } from '@qwen-code/channel-base'; +import { loadAccount, DEFAULT_BASE_URL } from './accounts.js'; +import { startPollLoop, getContextToken } from './monitor.js'; +import { sendText } from './send.js'; +import { getConfig, sendTyping } from './api.js'; +import { TypingStatus } from './types.js'; + +/** In-memory typing ticket cache: userId -> typingTicket */ +const typingTickets = new Map(); + +export class WeixinChannel extends ChannelBase { + private abortController: AbortController | null = null; + private baseUrl: string; + private token: string = ''; + + constructor(name: string, config: ChannelConfig, bridge: AcpBridge) { + super(name, config, bridge); + this.baseUrl = + (config as ChannelConfig & { baseUrl?: string }).baseUrl || + DEFAULT_BASE_URL; + } + + async connect(): Promise { + const account = loadAccount(); + if (!account) { + throw new Error( + 'WeChat account not configured. Run "qwen channel configure-weixin" first.', + ); + } + this.token = account.token; + if (account.baseUrl) { + this.baseUrl = account.baseUrl; + } + + this.abortController = new AbortController(); + + startPollLoop({ + baseUrl: this.baseUrl, + token: this.token, + onMessage: async (msg) => { + const envelope: Envelope = { + channelName: this.name, + senderId: msg.fromUserId, + senderName: msg.fromUserId, + chatId: msg.fromUserId, + text: msg.text, + isGroup: false, + isMentioned: false, + isReplyToBot: false, + }; + + this.handleInbound(envelope).catch((err) => { + const errMsg = + err instanceof Error ? err.message : JSON.stringify(err, null, 2); + process.stderr.write( + `[Weixin:${this.name}] Error handling message: ${errMsg}\n`, + ); + }); + }, + abortSignal: this.abortController.signal, + }).catch((err) => { + if (!this.abortController?.signal.aborted) { + process.stderr.write(`[Weixin:${this.name}] Poll loop error: ${err}\n`); + } + }); + + process.stderr.write( + `[Weixin:${this.name}] Connected to WeChat (${this.baseUrl})\n`, + ); + } + + override async handleInbound(envelope: Envelope): Promise { + // Check group gate before showing typing + const groupResult = this.groupGate.check(envelope); + if (!groupResult.allowed) { + return; + } + + // Show typing indicator while agent processes + await this.setTyping(envelope.chatId, true); + + try { + await super.handleInbound(envelope); + } finally { + await this.setTyping(envelope.chatId, false); + } + } + + async sendMessage(chatId: string, text: string): Promise { + const contextToken = getContextToken(chatId) || ''; + await sendText({ + to: chatId, + text, + baseUrl: this.baseUrl, + token: this.token, + contextToken, + }); + } + + disconnect(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + private async setTyping(userId: string, typing: boolean): Promise { + try { + let ticket = typingTickets.get(userId); + if (!ticket) { + const contextToken = getContextToken(userId); + const config = await getConfig( + this.baseUrl, + this.token, + userId, + contextToken, + ); + if (config.typing_ticket) { + ticket = config.typing_ticket; + typingTickets.set(userId, ticket); + } + } + if (!ticket) return; + + await sendTyping(this.baseUrl, this.token, { + ilink_user_id: userId, + typing_ticket: ticket, + status: typing ? TypingStatus.TYPING : TypingStatus.CANCEL, + }); + } catch { + // Typing is best-effort — don't fail the message flow + } + } +} diff --git a/packages/channels/weixin/src/accounts.ts b/packages/channels/weixin/src/accounts.ts new file mode 100644 index 000000000..c505b06ce --- /dev/null +++ b/packages/channels/weixin/src/accounts.ts @@ -0,0 +1,61 @@ +/** + * Credential storage for WeChat account. + * Stores account data in ~/.qwen/channels/weixin/ + */ + +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, + chmodSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'; + +export interface AccountData { + token: string; + baseUrl: string; + userId?: string; + savedAt: string; +} + +export function getStateDir(): string { + const dir = + process.env['WEIXIN_STATE_DIR'] || + join(homedir(), '.qwen', 'channels', 'weixin'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; +} + +function accountPath(): string { + return join(getStateDir(), 'account.json'); +} + +export function loadAccount(): AccountData | null { + const p = accountPath(); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf-8')) as AccountData; + } catch { + return null; + } +} + +export function saveAccount(data: AccountData): void { + const p = accountPath(); + writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); + chmodSync(p, 0o600); +} + +export function clearAccount(): void { + const p = accountPath(); + if (existsSync(p)) { + unlinkSync(p); + } +} diff --git a/packages/channels/weixin/src/api.ts b/packages/channels/weixin/src/api.ts new file mode 100644 index 000000000..f45ccfb30 --- /dev/null +++ b/packages/channels/weixin/src/api.ts @@ -0,0 +1,128 @@ +/** + * HTTP API wrapper for WeChat iLink Bot API. + */ + +import type { + GetUpdatesReq, + GetUpdatesResp, + SendMessageReq, + GetConfigResp, + SendTypingReq, + SendTypingResp, + BaseInfo, +} from './types.js'; + +const CHANNEL_VERSION = '0.1.0'; + +function baseInfo(): BaseInfo { + return { channel_version: CHANNEL_VERSION }; +} + +function randomUin(): string { + const buf = new Uint8Array(4); + crypto.getRandomValues(buf); + return btoa(String.fromCharCode(...buf)); +} + +function buildHeaders(token?: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-WECHAT-UIN': randomUin(), + }; + if (token) { + headers['AuthorizationType'] = 'ilink_bot_token'; + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +async function post( + baseUrl: string, + path: string, + body: unknown, + token?: string, + timeoutMs = 40000, + signal?: AbortSignal, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + if (signal) { + signal.addEventListener('abort', () => controller.abort(), { once: true }); + } + + try { + const resp = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: buildHeaders(token), + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + return (await resp.json()) as T; + } finally { + clearTimeout(timeout); + } +} + +export async function getUpdates( + baseUrl: string, + token: string, + getUpdatesBuf: string, + timeoutMs = 40000, + signal?: AbortSignal, +): Promise { + const body: GetUpdatesReq = { + get_updates_buf: getUpdatesBuf, + base_info: baseInfo(), + }; + try { + return await post( + baseUrl, + '/ilink/bot/getupdates', + body, + token, + timeoutMs, + signal, + ); + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') { + return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf }; + } + throw err; + } +} + +export async function sendMessage( + baseUrl: string, + token: string, + msg: SendMessageReq['msg'], +): Promise { + const body: SendMessageReq = { msg, base_info: baseInfo() }; + await post(baseUrl, '/ilink/bot/sendmessage', body, token); +} + +export async function getConfig( + baseUrl: string, + token: string, + userId: string, + contextToken?: string, +): Promise { + const body = { + ilink_user_id: userId, + context_token: contextToken, + base_info: baseInfo(), + }; + return post(baseUrl, '/ilink/bot/getconfig', body, token); +} + +export async function sendTyping( + baseUrl: string, + token: string, + req: Omit, +): Promise { + const body: SendTypingReq = { ...req, base_info: baseInfo() }; + return post(baseUrl, '/ilink/bot/sendtyping', body, token); +} diff --git a/packages/channels/weixin/src/index.ts b/packages/channels/weixin/src/index.ts new file mode 100644 index 000000000..9eec24cc6 --- /dev/null +++ b/packages/channels/weixin/src/index.ts @@ -0,0 +1 @@ +export { WeixinChannel } from './WeixinAdapter.js'; diff --git a/packages/channels/weixin/src/login.ts b/packages/channels/weixin/src/login.ts new file mode 100644 index 000000000..8771b2373 --- /dev/null +++ b/packages/channels/weixin/src/login.ts @@ -0,0 +1,112 @@ +/** + * QR code login flow for WeChat iLink Bot. + */ + +export interface LoginResult { + connected: boolean; + token?: string; + baseUrl?: string; + userId?: string; + message: string; +} + +/** Step 1: Get QR code from server and display in terminal */ +export async function startLogin(apiBaseUrl: string): Promise { + const resp = await fetch(`${apiBaseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`); + if (!resp.ok) { + throw new Error(`Failed to get QR code: HTTP ${resp.status}`); + } + const data = (await resp.json()) as { + qrcode?: string; + qrcode_img_content?: string; + }; + + if (!data.qrcode) { + throw new Error('No qrcode in response'); + } + + if (data.qrcode_img_content) { + process.stderr.write( + `QR code URL: ${data.qrcode_img_content}\nScan this URL with WeChat.\n`, + ); + } + + process.stderr.write('Scan the QR code with WeChat to connect.\n'); + return data.qrcode; +} + +/** Step 2: Poll for scan result */ +export async function waitForLogin(params: { + qrcodeId: string; + apiBaseUrl: string; + timeoutMs?: number; +}): Promise { + const { apiBaseUrl, timeoutMs = 480000 } = params; + let currentQrcodeId = params.qrcodeId; + const deadline = Date.now() + timeoutMs; + let retryCount = 0; + + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60000); + + const resp = await fetch( + `${apiBaseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(currentQrcodeId)}`, + { + headers: { 'iLink-App-ClientVersion': '1' }, + signal: controller.signal, + }, + ); + clearTimeout(timeout); + + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + + const data = (await resp.json()) as { + status?: string; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + ilink_user_id?: string; + }; + + switch (data.status) { + case 'confirmed': + return { + connected: true, + token: data.bot_token, + baseUrl: data.baseurl, + userId: data.ilink_user_id, + message: 'Connected to WeChat successfully!', + }; + case 'scaned': + process.stderr.write( + 'QR code scanned, waiting for confirmation...\n', + ); + break; + case 'expired': + retryCount++; + if (retryCount >= 3) { + return { + connected: false, + message: 'QR code expired after maximum retries.', + }; + } + process.stderr.write('QR code expired, refreshing...\n'); + currentQrcodeId = await startLogin(apiBaseUrl); + break; + default: + break; + } + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') { + continue; + } + throw err; + } + + await new Promise((r) => setTimeout(r, 1000)); + } + + return { connected: false, message: 'Login timed out.' }; +} diff --git a/packages/channels/weixin/src/monitor.ts b/packages/channels/weixin/src/monitor.ts new file mode 100644 index 000000000..e4a61ab71 --- /dev/null +++ b/packages/channels/weixin/src/monitor.ts @@ -0,0 +1,152 @@ +/** + * Long-polling loop: getUpdates -> callback. + * Platform-agnostic: the onMessage callback handles delivery. + */ + +import { getUpdates } from './api.js'; +import { MessageType, MessageItemType } from './types.js'; +import type { WeixinMessage } from './types.js'; +import { getStateDir } from './accounts.js'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** In-memory context token cache: userId -> contextToken */ +const contextTokens = new Map(); + +export function getContextToken(userId: string): string | undefined { + return contextTokens.get(userId); +} + +function cursorPath(): string { + return join(getStateDir(), 'cursor.txt'); +} + +function loadCursor(): string { + const p = cursorPath(); + if (existsSync(p)) return readFileSync(p, 'utf-8').trim(); + return ''; +} + +function saveCursor(cursor: string): void { + writeFileSync(cursorPath(), cursor, 'utf-8'); +} + +export interface ParsedMessage { + fromUserId: string; + messageId: string; + text: string; +} + +export type OnMessageCallback = (msg: ParsedMessage) => Promise; + +export async function startPollLoop(params: { + baseUrl: string; + token: string; + onMessage: OnMessageCallback; + abortSignal: AbortSignal; +}): Promise { + const { baseUrl, token, onMessage, abortSignal } = params; + + let cursor = loadCursor(); + let consecutiveErrors = 0; + let pollTimeoutMs = 40000; + + process.stderr.write('[weixin] Starting message poll loop...\n'); + + while (!abortSignal.aborted) { + try { + const resp = await getUpdates( + baseUrl, + token, + cursor, + pollTimeoutMs, + abortSignal, + ); + + if (resp.errcode === -14) { + process.stderr.write( + '[weixin] Session expired (errcode -14). Pausing 30s...\n', + ); + await new Promise((r) => setTimeout(r, 30000)); + continue; + } + + if (resp.ret !== 0 && resp.ret !== undefined) { + throw new Error( + `getUpdates error: ret=${resp.ret} errcode=${resp.errcode} ${resp.errmsg}`, + ); + } + + consecutiveErrors = 0; + + if (resp.get_updates_buf) { + cursor = resp.get_updates_buf; + saveCursor(cursor); + } + + // Respect server-suggested poll timeout + if (resp.longpolling_timeout_ms && resp.longpolling_timeout_ms > 0) { + pollTimeoutMs = resp.longpolling_timeout_ms + 5000; // add buffer for network + } + + if (resp.msgs && resp.msgs.length > 0) { + for (const msg of resp.msgs) { + await processMessage(msg, onMessage); + } + } + } catch (err: unknown) { + if (abortSignal.aborted) break; + + consecutiveErrors++; + process.stderr.write( + `[weixin] Poll error (${consecutiveErrors}): ${err instanceof Error ? err.message : err}\n`, + ); + + if (consecutiveErrors >= 3) { + process.stderr.write( + '[weixin] Too many consecutive errors, backing off 30s...\n', + ); + await new Promise((r) => setTimeout(r, 30000)); + consecutiveErrors = 0; + } else { + await new Promise((r) => setTimeout(r, 2000)); + } + } + } + + process.stderr.write('[weixin] Poll loop stopped.\n'); +} + +async function processMessage( + msg: WeixinMessage, + onMessage: OnMessageCallback, +): Promise { + if (msg.message_type !== MessageType.USER) return; + + const fromUserId = msg.from_user_id; + if (!fromUserId) return; + + // Cache context token (required for replies) + if (msg.context_token) { + contextTokens.set(fromUserId, msg.context_token); + } + + // Extract text content + let textContent = ''; + if (msg.item_list) { + for (const item of msg.item_list) { + if (item.type === MessageItemType.TEXT && item.text_item?.text) { + textContent += (textContent ? '\n' : '') + item.text_item.text; + } + // MVP: skip media items, text only + } + } + + if (!textContent) return; + + await onMessage({ + fromUserId, + messageId: String(msg.message_id || ''), + text: textContent, + }); +} diff --git a/packages/channels/weixin/src/send.ts b/packages/channels/weixin/src/send.ts new file mode 100644 index 000000000..54ca8fa52 --- /dev/null +++ b/packages/channels/weixin/src/send.ts @@ -0,0 +1,52 @@ +/** + * Send messages to WeChat users. + */ + +import { randomUUID } from 'node:crypto'; +import { sendMessage } from './api.js'; +import { MessageType, MessageState, MessageItemType } from './types.js'; + +/** Convert markdown to plain text (WeChat doesn't support markdown) */ +export function markdownToPlainText(text: string): string { + return text + .replace(/```[\s\S]*?\n([\s\S]*?)```/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/___(.+?)___/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '[$1]') + .replace(/^>\s+/gm, '') + .replace(/^[-*_]{3,}$/gm, '---') + .replace(/^[\s]*[-*+]\s+/gm, '- ') + .replace(/^[\s]*(\d+)\.\s+/gm, '$1. ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Send a text message */ +export async function sendText(params: { + to: string; + text: string; + baseUrl: string; + token: string; + contextToken: string; +}): Promise { + const { to, text, baseUrl, token, contextToken } = params; + const plainText = markdownToPlainText(text); + + await sendMessage(baseUrl, token, { + to_user_id: to, + from_user_id: '', + client_id: randomUUID(), + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: contextToken, + item_list: [{ type: MessageItemType.TEXT, text_item: { text: plainText } }], + }); +} diff --git a/packages/channels/weixin/src/types.ts b/packages/channels/weixin/src/types.ts new file mode 100644 index 000000000..7f2191b16 --- /dev/null +++ b/packages/channels/weixin/src/types.ts @@ -0,0 +1,128 @@ +/** + * WeChat iLink Bot API protocol types. + */ + +export const MessageType = { + NONE: 0, + USER: 1, + BOT: 2, +} as const; + +export const MessageItemType = { + NONE: 0, + TEXT: 1, + IMAGE: 2, + VOICE: 3, + FILE: 4, + VIDEO: 5, +} as const; + +export const MessageState = { + NEW: 0, + GENERATING: 1, + FINISH: 2, +} as const; + +export interface BaseInfo { + channel_version?: string; +} + +export interface CDNMedia { + encrypt_query_param?: string; + aes_key?: string; + encrypt_type?: number; +} + +export interface TextItem { + text?: string; +} + +export interface ImageItem { + media?: CDNMedia; + thumb_media?: CDNMedia; + aeskey?: string; + url?: string; + mid_size?: number; +} + +export interface VoiceItem { + media?: CDNMedia; + text?: string; +} + +export interface FileItem { + media?: CDNMedia; + file_name?: string; + md5?: string; + len?: string; +} + +export interface VideoItem { + media?: CDNMedia; + video_size?: number; +} + +export interface MessageItem { + type?: number; + text_item?: TextItem; + image_item?: ImageItem; + voice_item?: VoiceItem; + file_item?: FileItem; + video_item?: VideoItem; +} + +export interface WeixinMessage { + seq?: number; + message_id?: number; + from_user_id?: string; + to_user_id?: string; + client_id?: string; + create_time_ms?: number; + session_id?: string; + message_type?: number; + message_state?: number; + item_list?: MessageItem[]; + context_token?: string; +} + +export interface GetUpdatesReq { + get_updates_buf?: string; + base_info?: BaseInfo; +} + +export interface GetUpdatesResp { + ret?: number; + errcode?: number; + errmsg?: string; + msgs?: WeixinMessage[]; + get_updates_buf?: string; + longpolling_timeout_ms?: number; +} + +export interface SendMessageReq { + msg?: WeixinMessage; + base_info?: BaseInfo; +} + +export const TypingStatus = { + TYPING: 1, + CANCEL: 2, +} as const; + +export interface GetConfigResp { + ret?: number; + errmsg?: string; + typing_ticket?: string; +} + +export interface SendTypingReq { + ilink_user_id?: string; + typing_ticket?: string; + status?: number; + base_info?: BaseInfo; +} + +export interface SendTypingResp { + ret?: number; + errmsg?: string; +} diff --git a/packages/channels/weixin/tsconfig.json b/packages/channels/weixin/tsconfig.json new file mode 100644 index 000000000..8daf59408 --- /dev/null +++ b/packages/channels/weixin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../base" }] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f4023813..ffdc4092e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,6 +42,7 @@ "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/channel-base": "file:../channels/base", "@qwen-code/channel-telegram": "file:../channels/telegram", + "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 2d259a78e..978c13d06 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -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: () => {}, diff --git a/packages/cli/src/commands/channel/configure.ts b/packages/cli/src/commands/channel/configure.ts new file mode 100644 index 000000000..0da23152a --- /dev/null +++ b/packages/cli/src/commands/channel/configure.ts @@ -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 '); + } else { + writeStderrLine('\n' + result.message); + process.exit(1); + } + } catch (err) { + writeStderrLine( + `Login failed: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }, +}; diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts index 01443facb..ef26d19ac 100644 --- a/packages/cli/src/commands/channel/start.ts +++ b/packages/cli/src/commands/channel/start.ts @@ -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 = { ); 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 = { 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 = { 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.`);