/** * 飞书 (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 * as path from 'node:path' import * as fs from 'node:fs/promises' import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js' import { MessageDedup } from '../common/message-dedup.js' import { StreamingCard } from './streaming-card.js' import { enqueue } from '../common/chat-queue.js' import { loadConfig } from '../common/config.js' import { formatImHelp, formatImStatus, splitMessage, } from '../common/format.js' import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' import { optimizeMarkdownForFeishu } from './markdown-style.js' import { extractInboundPayload } from './extract-payload.js' import { FeishuMediaService } from './media.js' import { AttachmentStore } from '../common/attachment/attachment-store.js' import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js' import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js' import type { PendingUpload } from '../common/attachment/attachment-types.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) // Attachment plumbing — shared by inbound (download) and outbound (upload) paths. const attachmentStore = new AttachmentStore() const media = new FeishuMediaService(larkClient, attachmentStore) attachmentStore.gc().catch((err) => { console.warn('[Feishu] AttachmentStore.gc failed:', err instanceof Error ? err.message : err) }) // One streaming card lifecycle per chatId (CardKit main + patch fallback). const streamingCards = new Map() const pendingProjectSelection = new Map() const runtimeStates = new Map() // Per-chat outbound watchers for Agent-produced markdown image references. // `imageWatchers` extracts `![alt](src)` from streaming text; // `uploadedImageKeys` caches fingerprint → image_key so the same image // referenced multiple times in one turn isn't re-uploaded. const imageWatchers = new Map() const uploadedImageKeys = new Map>() // Bot's own open_id (resolved on first message) let botOpenId: string | null = null // WSClient reference for graceful shutdown let wsClient: InstanceType | null = null type ChatRuntimeState = { state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending' verb?: string model?: string pendingPermissionCount: number } // ---------- helpers ---------- function getRuntimeState(chatId: string): ChatRuntimeState { let state = runtimeStates.get(chatId) if (!state) { state = { state: 'idle', pendingPermissionCount: 0 } runtimeStates.set(chatId, state) } return state } /** Get the existing StreamingCard for this chat, or create one in 'idle' state. */ function getOrCreateStreamingCard(chatId: string): StreamingCard { let card = streamingCards.get(chatId) if (!card) { card = new StreamingCard({ larkClient, chatId }) streamingCards.set(chatId, card) } return card } function getImageWatcher(chatId: string): ImageBlockWatcher { let w = imageWatchers.get(chatId) if (!w) { w = new ImageBlockWatcher() imageWatchers.set(chatId, w) } return w } function getUploadedKeys(chatId: string): Map { let m = uploadedImageKeys.get(chatId) if (!m) { m = new Map() uploadedImageKeys.set(chatId, m) } return m } /** Upload a PendingUpload found in streaming output and send it as an * independent im.message.create({msg_type:'image'}) message — runs * fire-and-forget so the streaming card is never blocked. All failure * modes are non-fatal: log and skip. */ async function dispatchOutboundImage(chatId: string, pending: PendingUpload): Promise { const cache = getUploadedKeys(chatId) if (cache.has(pending.id)) return // already uploaded within this chat try { let buffer: Buffer let mime = 'image/png' switch (pending.source.kind) { case 'base64': { buffer = Buffer.from(pending.source.data, 'base64') mime = pending.source.mime break } case 'path': { buffer = await fs.readFile(pending.source.path) mime = pending.source.mime ?? 'image/png' break } case 'url': { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 30_000) try { const resp = await fetch(pending.source.url, { signal: controller.signal }) if (!resp.ok) throw new Error(`fetch ${pending.source.url} -> ${resp.status}`) buffer = Buffer.from(await resp.arrayBuffer()) mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png' } finally { clearTimeout(timer) } break } } const check = checkAttachmentLimit('image', buffer.length, mime) if (!check.ok) { console.warn('[Feishu] Outbound image rejected:', check.hint) return } const imageKey = await media.uploadImage(buffer, mime) cache.set(pending.id, imageKey) await media.sendImageMessage(chatId, imageKey) } catch (err) { console.error( '[Feishu] dispatchOutboundImage failed:', err instanceof Error ? err.message : err, ) } } /** Finalize and remove the streaming card (normal completion). */ async function finalizeStreamingCard(chatId: string): Promise { const card = streamingCards.get(chatId) if (!card) return streamingCards.delete(chatId) await card.finalize() } /** Abort and remove the streaming card (error path). Non-throwing. */ async function abortStreamingCard(chatId: string, err: Error): Promise { const card = streamingCards.get(chatId) if (!card) return streamingCards.delete(chatId) await card.abort(err).catch(() => {}) } function clearTransientChatState(chatId: string): void { // Abort any in-flight streaming card (best effort, don't block) const card = streamingCards.get(chatId) if (card) { streamingCards.delete(chatId) void card.abort(new Error('session cleared')).catch(() => {}) } imageWatchers.delete(chatId) uploadedImageKeys.delete(chatId) const runtime = getRuntimeState(chatId) runtime.state = 'idle' runtime.verb = undefined runtime.pendingPermissionCount = 0 } async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> { const stored = sessionStore.get(chatId) if (!stored) return null if (!bridge.hasSession(chatId)) { bridge.connectSession(chatId, stored.sessionId) bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) const opened = await bridge.waitForOpen(chatId) if (!opened) return null } return stored } async function buildStatusText(chatId: string): Promise { const stored = await ensureExistingSession(chatId) if (!stored) return formatImStatus(null) const runtime = getRuntimeState(chatId) let projectName = path.basename(stored.workDir) || stored.workDir let branch: string | null = null try { const gitInfo = await httpClient.getGitInfo(stored.sessionId) projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName branch = gitInfo.branch } catch { // Ignore git lookup failures and fall back to stored workDir } let taskCounts: | { total: number pending: number inProgress: number completed: number } | undefined try { const tasks = await httpClient.getTasksForSession(stored.sessionId) if (tasks.length > 0) { taskCounts = { total: tasks.length, pending: tasks.filter((task) => task.status === 'pending').length, inProgress: tasks.filter((task) => task.status === 'in_progress').length, completed: tasks.filter((task) => task.status === 'completed').length, } } } catch { // Ignore task lookup failures in IM status summary } return formatImStatus({ sessionId: stored.sessionId, projectName, branch, model: runtime.model, state: runtime.state, verb: runtime.verb, pendingPermissionCount: runtime.pendingPermissionCount, taskCounts, }) } /** Send a text message (post format). */ async function sendText(chatId: string, text: string, replyToMessageId?: string): Promise { 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): Promise { 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 } } /** Pretty-print an absolute path for IM display. * - Replace $HOME with `~` * - Middle-truncate if it's still very long, keeping the project tail visible */ function prettyPath(realPath: string, maxLen = 64): string { const home = process.env.HOME let p = realPath if (home) { if (p === home) return '~' if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}` } if (p.length <= maxLen) return p // Project name lives at the tail — keep more of the tail than the head. const tailLen = Math.floor(maxLen * 0.65) const headLen = maxLen - tailLen - 1 return `${p.slice(0, headLen)}…${p.slice(-tailLen)}` } /** Build an interactive project picker card — mobile-first layout. * * Design: one column_set per project with exactly 2 columns: * - Col 1 (weighted): project info (title markdown + small grey path) * - Col 2 (auto): "选择" button, vertically centered * * Only 2 columns with one weighted + one auto means the weight distribution * is trivial (auto takes its natural width, weighted takes the rest). This * avoids the layout issues seen in 3-column attempts. */ function buildProjectPickerCard(projects: RecentProject[]): Record { const items = projects.slice(0, 10) const total = projects.length const subtitleText = total > items.length ? `共 ${total} 个最近项目,显示前 ${items.length}` : `共 ${total} 个最近项目` const rows = items.map((p, i) => { const branch = p.branch ? ` · *${p.branch}*` : '' return { tag: 'column_set', flex_mode: 'stretch', horizontal_spacing: '8px', margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0', columns: [ // Col 1 — project info (title + notation path, stacked) { tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [ { tag: 'markdown', content: `**${p.projectName}**${branch}`, }, { tag: 'markdown', content: prettyPath(p.realPath, 56), text_size: 'notation', margin: '2px 0 0 0', }, ], }, // Col 2 — action button (auto width, vertically centered) { tag: 'column', width: 'auto', vertical_align: 'center', elements: [ { tag: 'button', text: { tag: 'plain_text', content: '选择' }, type: i === 0 ? 'primary' : 'default', size: 'small', value: { action: 'pick_project', realPath: p.realPath, projectName: p.projectName, }, }, ], }, ], } }) return { schema: '2.0', config: { wide_screen_mode: true, update_multi: true, }, header: { title: { tag: 'plain_text', content: '📁 选择项目' }, subtitle: { tag: 'plain_text', content: subtitleText }, template: 'blue', }, body: { elements: [ ...rows, { tag: 'hr', margin: '14px 0 0 0' }, { tag: 'markdown', content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`', text_size: 'notation', margin: '6px 0 0 0', }, ], }, } } /** Human-readable summary of a tool call for display in the permission card. */ type ToolCallSummary = { icon: string label: string /** Display string for the operation target (file path or command preview) */ target?: string /** Absolute file path for cross-directory detection, when applicable */ filePath?: string } /** Map a Claude Code tool call to an icon + human-readable Chinese label. * Unknown tools fall back to the raw tool name with a generic icon. */ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { const rec: Record = input && typeof input === 'object' ? (input as Record) : {} const str = (key: string): string | undefined => typeof rec[key] === 'string' ? (rec[key] as string) : undefined switch (toolName) { case 'Write': { const fp = str('file_path') return { icon: '✏️', label: '写入文件', target: fp, filePath: fp } } case 'Edit': case 'MultiEdit': case 'NotebookEdit': { const fp = str('file_path') ?? str('notebook_path') return { icon: '✏️', label: '修改文件', target: fp, filePath: fp } } case 'Read': { const fp = str('file_path') return { icon: '📖', label: '读取文件', target: fp, filePath: fp } } case 'Bash': case 'BashOutput': { return { icon: '🖥️', label: '执行命令', target: str('command') } } case 'Grep': { const pattern = str('pattern') return { icon: '🔍', label: '搜索内容', target: pattern ? `pattern: ${pattern}` : undefined, filePath: str('path'), } } case 'Glob': { const pattern = str('pattern') return { icon: '📁', label: '查找文件', target: pattern ? `pattern: ${pattern}` : undefined, filePath: str('path'), } } case 'WebFetch': return { icon: '🌐', label: '访问网页', target: str('url') } case 'WebSearch': return { icon: '🌐', label: '搜索网页', target: str('query') } default: return { icon: '🔧', label: toolName } } } /** True if `filePath` resolves to a location outside of `workDir`. * Relative paths are resolved against workDir first. */ function isOutsideWorkDir(filePath: string, workDir: string): boolean { const abs = path.isAbsolute(filePath) ? path.normalize(filePath) : path.resolve(workDir, filePath) const normWork = path.normalize(workDir).replace(/\/+$/, '') return abs !== normWork && !abs.startsWith(normWork + path.sep) } /** Truncate a single-line target preview (e.g. shell command) to maxLen. */ function truncateTarget(s: string, maxLen = 160): string { if (s.length <= maxLen) return s return s.slice(0, maxLen - 1) + '…' } /** Build a permission request card (Schema 2.0, mobile-friendly). * * Layout: * header → 🔐 需要权限确认 (orange / red if cross-dir) * body → **