mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
580 lines
16 KiB
TypeScript
580 lines
16 KiB
TypeScript
/**
|
||
* 飞书 (Feishu/Lark) Adapter for Claude Code Desktop
|
||
*
|
||
* 基于 @larksuiteoapi/node-sdk 的轻量飞书 Bot,直连服务端 /ws/:sessionId。
|
||
* 使用 WebSocket 长连接接收事件,无需公网地址。
|
||
*
|
||
* 启动:FEISHU_APP_ID=xxx FEISHU_APP_SECRET=xxx bun run feishu/index.ts
|
||
*/
|
||
|
||
import * as Lark from '@larksuiteoapi/node-sdk'
|
||
import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
|
||
import { MessageBuffer } from '../common/message-buffer.js'
|
||
import { MessageDedup } from '../common/message-dedup.js'
|
||
import { enqueue } from '../common/chat-queue.js'
|
||
import { loadConfig } from '../common/config.js'
|
||
import { splitMessage, formatToolUse, formatPermissionRequest, truncateInput } from '../common/format.js'
|
||
import { SessionStore } from '../common/session-store.js'
|
||
import { AdapterHttpClient } from '../common/http-client.js'
|
||
import { isPaired, tryPair } from '../common/pairing.js'
|
||
|
||
// ---------- init ----------
|
||
|
||
const config = loadConfig()
|
||
if (!config.feishu.appId || !config.feishu.appSecret) {
|
||
console.error('[Feishu] Missing FEISHU_APP_ID / FEISHU_APP_SECRET. Set env or ~/.claude/adapters.json')
|
||
process.exit(1)
|
||
}
|
||
|
||
const larkClient = new Lark.Client({
|
||
appId: config.feishu.appId,
|
||
appSecret: config.feishu.appSecret,
|
||
appType: Lark.AppType.SelfBuild,
|
||
domain: Lark.Domain.Feishu,
|
||
})
|
||
|
||
const bridge = new WsBridge(config.serverUrl, 'feishu')
|
||
const dedup = new MessageDedup()
|
||
const sessionStore = new SessionStore()
|
||
const httpClient = new AdapterHttpClient(config.serverUrl)
|
||
|
||
// Track state per chat
|
||
type ChatState = {
|
||
cardId?: string
|
||
sequence: number
|
||
replyMessageId?: string
|
||
}
|
||
const chatStates = new Map<string, ChatState>()
|
||
const buffers = new Map<string, MessageBuffer>()
|
||
const accumulatedText = new Map<string, string>()
|
||
const pendingProjectSelection = new Map<string, boolean>()
|
||
|
||
// Bot's own open_id (resolved on first message)
|
||
let botOpenId: string | null = null
|
||
// WSClient reference for graceful shutdown
|
||
let wsClient: InstanceType<typeof Lark.WSClient> | null = null
|
||
|
||
// ---------- helpers ----------
|
||
|
||
function isAllowedUser(openId: string): boolean {
|
||
try {
|
||
const cfgFile = JSON.parse(
|
||
require('node:fs').readFileSync(
|
||
require('node:path').join(
|
||
process.env.CLAUDE_CONFIG_DIR || require('node:path').join(require('node:os').homedir(), '.claude'),
|
||
'adapters.json'
|
||
),
|
||
'utf-8'
|
||
)
|
||
)
|
||
return isPaired('feishu', openId, cfgFile)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function getChatState(chatId: string): ChatState {
|
||
let state = chatStates.get(chatId)
|
||
if (!state) {
|
||
state = { sequence: 0 }
|
||
chatStates.set(chatId, state)
|
||
}
|
||
return state
|
||
}
|
||
|
||
function getBuffer(chatId: string): MessageBuffer {
|
||
let buf = buffers.get(chatId)
|
||
if (!buf) {
|
||
buf = new MessageBuffer(async (text, isComplete) => {
|
||
await flushToFeishu(chatId, text, isComplete)
|
||
})
|
||
buffers.set(chatId, buf)
|
||
}
|
||
return buf
|
||
}
|
||
|
||
/** Send a text message (post format). */
|
||
async function sendText(chatId: string, text: string, replyToMessageId?: string): Promise<string | undefined> {
|
||
const content = JSON.stringify({
|
||
zh_cn: { content: [[{ tag: 'md', text }]] },
|
||
})
|
||
|
||
try {
|
||
if (replyToMessageId) {
|
||
const resp = await larkClient.im.message.reply({
|
||
path: { message_id: replyToMessageId },
|
||
data: { content, msg_type: 'post' },
|
||
})
|
||
return resp.data?.message_id
|
||
}
|
||
const resp = await larkClient.im.message.create({
|
||
params: { receive_id_type: 'chat_id' },
|
||
data: {
|
||
receive_id: chatId,
|
||
msg_type: 'post' as const,
|
||
content,
|
||
},
|
||
})
|
||
return resp.data?.message_id
|
||
} catch (err) {
|
||
console.error('[Feishu] Send text error:', err)
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
/** Send an interactive card (for permission requests). */
|
||
async function sendCard(chatId: string, card: Record<string, unknown>): Promise<string | undefined> {
|
||
try {
|
||
const resp = await larkClient.im.message.create({
|
||
params: { receive_id_type: 'chat_id' },
|
||
data: {
|
||
receive_id: chatId,
|
||
msg_type: 'interactive',
|
||
content: JSON.stringify(card),
|
||
},
|
||
})
|
||
return resp.data?.message_id
|
||
} catch (err) {
|
||
console.error('[Feishu] Send card error:', err)
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
/** Update a message's content (patch). */
|
||
async function patchMessage(messageId: string, text: string): Promise<void> {
|
||
try {
|
||
await larkClient.im.message.patch({
|
||
path: { message_id: messageId },
|
||
data: {
|
||
content: JSON.stringify({
|
||
zh_cn: { content: [[{ tag: 'md', text }]] },
|
||
}),
|
||
},
|
||
})
|
||
} catch {
|
||
// patch may fail if message format changed — ignore
|
||
}
|
||
}
|
||
|
||
/** Build a permission request card. */
|
||
function buildPermissionCard(toolName: string, input: unknown, requestId: string): Record<string, unknown> {
|
||
const truncated = truncateInput(input, 300)
|
||
|
||
return {
|
||
schema: '2.0',
|
||
config: { wide_screen_mode: true },
|
||
header: {
|
||
title: { tag: 'plain_text', content: '🔐 需要权限确认' },
|
||
template: 'orange',
|
||
},
|
||
elements: [
|
||
{
|
||
tag: 'markdown',
|
||
content: `**工具**: ${toolName}\n**内容**:\n\`\`\`\n${truncated}\n\`\`\``,
|
||
},
|
||
{
|
||
tag: 'action',
|
||
actions: [
|
||
{
|
||
tag: 'button',
|
||
text: { tag: 'plain_text', content: '✅ 允许' },
|
||
type: 'primary',
|
||
value: { action: 'permit', requestId, allowed: true },
|
||
},
|
||
{
|
||
tag: 'button',
|
||
text: { tag: 'plain_text', content: '❌ 拒绝' },
|
||
type: 'danger',
|
||
value: { action: 'permit', requestId, allowed: false },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
async function flushToFeishu(chatId: string, newText: string, isComplete: boolean): Promise<void> {
|
||
const prev = accumulatedText.get(chatId) ?? ''
|
||
const fullText = prev + newText
|
||
accumulatedText.set(chatId, fullText)
|
||
|
||
const state = getChatState(chatId)
|
||
|
||
if (state.replyMessageId) {
|
||
const displayText = fullText + (isComplete ? '' : ' ▍')
|
||
await patchMessage(state.replyMessageId, displayText)
|
||
}
|
||
|
||
if (isComplete) {
|
||
if (!state.replyMessageId && fullText.trim()) {
|
||
const chunks = splitMessage(fullText, 30000)
|
||
for (const chunk of chunks) {
|
||
await sendText(chatId, chunk)
|
||
}
|
||
}
|
||
accumulatedText.delete(chatId)
|
||
chatStates.delete(chatId)
|
||
buffers.get(chatId)?.reset()
|
||
}
|
||
}
|
||
|
||
// ---------- session management ----------
|
||
|
||
async function ensureSession(chatId: string): Promise<boolean> {
|
||
if (bridge.hasSession(chatId)) return true
|
||
|
||
const stored = sessionStore.get(chatId)
|
||
if (stored) {
|
||
bridge.connectSession(chatId, stored.sessionId)
|
||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||
return true
|
||
}
|
||
|
||
const workDir = config.defaultProjectDir
|
||
if (workDir) {
|
||
return await createSessionForChat(chatId, workDir)
|
||
}
|
||
|
||
await showProjectPicker(chatId)
|
||
return false
|
||
}
|
||
|
||
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
|
||
try {
|
||
const sessionId = await httpClient.createSession(workDir)
|
||
sessionStore.set(chatId, sessionId, workDir)
|
||
bridge.connectSession(chatId, sessionId)
|
||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||
return true
|
||
} catch (err) {
|
||
await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function showProjectPicker(chatId: string): Promise<void> {
|
||
try {
|
||
const projects = await httpClient.listRecentProjects()
|
||
if (projects.length === 0) {
|
||
await sendText(chatId,
|
||
'没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在设置中配置默认项目。')
|
||
return
|
||
}
|
||
const lines = projects.slice(0, 10).map((p, i) =>
|
||
`${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
|
||
)
|
||
pendingProjectSelection.set(chatId, true)
|
||
await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}`)
|
||
} catch (err) {
|
||
await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
|
||
}
|
||
}
|
||
|
||
// ---------- server message handler ----------
|
||
|
||
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
|
||
const buf = getBuffer(chatId)
|
||
const state = getChatState(chatId)
|
||
|
||
switch (msg.type) {
|
||
case 'connected':
|
||
break
|
||
|
||
case 'status':
|
||
if (msg.state === 'thinking' && !state.replyMessageId) {
|
||
const mid = await sendText(chatId, '💭 思考中...')
|
||
if (mid) {
|
||
state.replyMessageId = mid
|
||
accumulatedText.set(chatId, '')
|
||
}
|
||
}
|
||
break
|
||
|
||
case 'content_start':
|
||
if (msg.blockType === 'text' && !state.replyMessageId) {
|
||
const mid = await sendText(chatId, '▍')
|
||
if (mid) {
|
||
state.replyMessageId = mid
|
||
accumulatedText.set(chatId, '')
|
||
}
|
||
}
|
||
break
|
||
|
||
case 'content_delta':
|
||
if (msg.text) {
|
||
buf.append(msg.text)
|
||
}
|
||
break
|
||
|
||
case 'thinking':
|
||
if (state.replyMessageId) {
|
||
await patchMessage(state.replyMessageId, `💭 ${msg.text.slice(0, 500)}...`)
|
||
}
|
||
break
|
||
|
||
case 'tool_use_complete': {
|
||
const info = formatToolUse(msg.toolName, msg.input)
|
||
await sendText(chatId, info)
|
||
break
|
||
}
|
||
|
||
case 'tool_result':
|
||
if (msg.isError) {
|
||
await sendText(chatId, '❌ 工具执行失败')
|
||
}
|
||
break
|
||
|
||
case 'permission_request': {
|
||
const card = buildPermissionCard(msg.toolName, msg.input, msg.requestId)
|
||
await sendCard(chatId, card)
|
||
break
|
||
}
|
||
|
||
case 'message_complete':
|
||
await buf.complete()
|
||
break
|
||
|
||
case 'error':
|
||
await sendText(chatId, `❌ ${msg.message}`)
|
||
break
|
||
}
|
||
}
|
||
|
||
// ---------- extract message text ----------
|
||
|
||
function extractText(content: string, msgType: string): string | null {
|
||
try {
|
||
const parsed = JSON.parse(content)
|
||
if (msgType === 'text') {
|
||
return parsed.text ?? null
|
||
}
|
||
if (msgType === 'post') {
|
||
const zhContent = parsed.zh_cn?.content ?? parsed.en_us?.content ?? []
|
||
return zhContent
|
||
.flat()
|
||
.filter((n: any) => n.tag === 'text' || n.tag === 'md')
|
||
.map((n: any) => n.text ?? n.content ?? '')
|
||
.join('')
|
||
.trim() || null
|
||
}
|
||
return null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function isBotMentioned(mentions?: Array<{ id?: { open_id?: string } }>): boolean {
|
||
if (!mentions || !botOpenId) return false
|
||
return mentions.some((m) => m.id?.open_id === botOpenId)
|
||
}
|
||
|
||
function stripMentions(text: string): string {
|
||
return text.replace(/@_user_\d+/g, '').trim()
|
||
}
|
||
|
||
// ---------- event handlers ----------
|
||
|
||
async function handleMessage(data: any): Promise<void> {
|
||
const event = data as {
|
||
sender?: { sender_id?: { open_id?: string } }
|
||
message?: {
|
||
message_id?: string
|
||
chat_id?: string
|
||
chat_type?: string
|
||
content?: string
|
||
message_type?: string
|
||
mentions?: Array<{ id?: { open_id?: string }; name?: string }>
|
||
}
|
||
}
|
||
|
||
const messageId = event.message?.message_id
|
||
const chatId = event.message?.chat_id
|
||
const senderOpenId = event.sender?.sender_id?.open_id
|
||
const chatType = event.message?.chat_type
|
||
const content = event.message?.content
|
||
const msgType = event.message?.message_type
|
||
|
||
if (!messageId || !chatId || !senderOpenId || !content || !msgType) return
|
||
|
||
if (!dedup.tryRecord(messageId)) return
|
||
|
||
// 只处理私聊
|
||
if (chatType === 'p2p') {
|
||
if (!isAllowedUser(senderOpenId)) {
|
||
// 尝试配对
|
||
const pairText = extractText(content, msgType)
|
||
if (pairText) {
|
||
const success = tryPair(pairText.trim(), { userId: senderOpenId, displayName: 'Feishu User' }, 'feishu')
|
||
if (success) {
|
||
await sendText(chatId, '✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。')
|
||
} else {
|
||
await sendText(chatId, '🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。')
|
||
}
|
||
}
|
||
return
|
||
}
|
||
} else {
|
||
// 群聊不处理
|
||
return
|
||
}
|
||
|
||
let text = extractText(content, msgType)
|
||
if (!text) return
|
||
|
||
text = stripMentions(text)
|
||
if (!text) return
|
||
|
||
// Handle commands
|
||
if (text === '/new' || text === '新会话') {
|
||
bridge.resetSession(chatId)
|
||
sessionStore.delete(chatId)
|
||
chatStates.delete(chatId)
|
||
accumulatedText.delete(chatId)
|
||
buffers.get(chatId)?.reset()
|
||
buffers.delete(chatId)
|
||
pendingProjectSelection.delete(chatId)
|
||
await showProjectPicker(chatId)
|
||
return
|
||
}
|
||
if (text === '/stop' || text === '停止') {
|
||
bridge.sendStopGeneration(chatId)
|
||
await sendText(chatId, '⏹ 已发送停止信号。')
|
||
return
|
||
}
|
||
if (text === '/projects' || text === '项目列表') {
|
||
await showProjectPicker(chatId)
|
||
return
|
||
}
|
||
|
||
// Check if user is responding to project selection
|
||
if (pendingProjectSelection.has(chatId)) {
|
||
const num = parseInt(text, 10)
|
||
if (num >= 1) {
|
||
try {
|
||
const projects = await httpClient.listRecentProjects()
|
||
const selected = projects[num - 1]
|
||
if (selected) {
|
||
pendingProjectSelection.delete(chatId)
|
||
await createSessionForChat(chatId, selected.realPath)
|
||
await sendText(chatId, `✅ 已选择 **${selected.projectName}**。现在可以开始对话了。`)
|
||
return
|
||
}
|
||
} catch { /* fall through */ }
|
||
}
|
||
await sendText(chatId, '请输入有效的编号。')
|
||
return
|
||
}
|
||
|
||
// Normal message flow
|
||
enqueue(chatId, async () => {
|
||
const ready = await ensureSession(chatId)
|
||
if (ready) {
|
||
bridge.sendUserMessage(chatId, text!)
|
||
}
|
||
})
|
||
}
|
||
|
||
async function handleCardAction(data: any): Promise<any> {
|
||
const event = data as {
|
||
operator?: { open_id?: string }
|
||
action?: { value?: { action?: string; requestId?: string; allowed?: boolean } }
|
||
context?: { open_chat_id?: string }
|
||
}
|
||
|
||
const action = event.action?.value?.action
|
||
if (action !== 'permit') return
|
||
|
||
const requestId = event.action?.value?.requestId
|
||
const allowed = event.action?.value?.allowed ?? false
|
||
const chatId = event.context?.open_chat_id
|
||
|
||
if (!requestId || !chatId) return
|
||
|
||
bridge.sendPermissionResponse(chatId, requestId, allowed)
|
||
|
||
const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝'
|
||
await sendText(chatId, statusText)
|
||
|
||
return {
|
||
toast: { type: 'info', content: statusText },
|
||
}
|
||
}
|
||
|
||
// ---------- resolve bot identity ----------
|
||
|
||
async function resolveBotOpenId(retries = 3): Promise<void> {
|
||
for (let i = 0; i < retries; i++) {
|
||
try {
|
||
const resp = await larkClient.contact.user.get({
|
||
path: { user_id: 'me' },
|
||
params: { user_id_type: 'open_id' },
|
||
})
|
||
botOpenId = (resp.data?.user as any)?.open_id ?? null
|
||
if (botOpenId) {
|
||
console.log(`[Feishu] Bot open_id: ${botOpenId}`)
|
||
return
|
||
}
|
||
} catch {
|
||
if (i < retries - 1) {
|
||
console.warn(`[Feishu] Could not resolve bot open_id, retrying (${i + 1}/${retries})...`)
|
||
await new Promise((r) => setTimeout(r, 2000 * (i + 1)))
|
||
}
|
||
}
|
||
}
|
||
console.warn('[Feishu] Could not resolve bot open_id (group @mention check may not work)')
|
||
}
|
||
|
||
// ---------- start ----------
|
||
|
||
async function start(): Promise<void> {
|
||
console.log('[Feishu] Starting bot...')
|
||
console.log(`[Feishu] Server: ${config.serverUrl}`)
|
||
console.log(`[Feishu] App ID: ${config.feishu.appId}`)
|
||
|
||
await resolveBotOpenId()
|
||
|
||
const dispatcher = new Lark.EventDispatcher({
|
||
encryptKey: config.feishu.encryptKey,
|
||
verificationToken: config.feishu.verificationToken,
|
||
})
|
||
|
||
dispatcher.register({
|
||
'im.message.receive_v1': async (data: any) => {
|
||
try {
|
||
await handleMessage(data)
|
||
} catch (err) {
|
||
console.error('[Feishu] Message handler error:', err)
|
||
}
|
||
},
|
||
'card.action.trigger': async (data: any) => {
|
||
try {
|
||
return await handleCardAction(data)
|
||
} catch (err) {
|
||
console.error('[Feishu] Card action error:', err)
|
||
}
|
||
},
|
||
} as any)
|
||
|
||
wsClient = new Lark.WSClient({
|
||
appId: config.feishu.appId,
|
||
appSecret: config.feishu.appSecret,
|
||
domain: Lark.Domain.Feishu,
|
||
loggerLevel: Lark.LoggerLevel.info,
|
||
})
|
||
|
||
await wsClient.start({ eventDispatcher: dispatcher })
|
||
console.log('[Feishu] Bot is running! (WebSocket connected)')
|
||
}
|
||
|
||
start().catch((err) => {
|
||
console.error('[Feishu] Failed to start:', err)
|
||
process.exit(1)
|
||
})
|
||
|
||
process.on('SIGINT', () => {
|
||
console.log('[Feishu] Shutting down...')
|
||
bridge.destroy()
|
||
dedup.destroy()
|
||
process.exit(0)
|
||
})
|