mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
814c02a786
Connecting Feishu meant creating a bot by hand on the open platform and pasting an App ID and App Secret back. Feishu also exposes an RFC 8628 device-authorization flow, so the desktop can now render a QR code, and confirming it in the app creates the bot and stores its credentials directly. `adapters/feishu/registration.ts` implements that protocol rather than importing `registerApp` from `@larksuiteoapi/node-sdk@1.73`: the repository pins 1.60 for the chat client, and the SDK runs the whole poll inside one un-cancellable promise where the desktop needs the stateless begin/poll pair the DingTalk registration already uses. The scan is create-only, so it can never rewrite the configuration of a bot the user already runs, and it pre-fills exactly the scopes, events and callbacks this adapter calls. International tenants finish on Lark's domain, which is now persisted and honoured by the client. WeCom, QQ and Slack join the same session model. WeCom and QQ bind by scanning; Slack has no scan flow, so it uses an app manifest that pre-fills the scopes and Socket Mode. All three run over long connections, so no public callback URL is needed, and all three accept private chats only — pairing authorizes one person, and answering in a group would extend that authorization to everyone else in the room. They are built on a new `adapters/common/chat-runtime.ts` instead of a fourth copy of the loop the five existing adapters each carry. A platform supplies a `ChatPort` — how to say something, how to open a streaming reply, optionally how to send an image — and the runtime owns pairing, command routing, session restore, permission bookkeeping and the translation of the server's stream. The existing five are deliberately left on their own copies; migrating them is a separate change with its own regression surface. Attachments are downloaded through a deferred loader that runs after the pairing gate and inside the per-chat queue. Resolving them eagerly would let an unpaired stranger make the adapter fetch bytes and write them under ~/.claude/im-downloads — on Slack with the bot token attached — and would let a slow attachment overtake a text message sent after it. The sidecar launcher's per-adapter branches become one table. It is declared above the mode dispatch on purpose: `runAdapters` is hoisted and runs at module top level, so a table declared below it is still in its temporal dead zone when the adapters mode reads it — which type checks, lints and unit tests all miss, and only the compiled binary reveals. Verified with the checks `check:impact` selects: adapters, server, desktop, electron, policy, chat-contract, agent-flow, docs, native (sidecar compile, packaging and an adapters-mode smoke against the real binary) and coverage. The scan flows themselves are not verified against live platforms — that needs real WeCom, QQ and Slack accounts and would create real bots. Claude-Session: https://claude.ai/code/session_01CCGoP316AK7wdQG3Ms6Uwq
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
/**
|
|
* 配对核心逻辑
|
|
*
|
|
* - 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
|
|
export type ImPlatform =
|
|
| 'telegram'
|
|
| 'feishu'
|
|
| 'wechat'
|
|
| 'dingtalk'
|
|
| 'whatsapp'
|
|
| 'wecom'
|
|
| 'qq'
|
|
| 'slack'
|
|
|
|
// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
|
|
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
|
|
const RATE_LIMIT_MAX_ATTEMPTS = 5
|
|
const failedAttempts = new Map<string, { count: number; firstAttempt: number }>()
|
|
|
|
function isRateLimited(userId: string | number): boolean {
|
|
const key = String(userId)
|
|
const record = failedAttempts.get(key)
|
|
if (!record) return false
|
|
if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
failedAttempts.delete(key)
|
|
return false
|
|
}
|
|
return record.count >= RATE_LIMIT_MAX_ATTEMPTS
|
|
}
|
|
|
|
function recordFailedAttempt(userId: string | number): void {
|
|
const key = String(userId)
|
|
const record = failedAttempts.get(key)
|
|
if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
failedAttempts.set(key, { count: 1, firstAttempt: Date.now() })
|
|
} else {
|
|
record.count++
|
|
}
|
|
}
|
|
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<string, any> {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function writeConfigFile(data: Record<string, any>): void {
|
|
const filePath = getConfigPath()
|
|
const dir = path.dirname(filePath)
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
const tmp = `${filePath}.tmp.${crypto.randomBytes(8).toString('hex')}`
|
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 })
|
|
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: ImPlatform,
|
|
userId: string | number,
|
|
config: Record<string, any>,
|
|
): 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
|
|
// 默认关闭:没有配置任何用户时拒绝访问(需要先配对)
|
|
if (pairedUsers.length === 0 && allowedUsers.length === 0) return false
|
|
|
|
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: ImPlatform,
|
|
): boolean {
|
|
const file = readConfigFile()
|
|
const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null }
|
|
|
|
// 速率限制检查
|
|
if (isRateLimited(senderInfo.userId)) return false
|
|
|
|
// 检查配对码是否有效
|
|
if (!pairing.code || !pairing.expiresAt) return false
|
|
if (Date.now() > pairing.expiresAt) return false
|
|
|
|
// 比较(忽略大小写和空格)
|
|
const input = messageText.trim().toUpperCase()
|
|
if (input !== pairing.code.toUpperCase()) {
|
|
recordFailedAttempt(senderInfo.userId)
|
|
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
|
|
}
|
|
|
|
/** 统一的用户授权检查(供各 adapter 调用) */
|
|
export function isAllowedUser(platform: ImPlatform, userId: string | number): boolean {
|
|
try {
|
|
const cfgFile = readConfigFile()
|
|
return isPaired(platform, userId, cfgFile)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|