mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
915d6f413e
commit
a627bb19f2
@ -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
|
||||
|
||||
68
adapters/common/__tests__/config.test.ts
Normal file
68
adapters/common/__tests__/config.test.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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' }), {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
34
adapters/wechat/__tests__/protocol.test.ts
Normal file
34
adapters/wechat/__tests__/protocol.test.ts
Normal 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
397
adapters/wechat/index.ts
Normal 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
371
adapters/wechat/protocol.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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.ts、src/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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<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
|
||||
|
||||
@ -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', {})
|
||||
},
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 服务',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
18
desktop/src/types/qrcode.d.ts
vendored
Normal 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
|
||||
}
|
||||
@ -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' },
|
||||
],
|
||||
|
||||
@ -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
60
docs/im/wechat.md
Normal 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"
|
||||
```
|
||||
|
||||
正常桌面端使用不需要手动设置这些环境变量,扫码绑定会写入本地配置。
|
||||
79
src/server/__tests__/adapters.test.ts
Normal file
79
src/server/__tests__/adapters.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user