mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-21 14:03:35 +08:00
This captures the pending worktree fixes before applying them to the current local main. The changes tighten IM adapter path and credential handling, preserve retry behavior for failed desktop notifications, and make Azure/OpenAI provider auth and stop reasons reflect actual runtime state. Constraint: Worktree was detached from an older local main with pending uncommitted fixes Rejected: Merge the detached HEAD directly | would also replay unrelated stale history Rejected: Leave notification dedupe as fire-and-forget | failed sends consumed retry keys Confidence: high Scope-risk: broad Directive: Keep adapter absolute-path matching constrained to configured work roots Tested: git diff --check Not-tested: full quality gate before local main integration
174 lines
5.5 KiB
TypeScript
174 lines
5.5 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
|
|
}
|
|
}
|
|
|
|
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,
|
|
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()
|