diff --git a/adapters/common/pairing.ts b/adapters/common/pairing.ts new file mode 100644 index 00000000..c668b5a1 --- /dev/null +++ b/adapters/common/pairing.ts @@ -0,0 +1,107 @@ +/** + * 配对核心逻辑 + * + * - generatePairingCode(): 生成 6 位安全配对码 + * - isPaired(): 检查用户是否已配对(pairedUsers + allowedUsers 并集) + * - tryPair(): 验证配对码,成功则写入 pairedUsers 并清除 code + */ + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import * as crypto from 'node:crypto' +import type { PairedUser, PairingState } from './config.js' + +const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L +const CODE_LENGTH = 6 +const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes + +function getConfigPath(): string { + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + return path.join(configDir, 'adapters.json') +} + +function readConfigFile(): Record { + try { + return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8')) + } catch { + return {} + } +} + +function writeConfigFile(data: Record): void { + const filePath = getConfigPath() + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + const tmp = `${filePath}.tmp.${Date.now()}` + fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8') + fs.renameSync(tmp, filePath) +} + +export function generatePairingCode(): string { + let code = '' + for (let i = 0; i < CODE_LENGTH; i++) { + code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)] + } + return code +} + +/** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */ +export function isPaired( + platform: 'telegram' | 'feishu', + userId: string | number, + config: Record, +): boolean { + const platformConfig = config[platform] ?? {} + const allowedUsers: (string | number)[] = platformConfig.allowedUsers ?? [] + const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? [] + + // allowedUsers 非空时检查 + if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true + // allowedUsers 为空且 pairedUsers 也为空 => 旧行为(允许所有人) + if (pairedUsers.length === 0 && allowedUsers.length === 0) return true + + return pairedUsers.some((p) => String(p.userId) === String(userId)) +} + +/** + * 尝试配对:验证消息文本是否匹配当前有效配对码。 + * 成功则写入 pairedUsers 并清除 pairing.code,返回 true。 + */ +export function tryPair( + messageText: string, + senderInfo: { userId: string | number; displayName: string }, + platform: 'telegram' | 'feishu', +): boolean { + const file = readConfigFile() + const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null } + + // 检查配对码是否有效 + if (!pairing.code || !pairing.expiresAt) return false + if (Date.now() > pairing.expiresAt) return false + + // 比较(忽略大小写和空格) + const input = messageText.trim().toUpperCase() + if (input !== pairing.code.toUpperCase()) return false + + // 配对成功:写入 pairedUsers + const platformConfig = file[platform] ?? {} + const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? [] + + // 避免重复 + const exists = pairedUsers.some((p) => String(p.userId) === String(senderInfo.userId)) + if (!exists) { + pairedUsers.push({ + userId: senderInfo.userId, + displayName: senderInfo.displayName, + pairedAt: Date.now(), + }) + } + + // 更新 config + file[platform] = { ...platformConfig, pairedUsers } + file.pairing = { code: null, expiresAt: null, createdAt: null } // 一次性使用 + writeConfigFile(file) + + return true +}