cc-haha/src/server/services/adapterService.ts
程序员阿江(Relakkes) a627bb19f2 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
2026-05-03 17:38:08 +08:00

154 lines
4.8 KiB
TypeScript

/**
* Adapter Service — 读写 IM Adapter 配置文件
*
* 配置文件:~/.claude/adapters.json
* 原子写入:先写临时文件,再 rename
*/
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
export type PairedUser = {
userId: string | number
displayName: string
pairedAt: number
}
export type PairingState = {
code?: string | null
expiresAt?: number | null
createdAt?: number | null
}
export type AdapterFileConfig = {
serverUrl?: string
defaultProjectDir?: string
pairing?: PairingState
telegram?: {
botToken?: string
allowedUsers?: number[]
pairedUsers?: PairedUser[]
defaultWorkDir?: string
}
feishu?: {
appId?: string
appSecret?: string
encryptKey?: string
verificationToken?: string
allowedUsers?: string[]
pairedUsers?: PairedUser[]
defaultWorkDir?: string
streamingCard?: boolean
}
wechat?: {
accountId?: string
botToken?: string
baseUrl?: string
userId?: string
allowedUsers?: string[]
pairedUsers?: PairedUser[]
defaultWorkDir?: string
}
}
function getConfigPath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'adapters.json')
}
function maskSecret(value: string | undefined): string | undefined {
if (!value) return value
if (value.length <= 4) return '****'
return '****' + value.slice(-4)
}
function isMasked(value: string | undefined): boolean {
return !!value && value.startsWith('****')
}
class AdapterService {
/** 读取原始配置(不脱敏) */
async getRawConfig(): Promise<AdapterFileConfig> {
try {
const raw = await fs.readFile(getConfigPath(), 'utf-8')
return JSON.parse(raw) as AdapterFileConfig
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return {}
}
throw ApiError.internal(`Failed to read adapter config: ${err}`)
}
}
/** 读取配置(敏感字段脱敏) */
async getConfig(): Promise<AdapterFileConfig> {
const config = await this.getRawConfig()
if (config.telegram?.botToken) {
config.telegram.botToken = maskSecret(config.telegram.botToken)
}
if (config.feishu) {
if (config.feishu.appSecret) config.feishu.appSecret = maskSecret(config.feishu.appSecret)
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 = '******'
}
return config
}
/** 更新配置(浅合并,敏感字段如果是脱敏值则保留原值) */
async updateConfig(patch: Partial<AdapterFileConfig>): Promise<void> {
const current = await this.getRawConfig()
// 保留已存储的密钥(如果前端传回的是脱敏值)
if (patch.telegram && isMasked(patch.telegram.botToken)) {
patch.telegram.botToken = current.telegram?.botToken
}
if (patch.feishu) {
if (isMasked(patch.feishu.appSecret)) patch.feishu.appSecret = current.feishu?.appSecret
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
}
const merged: AdapterFileConfig = {
...current,
...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,
}
await this.writeConfig(merged)
}
private async writeConfig(data: AdapterFileConfig): Promise<void> {
const filePath = getConfigPath()
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
const tmpFile = `${filePath}.tmp.${Date.now()}`
try {
await fs.writeFile(tmpFile, JSON.stringify(data, null, 2) + '\n', 'utf-8')
await fs.rename(tmpFile, filePath)
} catch (err) {
await fs.unlink(tmpFile).catch(() => {})
throw ApiError.internal(`Failed to write adapter config: ${err}`)
}
}
}
export const adapterService = new AdapterService()