From a627bb19f266406575e65e315925b6f5155ff656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 3 May 2026 17:38:08 +0800 Subject: [PATCH] feat: support WeChat as a first-class IM channel WeChat needs a QR-paired path instead of bot-token setup, so the adapter layer now includes the iLink protocol calls, desktop pairing UI, server-side bind/unbind APIs, and shared IM command behavior. Empty project history falls back to the user's default work directory so mobile /new works without pre-opening a desktop project. Constraint: Tencent iLink login returns a URL that the desktop UI must render as a QR image locally Constraint: IM adapters should keep /new, /projects, status, permission, and default workdir behavior consistent across WeChat, Feishu, and Telegram Rejected: Require users to paste absolute project paths for first WeChat sessions | mobile onboarding should work from the default user working directory Confidence: high Scope-risk: moderate Directive: Do not change WeChat polling back to overlapping intervals; getupdates is a long-poll endpoint and must remain serialized Tested: Real WeChat QR scan, inbound /status, outbound reply, and unbind E2E Tested: bun run check:adapters Tested: bun run quality:pr Not-tested: Re-scan live WeChat after the default-workdir fallback tweak; covered by adapter config tests and PR gate --- adapters/README.md | 9 +- adapters/common/__tests__/config.test.ts | 68 +++ adapters/common/__tests__/http-client.test.ts | 20 + .../common/attachment/attachment-types.ts | 2 +- adapters/common/config.ts | 69 ++- adapters/common/http-client.ts | 40 ++ adapters/common/pairing.ts | 6 +- adapters/feishu/index.ts | 13 +- adapters/package.json | 4 +- adapters/telegram/index.ts | 15 +- adapters/wechat/__tests__/protocol.test.ts | 34 ++ adapters/wechat/index.ts | 397 ++++++++++++++++++ adapters/wechat/protocol.ts | 371 ++++++++++++++++ desktop/sidecars/claude-sidecar.ts | 25 +- desktop/src-tauri/capabilities/default.json | 6 +- desktop/src-tauri/src/lib.rs | 5 +- desktop/src/api/adapters.ts | 15 + desktop/src/i18n/locales/en.ts | 18 +- desktop/src/i18n/locales/zh.ts | 18 +- desktop/src/pages/AdapterSettings.tsx | 148 ++++++- desktop/src/stores/adapterStore.ts | 30 +- desktop/src/types/adapter.ts | 9 + desktop/src/types/qrcode.d.ts | 18 + docs/.vitepress/config.mts | 1 + docs/im/index.md | 17 +- docs/im/wechat.md | 60 +++ src/server/__tests__/adapters.test.ts | 79 ++++ src/server/api/adapters.ts | 57 ++- src/server/services/adapterService.ts | 16 + 29 files changed, 1521 insertions(+), 49 deletions(-) create mode 100644 adapters/common/__tests__/config.test.ts create mode 100644 adapters/wechat/__tests__/protocol.test.ts create mode 100644 adapters/wechat/index.ts create mode 100644 adapters/wechat/protocol.ts create mode 100644 desktop/src/types/qrcode.d.ts create mode 100644 docs/im/wechat.md create mode 100644 src/server/__tests__/adapters.test.ts diff --git a/adapters/README.md b/adapters/README.md index fbc41f74..6a640161 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -5,6 +5,7 @@ 用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准: - `docs/im/index.md` +- `docs/im/wechat.md` - `docs/im/telegram.md` - `docs/im/feishu.md` @@ -24,7 +25,7 @@ Desktop Webapp Settings 注意两点: - IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入` -- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run telegram` 或 `bun run feishu` +- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run wechat`、`bun run telegram` 或 `bun run feishu` ## 快速启动 @@ -34,6 +35,8 @@ bun install bun run telegram # 或 bun run feishu +# 或 +bun run wechat ``` ## 开发 @@ -46,6 +49,7 @@ bun test bun test common/ bun test telegram/ bun test feishu/ +bun test wechat/ ``` ### 目录结构 @@ -59,6 +63,9 @@ adapters/ ├── feishu/ │ ├── media.ts # FeishuMediaService(@larksuiteoapi/node-sdk 封装) │ └── extract-payload.ts # 入站 im.message.receive_v1 事件解析 +├── wechat/ +│ ├── protocol.ts # 微信 iLink QR 登录 / getupdates / sendmessage 协议封装 +│ └── index.ts # 微信文本聊天 Adapter ├── package.json ├── tsconfig.json └── README.md diff --git a/adapters/common/__tests__/config.test.ts b/adapters/common/__tests__/config.test.ts new file mode 100644 index 00000000..6f7b4614 --- /dev/null +++ b/adapters/common/__tests__/config.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { getConfiguredWorkDir, loadConfig } from '../config.js' + +describe('adapter config defaults', () => { + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + const originalAdapterDefaultWorkDir = process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR + const originalPwd = process.env.PWD + + afterEach(() => { + restoreEnv('CLAUDE_CONFIG_DIR', originalConfigDir) + restoreEnv('CLAUDE_ADAPTER_DEFAULT_WORK_DIR', originalAdapterDefaultWorkDir) + restoreEnv('PWD', originalPwd) + }) + + it('uses the user shell working directory when no default project is configured', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-workdir-')) + try { + process.env.CLAUDE_CONFIG_DIR = configDir + delete process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR + process.env.PWD = workDir + + const config = loadConfig() + + expect(config.telegram.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(config.feishu.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir)) + expect(getConfiguredWorkDir(config, config.wechat)).toBe(fs.realpathSync(workDir)) + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + fs.rmSync(workDir, { recursive: true, force: true }) + } + }) + + it('keeps the explicit default project ahead of the platform default work dir', () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-config-')) + const defaultProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-project-')) + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-workdir-')) + try { + fs.writeFileSync( + path.join(configDir, 'adapters.json'), + JSON.stringify({ defaultProjectDir }), + ) + process.env.CLAUDE_CONFIG_DIR = configDir + process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR = workDir + + const config = loadConfig() + + expect(getConfiguredWorkDir(config, config.wechat)).toBe(defaultProjectDir) + expect(config.wechat.defaultWorkDir).toBe(fs.realpathSync(workDir)) + } finally { + fs.rmSync(configDir, { recursive: true, force: true }) + fs.rmSync(defaultProjectDir, { recursive: true, force: true }) + fs.rmSync(workDir, { recursive: true, force: true }) + } + }) +}) + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} diff --git a/adapters/common/__tests__/http-client.test.ts b/adapters/common/__tests__/http-client.test.ts index 7dfaf737..870ed85f 100644 --- a/adapters/common/__tests__/http-client.test.ts +++ b/adapters/common/__tests__/http-client.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' import { AdapterHttpClient } from '../http-client.js' describe('AdapterHttpClient', () => { @@ -53,6 +56,23 @@ describe('AdapterHttpClient', () => { expect(projects[0].projectName).toBe('my-app') }) + it('matchProject accepts an absolute local project path without recent history', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-')) + try { + globalThis.fetch = mock(() => { + throw new Error('recent projects should not be queried for absolute paths') + }) as any + + const result = await client.matchProject(projectDir) + + expect(result.project?.realPath).toBe(fs.realpathSync(projectDir)) + expect(result.project?.projectName).toBe(path.basename(projectDir)) + expect((globalThis.fetch as any).mock.calls).toHaveLength(0) + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }) + } + }) + it('createSession throws on server error', async () => { globalThis.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ error: 'BAD_REQUEST', message: 'workDir required' }), { diff --git a/adapters/common/attachment/attachment-types.ts b/adapters/common/attachment/attachment-types.ts index d5d152e5..533b1a36 100644 --- a/adapters/common/attachment/attachment-types.ts +++ b/adapters/common/attachment/attachment-types.ts @@ -6,7 +6,7 @@ import type { AttachmentRef } from '../ws-bridge.js' export type { AttachmentRef } /** Platform tag — used for local staging subdir and telemetry. */ -export type ImPlatform = 'feishu' | 'telegram' +export type ImPlatform = 'feishu' | 'telegram' | 'wechat' /** Result of downloading an IM resource into the local stage dir. */ export interface LocalAttachment { diff --git a/adapters/common/config.ts b/adapters/common/config.ts index 121145e0..b806636a 100644 --- a/adapters/common/config.ts +++ b/adapters/common/config.ts @@ -38,14 +38,30 @@ export type FeishuConfig = { streamingCard: boolean } +export type WechatConfig = { + accountId: string + botToken: string + baseUrl: string + userId: string + allowedUsers: string[] + pairedUsers: PairedUser[] + defaultWorkDir: string +} + export type AdapterConfig = { serverUrl: string defaultProjectDir: string pairing: PairingState telegram: TelegramConfig feishu: FeishuConfig + wechat: WechatConfig } +export type AdapterPlatformConfig = + | TelegramConfig + | FeishuConfig + | WechatConfig + function getConfigPath(): string { const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') return path.join(configDir, 'adapters.json') @@ -66,7 +82,9 @@ export function loadConfig(): AdapterConfig { const file = loadFile() const tg = file.telegram ?? {} const fs_ = file.feishu ?? {} + const wc = file.wechat ?? {} const pairing = file.pairing ?? {} + const fallbackWorkDir = resolveUserDefaultWorkDir() return { serverUrl: process.env.ADAPTER_SERVER_URL || file.serverUrl || 'ws://127.0.0.1:3456', @@ -80,7 +98,7 @@ export function loadConfig(): AdapterConfig { botToken: process.env.TELEGRAM_BOT_TOKEN || tg.botToken || '', allowedUsers: tg.allowedUsers ?? [], pairedUsers: tg.pairedUsers ?? [], - defaultWorkDir: tg.defaultWorkDir || process.cwd(), + defaultWorkDir: tg.defaultWorkDir || fallbackWorkDir, }, feishu: { appId: process.env.FEISHU_APP_ID || fs_.appId || '', @@ -89,8 +107,55 @@ export function loadConfig(): AdapterConfig { verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '', allowedUsers: fs_.allowedUsers ?? [], pairedUsers: fs_.pairedUsers ?? [], - defaultWorkDir: fs_.defaultWorkDir || process.cwd(), + defaultWorkDir: fs_.defaultWorkDir || fallbackWorkDir, streamingCard: fs_.streamingCard ?? false, }, + wechat: { + accountId: process.env.WECHAT_ACCOUNT_ID || wc.accountId || '', + botToken: process.env.WECHAT_BOT_TOKEN || wc.botToken || '', + baseUrl: process.env.WECHAT_BASE_URL || wc.baseUrl || 'https://ilinkai.weixin.qq.com', + userId: process.env.WECHAT_USER_ID || wc.userId || '', + allowedUsers: wc.allowedUsers ?? [], + pairedUsers: wc.pairedUsers ?? [], + defaultWorkDir: wc.defaultWorkDir || fallbackWorkDir, + }, + } +} + +export function getConfiguredWorkDir(config: AdapterConfig, platformConfig: AdapterPlatformConfig): string { + return config.defaultProjectDir || platformConfig.defaultWorkDir +} + +function resolveUserDefaultWorkDir(): string { + const candidates = [ + process.env.CLAUDE_ADAPTER_DEFAULT_WORK_DIR, + process.env.PWD, + process.cwd(), + os.homedir(), + ] + + for (const candidate of candidates) { + const resolved = resolveExistingDirectory(candidate) + if (resolved) return resolved + } + + return os.homedir() +} + +function resolveExistingDirectory(value: string | undefined): string | null { + const trimmed = value?.trim() + if (!trimmed) return null + + const expanded = trimmed === '~' + ? os.homedir() + : trimmed.startsWith('~/') + ? path.join(os.homedir(), trimmed.slice(2)) + : trimmed + + try { + const realPath = fs.realpathSync(expanded) + return fs.statSync(realPath).isDirectory() ? realPath : null + } catch { + return null } } diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts index f10606f8..3a68f58a 100644 --- a/adapters/common/http-client.ts +++ b/adapters/common/http-client.ts @@ -1,3 +1,7 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + export type RecentProject = { projectPath: string realPath: string @@ -85,6 +89,22 @@ export class AdapterHttpClient { * Returns { project, ambiguous[] } — ambiguous is set when multiple projects match. */ async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> { + const directPath = resolveExistingProjectPath(query) + if (directPath) { + return { + project: { + projectPath: directPath, + realPath: directPath, + projectName: path.basename(directPath) || directPath, + isGit: fs.existsSync(path.join(directPath, '.git')), + repoName: null, + branch: null, + modifiedAt: new Date().toISOString(), + sessionCount: 0, + }, + } + } + const projects = await this.listRecentProjects() // Try as 1-based index @@ -144,3 +164,23 @@ export class AdapterHttpClient { } } } + +function resolveExistingProjectPath(query: string): string | null { + const trimmed = query.trim() + if (!trimmed) return null + + const expanded = trimmed === '~' + ? os.homedir() + : trimmed.startsWith('~/') + ? path.join(os.homedir(), trimmed.slice(2)) + : trimmed + + if (!path.isAbsolute(expanded)) return null + + try { + const realPath = fs.realpathSync(expanded) + return fs.statSync(realPath).isDirectory() ? realPath : null + } catch { + return null + } +} diff --git a/adapters/common/pairing.ts b/adapters/common/pairing.ts index 4cd8259f..3d3ce2fb 100644 --- a/adapters/common/pairing.ts +++ b/adapters/common/pairing.ts @@ -74,7 +74,7 @@ export function generatePairingCode(): string { /** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */ export function isPaired( - platform: 'telegram' | 'feishu', + platform: 'telegram' | 'feishu' | 'wechat', userId: string | number, config: Record, ): boolean { @@ -97,7 +97,7 @@ export function isPaired( export function tryPair( messageText: string, senderInfo: { userId: string | number; displayName: string }, - platform: 'telegram' | 'feishu', + platform: 'telegram' | 'feishu' | 'wechat', ): boolean { const file = readConfigFile() const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null } @@ -139,7 +139,7 @@ export function tryPair( } /** 统一的用户授权检查(供各 adapter 调用) */ -export function isAllowedUser(platform: 'telegram' | 'feishu', userId: string | number): boolean { +export function isAllowedUser(platform: 'telegram' | 'feishu' | 'wechat', userId: string | number): boolean { try { const cfgFile = readConfigFile() return isPaired(platform, userId, cfgFile) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 46b52aa4..03be72f7 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -14,7 +14,7 @@ import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-b import { MessageDedup } from '../common/message-dedup.js' import { StreamingCard } from './streaming-card.js' import { enqueue } from '../common/chat-queue.js' -import { loadConfig } from '../common/config.js' +import { getConfiguredWorkDir, loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, @@ -50,6 +50,7 @@ const bridge = new WsBridge(config.serverUrl, 'feishu') const dedup = new MessageDedup() const sessionStore = new SessionStore() const httpClient = new AdapterHttpClient(config.serverUrl) +const defaultWorkDir = getConfiguredWorkDir(config, config.feishu) // Attachment plumbing — shared by inbound (download) and outbound (upload) paths. const attachmentStore = new AttachmentStore() @@ -654,7 +655,7 @@ async function ensureSession(chatId: string): Promise { return await bridge.waitForOpen(chatId) } - const workDir = config.defaultProjectDir + const workDir = defaultWorkDir if (workDir) { return await createSessionForChat(chatId, workDir) } @@ -698,7 +699,7 @@ async function showProjectPicker(chatId: string): Promise { const projects = await httpClient.listRecentProjects() if (projects.length === 0) { await sendText(chatId, - '没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在设置中配置默认项目。') + `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`) return } pendingProjectSelection.set(chatId, true) @@ -708,7 +709,7 @@ async function showProjectPicker(chatId: string): Promise { const lines = projects.slice(0, 10).map((p, i) => `${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}` ) - await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`) + await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号、名称或绝对路径> 快速新建会话`) } } catch (err) { await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) @@ -750,7 +751,7 @@ async function startNewSession(chatId: string, query?: string): Promise { await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`) } } else { - const workDir = config.defaultProjectDir + const workDir = defaultWorkDir if (workDir) { const ok = await createSessionForChat(chatId, workDir) if (ok) { @@ -883,7 +884,7 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< void card.abort(new Error('session reset')).catch(() => {}) } const stored = sessionStore.get(chatId) - const workDir = stored?.workDir || config.defaultProjectDir + const workDir = stored?.workDir || defaultWorkDir if (workDir) { await sendText(chatId, '⚠️ 会话上下文已失效,正在自动重建...') bridge.resetSession(chatId) diff --git a/adapters/package.json b/adapters/package.json index b88d4c60..6639e3f3 100644 --- a/adapters/package.json +++ b/adapters/package.json @@ -6,10 +6,12 @@ "scripts": { "telegram": "bun run telegram/index.ts", "feishu": "bun run feishu/index.ts", + "wechat": "bun run wechat/index.ts", "test": "bun test", "test:common": "bun test common/", "test:telegram": "bun test telegram/", - "test:feishu": "bun test feishu/" + "test:feishu": "bun test feishu/", + "test:wechat": "bun test wechat/" }, "dependencies": { "grammy": "^1.42.0", diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index aa84676d..e242ab81 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -11,7 +11,7 @@ import { WsBridge, type ServerMessage } from '../common/ws-bridge.js' import { MessageBuffer } from '../common/message-buffer.js' import { MessageDedup } from '../common/message-dedup.js' import { enqueue } from '../common/chat-queue.js' -import { loadConfig } from '../common/config.js' +import { getConfiguredWorkDir, loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, @@ -44,6 +44,7 @@ const bridge = new WsBridge(config.serverUrl, 'tg') const dedup = new MessageDedup() const sessionStore = new SessionStore() const httpClient = new AdapterHttpClient(config.serverUrl) +const defaultWorkDir = getConfiguredWorkDir(config, config.telegram) const attachmentStore = new AttachmentStore() const media = new TelegramMediaService(bot, attachmentStore) attachmentStore.gc().catch((err) => { @@ -225,7 +226,7 @@ async function ensureSession(chatId: string): Promise { return await bridge.waitForOpen(chatId) } - const workDir = config.defaultProjectDir + const workDir = defaultWorkDir if (workDir) { return await createSessionForChat(chatId, workDir) } @@ -265,7 +266,7 @@ async function showProjectPicker(chatId: string): Promise { const projects = await httpClient.listRecentProjects() if (projects.length === 0) { await bot.api.sendMessage(numericChatId, - '没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在 Settings → IM 接入中配置默认项目。') + `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`) return } @@ -274,7 +275,7 @@ async function showProjectPicker(chatId: string): Promise { ) pendingProjectSelection.set(chatId, true) await bot.api.sendMessage(numericChatId, - `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`) + `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号、名称或绝对路径> 快速新建会话`) } catch (err) { await bot.api.sendMessage(numericChatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) @@ -444,7 +445,7 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< // This happens when the API key or provider changed since the session was created. if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) { const stored = sessionStore.get(chatId) - const workDir = stored?.workDir || config.defaultProjectDir + const workDir = stored?.workDir || defaultWorkDir if (workDir) { await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,正在自动重建...') clearTransientChatState(chatId) @@ -486,7 +487,7 @@ bot.command('help', (ctx) => void sendHelp(ctx)) /** Reset session state and start a new session for chatId. * If `query` is provided, match a project by index or name; - * otherwise use defaultProjectDir or show the picker. */ + * otherwise use the configured/default work directory. */ async function startNewSession(chatId: string, query?: string): Promise { const numericChatId = Number(chatId) @@ -522,7 +523,7 @@ async function startNewSession(chatId: string, query?: string): Promise { `❌ ${err instanceof Error ? err.message : String(err)}`) } } else { - const workDir = config.defaultProjectDir + const workDir = defaultWorkDir if (workDir) { const ok = await createSessionForChat(chatId, workDir) if (ok) { diff --git a/adapters/wechat/__tests__/protocol.test.ts b/adapters/wechat/__tests__/protocol.test.ts new file mode 100644 index 00000000..32ed6a97 --- /dev/null +++ b/adapters/wechat/__tests__/protocol.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test' +import { buildClientVersion, extractWechatText } from '../protocol.js' + +describe('WeChat protocol helpers', () => { + it('encodes iLink client versions like the OpenClaw Weixin plugin', () => { + expect(buildClientVersion('2.1.7')).toBe((2 << 16) | (1 << 8) | 7) + expect(buildClientVersion('1.0.11')).toBe(65547) + }) + + it('extracts plain text from WeChat message items', () => { + expect(extractWechatText([ + { type: 1, text_item: { text: 'hello' } }, + ])).toBe('hello') + }) + + it('extracts voice transcription when text items are absent', () => { + expect(extractWechatText([ + { type: 3, voice_item: { text: 'voice text' } }, + ])).toBe('voice text') + }) + + it('preserves quoted text context', () => { + expect(extractWechatText([ + { + type: 1, + text_item: { text: 'reply' }, + ref_msg: { + title: 'quote title', + message_item: { type: 1, text_item: { text: 'quoted body' } }, + }, + }, + ])).toBe('[引用: quote title | quoted body]\nreply') + }) +}) diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts new file mode 100644 index 00000000..1a3d40fb --- /dev/null +++ b/adapters/wechat/index.ts @@ -0,0 +1,397 @@ +import * as path from 'node:path' +import { WsBridge, type ServerMessage } from '../common/ws-bridge.js' +import { MessageDedup } from '../common/message-dedup.js' +import { enqueue } from '../common/chat-queue.js' +import { getConfiguredWorkDir, loadConfig } from '../common/config.js' +import { + formatImHelp, + formatImStatus, + formatPermissionRequest, + splitMessage, +} from '../common/format.js' +import { SessionStore } from '../common/session-store.js' +import { AdapterHttpClient } from '../common/http-client.js' +import { isAllowedUser } from '../common/pairing.js' +import { + extractWechatText, + getWechatUpdates, + sendWechatText, + WECHAT_DEFAULT_BASE_URL, + type WechatMessage, +} from './protocol.js' + +const WECHAT_TEXT_LIMIT = 3500 +const GET_UPDATES_TIMEOUT_MS = 35_000 + +const config = loadConfig() +if (!config.wechat.botToken || !config.wechat.accountId) { + console.error('[WeChat] Missing QR-bound account. Bind WeChat in Desktop Settings > IM.') + process.exit(1) +} + +const baseUrl = config.wechat.baseUrl || WECHAT_DEFAULT_BASE_URL +const accountId = config.wechat.accountId +const botToken = config.wechat.botToken +const bridge = new WsBridge(config.serverUrl, 'wechat') +const dedup = new MessageDedup() +const sessionStore = new SessionStore() +const httpClient = new AdapterHttpClient(config.serverUrl) +const defaultWorkDir = getConfiguredWorkDir(config, config.wechat) +const pendingProjectSelection = new Map() +const runtimeStates = new Map() +const accumulatedText = new Map() +const contextTokens = new Map() +const pendingPermissions = new Map>() + +let getUpdatesBuf = '' +let stopped = false + +type ChatRuntimeState = { + state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending' + verb?: string + model?: string + pendingPermissionCount: number +} + +function getRuntimeState(chatId: string): ChatRuntimeState { + let state = runtimeStates.get(chatId) + if (!state) { + state = { state: 'idle', pendingPermissionCount: 0 } + runtimeStates.set(chatId, state) + } + return state +} + +async function sendText(chatId: string, text: string): Promise { + const chunks = splitMessage(text, WECHAT_TEXT_LIMIT) + const contextToken = contextTokens.get(chatId) + for (const chunk of chunks) { + await sendWechatText({ + baseUrl, + token: botToken, + to: chatId, + text: chunk, + contextToken, + }) + } + console.log(`[WeChat] Sent ${chunks.length} message chunk(s) to ${redactChatId(chatId)}`) +} + +function clearTransientChatState(chatId: string): void { + accumulatedText.delete(chatId) + pendingPermissions.delete(chatId) + const runtime = getRuntimeState(chatId) + runtime.state = 'idle' + runtime.verb = undefined + runtime.pendingPermissionCount = 0 +} + +async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { + const stored = sessionStore.get(chatId) + if (!stored) return null + + if (!bridge.hasSession(chatId)) { + bridge.connectSession(chatId, stored.sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) return null + } + + return stored +} + +async function buildStatusText(chatId: string): Promise { + const stored = await ensureExistingSession(chatId) + if (!stored) return formatImStatus(null) + + const runtime = getRuntimeState(chatId) + let projectName = path.basename(stored.workDir) || stored.workDir + let branch: string | null = null + + try { + const gitInfo = await httpClient.getGitInfo(stored.sessionId) + projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName + branch = gitInfo.branch + } catch { + // Keep IM status best-effort. + } + + return formatImStatus({ + sessionId: stored.sessionId, + projectName, + branch, + model: runtime.model, + state: runtime.state, + verb: runtime.verb, + pendingPermissionCount: runtime.pendingPermissionCount, + }) +} + +async function ensureSession(chatId: string): Promise { + if (bridge.hasSession(chatId)) return true + + const stored = sessionStore.get(chatId) + if (stored) { + bridge.connectSession(chatId, stored.sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + return await bridge.waitForOpen(chatId) + } + + const workDir = defaultWorkDir + if (workDir) return await createSessionForChat(chatId, workDir) + + await showProjectPicker(chatId) + return false +} + +async function createSessionForChat(chatId: string, workDir: string): Promise { + try { + bridge.resetSession(chatId) + clearTransientChatState(chatId) + const sessionId = await httpClient.createSession(workDir) + sessionStore.set(chatId, sessionId, workDir) + bridge.connectSession(chatId, sessionId) + bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) { + await sendText(chatId, '连接服务器超时,请重试。') + return false + } + return true + } catch (err) { + await sendText(chatId, `无法创建会话: ${err instanceof Error ? err.message : String(err)}`) + return false + } +} + +async function showProjectPicker(chatId: string): Promise { + try { + const projects = await httpClient.listRecentProjects() + if (projects.length === 0) { + await sendText(chatId, `没有找到最近的项目。发送 /new 会使用默认工作目录:${defaultWorkDir}\n也可以发送 /new /path/to/project 指定项目。`) + return + } + + const lines = projects.slice(0, 10).map((p, i) => + `${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}` + ) + pendingProjectSelection.set(chatId, true) + await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n下次可直接 /new <编号、名称或绝对路径> 快速新建会话`) + } catch (err) { + await sendText(chatId, `无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`) + } +} + +async function startNewSession(chatId: string, query?: string): Promise { + bridge.resetSession(chatId) + sessionStore.delete(chatId) + clearTransientChatState(chatId) + pendingProjectSelection.delete(chatId) + + if (query) { + try { + const { project, ambiguous } = await httpClient.matchProject(query) + if (project) { + const ok = await createSessionForChat(chatId, project.realPath) + if (ok) await sendText(chatId, `已新建会话:${project.projectName}${project.branch ? ` (${project.branch})` : ''}`) + return + } + if (ambiguous) { + const list = ambiguous.map((p, i) => `${i + 1}. ${p.projectName} - ${p.realPath}`).join('\n') + await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`) + return + } + await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`) + } catch (err) { + await sendText(chatId, err instanceof Error ? err.message : String(err)) + } + return + } + + const workDir = defaultWorkDir + if (workDir) { + const ok = await createSessionForChat(chatId, workDir) + if (ok) await sendText(chatId, '已新建会话,可以开始对话了。') + } else { + await showProjectPicker(chatId) + } +} + +async function handleServerMessage(chatId: string, msg: ServerMessage): Promise { + const runtime = getRuntimeState(chatId) + + switch (msg.type) { + case 'connected': + break + case 'status': + runtime.state = msg.state + runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined + break + case 'content_delta': + if (typeof msg.text === 'string' && msg.text) { + accumulatedText.set(chatId, (accumulatedText.get(chatId) ?? '') + msg.text) + } + break + case 'permission_request': { + runtime.pendingPermissionCount += 1 + runtime.state = 'permission_pending' + let pending = pendingPermissions.get(chatId) + if (!pending) { + pending = new Set() + pendingPermissions.set(chatId, pending) + } + pending.add(msg.requestId) + await sendText( + chatId, + `${formatPermissionRequest(msg.toolName, msg.input, msg.requestId)}\n\n回复 /allow ${msg.requestId} 允许,或 /deny ${msg.requestId} 拒绝。`, + ) + break + } + case 'message_complete': { + runtime.state = 'idle' + runtime.verb = undefined + const text = accumulatedText.get(chatId) + accumulatedText.delete(chatId) + if (text?.trim()) await sendText(chatId, text) + break + } + case 'error': + runtime.state = 'idle' + runtime.verb = undefined + accumulatedText.delete(chatId) + await sendText(chatId, `错误: ${msg.message}`) + break + case 'system_notification': + if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') { + const model = (msg.data as Record).model + if (typeof model === 'string' && model.trim()) runtime.model = model + } + break + } +} + +async function routeUserMessage(message: WechatMessage): Promise { + const chatId = message.from_user_id + if (!chatId) return + const messageKey = `${message.message_id ?? ''}:${message.seq ?? ''}:${message.create_time_ms ?? ''}` + if (!dedup.tryRecord(messageKey)) return + if (message.context_token) contextTokens.set(chatId, message.context_token) + + const text = extractWechatText(message.item_list).trim() + if (!text) return + console.log(`[WeChat] Received from ${redactChatId(chatId)}: ${text.slice(0, 80)}`) + + if (!isAllowedUser('wechat', chatId)) { + await sendText(chatId, '未绑定。请在 Claude Code 桌面端「设置 -> IM 接入 -> 微信」中扫码绑定。') + return + } + + enqueue(chatId, async () => { + if (text === '/help' || text === '帮助') { + await sendText(chatId, formatImHelp()) + return + } + if (text === '/status' || text === '状态') { + await sendText(chatId, await buildStatusText(chatId)) + return + } + if (text === '/projects' || text === '项目列表') { + await showProjectPicker(chatId) + return + } + if (text === '/new' || text === '新会话' || text.startsWith('/new ')) { + const arg = text.startsWith('/new ') ? text.slice(5).trim() : '' + await startNewSession(chatId, arg || undefined) + return + } + if (text === '/stop' || text === '停止') { + const stored = await ensureExistingSession(chatId) + if (!stored) { + await sendText(chatId, formatImStatus(null)) + return + } + bridge.sendStopGeneration(chatId) + await sendText(chatId, '已发送停止信号。') + return + } + if (text === '/clear' || text === '清空') { + const stored = await ensureExistingSession(chatId) + if (!stored) { + await sendText(chatId, formatImStatus(null)) + return + } + clearTransientChatState(chatId) + const sent = bridge.sendUserMessage(chatId, '/clear') + await sendText(chatId, sent ? '已清空当前会话上下文。' : '无法发送 /clear,请先发送 /new 重新连接会话。') + return + } + if (text.startsWith('/allow ') || text.startsWith('/deny ')) { + const requestId = text.split(/\s+/)[1] + if (!requestId) return + const allowed = text.startsWith('/allow ') + const sent = bridge.sendPermissionResponse(chatId, requestId, allowed) + const runtime = getRuntimeState(chatId) + runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1) + pendingPermissions.get(chatId)?.delete(requestId) + await sendText(chatId, sent ? (allowed ? '已允许。' : '已拒绝。') : '权限响应发送失败,请检查会话状态。') + return + } + if (pendingProjectSelection.has(chatId)) { + await startNewSession(chatId, text) + return + } + + const ready = await ensureSession(chatId) + if (!ready) return + const sent = bridge.sendUserMessage(chatId, text) + if (!sent) await sendText(chatId, '消息发送失败,连接可能已断开。请发送 /new 重新开始。') + }) +} + +async function pollLoop(): Promise { + while (!stopped) { + try { + const resp = await getWechatUpdates({ + baseUrl, + token: botToken, + getUpdatesBuf, + timeoutMs: GET_UPDATES_TIMEOUT_MS, + }) + if (resp.get_updates_buf) getUpdatesBuf = resp.get_updates_buf + const hasRetError = typeof resp.ret === 'number' && resp.ret !== 0 + const hasErrCode = typeof resp.errcode === 'number' && resp.errcode !== 0 + if (hasRetError || hasErrCode) { + console.warn(`[WeChat] getupdates error: ${resp.errcode ?? resp.ret} ${resp.errmsg ?? ''}`) + await sleep(3000) + continue + } + for (const msg of resp.msgs ?? []) { + await routeUserMessage(msg) + } + } catch (err) { + console.error('[WeChat] poll loop error:', err instanceof Error ? err.message : err) + await sleep(3000) + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function redactChatId(chatId: string): string { + if (chatId.length <= 12) return chatId + return `${chatId.slice(0, 6)}...${chatId.slice(-6)}` +} + +console.log('[WeChat] Starting adapter...') +console.log(`[WeChat] Account: ${accountId}`) +void pollLoop() + +process.on('SIGINT', () => { + console.log('[WeChat] Shutting down...') + stopped = true + bridge.destroy() + dedup.destroy() + process.exit(0) +}) diff --git a/adapters/wechat/protocol.ts b/adapters/wechat/protocol.ts new file mode 100644 index 00000000..7aa03d27 --- /dev/null +++ b/adapters/wechat/protocol.ts @@ -0,0 +1,371 @@ +import crypto from 'node:crypto' + +export const WECHAT_DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com' +export const WECHAT_DEFAULT_BOT_TYPE = '3' + +const ILINK_APP_ID = 'bot' +const CHANNEL_VERSION = '2.1.7' +const ILINK_APP_CLIENT_VERSION = buildClientVersion(CHANNEL_VERSION) +const QR_LOGIN_TTL_MS = 5 * 60_000 +const QR_STATUS_TIMEOUT_MS = 35_000 +const GET_UPDATES_TIMEOUT_MS = 35_000 +const API_TIMEOUT_MS = 15_000 + +type QrLoginStatus = 'wait' | 'scaned' | 'confirmed' | 'expired' | 'scaned_but_redirect' + +type ActiveLogin = { + sessionKey: string + qrcode: string + qrcodeUrl: string + startedAt: number + currentApiBaseUrl: string +} + +type QrCodeResponse = { + qrcode: string + qrcode_img_content: string +} + +type QrStatusResponse = { + status: QrLoginStatus + bot_token?: string + ilink_bot_id?: string + baseurl?: string + ilink_user_id?: string + redirect_host?: string +} + +export type WechatQrStartResult = { + qrcodeUrl?: string + message: string + sessionKey: string +} + +export type WechatQrPollResult = { + connected: boolean + status: QrLoginStatus | 'not_started' + message: string + botToken?: string + accountId?: string + baseUrl?: string + userId?: string +} + +export type WechatMessageItem = { + type?: number + text_item?: { text?: string } + voice_item?: { text?: string } + ref_msg?: { + title?: string + message_item?: WechatMessageItem + } +} + +export type WechatMessage = { + 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?: WechatMessageItem[] + context_token?: string +} + +export type WechatGetUpdatesResp = { + ret?: number + errcode?: number + errmsg?: string + msgs?: WechatMessage[] + get_updates_buf?: string + longpolling_timeout_ms?: number +} + +const activeLogins = new Map() + +export function buildClientVersion(version: string): number { + const parts = version.split('.').map((p) => parseInt(p, 10)) + const major = parts[0] ?? 0 + const minor = parts[1] ?? 0 + const patch = parts[2] ?? 0 + return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff) +} + +export function extractWechatText(itemList?: WechatMessageItem[]): string { + if (!itemList?.length) return '' + for (const item of itemList) { + if (item.type === 1 && item.text_item?.text != null) { + const text = String(item.text_item.text) + const ref = item.ref_msg + if (!ref) return text + const parts: string[] = [] + if (ref.title) parts.push(ref.title) + if (ref.message_item) { + const refBody = extractWechatText([ref.message_item]) + if (refBody) parts.push(refBody) + } + return parts.length ? `[引用: ${parts.join(' | ')}]\n${text}` : text + } + if (item.type === 3 && item.voice_item?.text) { + return item.voice_item.text + } + } + return '' +} + +export async function startWechatLoginWithQr(opts: { + force?: boolean + sessionKey?: string + botType?: string +} = {}): Promise { + purgeExpiredLogins() + + const sessionKey = opts.sessionKey || crypto.randomUUID() + const existing = activeLogins.get(sessionKey) + if (!opts.force && existing && isLoginFresh(existing)) { + return { + qrcodeUrl: existing.qrcodeUrl, + message: '二维码已就绪,请使用微信扫描。', + sessionKey, + } + } + + const botType = opts.botType || WECHAT_DEFAULT_BOT_TYPE + const rawText = await apiGetFetch({ + baseUrl: WECHAT_DEFAULT_BASE_URL, + endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, + label: 'wechatQrStart', + }) + const qr = JSON.parse(rawText) as QrCodeResponse + if (!qr.qrcode || !qr.qrcode_img_content) { + throw new Error('WeChat QR response did not include a QR code URL') + } + + activeLogins.set(sessionKey, { + sessionKey, + qrcode: qr.qrcode, + qrcodeUrl: qr.qrcode_img_content, + startedAt: Date.now(), + currentApiBaseUrl: WECHAT_DEFAULT_BASE_URL, + }) + + return { + qrcodeUrl: qr.qrcode_img_content, + message: '使用微信扫描二维码完成绑定。', + sessionKey, + } +} + +export async function pollWechatLoginWithQr(opts: { + sessionKey: string +}): Promise { + purgeExpiredLogins() + + const login = activeLogins.get(opts.sessionKey) + if (!login) { + return { + connected: false, + status: 'not_started', + message: '当前没有进行中的微信绑定,请重新生成二维码。', + } + } + + const status = await pollQrStatus(login.currentApiBaseUrl, login.qrcode) + switch (status.status) { + case 'wait': + return { connected: false, status: 'wait', message: '等待扫码。' } + case 'scaned': + return { connected: false, status: 'scaned', message: '已扫码,请在微信中确认。' } + case 'scaned_but_redirect': + if (status.redirect_host) { + login.currentApiBaseUrl = `https://${status.redirect_host}` + } + return { connected: false, status: 'scaned_but_redirect', message: '已扫码,正在切换微信网关。' } + case 'expired': + activeLogins.delete(opts.sessionKey) + return { connected: false, status: 'expired', message: '二维码已过期,请重新生成。' } + case 'confirmed': + activeLogins.delete(opts.sessionKey) + if (!status.bot_token || !status.ilink_bot_id) { + return { connected: false, status: 'confirmed', message: '微信已确认,但服务端未返回完整凭据。' } + } + return { + connected: true, + status: 'confirmed', + message: '微信绑定成功。', + botToken: status.bot_token, + accountId: status.ilink_bot_id, + baseUrl: status.baseurl || login.currentApiBaseUrl || WECHAT_DEFAULT_BASE_URL, + userId: status.ilink_user_id, + } + } +} + +export async function getWechatUpdates(params: { + baseUrl: string + token: string + getUpdatesBuf?: string + timeoutMs?: number +}): Promise { + try { + const rawText = await apiPostFetch({ + baseUrl: params.baseUrl, + endpoint: 'ilink/bot/getupdates', + body: JSON.stringify({ + get_updates_buf: params.getUpdatesBuf ?? '', + base_info: buildBaseInfo(), + }), + token: params.token, + timeoutMs: params.timeoutMs ?? GET_UPDATES_TIMEOUT_MS, + label: 'wechatGetUpdates', + }) + return JSON.parse(rawText) as WechatGetUpdatesResp + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return { ret: 0, msgs: [], get_updates_buf: params.getUpdatesBuf } + } + throw err + } +} + +export async function sendWechatText(params: { + baseUrl: string + token: string + to: string + text: string + contextToken?: string + timeoutMs?: number +}): Promise { + const body = { + msg: { + from_user_id: '', + to_user_id: params.to, + client_id: `claude-code-haha-wechat-${crypto.randomUUID()}`, + message_type: 2, + message_state: 2, + item_list: params.text ? [{ type: 1, text_item: { text: params.text } }] : undefined, + context_token: params.contextToken, + }, + base_info: buildBaseInfo(), + } + + await apiPostFetch({ + baseUrl: params.baseUrl, + endpoint: 'ilink/bot/sendmessage', + body: JSON.stringify(body), + token: params.token, + timeoutMs: params.timeoutMs ?? API_TIMEOUT_MS, + label: 'wechatSendMessage', + }) +} + +async function pollQrStatus(apiBaseUrl: string, qrcode: string): Promise { + try { + const rawText = await apiGetFetch({ + baseUrl: apiBaseUrl, + endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, + timeoutMs: QR_STATUS_TIMEOUT_MS, + label: 'wechatQrStatus', + }) + return JSON.parse(rawText) as QrStatusResponse + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return { status: 'wait' } + return { status: 'wait' } + } +} + +async function apiGetFetch(params: { + baseUrl: string + endpoint: string + timeoutMs?: number + label: string +}): Promise { + const url = new URL(params.endpoint, ensureTrailingSlash(params.baseUrl)) + const controller = params.timeoutMs ? new AbortController() : undefined + const timer = controller ? setTimeout(() => controller.abort(), params.timeoutMs) : undefined + try { + const res = await fetch(url.toString(), { + method: 'GET', + headers: buildCommonHeaders(), + ...(controller ? { signal: controller.signal } : {}), + }) + const rawText = await res.text() + if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`) + return rawText + } finally { + if (timer) clearTimeout(timer) + } +} + +async function apiPostFetch(params: { + baseUrl: string + endpoint: string + body: string + token?: string + timeoutMs: number + label: string +}): Promise { + const url = new URL(params.endpoint, ensureTrailingSlash(params.baseUrl)) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), params.timeoutMs) + try { + const res = await fetch(url.toString(), { + method: 'POST', + headers: buildHeaders({ token: params.token, body: params.body }), + body: params.body, + signal: controller.signal, + }) + const rawText = await res.text() + if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`) + return rawText + } finally { + clearTimeout(timer) + } +} + +function buildBaseInfo(): { channel_version: string } { + return { channel_version: CHANNEL_VERSION } +} + +function buildCommonHeaders(): Record { + return { + 'iLink-App-Id': ILINK_APP_ID, + 'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION), + } +} + +function buildHeaders(opts: { token?: string; body: string }): Record { + const headers: Record = { + 'Content-Type': 'application/json', + AuthorizationType: 'ilink_bot_token', + 'Content-Length': String(Buffer.byteLength(opts.body, 'utf-8')), + 'X-WECHAT-UIN': randomWechatUin(), + ...buildCommonHeaders(), + } + if (opts.token?.trim()) { + headers.Authorization = `Bearer ${opts.token.trim()}` + } + return headers +} + +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/` +} + +function randomWechatUin(): string { + const uint32 = crypto.randomBytes(4).readUInt32BE(0) + return Buffer.from(String(uint32), 'utf-8').toString('base64') +} + +function isLoginFresh(login: ActiveLogin): boolean { + return Date.now() - login.startedAt < QR_LOGIN_TTL_MS +} + +function purgeExpiredLogins(): void { + for (const [sessionKey, login] of activeLogins) { + if (!isLoginFresh(login)) activeLogins.delete(sessionKey) + } +} diff --git a/desktop/sidecars/claude-sidecar.ts b/desktop/sidecars/claude-sidecar.ts index cd0937f0..2cd4e1df 100644 --- a/desktop/sidecars/claude-sidecar.ts +++ b/desktop/sidecars/claude-sidecar.ts @@ -8,7 +8,7 @@ * * claude-sidecar server --app-root --host 127.0.0.1 --port 12345 * claude-sidecar cli --app-root [其它 CLI 参数...] - * claude-sidecar adapters --app-root [--feishu] [--telegram] + * claude-sidecar adapters --app-root [--feishu] [--telegram] [--wechat] * * 任何模式都必须先做 process.env / process.argv 设置,再 await 进入相应的 * 子模块树。原因:src/server/index.ts、src/entrypoints/cli.tsx、以及 @@ -52,11 +52,12 @@ if (mode === 'adapters') { async function runAdapters(rawArgs: string[]): Promise { // adapters 模式的参数解析独立于 server/cli —— 这里只接受 --feishu / - // --telegram 选择启用哪个适配器,再加可选的 --app-root(透传给 + // --telegram / --wechat 选择启用哪个适配器,再加可选的 --app-root(透传给 // adapters/common/config.ts 内的 process.env 读取)。 let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null let enableFeishu = false let enableTelegram = false + let enableWechat = false for (let i = 0; i < rawArgs.length; i++) { const arg = rawArgs[i] @@ -73,12 +74,16 @@ async function runAdapters(rawArgs: string[]): Promise { enableTelegram = true continue } + if (arg === '--wechat') { + enableWechat = true + continue + } console.warn(`claude-sidecar adapters: ignoring unknown arg "${arg}"`) } - if (!enableFeishu && !enableTelegram) { + if (!enableFeishu && !enableTelegram && !enableWechat) { console.error( - 'claude-sidecar adapters: must enable at least one of --feishu / --telegram', + 'claude-sidecar adapters: must enable at least one of --feishu / --telegram / --wechat', ) process.exit(2) } @@ -125,6 +130,18 @@ async function runAdapters(rawArgs: string[]): Promise { } } + if (enableWechat) { + if (!config.wechat.accountId || !config.wechat.botToken) { + console.warn( + '[claude-sidecar] --wechat requested but no QR-bound WeChat account found in env or ~/.claude/adapters.json — skipping', + ) + } else { + console.log('[claude-sidecar] starting WeChat adapter') + await import('../../adapters/wechat/index.ts') + started += 1 + } + } + if (started === 0) { console.error( '[claude-sidecar] no adapter could be started — check credentials in env or ~/.claude/adapters.json', diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 74d0a118..a66e1e71 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -24,7 +24,7 @@ { "args": [ "adapters", - { "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" } + { "validator": "regex", "value": "^(feishu|telegram|wechat|lark|slack)(:.+)?$" } ], "name": "binaries/claude-sidecar", "sidecar": true @@ -53,7 +53,7 @@ { "args": [ "adapters", - { "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" } + { "validator": "regex", "value": "^(feishu|telegram|wechat|lark|slack)(:.+)?$" } ], "name": "binaries/claude-sidecar", "sidecar": true @@ -82,7 +82,7 @@ { "args": [ "adapters", - { "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" } + { "validator": "regex", "value": "^(feishu|telegram|wechat|lark|slack)(:.+)?$" } ], "name": "binaries/claude-sidecar", "sidecar": true diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 96d3af24..8af99b57 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -64,7 +64,7 @@ struct StoredWindowState { /// 与 ServerState 平级的 adapter 子进程状态。 /// -/// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期 +/// adapter sidecar(claude-sidecar adapters --feishu --telegram --wechat)的生命周期 /// 跟 server 不同:它没有 HTTP 端口可探活,没配凭据时会自己干净退出, /// 而且需要支持运行时热重启 —— 用户在设置页保存飞书 / Telegram 凭据后, /// 前端会通过 invoke('restart_adapters_sidecar') 来重启它,让新凭据生效。 @@ -120,7 +120,7 @@ fn get_server_url(state: State<'_, ServerState>) -> Result { .unwrap_or_else(|| "desktop server did not start".to_string())) } -/// 前端在设置页保存飞书 / Telegram 凭据后调用,触发 adapter sidecar 热重启。 +/// 前端在设置页保存飞书 / Telegram / 微信凭据后调用,触发 adapter sidecar 热重启。 /// /// 流程: /// 1. kill 当前 adapter 子进程(如果在跑) @@ -988,6 +988,7 @@ fn start_adapters_sidecar(app: &AppHandle) -> Result { &app_root_arg, "--feishu", "--telegram", + "--wechat", ]); let (mut rx, child) = sidecar diff --git a/desktop/src/api/adapters.ts b/desktop/src/api/adapters.ts index 1fbcb1f5..03586e6b 100644 --- a/desktop/src/api/adapters.ts +++ b/desktop/src/api/adapters.ts @@ -9,4 +9,19 @@ export const adaptersApi = { updateConfig(patch: Partial) { return api.put('/api/adapters', patch) }, + + startWechatLogin() { + return api.post<{ qrcodeUrl?: string; message: string; sessionKey: string }>('/api/adapters/wechat/login/start', {}) + }, + + pollWechatLogin(sessionKey: string) { + return api.post< + | AdapterFileConfig + | { connected: false; status: string; message: string } + >('/api/adapters/wechat/login/poll', { sessionKey }, { timeout: 45_000 }) + }, + + unbindWechat() { + return api.post('/api/adapters/wechat/unbind', {}) + }, } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d44a52b0..30d05a8d 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -212,9 +212,10 @@ export const en = { // Settings > Adapters 'settings.tab.adapters': 'IM Adapters', - 'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via Telegram or Feishu.', + 'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via WeChat, Telegram, or Feishu.', 'settings.adapters.telegram': 'Telegram', 'settings.adapters.feishu': 'Feishu', + 'settings.adapters.wechat': 'WeChat', 'settings.adapters.botToken': 'Bot Token', 'settings.adapters.botTokenPlaceholder': 'Paste token from @BotFather', 'settings.adapters.appId': 'App ID', @@ -226,9 +227,10 @@ export const en = { 'settings.adapters.verificationToken': 'Verification Token', 'settings.adapters.verificationTokenPlaceholder': 'Optional', 'settings.adapters.allowedUsers': 'Allowed Users', - 'settings.adapters.allowedUsersHint': 'Comma-separated. Leave empty to allow everyone.', + 'settings.adapters.allowedUsersHint': 'Comma-separated. Leave empty to allow paired users only.', 'settings.adapters.tgAllowedUsersPlaceholder': 'e.g. 123456789, 987654321', 'settings.adapters.fsAllowedUsersPlaceholder': 'e.g. ou_xxx, ou_yyy', + 'settings.adapters.wcAllowedUsersPlaceholder': 'e.g. wx_user_id_1, wx_user_id_2', 'settings.adapters.defaultProject': 'Default Project', 'settings.adapters.defaultProjectHint': 'Default working directory for new IM sessions. If empty, the bot will ask you to choose.', 'settings.adapters.streamingCard': 'Streaming Card Mode', @@ -246,13 +248,23 @@ export const en = { 'settings.adapters.codeExpired': 'Expired', 'settings.adapters.codeExpiresIn': 'Expires in', 'settings.adapters.minutes': 'min', - 'settings.adapters.pairingCodeHint': 'Send this code to the Bot in Feishu or Telegram private chat.', + 'settings.adapters.pairingCodeHint': 'Send this code to the Bot in Feishu or Telegram private chat. WeChat uses QR binding.', 'settings.adapters.pairedUsers': 'Paired Users', 'settings.adapters.noPairedUsers': 'No paired users yet', 'settings.adapters.unbind': 'Unbind', 'settings.adapters.unbindConfirm': 'Are you sure you want to unbind this user? They will need to re-pair to use the bot.', 'settings.adapters.platform.telegram': 'Telegram', 'settings.adapters.platform.feishu': 'Feishu', + 'settings.adapters.platform.wechat': 'WeChat', + 'settings.adapters.wechatBind': 'Scan to Bind', + 'settings.adapters.wechatRebind': 'Rescan', + 'settings.adapters.wechatConnected': 'WeChat is bound', + 'settings.adapters.wechatNotConnected': 'WeChat is not bound', + 'settings.adapters.wechatQrHint': 'Generate a QR code, scan it in WeChat, then confirm. The adapter restarts after binding.', + 'settings.adapters.wechatQrAlt': 'WeChat binding QR code', + 'settings.adapters.wechatWaiting': 'Waiting for WeChat confirmation...', + 'settings.adapters.wechatBindSuccess': 'WeChat bound successfully.', + 'settings.adapters.wechatAllowedUsersHint': 'The scanned user is paired automatically. Add extra WeChat user IDs here if needed.', // Settings > MCP 'settings.mcp.title': 'MCP servers', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 1a98596b..c8831acc 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -214,9 +214,10 @@ export const zh: Record = { // Settings > Adapters 'settings.tab.adapters': 'IM 接入', - 'settings.adapters.description': '配置即时通讯适配器,通过 Telegram 或飞书与 Claude Code 对话。', + 'settings.adapters.description': '配置即时通讯适配器,通过微信、Telegram 或飞书与 Claude Code 对话。', 'settings.adapters.telegram': 'Telegram', 'settings.adapters.feishu': '飞书 (Feishu)', + 'settings.adapters.wechat': '微信', 'settings.adapters.botToken': 'Bot Token', 'settings.adapters.botTokenPlaceholder': '粘贴从 @BotFather 获取的 Token', 'settings.adapters.appId': 'App ID', @@ -228,9 +229,10 @@ export const zh: Record = { 'settings.adapters.verificationToken': 'Verification Token', 'settings.adapters.verificationTokenPlaceholder': '可选', 'settings.adapters.allowedUsers': '允许的用户', - 'settings.adapters.allowedUsersHint': '逗号分隔。留空允许所有人。', + 'settings.adapters.allowedUsersHint': '逗号分隔。留空则只允许已配对用户。', 'settings.adapters.tgAllowedUsersPlaceholder': '如 123456789, 987654321', 'settings.adapters.fsAllowedUsersPlaceholder': '如 ou_xxx, ou_yyy', + 'settings.adapters.wcAllowedUsersPlaceholder': '如 wx_user_id_1, wx_user_id_2', 'settings.adapters.defaultProject': '默认项目', 'settings.adapters.defaultProjectHint': '新 IM 会话的默认工作目录。留空则由 Bot 询问选择。', 'settings.adapters.streamingCard': '流式卡片模式', @@ -248,13 +250,23 @@ export const zh: Record = { 'settings.adapters.codeExpired': '已过期', 'settings.adapters.codeExpiresIn': '有效期剩余', 'settings.adapters.minutes': '分钟', - 'settings.adapters.pairingCodeHint': '请在飞书或 Telegram 私聊中发送此配对码给 Bot。', + 'settings.adapters.pairingCodeHint': '请在飞书或 Telegram 私聊中发送此配对码给 Bot。微信使用扫码绑定。', 'settings.adapters.pairedUsers': '已配对用户', 'settings.adapters.noPairedUsers': '暂无已配对用户', 'settings.adapters.unbind': '解绑', 'settings.adapters.unbindConfirm': '确定要解绑该用户吗?解绑后需重新配对才能使用。', 'settings.adapters.platform.telegram': 'Telegram', 'settings.adapters.platform.feishu': '飞书', + 'settings.adapters.platform.wechat': '微信', + 'settings.adapters.wechatBind': '扫码绑定', + 'settings.adapters.wechatRebind': '重新扫码', + 'settings.adapters.wechatConnected': '微信已绑定', + 'settings.adapters.wechatNotConnected': '微信未绑定', + 'settings.adapters.wechatQrHint': '生成二维码后使用微信扫码并确认,绑定完成后适配器会自动重启。', + 'settings.adapters.wechatQrAlt': '微信绑定二维码', + 'settings.adapters.wechatWaiting': '等待微信扫码确认...', + 'settings.adapters.wechatBindSuccess': '微信绑定成功。', + 'settings.adapters.wechatAllowedUsersHint': '扫码用户会自动加入已配对用户;这里可额外填写允许的微信用户 ID。', // Settings > MCP 'settings.mcp.title': 'MCP 服务', diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index bc7e6769..fc8f6a63 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -5,12 +5,22 @@ import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { DirectoryPicker } from '../components/shared/DirectoryPicker' import { ConfirmDialog } from '../components/shared/ConfirmDialog' +import QRCode from 'qrcode' -type ImTab = 'feishu' | 'telegram' +type ImTab = 'feishu' | 'wechat' | 'telegram' export function AdapterSettings() { const t = useTranslation() - const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore() + const { + config, + isLoading, + fetchConfig, + updateConfig, + generatePairingCode, + startWechatLogin, + pollWechatLogin, + removePairedUser, + } = useAdapterStore() // Active IM tab —— Feishu 默认展示,在前 const [activeIm, setActiveIm] = useState('feishu') @@ -31,6 +41,13 @@ export function AdapterSettings() { const [fsAllowedUsers, setFsAllowedUsers] = useState('') const [fsStreamingCard, setFsStreamingCard] = useState(false) + // WeChat + const [wcAllowedUsers, setWcAllowedUsers] = useState('') + const [wechatQrUrl, setWechatQrUrl] = useState(null) + const [wechatSessionKey, setWechatSessionKey] = useState(null) + const [wechatStatus, setWechatStatus] = useState('') + const [isWechatBinding, setIsWechatBinding] = useState(false) + const [isSaving, setIsSaving] = useState(false) const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle') const [saveError, setSaveError] = useState('') @@ -38,7 +55,7 @@ export function AdapterSettings() { // Pairing const [pairingCode, setPairingCode] = useState(null) const [isGenerating, setIsGenerating] = useState(false) - const [pendingUnbind, setPendingUnbind] = useState<{ platform: 'telegram' | 'feishu'; userId: string | number } | null>(null) + const [pendingUnbind, setPendingUnbind] = useState<{ platform: 'telegram' | 'feishu' | 'wechat'; userId: string | number } | null>(null) const [isUnbinding, setIsUnbinding] = useState(false) useEffect(() => { @@ -56,8 +73,46 @@ export function AdapterSettings() { setFsVerificationToken(config.feishu?.verificationToken ?? '') setFsAllowedUsers(config.feishu?.allowedUsers?.join(', ') ?? '') setFsStreamingCard(config.feishu?.streamingCard ?? false) + setWcAllowedUsers(config.wechat?.allowedUsers?.join(', ') ?? '') }, [config]) + useEffect(() => { + if (!wechatSessionKey) return + + let cancelled = false + let timer: number | null = null + + const poll = async () => { + try { + const result = await pollWechatLogin(wechatSessionKey) + if (cancelled) return + if (result.connected) { + setWechatStatus(t('settings.adapters.wechatBindSuccess')) + setWechatQrUrl(null) + setWechatSessionKey(null) + setIsWechatBinding(false) + return + } + if (result.message) { + setWechatStatus(result.message) + } + } catch (err) { + if (!cancelled) setWechatStatus(err instanceof Error ? err.message : 'WeChat bind failed') + } + + if (!cancelled) { + timer = window.setTimeout(() => void poll(), 1200) + } + } + + timer = window.setTimeout(() => void poll(), 1200) + + return () => { + cancelled = true + if (timer != null) window.clearTimeout(timer) + } + }, [wechatSessionKey, pollWechatLogin, t]) + async function handleSave() { setIsSaving(true) setSaveStatus('idle') @@ -93,6 +148,16 @@ export function AdapterSettings() { streamingCard: fsStreamingCard, } + const wcUsers = wcAllowedUsers + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + patch.wechat = { + ...config.wechat, + allowedUsers: wcUsers.length ? wcUsers : [], + } + await updateConfig(patch) setSaveStatus('saved') setTimeout(() => setSaveStatus('idle'), 2000) @@ -116,7 +181,29 @@ export function AdapterSettings() { } }, [generatePairingCode]) - const handleUnbind = useCallback(async (platform: 'telegram' | 'feishu', userId: string | number) => { + const handleWechatBind = useCallback(async () => { + setIsWechatBinding(true) + setWechatStatus('') + try { + const result = await startWechatLogin() + if (!result.qrcodeUrl) { + throw new Error(result.message || 'WeChat QR URL missing') + } + const qrDataUrl = await QRCode.toDataURL(result.qrcodeUrl, { + errorCorrectionLevel: 'M', + margin: 1, + scale: 8, + }) + setWechatQrUrl(qrDataUrl) + setWechatSessionKey(result.sessionKey) + setWechatStatus(result.message) + } catch (err) { + setWechatStatus(err instanceof Error ? err.message : 'WeChat bind failed') + setIsWechatBinding(false) + } + }, [startWechatLogin]) + + const handleUnbind = useCallback(async (platform: 'telegram' | 'feishu' | 'wechat', userId: string | number) => { setPendingUnbind({ platform, userId }) }, []) @@ -136,6 +223,7 @@ export function AdapterSettings() { const allPairedUsers = [ ...(config.telegram?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'telegram' as const })), ...(config.feishu?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'feishu' as const })), + ...(config.wechat?.pairedUsers ?? []).map((u) => ({ ...u, platform: 'wechat' as const })), ] // Check pairing expiry @@ -253,6 +341,11 @@ export function AdapterSettings() { active={activeIm === 'feishu'} onClick={() => setActiveIm('feishu')} /> + setActiveIm('wechat')} + /> )} + {activeIm === 'wechat' && ( +
+
+
+
+
+ {config.wechat?.accountId ? t('settings.adapters.wechatConnected') : t('settings.adapters.wechatNotConnected')} +
+

+ {t('settings.adapters.wechatQrHint')} +

+
+ +
+ + {wechatQrUrl && ( +
+ {t('settings.adapters.wechatQrAlt')} +
+ {wechatStatus || t('settings.adapters.wechatWaiting')} +
+
+ )} + + {!wechatQrUrl && wechatStatus && ( +

{wechatStatus}

+ )} +
+ +
+ setWcAllowedUsers(e.target.value)} + placeholder={t('settings.adapters.wcAllowedUsersPlaceholder')} + /> +

{t('settings.adapters.wechatAllowedUsersHint')}

+
+
+ )} + {activeIm === 'telegram' && (
Promise updateConfig: (patch: Partial) => Promise generatePairingCode: () => Promise - removePairedUser: (platform: 'telegram' | 'feishu', userId: string | number) => Promise + startWechatLogin: () => Promise<{ qrcodeUrl?: string; message: string; sessionKey: string }> + pollWechatLogin: (sessionKey: string) => Promise<{ connected: boolean; message?: string }> + removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat', userId: string | number) => Promise } export const useAdapterStore = create((set, get) => ({ @@ -90,7 +92,31 @@ export const useAdapterStore = create((set, get) => ({ return code }, + startWechatLogin: async () => { + return adaptersApi.startWechatLogin() + }, + + pollWechatLogin: async (sessionKey) => { + const result = await adaptersApi.pollWechatLogin(sessionKey) + if ('connected' in result && result.connected === false) { + return { connected: false, message: result.message } + } + if ('wechat' in result || 'telegram' in result || 'feishu' in result) { + set({ config: result }) + void notifyTauriRestartAdapters() + return { connected: true } + } + return { connected: false } + }, + removePairedUser: async (platform, userId) => { + if (platform === 'wechat') { + const config = await adaptersApi.unbindWechat() + set({ config }) + void notifyTauriRestartAdapters() + return + } + const { config } = get() const platformConfig = config[platform] if (!platformConfig) return diff --git a/desktop/src/types/adapter.ts b/desktop/src/types/adapter.ts index 40b42eb8..552fd661 100644 --- a/desktop/src/types/adapter.ts +++ b/desktop/src/types/adapter.ts @@ -30,4 +30,13 @@ export type AdapterFileConfig = { defaultWorkDir?: string streamingCard?: boolean } + wechat?: { + accountId?: string + botToken?: string + baseUrl?: string + userId?: string + allowedUsers?: string[] + pairedUsers?: PairedUser[] + defaultWorkDir?: string + } } diff --git a/desktop/src/types/qrcode.d.ts b/desktop/src/types/qrcode.d.ts new file mode 100644 index 00000000..6c6a725c --- /dev/null +++ b/desktop/src/types/qrcode.d.ts @@ -0,0 +1,18 @@ +declare module 'qrcode' { + type ToDataURLOptions = { + errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H' + margin?: number + scale?: number + width?: number + color?: { + dark?: string + light?: string + } + } + + const QRCode: { + toDataURL(text: string, options?: ToDataURLOptions): Promise + } + + export default QRCode +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 66547160..7d74aee5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -60,6 +60,7 @@ const zhSidebar = [ collapsed: false, items: [ { text: '总览', link: '/im/' }, + { text: '微信', link: '/im/wechat' }, { text: 'Telegram', link: '/im/telegram' }, { text: '飞书', link: '/im/feishu' }, ], diff --git a/docs/im/index.md b/docs/im/index.md index 7e04f6bc..66ca10f2 100644 --- a/docs/im/index.md +++ b/docs/im/index.md @@ -1,7 +1,7 @@ # IM 接入 > 当前可用的 IM 接入方案总览。 -> 如果你只是想把 Telegram 或飞书接进来,从这篇开始。 +> 如果你只是想把微信、Telegram 或飞书接进来,从这篇开始。 ## 当前方案是什么 @@ -16,7 +16,7 @@ flowchart TD C --> D["本地配置持久化"] D --> E["~/.claude/adapters.json"] - E --> F["Telegram / 飞书 Adapter 进程"] + E --> F["微信 / Telegram / 飞书 Adapter 进程"] F --> G["加载平台配置"] F --> H["校验配对与授权"] F --> I["读取 / 写入会话映射"] @@ -41,14 +41,14 @@ flowchart TD U --> V["流式消息 / 权限请求 / 状态事件"] V --> S S --> F - F --> W["Telegram / 飞书用户"] + F --> W["微信 / Telegram / 飞书用户"] ``` 可以把这条链路理解成四层: - 配置层:桌面端 webapp 负责填写平台凭据、默认项目和配对码管理 - 存储层:本地服务端把配置写入 `~/.claude/adapters.json` -- 适配层:Telegram / 飞书 adapter 进程负责接 IM 平台、做授权检查、恢复或创建会话 +- 适配层:微信 / Telegram / 飞书 adapter 进程负责接 IM 平台、做授权检查、恢复或创建会话 - 会话层:adapter 通过 HTTP 创建 session,再通过 WebSocket 把 IM 消息桥接到 Claude Code 会话 ## 用户怎么用 @@ -59,7 +59,7 @@ flowchart TD - `serverUrl` - `defaultProjectDir` -- Telegram 或飞书各自的凭据 +- 微信扫码绑定,或填写 Telegram / 飞书各自的凭据 - 可选 `allowedUsers` 这里的配置会通过 `GET /api/adapters` 和 `PUT /api/adapters` 读写到 `~/.claude/adapters.json`。 @@ -73,7 +73,7 @@ flowchart TD - 码有效期 60 分钟 - 配对成功后立即失效 -配对码是平台无关的,同一个码可以在 Telegram 或飞书私聊里使用一次。 +配对码是平台无关的,同一个码可以在 Telegram 或飞书私聊里使用一次。微信不使用配对码;在微信标签页扫码并确认后就会把扫码用户绑定到本机。 ### 3. 启动对应 Adapter 进程 @@ -85,6 +85,8 @@ bun install bun run telegram # 或 bun run feishu +# 或 +bun run wechat ``` ### 4. 在 IM 里私聊 Bot @@ -105,6 +107,7 @@ bun run feishu - `pairing` - `telegram` - `feishu` +- `wechat` 其中: @@ -152,9 +155,11 @@ Adapter 不是直接把消息丢给一个全局 Claude 进程,而是: - Telegram:`grammy`,按钮审批,纯私聊模式 - 飞书:`@larksuiteoapi/node-sdk`,长连接事件订阅,交互卡片审批,当前只处理 `p2p` +- 微信:扫码绑定账号,`getupdates` 长轮询接收消息,文本命令审批使用 `/allow ` / `/deny ` 分别看: +- [微信接入](./wechat.md) - [Telegram 接入](./telegram.md) - [飞书接入](./feishu.md) diff --git a/docs/im/wechat.md b/docs/im/wechat.md new file mode 100644 index 00000000..72806139 --- /dev/null +++ b/docs/im/wechat.md @@ -0,0 +1,60 @@ +# 微信接入 + +> 微信 Adapter 的接入教程。微信不需要手动填写 Bot Token,在桌面端扫码确认后即可绑定。 + +## 适用场景 + +微信方案适合个人私聊远程使用。当前实现先支持文本聊天、项目选择、状态查看、停止生成和权限审批;附件收发后续再补齐。 + +## 1. 在桌面端扫码绑定 + +打开桌面端 `设置 -> IM 接入 -> 微信`: + +1. 点击「扫码绑定」 +2. 使用微信扫描页面里的二维码 +3. 在微信中确认 +4. 页面显示绑定成功后,桌面端会重启 IM adapter sidecar + +扫码成功后,桌面端会把微信返回的账号凭据写入 `~/.claude/adapters.json` 的 `wechat` 配置,并把扫码用户加入 `pairedUsers`。 + +## 2. 发送消息 + +绑定后,直接在微信里发送自然语言消息即可。 + +如果没有配置默认项目,Adapter 会先返回最近项目列表,回复编号后再开始会话。 + +## 支持的命令 + +- `/projects` — 切换项目,重新显示最近项目列表 +- `/status` — 查看当前会话的项目、模型和运行状态 +- `/clear` — 清空当前会话上下文,保留项目绑定 +- `/new` — 清空当前 chat 绑定的 session,并重新选择项目 +- `/help` — 显示当前可用命令 +- `/stop` — 向当前 session 发送 `stop_generation` +- `/allow ` — 允许一次权限请求 +- `/deny ` — 拒绝一次权限请求 + +## 解绑 + +在桌面端 `设置 -> IM 接入` 的「已配对用户」列表里点击微信用户右侧的「解绑」。 + +解绑会清空微信账号凭据和已配对用户,并重启 adapter sidecar。解绑后需要重新扫码才能继续使用。 + +## 本地开发启动 + +```bash +cd adapters +bun install +bun run wechat +``` + +可选环境变量: + +```bash +export WECHAT_ACCOUNT_ID="..." +export WECHAT_BOT_TOKEN="..." +export WECHAT_BASE_URL="https://ilinkai.weixin.qq.com" +export ADAPTER_SERVER_URL="ws://127.0.0.1:3456" +``` + +正常桌面端使用不需要手动设置这些环境变量,扫码绑定会写入本地配置。 diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts new file mode 100644 index 00000000..368a42c4 --- /dev/null +++ b/src/server/__tests__/adapters.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { handleAdaptersApi } from '../api/adapters.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-adapters-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir +} + +async function teardown() { + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } else { + delete process.env.CLAUDE_CONFIG_DIR + } + await fs.rm(tmpDir, { recursive: true, force: true }) +} + +function makeRequest(method: string, pathName: string, body?: Record) { + const url = new URL(pathName, 'http://localhost:3456') + const req = new Request(url.toString(), { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) + const segments = url.pathname.split('/').filter(Boolean) + return { req, url, segments } +} + +describe('Adapters API', () => { + beforeEach(setup) + afterEach(teardown) + + it('masks WeChat bot tokens in GET responses', async () => { + const put = makeRequest('PUT', '/api/adapters', { + wechat: { + accountId: 'bot-1', + botToken: 'wechat-secret-token', + baseUrl: 'https://ilinkai.weixin.qq.com', + userId: 'wx-user', + pairedUsers: [{ userId: 'wx-user', displayName: 'WeChat User', pairedAt: 1 }], + }, + }) + expect((await handleAdaptersApi(put.req, put.url, put.segments)).status).toBe(200) + + const get = makeRequest('GET', '/api/adapters') + const res = await handleAdaptersApi(get.req, get.url, get.segments) + expect(res.status).toBe(200) + const json = await res.json() as any + expect(json.wechat.botToken).toBe('****oken') + expect(json.wechat.accountId).toBe('bot-1') + }) + + it('clears WeChat credentials on unbind', async () => { + const put = makeRequest('PUT', '/api/adapters', { + wechat: { + accountId: 'bot-1', + botToken: 'wechat-secret-token', + userId: 'wx-user', + pairedUsers: [{ userId: 'wx-user', displayName: 'WeChat User', pairedAt: 1 }], + }, + }) + await handleAdaptersApi(put.req, put.url, put.segments) + + const unbind = makeRequest('POST', '/api/adapters/wechat/unbind') + const res = await handleAdaptersApi(unbind.req, unbind.url, unbind.segments) + expect(res.status).toBe(200) + const json = await res.json() as any + expect(json.wechat.botToken).toBeUndefined() + expect(json.wechat.accountId).toBeUndefined() + expect(json.wechat.pairedUsers).toEqual([]) + }) +}) diff --git a/src/server/api/adapters.ts b/src/server/api/adapters.ts index c72a364a..cd6610ac 100644 --- a/src/server/api/adapters.ts +++ b/src/server/api/adapters.ts @@ -7,8 +7,13 @@ import { adapterService } from '../services/adapterService.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' +import { + pollWechatLoginWithQr, + startWechatLoginWithQr, + WECHAT_DEFAULT_BASE_URL, +} from '../../../adapters/wechat/protocol.js' -const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu', 'pairing']) +const ALLOWED_TOP_KEYS = new Set(['serverUrl', 'defaultProjectDir', 'telegram', 'feishu', 'wechat', 'pairing']) export async function handleAdaptersApi( req: Request, @@ -16,6 +21,11 @@ export async function handleAdaptersApi( _segments: string[], ): Promise { try { + const tail = _segments.slice(2) + if (tail[0] === 'wechat') { + return handleWechatAdaptersApi(req, tail.slice(1)) + } + if (req.method === 'GET') { const config = await adapterService.getConfig() return Response.json(config) @@ -39,3 +49,48 @@ export async function handleAdaptersApi( return errorResponse(error) } } + +async function handleWechatAdaptersApi(req: Request, tail: string[]): Promise { + if (req.method === 'POST' && tail[0] === 'login' && tail[1] === 'start') { + const result = await startWechatLoginWithQr({ force: true }) + return Response.json(result) + } + + if (req.method === 'POST' && tail[0] === 'login' && tail[1] === 'poll') { + const body = (await req.json()) as { sessionKey?: string } + if (!body.sessionKey) throw ApiError.badRequest('Missing sessionKey') + const result = await pollWechatLoginWithQr({ sessionKey: body.sessionKey }) + if (result.connected) { + const pairedUsers = result.userId + ? [{ userId: result.userId, displayName: 'WeChat User', pairedAt: Date.now() }] + : [] + await adapterService.updateConfig({ + wechat: { + accountId: result.accountId, + botToken: result.botToken, + baseUrl: result.baseUrl || WECHAT_DEFAULT_BASE_URL, + userId: result.userId, + pairedUsers, + allowedUsers: [], + }, + }) + } + return Response.json(result.connected ? await adapterService.getConfig() : result) + } + + if (req.method === 'POST' && tail[0] === 'unbind') { + await adapterService.updateConfig({ + wechat: { + accountId: undefined, + botToken: undefined, + baseUrl: WECHAT_DEFAULT_BASE_URL, + userId: undefined, + pairedUsers: [], + allowedUsers: [], + }, + }) + return Response.json(await adapterService.getConfig()) + } + + throw new ApiError(404, 'Unknown WeChat adapter endpoint', 'NOT_FOUND') +} diff --git a/src/server/services/adapterService.ts b/src/server/services/adapterService.ts index 754373d5..546a6733 100644 --- a/src/server/services/adapterService.ts +++ b/src/server/services/adapterService.ts @@ -42,6 +42,15 @@ export type AdapterFileConfig = { defaultWorkDir?: string streamingCard?: boolean } + wechat?: { + accountId?: string + botToken?: string + baseUrl?: string + userId?: string + allowedUsers?: string[] + pairedUsers?: PairedUser[] + defaultWorkDir?: string + } } function getConfigPath(): string { @@ -84,6 +93,9 @@ class AdapterService { if (config.feishu.encryptKey) config.feishu.encryptKey = maskSecret(config.feishu.encryptKey) if (config.feishu.verificationToken) config.feishu.verificationToken = maskSecret(config.feishu.verificationToken) } + if (config.wechat?.botToken) { + config.wechat.botToken = maskSecret(config.wechat.botToken) + } if (config.pairing?.code) { config.pairing.code = '******' } @@ -103,6 +115,9 @@ class AdapterService { if (isMasked(patch.feishu.encryptKey)) patch.feishu.encryptKey = current.feishu?.encryptKey if (isMasked(patch.feishu.verificationToken)) patch.feishu.verificationToken = current.feishu?.verificationToken } + if (patch.wechat && isMasked(patch.wechat.botToken)) { + patch.wechat.botToken = current.wechat?.botToken + } if (patch.pairing && isMasked(patch.pairing.code ?? undefined)) { patch.pairing.code = current.pairing?.code } @@ -112,6 +127,7 @@ class AdapterService { ...patch, telegram: patch.telegram ? { ...current.telegram, ...patch.telegram } : current.telegram, feishu: patch.feishu ? { ...current.feishu, ...patch.feishu } : current.feishu, + wechat: patch.wechat ? { ...current.wechat, ...patch.wechat } : current.wechat, pairing: patch.pairing !== undefined ? { ...current.pairing, ...patch.pairing } : current.pairing, }