mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Add a WhatsApp adapter backed by Baileys linked-device auth, plus desktop QR binding UI, server config endpoints, sidecar startup wiring, tests, and documentation. Constraint: Uses WhatsApp Web linked-device auth, not Meta WhatsApp Business Cloud API. Tested: - cd adapters && bun run check:adapters - bun test src/server/__tests__/adapters.test.ts - cd desktop && bun run check:desktop - bun run check:native - bun run check:persistence-upgrade - bun run check:docs Not-tested: - Live WhatsApp QR pairing, because no WhatsApp account/device was exercised here. - bun run check:server, because the existing src/server/__tests__/conversations.test.ts timeout still fails independently. Confidence: medium Scope-risk: moderate
182 lines
5.7 KiB
TypeScript
182 lines
5.7 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
|
|
}
|
|
dingtalk?: {
|
|
clientId?: string
|
|
clientSecret?: string
|
|
allowedUsers?: string[]
|
|
pairedUsers?: PairedUser[]
|
|
defaultWorkDir?: string
|
|
endpoint?: string
|
|
permissionCardTemplateId?: string
|
|
}
|
|
whatsapp?: {
|
|
accountJid?: string
|
|
authDir?: 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.dingtalk?.clientSecret) {
|
|
config.dingtalk.clientSecret = maskSecret(config.dingtalk.clientSecret)
|
|
}
|
|
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.dingtalk && isMasked(patch.dingtalk.clientSecret)) {
|
|
patch.dingtalk.clientSecret = current.dingtalk?.clientSecret
|
|
}
|
|
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,
|
|
dingtalk: patch.dingtalk ? { ...current.dingtalk, ...patch.dingtalk } : current.dingtalk,
|
|
whatsapp: patch.whatsapp ? { ...current.whatsapp, ...patch.whatsapp } : current.whatsapp,
|
|
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, mode: 0o700 })
|
|
|
|
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
|
try {
|
|
await fs.writeFile(tmpFile, JSON.stringify(data, null, 2) + '\n', {
|
|
encoding: 'utf-8',
|
|
mode: 0o600,
|
|
})
|
|
await fs.rename(tmpFile, filePath)
|
|
await fs.chmod(filePath, 0o600).catch(() => {})
|
|
} catch (err) {
|
|
await fs.unlink(tmpFile).catch(() => {})
|
|
throw ApiError.internal(`Failed to write adapter config: ${err}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const adapterService = new AdapterService()
|