Files
claude-code-haha/adapters/feishu/index.ts
T
程序员阿江(Relakkes) 82e6e27687 feat: add IM adapter integration (Telegram + Feishu) with web settings UI
Implement IM adapters allowing users to chat with Claude Code from Telegram
and Feishu/Lark. Includes persistent session management (chatId→sessionId
mapping), project selection via /projects command, and a web UI settings page
for configuring bot tokens, allowed users, and default project directory.

Key changes:
- adapters/: Telegram and Feishu adapter scripts with shared common modules
  (WsBridge, MessageBuffer, SessionStore, HttpClient, config, formatting)
- Backend: adapterService + REST API (GET/PUT /api/adapters) with secret masking
- Frontend: AdapterSettings page in Settings tab with i18n support
- DirectoryPicker: use React Portal for dropdown to fix overflow clipping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:38:51 +08:00

552 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'
// ---------- 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 isAllowed(openId: string): boolean {
const list = config.feishu.allowedUsers
return list.length === 0 || list.includes(openId)
}
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 (!isAllowed(senderOpenId)) return
if (chatType === 'group') {
if (!isBotMentioned(event.message?.mentions)) 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)
})