Chat with Claude Code from Feishu/Lark: two-way chat, images, permission and question cards, slash commands — via lark-cli

Chat with Claude Code from Feishu / Lark. Messages you send the bot become turns in the Claude Code session running on your machine, and each answer comes back as a reply in Feishu. Permission prompts and questions show up as interactive cards you can answer from your phone.
This is a Claude Code mod (a function-hooks plugin) built on lark-cli.
💻 电脑端), and their answers are posted there as well./tmp/feishu-mod/<message_id>/, and Claude reads them from there. If an answer mentions a local image by its absolute path, that image is sent back.AskUserQuestion (single choice, multiple choice, or free text) is sent as a Feishu form card at the same time as the local dialog./compact, /doctor, a skill name, and so on. Commands that would open a panel at the computer are adapted for Feishu instead:/config and /model open a live settings card. Toggles become buttons and choices become selects; each click applies immediately and the card redraws./cost, /usage, /context, /stats, /status and /help are answered as text./resume, /mcp, /login, /theme, …) are not run; Feishu is told to use the computer.Typing reaction while Claude works on them. It is removed when the reply is sent, and CrossMark marks a failure.In a Claude Code terminal session:
/plugin install feishu-mod --marketplace Jianyuuuuu/claude-code-feishu-mod
Answer y to Add marketplace?, then pick a scope (user is the default).
npm i -g @larksuite/cliclaude-code profile. The command prints a sign-in link and a QR code: lark-cli config init --new --name claude-code
im.message.receive_v1;card.action.trigger). Without it, card buttons do nothing;im:message, im:message:readonly, im:message.p2p_msg:readonly, im:message:send_as_bot, im:message.reactions:write_only;To check the setup: lark-cli --profile claude-code event consume card.action.trigger --as bot --dry-run should report every precondition as ok.
Use a dedicated app. When several long-connection clients subscribe to the same app, each event is delivered to only one of them at random.
| Command | What it does |
|---|---|
/feishu on | Start the bridge in this session (the status line shows 飞书 ● 在线) |
/feishu off | Stop it |
/feishu status | Connection, allowed users, the Feishu chat in use, cards waiting |
/feishu allow last | Allow the most recent unknown sender (or pass an ou_… open_id) |
/feishu deny ou_xxx | Remove a user from the allow list |
First run: /feishu on, send the bot a direct message, run /feishu allow last, then message it again. Your last direct chat with the bot becomes the chat that cards and mirrored prompts go to.
To keep the allow list across reinstalls and new sessions, set it in ~/.claude/settings.json (or /config):
"pluginConfigs": {
"feishu-mod@jianyuuuuu": {
"options": { "allowedUsers": "ou_xxx,ou_yyy", "homeChat": "oc_xxx" }
}
}
allowedUsers adds to the users allowed with /feishu allow; homeChat is used until a direct message sets the chat.
lark-cli … event consume im.message.receive_v1 runs in the background and streams NDJSON events. If it exits, it reconnects after 5 seconds.message_id and submitted with $.prompt.submit. While the session is busy they queue.[飞书消息 om_…]) ties each turn to its message. On turn.complete, the answer is sent with im +messages-reply --markdown, split into chunks with idempotency keys.classic.PermissionRequest hook, which runs while the local dialog is open. The hook sends a card and waits for a matching card.action.trigger in 60-second slices. The answer comes back as the hook's decision (updatedPermissions with destination: session for "don't ask again", or updatedInput.answers for questions). If the call is settled at the computer first, a tool.call hook marks the card as handled there.hooks/lib.ts sets this.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT
hooks/register.ts 694 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FeishuArmed, FeishuMirrored, FeishuModStatus, FeishuPendingCard } from '../types'
5import {
6 answersFromForm,
7 answersMarkdown,
8 approvalCard,
9 configCard,
10 questionCard,
11 statusCard,
12 resolvedCard,
13 type ApprovalChoice,
14 type AskQuestion,
15 type Card,
16 type ConfigRowView,
17} from './cards'
18import {
19 buildPrompt,
20 chunkReply,
21 describeToolInput,
22 findResources,
23 formatUsage,
24 LIMIT_NAMES,
25 localTime,
26 tokens,
27 isChatId,
28 isMessageId,
29 isOpenId,
30 localImages,
31 markerOf,
32 MEDIA_TYPES,
33 parseEvent,
34 parseObject,
35 parseSlash,
36 REPLY_GUIDE,
37 safeName,
38 sentMessageId,
39 splitLines,
40 type FeishuMessage,
41} from './lib'
42
43const PLUGIN = 'feishu-mod'
44const PROFILE = 'claude-code'
45const LARK = 'lark-cli'
46const ALLOW_KEY = 'allow'
47const LAST_KEY = 'lastUnknownSender'
48const HOME_KEY = 'homeChat'
49const DOWNLOADS = '/tmp/feishu-mod'
50/** How long a card waits for a click, in short slices so a local answer stops the wait. */
51const CARD_WAIT_S = 1800
52const CARD_SLICE_S = 30
53
54const isOn = atom({ plugin: 'feishu-mod', key: 'isOn' } as const, false)
55const status = atom({ plugin: 'feishu-mod', key: 'status' } as const, 'off' as FeishuModStatus)
56const turns = atom({ plugin: 'feishu-mod', key: 'turns' } as const, {} as Record<string, string>)
57const seen = atom({ plugin: 'feishu-mod', key: 'seen' } as const, [] as string[])
58const mirrored = atom({ plugin: 'feishu-mod', key: 'mirrored' } as const, [] as FeishuMirrored[])
59const armed = atom({ plugin: 'feishu-mod', key: 'armed' } as const, null as FeishuArmed | null)
60const pending = atom({ plugin: 'feishu-mod', key: 'pending' } as const, {} as Record<string, FeishuPendingCard>)
61const typing = atom({ plugin: 'feishu-mod', key: 'typing' } as const, {} as Record<string, string>)
62const model = atom({ plugin: 'feishu-mod', key: 'model' } as const, '')
63
64type $ = EngineInterface
65
66// The running consumers of this module load; a reload drops them with the module.
67const consumers = new Set<AsyncGenerator<unknown, unknown>>()
68let loopId = 0
69const warnedSenders = new Set<string>()
70
71const STATUS_TEXT: Record<FeishuModStatus, string | undefined> = {
72 off: undefined,
73 starting: '飞书 ⋯ 连接中',
74 listening: '飞书 ● 在线',
75 error: '飞书 ✕ 断开,重连中',
76}
77
78const DEBUG_LOG = `${DOWNLOADS}/debug.log`
79const CLICKS = `${DOWNLOADS}/clicks`
80
81/** To Claude Code's debug log, and to /tmp/feishu-mod/debug.log for when no --debug is on. */
82function debug($: $, text: string): void {
83 $.ui.log(`${PLUGIN}: ${text}`, { to: 'debug' })
84 void $.process.run(['sh', '-c', 'mkdir -p "$1" && cat >> "$2"', 'sh', DOWNLOADS, DEBUG_LOG], {
85 stdin: `${new Date().toISOString()} ${text}\n`,
86 }).catch(() => undefined)
87}
88const rid = () => crypto.randomUUID().replace(/-/g, '').slice(0, 16)
89
90async function setStatus($: $, next: FeishuModStatus): Promise<void> {
91 await update($, status, () => next)
92 $.ui.status(STATUS_TEXT[next])
93}
94
95/** Users and chat from settings (`pluginConfigs`), which outlive the store when the plugin is reinstalled. */
96let configAllow: string[] = []
97let configHome: string | null = null
98
99async function allowList($: $): Promise<string[]> {
100 const value = await $.store.get(ALLOW_KEY)
101 const stored = Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
102 return [...new Set([...configAllow, ...stored])]
103}
104
105async function homeChat($: $): Promise<string | null> {
106 const value = await $.store.get(HOME_KEY)
107 return typeof value === 'string' && isChatId(value) ? value : configHome
108}
109
110/** The bridge routes to Feishu only while it is on and knows where to send. */
111async function remoteChat($: $): Promise<string | null> {
112 return (await read($, isOn)) ? homeChat($) : null
113}
114
115// ── lark-cli ────────────────────────────────────────────────────────────────
116
117function lark($: $, args: string[], init: { cwd?: string; timeoutMs?: number } = {}) {
118 return $.process.run([LARK, '--profile', PROFILE, ...args], { timeoutMs: 60_000, ...init })
119}
120
121async function react($: $, messageId: string, emoji: string): Promise<string | null> {
122 const res = await lark($, [
123 'api', 'POST', `/open-apis/im/v1/messages/${messageId}/reactions`, '--as', 'bot',
124 '--data', JSON.stringify({ reaction_type: { emoji_type: emoji } }),
125 ]).catch(() => undefined)
126 if (res && res.exitCode !== 0) debug($, `reaction failed: ${res.stderr || res.stdout}`)
127 return res ? (/"reaction_id"\s*:\s*"([^"]+)"/.exec(res.stdout)?.[1] ?? null) : null
128}
129
130/** Hermes' processing badge: Typing while working, removed on reply, CrossMark on failure. */
131async function startWorking($: $, messageId: string): Promise<void> {
132 const id = await react($, messageId, 'Typing')
133 if (id) await update($, typing, map => ({ ...map, [messageId]: id }))
134}
135
136async function stopWorking($: $, messageId: string, isFailed: boolean): Promise<void> {
137 const id = (await read($, typing))[messageId]
138 if (id) {
139 await update($, typing, map => {
140 const { [messageId]: _, ...rest } = map
141 return rest
142 })
143 await lark($, ['api', 'DELETE', `/open-apis/im/v1/messages/${messageId}/reactions/${id}`, '--as', 'bot']).catch(() => undefined)
144 }
145 if (isFailed) await react($, messageId, 'CrossMark')
146}
147
148async function reply($: $, messageId: string, text: string): Promise<boolean> {
149 for (const [i, piece] of chunkReply(text).entries()) {
150 const res = await lark($, [
151 'im', '+messages-reply', '--as', 'bot', '--message-id', messageId,
152 '--markdown', piece, '--idempotency-key', `${messageId}-${i}`.slice(0, 50),
153 ])
154 if (res.exitCode !== 0) {
155 $.ui.toast(`飞书回复失败:${(res.stderr || res.stdout).slice(0, 300)}`)
156 return false
157 }
158 }
159 return true
160}
161
162async function replyImage($: $, messageId: string, path: string): Promise<void> {
163 const cut = path.lastIndexOf('/')
164 const res = await lark($, ['im', '+messages-reply', '--as', 'bot', '--message-id', messageId, '--image', path.slice(cut + 1)], {
165 cwd: path.slice(0, cut) || '/',
166 })
167 if (res.exitCode !== 0) debug($, `image reply failed: ${res.stderr || res.stdout}`)
168}
169
170async function sendMarkdown($: $, chatId: string, text: string): Promise<string | null> {
171 const res = await lark($, ['im', '+messages-send', '--as', 'bot', '--chat-id', chatId, '--markdown', text])
172 if (res.exitCode !== 0) debug($, `send failed: ${res.stderr || res.stdout}`)
173 return sentMessageId(res.stdout)
174}
175
176async function sendCard($: $, chatId: string, card: Card): Promise<string | null> {
177 const res = await lark($, [
178 'im', '+messages-send', '--as', 'bot', '--chat-id', chatId,
179 '--msg-type', 'interactive', '--content', JSON.stringify(card),
180 ])
181 if (res.exitCode !== 0) $.ui.toast(`飞书卡片发送失败:${(res.stderr || res.stdout).slice(0, 300)}`)
182 return sentMessageId(res.stdout)
183}
184
185async function patchCard($: $, messageId: string, card: Card): Promise<void> {
186 const res = await lark($, [
187 'api', 'PATCH', `/open-apis/im/v1/messages/${messageId}`, '--as', 'bot',
188 '--data', JSON.stringify({ content: JSON.stringify(card) }),
189 ]).catch(() => undefined)
190 if (res && res.exitCode !== 0) debug($, `card update failed: ${res.stderr || res.stdout}`)
191}
192
193/**
194 * Waits for a click on the card tagged `id` by an allowed user. Feishu lets one
195 * consumer hold card.action.trigger, so the standing listener writes each click
196 * to CLICKS/<id>.json and this waits for that file in a shell `$.process.run`,
197 * which costs the calling hook none of its own time budget.
198 */
199async function waitCardAction($: $, id: string, seconds: number): Promise<Record<string, unknown> | null> {
200 const deadline = (await $.clock.now()) + seconds * 1000
201 const file = `${CLICKS}/${id}.json`
202 for (;;) {
203 // The card was settled at the computer, or the bridge went off: stop waiting.
204 if (!(await read($, pending))[id] || !(await read($, isOn))) return null
205 const left = Math.floor((deadline - (await $.clock.now())) / 1000)
206 if (left < 2) return null
207 const slice = Math.min(left, CARD_SLICE_S)
208 const res = await $.process.run(
209 ['sh', '-c', 'i=0; while [ "$i" -lt "$2" ]; do if [ -f "$1" ]; then cat "$1"; rm -f "$1"; exit 0; fi; sleep 1; i=$((i+1)); done', 'sh', file, String(slice)],
210 { timeoutMs: slice * 1000 + 15_000 },
211 ).catch(err => {
212 debug($, `card wait failed: ${String(err)}`)
213 return null
214 })
215 if (!res || res.exitCode !== 0) return null
216 const ev = parseObject(res.stdout.trim())
217 if (!ev) continue
218 if ((await allowList($)).includes(String(ev.operator_id ?? ''))) return ev
219 debug($, `ignored a card click by ${String(ev.operator_id)}`)
220 }
221}
222
223/** Every card click arrives here: settings act at once, the rest go to their waiter. */
224async function handleCardClick($: $, ev: Record<string, unknown>): Promise<void> {
225 const value = parseObject(String(ev.action_value ?? '')) ?? {}
226 debug($, `card click kind=${String(value.kind)} by ${String(ev.operator_id)}`)
227 if (value.kind === 'config') return handleConfigClick($, ev)
228 const id = typeof value.rid === 'string' && /^[0-9a-f]{16}$/.test(value.rid) ? value.rid : null
229 if (!id || !(await read($, pending))[id]) return
230 await $.process.run(['sh', '-c', 'mkdir -p "$1" && cat > "$2.tmp" && mv "$2.tmp" "$2"', 'sh', CLICKS, `${CLICKS}/${id}.json`], {
231 stdin: JSON.stringify(ev),
232 })
233}
234
235async function forget($: $, id: string): Promise<FeishuPendingCard | undefined> {
236 const card = (await read($, pending))[id]
237 await update($, pending, map => {
238 const { [id]: _, ...rest } = map
239 return rest
240 })
241 return card
242}
243
244// ── inbound ─────────────────────────────────────────────────────────────────
245
246async function downloadResources($: $, m: FeishuMessage): Promise<string[]> {
247 if (!MEDIA_TYPES.has(m.messageType)) return []
248 const raw = await lark($, ['api', 'GET', `/open-apis/im/v1/messages/${m.messageId}`, '--as', 'bot'])
249 const data = parseObject(raw.stdout)?.data as { items?: Array<{ body?: { content?: string } }> } | undefined
250 const body = parseObject(data?.items?.[0]?.body?.content ?? '')
251 const resources = findResources(body).slice(0, 10)
252 if (!resources.length) return []
253 const dir = `${DOWNLOADS}/${m.messageId}`
254 await $.process.run(['mkdir', '-p', dir])
255 for (const r of resources) {
256 const res = await lark($, [
257 'im', '+messages-resources-download', '--as', 'bot', '--message-id', m.messageId,
258 '--file-key', r.key, '--type', r.type, '--output', `${dir}/${safeName(r)}`,
259 ], { timeoutMs: 120_000 })
260 if (res.exitCode !== 0 && r.type === 'image') {
261 debug($, `download ${r.key} as image failed, retrying as file`)
262 await lark($, [
263 'im', '+messages-resources-download', '--as', 'bot', '--message-id', m.messageId,
264 '--file-key', r.key, '--type', 'file', '--output', `${dir}/${safeName(r)}`,
265 ], { timeoutMs: 120_000 })
266 } else if (res.exitCode !== 0) debug($, `download ${r.key} failed: ${res.stderr || res.stdout}`)
267 }
268 const listed = await $.process.run(['ls', '-1', dir])
269 return listed.stdout.split('\n').map(f => f.trim()).filter(Boolean).map(f => `${dir}/${f}`)
270}
271
272/** Commands that open a panel at the computer: answered here from the session's own figures. */
273/** The machine's offset from UTC in minutes, from `date +%z` (the module's own clock may run in UTC). */
274async function localOffset($: $): Promise<number> {
275 const out = (await $.process.run(['date', '+%z']).catch(() => null))?.stdout.trim() ?? ''
276 const m = /^([+-])(\d{2})(\d{2})$/.exec(out)
277 return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0
278}
279
280/** /status, /cost and /usage as a card (text when the card is refused), from the session's own figures. */
281async function sendUsage($: $, m: FeishuMessage, full: boolean): Promise<void> {
282 const u = await $.session.usage()
283 const now = await $.clock.now()
284 const offset = await localOffset($)
285 const cwd = full ? await $.session.cwd() : undefined
286 const home = cwd ? /^\/(?:Users|home)\/[^/]+/.exec(cwd)?.[0] : undefined
287 const version = full ? (await $.session.version()).version : undefined
288 const usedModel = (await read($, model)) || undefined
289 const pct = u.context.percent ?? (u.context.tokens !== undefined ? (u.context.tokens / u.context.window) * 100 : undefined)
290 const card = statusCard({
291 title: full && version ? `Claude Code ${version}` : 'Claude Code',
292 costUsd: u.cost?.usd,
293 contextPercent: pct,
294 contextText: `${tokens(u.context.tokens)} / ${tokens(u.context.window)}`,
295 model: usedModel,
296 limits: u.rateLimits.map(r => ({
297 name: LIMIT_NAMES[r.kind] ?? r.kind,
298 percent: r.percentUsed,
299 reset: r.resetsAt ? `${localTime(r.resetsAt, offset)} 重置` : undefined,
300 })),
301 footnote: cwd ? (home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd) : undefined,
302 })
303 if (await sendCard($, m.chatId, card)) return
304 await reply($, m.messageId, formatUsage({ version, cwd, home, model: usedModel, costUsd: u.cost?.usd, context: u.context, rateLimits: u.rateLimits }, now, offset, full))
305}
306
307const USAGE_COMMANDS = new Set(['cost', 'usage', 'context', 'stats'])
308/** Panels that only make sense at the computer: not run from Feishu at all. */
309const LOCAL_ONLY = new Set([
310 'resume', 'mcp', 'agents', 'plugin', 'plugins', 'permissions', 'hooks', 'memory', 'login', 'logout',
311 'theme', 'ide', 'rewind', 'tasks', 'bashes', 'export', 'terminal-setup', 'vim', 'keybindings',
312 'privacy-settings', 'output-style', 'statusline', 'add-dir', 'install-github-app', 'feedback', 'upgrade',
313])
314
315async function configRows($: $, command: string): Promise<ConfigRowView[]> {
316 const rows = (await $.config.list()).map(r => ({
317 key: r.key, label: r.label, kind: r.kind, value: r.value, options: r.options, isLocked: r.isLocked,
318 }))
319 if (command === 'model') return rows.filter(r => r.key === 'model')
320 return rows.filter(r => r.kind === 'boolean' || r.kind === 'choice').slice(0, 40)
321}
322
323const configTitle = (view: string) => (view === 'model' ? '模型' : '设置')
324
325/** /config and /model as a card; its clicks are handled by the standing listener, so it never expires. */
326async function configSession($: $, m: FeishuMessage, command: string): Promise<void> {
327 const rows = await configRows($, command)
328 if (command === 'model' && !rows.length) {
329 await reply($, m.messageId, '这里读不到模型设置,请在电脑上用 /model 切换。')
330 return
331 }
332 await sendCard($, m.chatId, configCard(command, configTitle(command), rows, '点按钮或下拉框,立即生效。'))
333}
334
335/** One click on any settings card: set the row, then redraw that card. */
336async function handleConfigClick($: $, ev: Record<string, unknown>): Promise<void> {
337 if (!(await allowList($)).includes(String(ev.operator_id ?? ''))) return
338 const value = parseObject(String(ev.action_value ?? '')) ?? {}
339 const view = value.view === 'model' ? 'model' : 'config'
340 const key = typeof value.key === 'string' ? value.key : ''
341 const raw = typeof value.set === 'string' ? value.set : String(ev.option ?? '')
342 const row = (await configRows($, view)).find(r => r.key === key)
343 let note = ''
344 if (row && raw) {
345 const res = await $.config.set({ key, value: row.kind === 'boolean' ? raw === 'true' : raw })
346 note = res.deny ? `没改成:${res.deny}` : `已把 **${row.label}** 设为 ${String(res.value)}`
347 $.ui.toast(`飞书:${note.replace(/\*\*/g, '')}`)
348 }
349 const cardId = String(ev.message_id ?? '')
350 if (isMessageId(cardId)) await patchCard($, cardId, configCard(view, configTitle(view), await configRows($, view), note))
351}
352
353async function helpText($: $): Promise<string> {
354 const list = await $.command.list()
355 return list.slice(0, 80).map(c => `/${c.name} — ${c.description.slice(0, 60)}`).join('\n')
356}
357const COMMAND_WAIT_MS = 30_000
358
359async function runSlash($: $, m: FeishuMessage, command: string, args: string): Promise<void> {
360 if (command === 'feishu') {
361 await reply($, m.messageId, await feishuCommand($, args))
362 return
363 }
364 if (USAGE_COMMANDS.has(command)) {
365 await sendUsage($, m, false)
366 return
367 }
368 if (command === 'config' || command === 'model') return configSession($, m, command)
369 if (command === 'status') return sendUsage($, m, true)
370 if (command === 'help') return void (await reply($, m.messageId, await helpText($)))
371 if (LOCAL_ONLY.has(command)) {
372 await reply($, m.messageId, `/${command} 是电脑上的交互界面,需要在电脑上操作。`)
373 return
374 }
375 await startWorking($, m.messageId)
376 // A command that starts a turn (a skill, a prompt command) answers there.
377 await update($, armed, () => ({ messageId: m.messageId, until: Date.now() + 60_000 }))
378 try {
379 const ran = $.command.run({ command, args }).then(r => (r.text ?? '').trim())
380 const out = await Promise.race([ran, $.clock.sleep(COMMAND_WAIT_MS).then(() => null)])
381 if (out === null) {
382 void ran.catch(() => undefined)
383 await update($, armed, a => (a?.messageId === m.messageId ? null : a))
384 await reply($, m.messageId, `/${command} 在电脑上打开了交互界面,飞书里显示不了,请到电脑上查看(按 Esc 关闭)。`)
385 await stopWorking($, m.messageId, false)
386 return
387 }
388 if (out) await reply($, m.messageId, '```\n' + out.slice(0, 6000) + '\n```')
389 // A turn the command started took the armed message and answers it; otherwise we are done.
390 const startedTurn = Object.values(await read($, turns)).includes(m.messageId)
391 if (!startedTurn) {
392 await update($, armed, a => (a?.messageId === m.messageId ? null : a))
393 if (!out) await reply($, m.messageId, `/${command} 已执行。`)
394 await stopWorking($, m.messageId, false)
395 }
396 } catch (err) {
397 await update($, armed, () => null)
398 await reply($, m.messageId, `/${command} 执行失败:${String(err).slice(0, 500)}`)
399 await stopWorking($, m.messageId, true)
400 }
401}
402
403async function handle($: $, m: FeishuMessage): Promise<void> {
404 debug($, `event ${m.messageId} from ${m.senderId} (${m.chatType}/${m.messageType})`)
405 if (m.senderType && m.senderType !== 'user') return
406 if ((await read($, seen)).includes(m.messageId)) return
407 await update($, seen, list => [...list, m.messageId].slice(-200))
408
409 if (!(await allowList($)).includes(m.senderId)) {
410 await $.store.set(LAST_KEY, m.senderId)
411 if (!warnedSenders.has(m.senderId)) {
412 warnedSenders.add(m.senderId)
413 $.ui.toast(`飞书:未授权的发送者 ${m.senderId}。确认是你本人后运行 /feishu allow last`)
414 }
415 return
416 }
417 if (m.chatType === 'p2p' && isChatId(m.chatId)) await $.store.set(HOME_KEY, m.chatId)
418
419 const slash = m.messageType === 'text' ? parseSlash(m.content) : null
420 if (slash) {
421 void runSlash($, m, slash.command, slash.args).catch(err => debug($, `slash: ${String(err)}`))
422 return
423 }
424
425 await startWorking($, m.messageId)
426 const files = await downloadResources($, m).catch(err => {
427 debug($, `download: ${String(err)}`)
428 return [] as string[]
429 })
430 if (!m.content.trim() && !files.length) return stopWorking($, m.messageId, false)
431 await $.prompt.submit({ text: buildPrompt(m, files), asUser: true })
432}
433
434/** Keeps one `lark-cli event consume` running while the bridge is on, line by line. */
435async function runStream(
436 $: $,
437 id: number,
438 args: string[],
439 onLine: (line: string) => Promise<void>,
440 isPrimary: boolean,
441): Promise<void> {
442 while (id === loopId && (await read($, isOn))) {
443 if (isPrimary) await setStatus($, 'starting')
444 let buffer = ''
445 try {
446 // An unbounded consume exits when stdin closes; a timeout makes it ignore that.
447 const stream = $.process.spawn({ argv: [LARK, '--profile', PROFILE, 'event', 'consume', ...args, '--as', 'bot', '--timeout', '720h'] })
448 consumers.add(stream)
449 try {
450 for await (const chunk of stream) {
451 if (id !== loopId) break
452 if (isPrimary && (await read($, status)) !== 'listening') await setStatus($, 'listening')
453 if (chunk.stream === 'stderr') {
454 debug($, chunk.text.trim())
455 continue
456 }
457 const { lines, rest } = splitLines(buffer, chunk.text)
458 buffer = rest
459 for (const line of lines) await onLine(line).catch(err => debug($, String(err)))
460 }
461 } finally {
462 consumers.delete(stream)
463 }
464 } catch (err) {
465 debug($, `consumer ${args[0]} failed: ${String(err)}`)
466 }
467 if (id !== loopId || !(await read($, isOn))) break
468 if (isPrimary) await setStatus($, 'error')
469 await $.clock.sleep(5000)
470 }
471}
472
473async function runConsumer($: $): Promise<void> {
474 const id = ++loopId
475 await Promise.all([
476 runStream($, id, ['im.message.receive_v1'], async line => {
477 const message = parseEvent(line)
478 if (message) await handle($, message)
479 }, true),
480 runStream($, id, ['card.action.trigger'], async line => {
481 const ev = parseObject(line)
482 if (ev) await handleCardClick($, ev)
483 }, false),
484 ])
485}
486
487async function stopConsumer($: $): Promise<void> {
488 loopId++
489 const running = [...consumers]
490 consumers.clear()
491 await Promise.all(running.map(s => s.return(undefined).catch(() => undefined)))
492 await setStatus($, 'off')
493}
494
495// ── /feishu ─────────────────────────────────────────────────────────────────
496
497async function feishuCommand($: $, argText: string): Promise<string> {
498 const [verb = 'status', arg = ''] = argText.trim().split(/\s+/)
499 switch (verb) {
500 case 'on': {
501 if (await read($, isOn)) return '飞书桥接已经开着。'
502 await update($, isOn, () => true)
503 void runConsumer($).catch(() => undefined)
504 const allowed = await allowList($)
505 return allowed.length
506 ? `飞书桥接已开启(profile ${PROFILE}),授权用户 ${allowed.length} 个。`
507 : `飞书桥接已开启(profile ${PROFILE})。还没有授权用户:先给机器人发一条消息,再运行 /feishu allow last。`
508 }
509 case 'off':
510 await update($, isOn, () => false)
511 await stopConsumer($)
512 return '飞书桥接已关闭。'
513 case 'allow':
514 case 'deny': {
515 const last = await $.store.get(LAST_KEY)
516 const target = arg === 'last' && typeof last === 'string' ? last : arg
517 if (!isOpenId(target)) return '需要一个 open_id(ou_xxx),或用 last 表示最近一个未授权的发送者。'
518 const list = await allowList($)
519 const nextList = verb === 'allow' ? [...new Set([...list, target])] : list.filter(x => x !== target)
520 await $.store.set(ALLOW_KEY, nextList)
521 if (verb === 'deny' && configAllow.includes(target))
522 return `${target} 写在设置的 allowedUsers 里,仍然有效;要移除请改 settings.json 的 pluginConfigs。`
523 return `${verb === 'allow' ? '已授权' : '已移除'} ${target}。当前授权:${nextList.join(', ') || '无'}`
524 }
525 default:
526 return [
527 `状态:${await read($, status)}(profile ${PROFILE})`,
528 `授权用户:${(await allowList($)).join(', ') || '无'}`,
529 `飞书会话:${(await homeChat($)) ?? '未知(先在飞书私聊机器人一次)'}`,
530 `等待中的飞书卡片:${Object.keys(await read($, pending)).length}`,
531 `最近未授权的发送者:${String((await $.store.get(LAST_KEY)) ?? '无')}`,
532 ].join('\n')
533 }
534}
535
536// ── hooks ───────────────────────────────────────────────────────────────────
537
538export const register: Register = (on, options) => {
539 configAllow = String(options.allowedUsers ?? '')
540 .split(',')
541 .map(v => v.trim())
542 .filter(isOpenId)
543 const home = String(options.homeChat ?? '').trim()
544 configHome = isChatId(home) ? home : null
545 on('session.start', async ($, e, next) => {
546 await $.command.register({
547 name: 'feishu',
548 description: '飞书桥接:on | off | status | allow <open_id|last> | deny <open_id>',
549 argumentHint: 'on|off|status|allow <ou_…|last>|deny <ou_…>',
550 })
551 const started = await next(e)
552 // A reload keeps $.state: resume a bridge that was on.
553 if (await read($, isOn)) void runConsumer($).catch(() => undefined)
554 return started
555 })
556
557 on('command.run', { command: 'feishu' }, async ($, e) => ({ text: await feishuCommand($, e.args) }))
558
559 on('prompt.submit', async ($, e, next) => {
560 // Our own submissions carry the reply rules as context the person never sees.
561 if (e.origin?.kind === 'plugin' && e.origin.name === PLUGIN && markerOf(e.text)) {
562 return next({ ...e, context: [...(e.context ?? []), REPLY_GUIDE] })
563 }
564 // A prompt typed at the computer is mirrored to Feishu, and its answer follows it there.
565 const chat = e.origin?.kind === 'composer' ? await remoteChat($) : null
566 if (chat && e.text.trim()) {
567 const messageId = await sendMarkdown($, chat, `💻 **电脑端**\n${e.text.slice(0, 3000)}`)
568 if (messageId && e.turnId) {
569 const turnId = e.turnId
570 await update($, turns, map => (map[turnId] ? map : { ...map, [turnId]: messageId }))
571 } else if (messageId) {
572 await update($, mirrored, list => [...list, { text: e.text, messageId }].slice(-20))
573 }
574 }
575 return next(e)
576 }).catch(($, e, next) => next(e))
577
578 on('turn.start', async ($, e, next) => {
579 let messageId = markerOf(e.text)
580 if (!messageId) {
581 const queue = await read($, mirrored)
582 const hit = queue.find(q => q.text === e.text || e.text.startsWith(q.text))
583 if (hit) {
584 messageId = hit.messageId
585 await update($, mirrored, list => list.filter(q => q !== hit && q.messageId !== hit.messageId))
586 }
587 }
588 if (!messageId) {
589 const pending = await read($, armed)
590 if (pending && pending.until > Date.now()) messageId = pending.messageId
591 if (pending) await update($, armed, () => null)
592 }
593 if (messageId) {
594 const id = messageId
595 await update($, turns, map => ({ ...map, [e.turnId]: id }))
596 }
597 return next(e)
598 })
599
600 on('turn.complete', async ($, e, next) => {
601 const result = await next(e)
602 if (e.agentId) return result
603 const used = e.usage?.model
604 if (used) await update($, model, () => used)
605 const messageId = (await read($, turns))[e.turnId]
606 if (!messageId) return result
607 await update($, turns, map => {
608 const { [e.turnId]: _, ...rest } = map
609 return rest
610 })
611
612 const text =
613 e.reason === 'answer' ? e.answer.trim() || '(本轮没有文字回答)'
614 : e.reason === 'aborted' ? '(这一轮在电脑上被中断了)'
615 : e.reason === 'refusal' ? '(这一轮被模型拒绝了)'
616 : '(这一轮因 API 错误中断)'
617 const ok = await reply($, messageId, text)
618 if (e.reason === 'answer') {
619 for (const path of localImages(e.answer)) {
620 const st = await $.fs.stat(path).catch(() => null)
621 if (st) await replyImage($, messageId, path)
622 }
623 }
624 await stopWorking($, messageId, !ok || e.reason !== 'answer')
625 return result
626 })
627
628 // A permission ask (AskUserQuestion included) goes to Feishu while the dialog
629 // stays up at the computer: whichever answers first settles it.
630 on('classic.PermissionRequest', async ($, e, next) => {
631 const chat = await remoteChat($)
632 if (!chat) return next(e)
633 const input = (typeof e.tool_input === 'object' && e.tool_input !== null ? e.tool_input : {}) as Record<string, unknown>
634 const isQuestion = e.tool_name === 'AskUserQuestion' && Array.isArray(input.questions)
635 const questions = (isQuestion ? input.questions : []) as AskQuestion[]
636 const summary = isQuestion ? '' : describeToolInput(e.tool_name, input)
637
638 const id = rid()
639 await update($, pending, map => ({ ...map, [id]: { cardId: '', tool: e.tool_name } }))
640 const click = waitCardAction($, id, CARD_WAIT_S)
641 const cardId = await sendCard($, chat, isQuestion ? questionCard(id, questions) : approvalCard(id, e.tool_name, summary))
642 if (!cardId) {
643 await forget($, id)
644 return next(e)
645 }
646 await update($, pending, map => (map[id] ? { ...map, [id]: { cardId, tool: e.tool_name } } : map))
647
648 const ev = await click
649 if (!(await forget($, id)) || !ev) {
650 // Settled at the computer (its card is updated there), or timed out.
651 if (ev === null && (await read($, isOn))) {
652 await patchCard($, cardId, resolvedCard('已超时', 'grey', '回到电脑上处理。'))
653 }
654 return next(e)
655 }
656
657 if (isQuestion) {
658 const answers = answersFromForm(questions, parseObject(String(ev.form_value ?? '')) ?? {})
659 await patchCard($, cardId, resolvedCard('已回答', 'green', answersMarkdown(answers)))
660 $.ui.toast('飞书:已回答问题')
661 return { decision: { behavior: 'allow' as const, updatedInput: { ...input, answers } } }
662 }
663
664 const choice = (parseObject(String(ev.action_value ?? ''))?.choice ?? 'deny') as ApprovalChoice
665 const label = choice === 'once' ? '已允许一次' : choice === 'session' ? '本会话不再询问' : '已拒绝'
666 await patchCard($, cardId, resolvedCard(`${label}:${e.tool_name}`, choice === 'deny' ? 'red' : 'green', summary))
667 $.ui.toast(`飞书:${label} ${e.tool_name}`)
668 if (choice === 'deny') return { decision: { behavior: 'deny' as const, message: '用户在飞书上拒绝了这次操作。' } }
669 if (choice === 'once') return { decision: { behavior: 'allow' as const } }
670 const rules = (e.permission_suggestions ?? []).filter(u => u.type === 'addRules')
671 const updatedPermissions = rules.length
672 ? rules.map(u => ({ ...u, destination: 'session' as const }))
673 : [{ type: 'addRules' as const, rules: [{ toolName: e.tool_name }], behavior: 'allow' as const, destination: 'session' as const }]
674 return { decision: { behavior: 'allow' as const, updatedPermissions } }
675 }).catch(($, e, next) => next(e))
676
677 // Once a tool call settles, any of its cards still waiting were answered at the computer.
678 on('tool.call', async ($, e, next) => {
679 const result = await next(e)
680 const waiting = Object.entries(await read($, pending)).filter(([, card]) => card.tool === e.tool && card.cardId)
681 for (const [id, card] of waiting) {
682 await forget($, id)
683 await patchCard($, card.cardId, resolvedCard(`已在电脑上处理:${e.tool}`, 'grey', '这张卡片不再需要操作。'))
684 }
685 return result
686 })
687
688 on('session.end', async ($, e, next) => {
689 // A /clear ends the conversation, not the session: keep listening.
690 if (e.reason !== 'clear') await stopConsumer($)
691 return next(e)
692 })
693}
694hooks/cards.ts 204 lines1// Feishu interactive cards, as plain JSON. Pure: the tests build and read them.
2
3export type Card = Record<string, unknown>
4
5export type AskQuestion = {
6 question: string
7 header?: string
8 multiSelect?: boolean
9 options?: ReadonlyArray<{ label: string; description?: string }>
10}
11
12const text = (content: string) => ({ tag: 'plain_text', content })
13
14function card1(title: string, template: string, markdown: string, actions?: Card[]): Card {
15 const elements: Card[] = [{ tag: 'markdown', content: markdown }]
16 if (actions?.length) elements.push({ tag: 'action', actions })
17 return {
18 config: { wide_screen_mode: true, update_multi: true },
19 header: { title: text(title), template },
20 elements,
21 }
22}
23
24const button = (label: string, type: string, value: Record<string, string>): Card => ({
25 tag: 'button',
26 text: text(label),
27 type,
28 value,
29})
30
31export type ApprovalChoice = 'once' | 'session' | 'deny'
32
33export function approvalCard(rid: string, tool: string, summary: string, reason?: string): Card {
34 // Answerable here or at the computer, whichever comes first.
35 const v = (choice: ApprovalChoice) => ({ rid, kind: 'approval', choice })
36 return card1(
37 `需要授权:${tool}`,
38 'orange',
39 [summary, reason ? `\n<font color='grey'>${reason}</font>` : ''].join(''),
40 [
41 button('✅ 允许一次', 'primary', v('once')),
42 button('✅ 本会话不再询问', 'default', v('session')),
43 button('❌ 拒绝', 'danger', v('deny')),
44 ],
45 )
46}
47
48export function resolvedCard(title: string, template: 'green' | 'red' | 'grey', markdown: string): Card {
49 return card1(title, template, markdown)
50}
51
52/** One form for up to four questions: a select (or multi-select) and an "other" box each. */
53export function questionCard(rid: string, questions: readonly AskQuestion[]): Card {
54 const elements: Card[] = []
55 questions.forEach((q, i) => {
56 const opts = (q.options ?? []).map((o, j) => ({ text: text(o.label), value: String(j) }))
57 const notes = (q.options ?? [])
58 .filter(o => o.description)
59 .map(o => `- **${o.label}**:${o.description}`)
60 .join('\n')
61 elements.push({ tag: 'markdown', content: `**${q.header ? `[${q.header}] ` : ''}${q.question}**${notes ? `\n${notes}` : ''}` })
62 if (opts.length) {
63 elements.push({
64 tag: q.multiSelect ? 'multi_select_static' : 'select_static',
65 name: `q${i}`,
66 placeholder: text(q.multiSelect ? '可多选' : '请选择'),
67 options: opts,
68 required: false,
69 width: 'fill',
70 })
71 }
72 elements.push({
73 tag: 'input',
74 name: `q${i}_other`,
75 placeholder: text(opts.length ? '或者直接写你的回答(可留空)' : '你的回答'),
76 required: false,
77 width: 'fill',
78 })
79 })
80 elements.push({
81 tag: 'button',
82 name: 'submit',
83 text: text('提交'),
84 type: 'primary_filled',
85 form_action_type: 'submit',
86 behaviors: [{ type: 'callback', value: { rid, kind: 'question' } }],
87 })
88 return {
89 schema: '2.0',
90 config: { update_multi: true, width_mode: 'fill' },
91 header: { title: text('Claude 想问你'), template: 'blue' },
92 body: { elements: [{ tag: 'form', name: 'ask', elements }] },
93 }
94}
95
96/** Maps a form submission back to AskUserQuestion's answers (question -> label(s)). */
97export function answersFromForm(
98 questions: readonly AskQuestion[],
99 form: Record<string, unknown>,
100): Record<string, string> {
101 const answers: Record<string, string> = {}
102 questions.forEach((q, i) => {
103 const labels = (q.options ?? []).map(o => o.label)
104 const raw = form[`q${i}`]
105 const picked = (Array.isArray(raw) ? raw : raw === undefined || raw === '' ? [] : [raw])
106 .map(v => labels[Number(v)])
107 .filter((l): l is string => typeof l === 'string')
108 const other = typeof form[`q${i}_other`] === 'string' ? (form[`q${i}_other`] as string).trim() : ''
109 const all = other ? [...picked, other] : picked
110 if (all.length) answers[q.question] = all.join(', ')
111 })
112 return answers
113}
114
115export function answersMarkdown(answers: Record<string, string>): string {
116 const lines = Object.entries(answers).map(([q, a]) => `- ${q}\n **${a}**`)
117 return lines.length ? lines.join('\n') : '(未作答)'
118}
119
120export type ConfigRowView = {
121 key: string
122 label: string
123 kind: 'boolean' | 'choice' | 'text' | 'number'
124 value: boolean | string | number | readonly string[]
125 options?: readonly string[]
126 isLocked: boolean
127}
128
129const show = (v: ConfigRowView['value']): string => (Array.isArray(v) ? v.join(', ') : String(v))
130
131/** A settings card: toggles as buttons, choices as selects; the rest read-only. `view` redraws it. */
132export function configCard(view: string, title: string, rows: readonly ConfigRowView[], note?: string): Card {
133 const elements: Card[] = []
134 if (note) elements.push({ tag: 'markdown', content: note })
135 for (const row of rows) {
136 const editable = !row.isLocked && (row.kind === 'boolean' || (row.kind === 'choice' && (row.options?.length ?? 0) > 0))
137 elements.push({ tag: 'markdown', content: `**${row.label}**:${show(row.value)}${row.isLocked ? '(已锁定)' : ''}` })
138 if (!editable) continue
139 if (row.kind === 'boolean') {
140 const next = row.value === true ? 'false' : 'true'
141 elements.push({
142 tag: 'action',
143 actions: [button(row.value === true ? '关闭' : '开启', row.value === true ? 'default' : 'primary', { kind: 'config', view, key: row.key, set: next })],
144 })
145 } else {
146 elements.push({
147 tag: 'action',
148 actions: [{
149 tag: 'select_static',
150 placeholder: text(`选择 ${row.label}`),
151 initial_option: typeof row.value === 'string' ? row.value : undefined,
152 options: (row.options ?? []).slice(0, 50).map(o => ({ text: text(o), value: o })),
153 value: { kind: 'config', view, key: row.key },
154 }],
155 })
156 }
157 }
158 if (!rows.length) elements.push({ tag: 'markdown', content: '没有可以在飞书里修改的设置。' })
159 return {
160 config: { wide_screen_mode: true, update_multi: true },
161 header: { title: text(title), template: 'blue' },
162 elements,
163 }
164}
165
166export type StatusView = {
167 title: string
168 subtitle?: string
169 costUsd?: number
170 contextPercent?: number
171 contextText: string
172 model?: string
173 limits: ReadonlyArray<{ name: string; percent: number; reset?: string }>
174 footnote?: string
175}
176
177const tone = (p: number) => (p >= 80 ? 'red' : p >= 50 ? 'orange' : 'green')
178const md = (content: string, align: 'left' | 'center' | 'right' = 'left'): Card => ({ tag: 'markdown', content, text_align: align })
179const column = (elements: Card[], weight = 1): Card => ({ tag: 'column', width: 'weighted', weight, vertical_align: 'center', elements })
180
181/** "claude-opus-5-5" -> "Opus 5.5"; anything else as given. */
182export function prettyModel(id: string): string {
183 const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
184 return m ? `${(m[1] ?? '').charAt(0).toUpperCase()}${(m[1] ?? '').slice(1)} ${m[2]}.${m[3]}` : id
185}
186
187/** /status, /cost, /usage as a small card: one line each, percentages colored. */
188export function statusCard(v: StatusView): Card {
189 const pct = (p: number) => `<font color='${tone(p)}'>**${Math.round(p)}%**</font>`
190 const lines: string[] = []
191 const head: string[] = []
192 if (v.costUsd !== undefined) head.push(`花费 **$${v.costUsd.toFixed(2)}**`)
193 head.push(`上下文 ${v.contextPercent === undefined ? v.contextText : pct(v.contextPercent)}`)
194 lines.push(head.join(' '))
195 for (const l of v.limits) lines.push(`${l.name} ${pct(l.percent)}${l.reset ? ` <font color='grey'>${l.reset}</font>` : ''}`)
196 if (v.footnote) lines.push(`<font color='grey'>${v.footnote}</font>`)
197 const title = [v.title, v.model ? prettyModel(v.model) : ''].filter(Boolean).join(' · ')
198 return {
199 config: { wide_screen_mode: true },
200 header: { title: text(title), template: 'blue' },
201 elements: [{ tag: 'markdown', content: lines.join('\n') }],
202 }
203}
204hooks/lib.ts 229 lines1// Pure helpers: no `$`, so the tests can call them directly.
2
3export type FeishuMessage = {
4 messageId: string
5 chatId: string
6 chatType: string
7 senderId: string
8 senderType: string
9 messageType: string
10 content: string
11}
12
13export type Resource = { key: string; type: 'image' | 'file'; name?: string }
14
15const MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,64}$/
16const OPEN_ID = /^ou_[A-Za-z0-9_-]{1,64}$/
17const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,64}$/
18const RESOURCE_KEY = /^(img|file)_[A-Za-z0-9_-]{1,128}$/
19const MARKER = /\[飞书消息 (om_[A-Za-z0-9_-]{1,64})\]/
20
21export const isMessageId = (id: string): boolean => MESSAGE_ID.test(id)
22export const isOpenId = (id: string): boolean => OPEN_ID.test(id)
23export const isChatId = (id: string): boolean => CHAT_ID.test(id)
24
25/** Message types whose files are worth fetching. */
26export const MEDIA_TYPES = new Set(['image', 'file', 'post', 'media', 'audio'])
27
28/** Splits streamed stdout into complete lines, keeping the unfinished tail. */
29export function splitLines(buffer: string, text: string): { lines: string[]; rest: string } {
30 const parts = (buffer + text).split('\n')
31 const rest = parts.pop() ?? ''
32 return { lines: parts.map(line => line.trim()).filter(Boolean), rest }
33}
34
35/** One NDJSON line of `lark-cli event consume im.message.receive_v1`, or null. */
36export function parseEvent(line: string): FeishuMessage | null {
37 const o = parseObject(line)
38 if (!o) return null
39 const str = (v: unknown): string => (typeof v === 'string' ? v : '')
40 const messageId = str(o.message_id) || str(o.id)
41 if (!isMessageId(messageId)) return null
42 return {
43 messageId,
44 chatId: str(o.chat_id),
45 chatType: str(o.chat_type),
46 senderId: str(o.sender_id),
47 senderType: str(o.sender_type),
48 messageType: str(o.message_type),
49 content: str(o.content),
50 }
51}
52
53export function parseObject(text: string): Record<string, unknown> | null {
54 try {
55 const v: unknown = JSON.parse(text)
56 return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
57 } catch {
58 return null
59 }
60}
61
62/** The prompt the session reads; the marker lets turn.start find the message again. */
63export function buildPrompt(m: FeishuMessage, files: readonly string[] = []): string {
64 const where = m.chatType === 'p2p' ? '私聊' : '群聊'
65 const lines = [`[飞书消息 ${m.messageId}] (${where})`, m.content]
66 if (files.length) {
67 lines.push('', '附件已下载到本地(图片可以直接用 Read 查看):', ...files.map(f => `- ${f}`))
68 }
69 return lines.join('\n')
70}
71
72export function markerOf(text: string): string | null {
73 return MARKER.exec(text)?.[1] ?? null
74}
75
76/** `/name args` typed in Feishu, or null for an ordinary message. */
77export function parseSlash(text: string): { command: string; args: string } | null {
78 const m = /^\/([A-Za-z][\w:.-]{0,63})(?:\s+([\s\S]*))?$/.exec(text.trim())
79 return m ? { command: m[1] ?? '', args: (m[2] ?? '').trim() } : null
80}
81
82/** Every image_key / file_key a raw message body holds, post bodies included. */
83export function findResources(body: unknown): Resource[] {
84 const out: Resource[] = []
85 const seen = new Set<string>()
86 const walk = (v: unknown): void => {
87 if (Array.isArray(v)) return v.forEach(walk)
88 if (typeof v !== 'object' || v === null) return
89 const o = v as Record<string, unknown>
90 const image = o.image_key
91 const file = o.file_key
92 if (typeof file === 'string' && RESOURCE_KEY.test(file) && !seen.has(file)) {
93 seen.add(file)
94 out.push({ key: file, type: 'file', name: typeof o.file_name === 'string' ? o.file_name : undefined })
95 } else if (typeof image === 'string' && RESOURCE_KEY.test(image) && !seen.has(image)) {
96 seen.add(image)
97 out.push({ key: image, type: 'image' })
98 }
99 Object.values(o).forEach(walk)
100 }
101 walk(body)
102 return out
103}
104
105/** A file name safe to write under the download folder. */
106export function safeName(r: Resource): string {
107 const base = (r.name ?? r.key).replace(/[^\w.\-一-鿿]+/g, '_').replace(/^\.+/, '').slice(0, 120)
108 return base || r.key
109}
110
111/** Absolute paths of local images an answer mentions, in order, without repeats. */
112export function localImages(answer: string): string[] {
113 const found = answer.match(/\/[^\s`'"()<>\[\]]+\.(?:png|jpe?g|gif|webp)\b/gi) ?? []
114 return [...new Set(found)].filter(p => !p.includes('..')).slice(0, 5)
115}
116
117/** The message_id a lark-cli send or reply printed, if any. */
118export function sentMessageId(stdout: string): string | null {
119 return /"message_id"\s*:\s*"(om_[A-Za-z0-9_-]{1,64})"/.exec(stdout)?.[1] ?? null
120}
121
122/** Cuts a reply into pieces Feishu accepts, preferring paragraph then line breaks. */
123export function chunkReply(text: string, max = 3500): string[] {
124 const out: string[] = []
125 let rest = text.trim()
126 while (rest.length > max) {
127 const window = rest.slice(0, max)
128 let cut = window.lastIndexOf('\n\n')
129 if (cut < max / 2) cut = window.lastIndexOf('\n')
130 if (cut < max / 2) cut = max
131 out.push(rest.slice(0, cut).trimEnd())
132 rest = rest.slice(cut).trimStart()
133 }
134 if (rest) out.push(rest)
135 return out
136}
137
138/** A short, readable summary of a tool call for an approval card. */
139export function describeToolInput(tool: string, input: unknown): string {
140 const o = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
141 const pick = (k: string): string => (typeof o[k] === 'string' ? (o[k] as string) : '')
142 const clip = (s: string, n = 1500): string => (s.length > n ? `${s.slice(0, n)}…` : s)
143 if (tool === 'Bash') {
144 const desc = pick('description')
145 return `${desc ? `${desc}\n` : ''}\`\`\`bash\n${clip(pick('command'))}\n\`\`\``
146 }
147 const path = pick('file_path') || pick('notebook_path') || pick('path')
148 if (path) return `\`${path}\``
149 const url = pick('url')
150 if (url) return url
151 return `\`\`\`json\n${clip(JSON.stringify(o, null, 2))}\n\`\`\``
152}
153
154export const REPLY_GUIDE = [
155 '这条消息来自飞书机器人,你本轮的最终回答会被自动作为飞书回复发回给对方。',
156 '用简体中文,结论先行,简短;可以用简单的 Markdown(加粗、列表、代码块),不要用表格。',
157 '不要在回答里自己调用 lark-cli 发送回复,桥接会处理。要发图片给对方时,在回答里写出图片的本地绝对路径即可。',
158].join('\n')
159
160export type UsageView = {
161 version?: string
162 cwd?: string
163 home?: string
164 model?: string
165 costUsd?: number
166 context: { tokens?: number; window: number; percent?: number }
167 rateLimits: ReadonlyArray<{ kind: string; percentUsed: number; resetsAt?: string }>
168}
169
170export const LIMIT_NAMES: Record<string, string> = {
171 five_hour: '5 小时额度',
172 seven_day: '7 天额度',
173 seven_day_opus: '7 天额度(Opus)',
174 seven_day_sonnet: '7 天额度(Sonnet)',
175 spend_limit: '花费上限',
176}
177
178export const tokens = (n?: number): string =>
179 n === undefined ? '?' : n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(2)}M` : n >= 1000 ? `${+(n / 1000).toFixed(1)}k` : String(n)
180
181export function bar(percent: number, width = 10): string {
182 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
183 return '▓'.repeat(filled) + '░'.repeat(width - filled)
184}
185
186/** "2 小时 59 分后" / "3 天 7 小时后", from now until `iso`. */
187export function untilText(iso: string, now: number): string {
188 const ms = Date.parse(iso) - now
189 if (!Number.isFinite(ms)) return ''
190 if (ms <= 0) return '即将'
191 const min = Math.round(ms / 60_000)
192 const d = Math.floor(min / 1440)
193 const h = Math.floor((min % 1440) / 60)
194 const m = min % 60
195 if (d) return `${d} 天${h ? ` ${h} 小时` : ''}后`
196 if (h) return `${h} 小时${m ? ` ${m} 分` : ''}后`
197 return `${m} 分钟后`
198}
199
200/** Local wall time of `iso`, "10/7 02:10", in the given offset (minutes east of UTC). */
201export function localTime(iso: string, offsetMin: number): string {
202 const t = Date.parse(iso)
203 if (!Number.isFinite(t)) return ''
204 const d = new Date(t + offsetMin * 60_000)
205 const pad = (n: number) => String(n).padStart(2, '0')
206 return `${d.getUTCMonth() + 1}/${d.getUTCDate()} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
207}
208
209/** The usage / status reply, as Feishu markdown. `full` adds version, directory and model. */
210export function formatUsage(u: UsageView, now: number, offsetMin: number, full: boolean): string {
211 const lines: string[] = []
212 if (full && u.version) lines.push(`**Claude Code ${u.version}**`)
213 if (full && u.cwd) lines.push(`📁 ${u.home && u.cwd.startsWith(u.home) ? `~${u.cwd.slice(u.home.length)}` : u.cwd}`)
214 if (full && u.model) lines.push(`🧠 模型:${u.model}`)
215 if (u.costUsd !== undefined) lines.push(`💰 本会话花费:**$${u.costUsd.toFixed(2)}**`)
216 const pct = u.context.percent ?? (u.context.tokens !== undefined ? (u.context.tokens / u.context.window) * 100 : undefined)
217 lines.push(`📊 上下文:${tokens(u.context.tokens)} / ${tokens(u.context.window)}${pct === undefined ? '' : `(${Math.round(pct)}%)`}`)
218 if (pct !== undefined) lines.push(bar(pct))
219 if (u.rateLimits.length) lines.push('')
220 for (const r of u.rateLimits) {
221 const name = LIMIT_NAMES[r.kind] ?? r.kind
222 const warn = r.percentUsed >= 80 ? ' ⚠️' : ''
223 const reset = r.resetsAt ? `,${untilText(r.resetsAt, now)}重置(${localTime(r.resetsAt, offsetMin)})` : ''
224 lines.push(`⏱ ${name}:已用 **${Math.round(r.percentUsed)}%**${warn}${reset}`)
225 lines.push(bar(r.percentUsed))
226 }
227 return lines.join('\n')
228}
229types/index.d.ts 33 lines1export type FeishuModStatus = 'off' | 'starting' | 'listening' | 'error'
2
3/** A prompt typed at the computer, mirrored to Feishu and waiting for its turn. */
4export type FeishuMirrored = { text: string; messageId: string }
5
6/** A card waiting for a click: the sent card's message_id ('' while sending) and its tool. */
7export type FeishuPendingCard = { cardId: string; tool: string }
8
9/** A Feishu slash command's message, answered by the next turn it starts. */
10export type FeishuArmed = { messageId: string; until: number }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'feishu-mod': {
15 /** Whether the bridge should be consuming events in this session. */
16 isOn: boolean
17 status: FeishuModStatus
18 /** turnId -> the Feishu message_id the turn's answer replies to. */
19 turns: Record<string, string>
20 /** Recently handled message_ids, newest last. */
21 seen: string[]
22 mirrored: FeishuMirrored[]
23 armed: FeishuArmed | null
24 /** rid -> a card waiting for a click. */
25 pending: Record<string, FeishuPendingCard>
26 /** message_id -> the reaction_id of its Typing badge. */
27 typing: Record<string, string>
28 /** The model the last main-loop turn ran on. */
29 model: string
30 }
31 }
32}
33