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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 17:38:08 +08:00
parent 915d6f413e
commit a627bb19f2
29 changed files with 1521 additions and 49 deletions

View File

@ -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

View File

@ -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
}
}

View File

@ -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' }), {

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<string, any>,
): 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)

View File

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

View File

@ -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",

View File

@ -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<boolean> {
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<void> {
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<void> {
)
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<void> {
const numericChatId = Number(chatId)
@ -522,7 +523,7 @@ async function startNewSession(chatId: string, query?: string): Promise<void> {
`${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) {

View File

@ -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')
})
})

397
adapters/wechat/index.ts Normal file
View File

@ -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<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
const accumulatedText = new Map<string, string>()
const contextTokens = new Map<string, string>()
const pendingPermissions = new Map<string, Set<string>>()
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<void> {
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<string> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<string, unknown>).model
if (typeof model === 'string' && model.trim()) runtime.model = model
}
break
}
}
async function routeUserMessage(message: WechatMessage): Promise<void> {
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<void> {
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<void> {
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)
})

371
adapters/wechat/protocol.ts Normal file
View File

@ -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<string, ActiveLogin>()
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<WechatQrStartResult> {
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<WechatQrPollResult> {
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<WechatGetUpdatesResp> {
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<void> {
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<QrStatusResponse> {
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<string> {
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<string> {
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<string, string> {
return {
'iLink-App-Id': ILINK_APP_ID,
'iLink-App-ClientVersion': String(ILINK_APP_CLIENT_VERSION),
}
}
function buildHeaders(opts: { token?: string; body: string }): Record<string, string> {
const headers: Record<string, string> = {
'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)
}
}

View File

@ -8,7 +8,7 @@
*
* claude-sidecar server --app-root <path> --host 127.0.0.1 --port 12345
* claude-sidecar cli --app-root <path> [ CLI ...]
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram]
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram] [--wechat]
*
* process.env / process.argv await
* src/server/index.tssrc/entrypoints/cli.tsx
@ -52,11 +52,12 @@ if (mode === 'adapters') {
async function runAdapters(rawArgs: string[]): Promise<void> {
// 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<void> {
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<void> {
}
}
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',

View File

@ -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

View File

@ -64,7 +64,7 @@ struct StoredWindowState {
/// 与 ServerState 平级的 adapter 子进程状态。
///
/// adapter sidecarclaude-sidecar adapters --feishu --telegram)的生命周期
/// adapter sidecarclaude-sidecar adapters --feishu --telegram --wechat)的生命周期
/// 跟 server 不同:它没有 HTTP 端口可探活,没配凭据时会自己干净退出,
/// 而且需要支持运行时热重启 —— 用户在设置页保存飞书 / Telegram 凭据后,
/// 前端会通过 invoke('restart_adapters_sidecar') 来重启它,让新凭据生效。
@ -120,7 +120,7 @@ fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
.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<CommandChild, String> {
&app_root_arg,
"--feishu",
"--telegram",
"--wechat",
]);
let (mut rx, child) = sidecar

View File

@ -9,4 +9,19 @@ export const adaptersApi = {
updateConfig(patch: Partial<AdapterFileConfig>) {
return api.put<AdapterFileConfig>('/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<AdapterFileConfig>('/api/adapters/wechat/unbind', {})
},
}

View File

@ -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',

View File

@ -214,9 +214,10 @@ export const zh: Record<TranslationKey, string> = {
// 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<TranslationKey, string> = {
'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<TranslationKey, string> = {
'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 服务',

View File

@ -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<ImTab>('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<string | null>(null)
const [wechatSessionKey, setWechatSessionKey] = useState<string | null>(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<string | null>(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')}
/>
<ImTabButton
label={t('settings.adapters.wechat')}
active={activeIm === 'wechat'}
onClick={() => setActiveIm('wechat')}
/>
<ImTabButton
label={t('settings.adapters.telegram')}
active={activeIm === 'telegram'}
@ -317,6 +410,53 @@ export function AdapterSettings() {
</div>
)}
{activeIm === 'wechat' && (
<div className="p-4 space-y-4">
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{config.wechat?.accountId ? t('settings.adapters.wechatConnected') : t('settings.adapters.wechatNotConnected')}
</div>
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.adapters.wechatQrHint')}
</p>
</div>
<Button onClick={handleWechatBind} loading={isWechatBinding && !wechatQrUrl}>
{config.wechat?.accountId ? t('settings.adapters.wechatRebind') : t('settings.adapters.wechatBind')}
</Button>
</div>
{wechatQrUrl && (
<div className="flex items-start gap-4">
<img
src={wechatQrUrl}
alt={t('settings.adapters.wechatQrAlt')}
className="h-40 w-40 rounded-lg border border-[var(--color-border)] bg-white object-contain p-2"
/>
<div className="pt-2 text-sm text-[var(--color-text-secondary)]">
{wechatStatus || t('settings.adapters.wechatWaiting')}
</div>
</div>
)}
{!wechatQrUrl && wechatStatus && (
<p className="text-sm text-[var(--color-text-secondary)]">{wechatStatus}</p>
)}
</div>
<div className="flex flex-col gap-1">
<Input
label={t('settings.adapters.allowedUsers')}
value={wcAllowedUsers}
onChange={(e) => setWcAllowedUsers(e.target.value)}
placeholder={t('settings.adapters.wcAllowedUsersPlaceholder')}
/>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.wechatAllowedUsersHint')}</p>
</div>
</div>
)}
{activeIm === 'telegram' && (
<div className="p-4 space-y-4">
<Input

View File

@ -4,7 +4,7 @@ import type { AdapterFileConfig } from '../types/adapter'
/**
* Tauri command kill + respawn adapter sidecar
* ~/.claude/adapters.json / Telegram
* ~/.claude/adapters.json / Telegram /
* WebSocket
*
* Tauri /
@ -48,7 +48,9 @@ type AdapterStore = {
fetchConfig: () => Promise<void>
updateConfig: (patch: Partial<AdapterFileConfig>) => Promise<void>
generatePairingCode: () => Promise<string>
removePairedUser: (platform: 'telegram' | 'feishu', userId: string | number) => Promise<void>
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<void>
}
export const useAdapterStore = create<AdapterStore>((set, get) => ({
@ -90,7 +92,31 @@ export const useAdapterStore = create<AdapterStore>((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

View File

@ -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
}
}

18
desktop/src/types/qrcode.d.ts vendored Normal file
View File

@ -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<string>
}
export default QRCode
}

View File

@ -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' },
],

View File

@ -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 <requestId>` / `/deny <requestId>`
分别看:
- [微信接入](./wechat.md)
- [Telegram 接入](./telegram.md)
- [飞书接入](./feishu.md)

60
docs/im/wechat.md Normal file
View File

@ -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 <requestId>` — 允许一次权限请求
- `/deny <requestId>` — 拒绝一次权限请求
## 解绑
在桌面端 `设置 -> 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"
```
正常桌面端使用不需要手动设置这些环境变量,扫码绑定会写入本地配置。

View File

@ -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<string, unknown>) {
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([])
})
})

View File

@ -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<Response> {
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<Response> {
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')
}

View File

@ -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,
}