@router-agent/channel 0.3.31 → 0.3.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,645 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { createInterface } from 'node:readline';
3
- import { noopLogger, errorMessage } from '@router-agent/infra/logger.js';
4
- import { parseImageKeysFromContent } from './feishu-image.js';
5
- /** 只有附件、没有可执行文字的消息类型。lark-cli 会把正文预渲染成占位文本。 */
6
- export const MEDIA_MESSAGE_TYPES = new Set(['image', 'file', 'audio', 'media', 'video', 'sticker']);
7
- /** 归一化并返回丢弃原因;正常路径用 normalizeFeishuEvent。 */
8
- export function normalizeFeishuEventDetailed(raw, cfg, chatName = '', allowUnaddressed) {
9
- const event = raw;
10
- const messageId = event.message_id ?? event.id;
11
- const chatId = event.chat_id;
12
- if (!messageId || !chatId)
13
- return { dropped: 'missing-id' };
14
- const isP2p = event.chat_type === 'p2p';
15
- const chat = cfg.chats.find(item => item.chat === chatId);
16
- const monitored = isP2p ? cfg.directMessages : Boolean(chat);
17
- if (!monitored)
18
- return { dropped: isP2p ? 'dm:disabled' : 'chat:not-monitored', ...(chatId ? { chatId } : {}) };
19
- const senderId = event.sender_id ?? '';
20
- const senderIsBot = event.sender_type === 'bot';
21
- // 机器人自己的消息永远丢弃,否则会自激。
22
- if (senderIsBot && cfg.botOpenIds.includes(senderId))
23
- return { dropped: 'sender:self', chatId };
24
- const messageType = String(event.message_type ?? 'text');
25
- // 系统消息(建群 / 拉人 / 改群名)不是需求。
26
- if (messageType === 'system')
27
- return { dropped: 'system-message', chatId };
28
- // 互动卡片的 content 是原始卡片 JSON(lark-cli 唯一不预渲染的类型),不能当需求读。
29
- if (messageType === 'interactive')
30
- return { dropped: 'card-message', chatId };
31
- const mediaOnly = MEDIA_MESSAGE_TYPES.has(messageType);
32
- // 线程归属:话题 ID 最稳定,用来把同一话题的消息收敛到一条线程。
33
- // 实测飞书在话题面板里直接发言时 root_id / parent_id 都为空,只剩 thread_id;
34
- // 只认 root_id 会把同一话题的消息拆成独立线程,导致订阅扇出丢失(见 tk-56 的「合并」)。
35
- // 放在 @ 判断之前算:未寻址消息放行时也要拿线程标识去查「这个话题绑没绑工单」。
36
- const topicId = String(event.thread_id ?? '');
37
- const rootId = event.root_id ?? '';
38
- // 规范线程 ID 的尾段必须是 om_ 消息 ID:飞书 `+messages-reply --message-id` 只接受 om_,
39
- // 话题 ID(omt_)只参与归属匹配,不能写进规范 ID,否则回复会打不出去。
40
- const threadKey = rootId || messageId;
41
- // 线程标识全集:话题 ID / 根消息 ID / 消息自身 ID,任一到场都能落回同一条线程。
42
- const threadKeys = uniqueKeys(topicId, rootId, messageId);
43
- // 只接收 @ 的群里,未 @ 的消息不进入领域。
44
- const mentionIds = (event.mentions ?? []).map(m => m.id ?? '').filter(Boolean);
45
- const mentionNames = (event.mentions ?? []).map(m => m.name ?? '').filter(Boolean);
46
- const mentionedBot = mentionIds.some(id => cfg.botOpenIds.includes(id)) || mentionNames.some(name => cfg.botNames.includes(name));
47
- // 未 @ 机器人的消息是否放行:已绑定工单的话题内,名单内成员不 @ 也要触发回复(tk-59)。
48
- // 放行结果记在 Trigger 上,后续的表情(shouldAcknowledge)与回执(agent-loop)都靠它判断。
49
- let unaddressedAdmitted = false;
50
- if (!isP2p && chat?.mode === 'mention' && !mentionedBot) {
51
- unaddressedAdmitted = allowUnaddressed?.({ chatId, actorId: senderId, threadKeys }) ?? false;
52
- if (!unaddressedAdmitted)
53
- return { dropped: 'mention:required', chatId, actorId: senderId };
54
- }
55
- const text = (event.content ?? '').trim();
56
- if (!text && !mediaOnly)
57
- return { dropped: 'empty-content', chatId, actorId: senderId };
58
- // 被引用消息:lark-cli 用 reply_to,飞书原始事件用 parent_id。正文不在 content 里,必须回查。
59
- const replyTo = event.reply_to || event.parent_id || '';
60
- // 话题内直接发言时,飞书会把 reply_to / parent_id 自动填成话题根消息(root_id),
61
- // 这不是用户主动引用。若拿它回查,每条话题回复都会被拼上根消息正文(tk-51 的
62
- // 「合并」被显示成「[引用 …] 移除 AICoding 页面…」)。只有 reply_to 与 root_id
63
- // 不同,才是真正的引用;没有话题 ID 的普通群引用不受此影响。
64
- const quotedId = topicId && rootId && replyTo === rootId ? '' : replyTo;
65
- const imageKeys = mediaOnly || messageType === 'post' ? parseImageKeysFromContent(text) : [];
66
- const rawMeta = {};
67
- if (quotedId)
68
- rawMeta.parentId = quotedId;
69
- // 话题根消息被飞书当成 reply_to:记下来便于排查「为什么没显示引用」。
70
- else if (replyTo)
71
- rawMeta.quoteIgnored = replyTo;
72
- if (messageType !== 'text')
73
- rawMeta.messageType = messageType;
74
- if (imageKeys.length > 0)
75
- rawMeta.imageKeys = imageKeys;
76
- const trigger = {
77
- id: `feishu_${messageId}`,
78
- source: 'feishu',
79
- externalId: messageId,
80
- threadId: `feishu:${chatId}:${threadKey}`,
81
- threadKeys,
82
- chatId,
83
- chatName,
84
- actorId: senderId,
85
- actorName: cfg.actorNames?.get(senderId) ?? '',
86
- isBot: senderIsBot,
87
- text: text || '[附件]',
88
- addressed: isP2p ? 'direct' : mentionedBot ? 'mention' : 'none',
89
- ...(unaddressedAdmitted ? { unaddressedAdmitted: true } : {}),
90
- mediaOnly,
91
- receivedAt: toIso(event.create_time),
92
- disposition: 'pending',
93
- // 记住被引用消息的 ID:正文要靠 lark-cli 回查,见 attachQuotedMessage。
94
- // 图片消息同时记下 image_key,入库前由 attachImageText 下载并转文本。
95
- raw: rawMeta,
96
- };
97
- return { trigger, chatId, actorId: trigger.actorId, addressed: trigger.addressed };
98
- }
99
- /** 把一条飞书事件翻译成领域 Trigger;不在监听范围内的消息返回 null。 */
100
- export function normalizeFeishuEvent(raw, cfg, chatName = '', allowUnaddressed) {
101
- return normalizeFeishuEventDetailed(raw, cfg, chatName, allowUnaddressed).trigger ?? null;
102
- }
103
- function toIso(ms) {
104
- const value = Number(ms);
105
- if (!Number.isFinite(value) || value <= 0)
106
- return new Date().toISOString();
107
- return new Date(value).toISOString();
108
- }
109
- /** 去掉空串与重复项的线程标识列表;顺序即优先级。 */
110
- function uniqueKeys(...values) {
111
- const seen = new Set();
112
- const keys = [];
113
- for (const value of values) {
114
- const key = value.trim();
115
- if (!key || seen.has(key))
116
- continue;
117
- seen.add(key);
118
- keys.push(key);
119
- }
120
- return keys;
121
- }
122
- /** 单条消息回查命令(`+messages-mget` 支持批量,这里只查一条)。 */
123
- export function quotedMessageCommand(messageId, as = 'bot') {
124
- return ['im', '+messages-mget', '--message-ids', messageId, '--as', as, '--format', 'json'];
125
- }
126
- /** 从 `+messages-mget` 的输出里取第一条消息的正文与发送者。 */
127
- export function parseQuotedMessage(stdout, fallbackId = '') {
128
- let json;
129
- try {
130
- json = JSON.parse(stdout);
131
- }
132
- catch {
133
- return null;
134
- }
135
- const root = json;
136
- const list = root?.data?.messages ?? root?.messages;
137
- const message = Array.isArray(list) ? list[0] : undefined;
138
- if (!message)
139
- return null;
140
- const text = extractMessageText(message);
141
- const images = parseImageKeysFromContent(String(message.content ?? '')).map(key => ({ key }));
142
- if (!text && images.length === 0)
143
- return null;
144
- return {
145
- id: String(message.message_id ?? fallbackId),
146
- text,
147
- author: String(message.sender?.name ?? ''),
148
- images,
149
- };
150
- }
151
- /**
152
- * 从 `+messages-mget` 的输出里取每条消息的话题 ID(`thread_id`,前缀 omt_)。
153
- * 用于给存量线程回填别名:老线程用根消息 ID 当线程 ID,需要补上话题 ID 才能命中话题内直发的消息。
154
- */
155
- export function parseMessageThreadIds(stdout) {
156
- const result = new Map();
157
- let json;
158
- try {
159
- json = JSON.parse(stdout);
160
- }
161
- catch {
162
- return result;
163
- }
164
- const root = json;
165
- const list = root?.data?.messages ?? root?.messages;
166
- if (!Array.isArray(list))
167
- return result;
168
- for (const item of list) {
169
- const id = String(item?.message_id ?? '');
170
- const threadId = String(item?.thread_id ?? '');
171
- if (id && threadId)
172
- result.set(id, threadId);
173
- }
174
- return result;
175
- }
176
- /** text / post 两种消息取正文;其余类型(图片、文件等)没有可读文本。 */
177
- function extractMessageText(message) {
178
- const type = String(message.msg_type ?? 'text');
179
- const content = String(message.content ?? '');
180
- let text = '';
181
- if (type === 'text')
182
- text = content;
183
- else if (type === 'post')
184
- text = postText(content);
185
- if (!text)
186
- return '';
187
- // 富文本里的 @ 是占位 key(如 @_user_1),按 mentions 还原成名字。
188
- for (const mention of message.mentions ?? []) {
189
- const key = String(mention.key ?? '');
190
- if (key)
191
- text = text.split(key).join(`@${mention.name ?? ''}`);
192
- }
193
- return text.trim();
194
- }
195
- function postText(content) {
196
- try {
197
- const parts = [];
198
- const walk = (node) => {
199
- if (Array.isArray(node)) {
200
- node.forEach(walk);
201
- return;
202
- }
203
- if (node && typeof node === 'object') {
204
- const obj = node;
205
- if (typeof obj.text === 'string')
206
- parts.push(obj.text);
207
- for (const [key, value] of Object.entries(obj))
208
- if (key !== 'text')
209
- walk(value);
210
- }
211
- };
212
- walk(JSON.parse(content));
213
- return parts.join('');
214
- }
215
- catch {
216
- return '';
217
- }
218
- }
219
- const QUOTED_MAX_CHARS = 800;
220
- /**
221
- * 把被引用消息的正文拼到 trigger 文本前面。
222
- *
223
- * 飞书的 `im.message.receive_v1` 只在 content 里放用户新输入的文字,引用内容只给 parent_id。
224
- * 不回查的话,「引用某条消息 + @机器人」在模型眼里就是一条空的 @。
225
- * 回查失败不阻塞主流程,按原消息继续。
226
- */
227
- export async function attachQuotedMessage(trigger, runner, opts = {}) {
228
- const logger = opts.logger ?? noopLogger;
229
- const parentId = readParentId(trigger.raw);
230
- if (!parentId)
231
- return trigger;
232
- try {
233
- const res = await runner.run(quotedMessageCommand(parentId, opts.as ?? 'bot'));
234
- if (res.code !== 0) {
235
- logger.warn('feishu:quote-fetch-failed', { messageId: parentId, code: res.code, message: res.stderr.slice(0, 200) });
236
- return trigger;
237
- }
238
- const quoted = parseQuotedMessage(res.stdout, parentId);
239
- if (!quoted) {
240
- logger.warn('feishu:quote-parse-failed', { messageId: parentId, sample: res.stdout.slice(0, 200) });
241
- return trigger;
242
- }
243
- const max = opts.maxChars ?? QUOTED_MAX_CHARS;
244
- const body = quoted.text.length > max ? `${quoted.text.slice(0, max)}…` : quoted.text;
245
- const who = quoted.author ? ` ${quoted.author}` : '';
246
- // 引用的是一条只有图片的消息时没有正文,先记下资源,入库前由 attachImageText 下载转文本。
247
- const header = body ? `[引用${who}的消息] ${body}` : '';
248
- const text = [header, trigger.text.trim()].filter(Boolean).join('\n\n');
249
- const quoteImages = quoted.images.map(image => ({ key: image.key, messageId: parentId }));
250
- return {
251
- ...trigger,
252
- text: text || trigger.text,
253
- raw: {
254
- ...trigger.raw,
255
- ...(body ? { quote: quoted } : {}),
256
- ...(quoteImages.length > 0 ? { quoteImages } : {}),
257
- },
258
- };
259
- }
260
- catch (error) {
261
- logger.warn('feishu:quote-fetch-failed', { messageId: parentId, message: errorMessage(error) });
262
- return trigger;
263
- }
264
- }
265
- function readParentId(raw) {
266
- if (!raw || typeof raw !== 'object')
267
- return '';
268
- const value = raw.parentId;
269
- return typeof value === 'string' ? value : '';
270
- }
271
- /** lark-cli 把例行信息写在 stderr 上,需要区分「就绪」与「真错误」。 */
272
- export function larkStderrLevel(line) {
273
- if (/ready event_key|listening for events/i.test(line))
274
- return 'ready';
275
- if (/\b(error|failed|denied|unauthorized|invalid|refused)\b/i.test(line))
276
- return 'error';
277
- // lark-cli 的错误常以多行 JSON 输出,按结构化字段再判一次。
278
- if (/"ok"\s*:\s*false|failed_precondition|another event bus|already connected/i.test(line))
279
- return 'error';
280
- return 'ignore';
281
- }
282
- /** 常驻消费飞书消息事件(NDJSON 一行一条)。 */
283
- export class FeishuConsumer {
284
- proc = null;
285
- stopping = false;
286
- restarts = 0;
287
- restartTimer = null;
288
- opts;
289
- constructor(opts) {
290
- this.opts = opts;
291
- }
292
- start() {
293
- if (this.proc)
294
- return;
295
- this.stopping = false;
296
- const bin = this.opts.bin ?? 'lark-cli';
297
- const spawnFn = this.opts.spawnImpl ?? spawn;
298
- // 关键:lark-cli 把 stdin EOF 当作退出信号,stdin 必须是保持打开的管道。
299
- const child = spawnFn(bin, ['event', 'consume', 'im.message.receive_v1', '--as', this.opts.as ?? 'bot'], {
300
- stdio: ['pipe', 'pipe', 'pipe'],
301
- ...(this.opts.env ? { env: this.opts.env } : {}),
302
- });
303
- this.proc = child;
304
- child.stdin?.on('error', () => { });
305
- const rl = createInterface({ input: child.stdout });
306
- rl.on('line', line => {
307
- const trimmed = line.trim();
308
- if (!trimmed)
309
- return;
310
- this.opts.onLine?.(trimmed);
311
- try {
312
- this.opts.onEvent(JSON.parse(trimmed));
313
- }
314
- catch {
315
- this.opts.onError?.(new Error(`feishu:invalid-json: ${trimmed.slice(0, 200)}`));
316
- }
317
- });
318
- child.stderr?.on('data', chunk => {
319
- const text = String(chunk).trim();
320
- if (!text)
321
- return;
322
- const level = larkStderrLevel(text);
323
- if (level === 'ready') {
324
- this.restarts = 0;
325
- this.opts.onReady?.();
326
- }
327
- else if (level === 'error') {
328
- this.opts.onError?.(new Error(`feishu:stderr: ${text.slice(0, 300)}`));
329
- }
330
- });
331
- child.on('error', error => {
332
- // spawn 失败(例如 lark-cli 没装)不会触发 exit,必须单独处理,否则进程会直接抛错退出。
333
- if (this.proc === child)
334
- this.proc = null;
335
- this.opts.onError?.(new Error(`feishu:spawn-failed: ${error.message}`));
336
- this.scheduleRestart();
337
- });
338
- child.on('exit', code => {
339
- // 只清理「当前这条」子进程,避免旧进程的迟到事件把新连接置空。
340
- if (this.proc === child)
341
- this.proc = null;
342
- if (this.stopping)
343
- return;
344
- if (code !== 0)
345
- this.opts.onError?.(new Error(`feishu:consumer-exit: ${code}`));
346
- this.scheduleRestart();
347
- });
348
- }
349
- /** 事件总线冲突等失败通常是临时的,按指数退避重连。 */
350
- scheduleRestart() {
351
- if (this.stopping || this.restartTimer)
352
- return;
353
- this.restarts += 1;
354
- const delay = Math.min((this.opts.restartBaseMs ?? 5000) * 2 ** (this.restarts - 1), 60_000);
355
- this.opts.onReconnect?.({ attempt: this.restarts, delayMs: delay });
356
- this.restartTimer = setTimeout(() => {
357
- this.restartTimer = null;
358
- if (!this.stopping)
359
- this.start();
360
- }, delay);
361
- this.restartTimer.unref?.();
362
- }
363
- stop() {
364
- this.stopping = true;
365
- if (this.restartTimer) {
366
- clearTimeout(this.restartTimer);
367
- this.restartTimer = null;
368
- }
369
- try {
370
- this.proc?.stdin?.end();
371
- }
372
- catch { /* ignore */ }
373
- this.proc?.kill('SIGTERM');
374
- this.proc = null;
375
- }
376
- get running() {
377
- return this.proc !== null;
378
- }
379
- }
380
- export function feishuReplyCommand(messageId, text, idempotencyKey) {
381
- return ['im', '+messages-reply', '--message-id', messageId, `--text=${text}`, '--reply-in-thread', '--idempotency-key', idempotencyKey, '--as', 'bot', '--json'];
382
- }
383
- /** 给一条消息加确认表情(飞书 emoji_type,如 OK / THUMBSUP / OnIt)。 */
384
- export function feishuReactionCommand(messageId, emojiType) {
385
- return [
386
- 'im', 'reactions', 'create',
387
- '--message-id', messageId,
388
- '--data', JSON.stringify({ reaction_type: { emoji_type: emojiType } }),
389
- '--as', 'bot',
390
- '--json',
391
- ];
392
- }
393
- /** 撤销自己加的表情。 */
394
- export function feishuUnreactCommand(messageId, reactionId) {
395
- return ['im', 'reactions', 'delete', '--message-id', messageId, '--reaction-id', reactionId, '--as', 'bot', '--json'];
396
- }
397
- /** 从 reactions create 的返回里取 reaction_id。 */
398
- export function parseReactionId(stdout) {
399
- try {
400
- const doc = JSON.parse(stdout);
401
- return String(doc?.data?.reaction_id ?? doc?.data?.reactionId ?? '');
402
- }
403
- catch {
404
- return '';
405
- }
406
- }
407
- export function feishuSendCommand(chatId, text, idempotencyKey) {
408
- return ['im', '+messages-send', '--chat-id', chatId, `--text=${text}`, '--idempotency-key', idempotencyKey, '--as', 'bot', '--json'];
409
- }
410
- /** 单行且没有 Markdown 语法的短回复,直接用纯文本发送,不必为了几个字开卡片。 */
411
- const SIMPLE_MESSAGE_MAX = 200;
412
- /** 需要 Markdown 渲染的行内语法。 */
413
- const INLINE_MARKDOWN = [
414
- /`[^`\n]+`/, // 行内代码
415
- /\*\*[^*\n]+\*\*/, // 加粗
416
- /~~[^~\n]+~~/, // 删除线
417
- /!\[[^\]\n]*\]\([^)\n]+\)/, // 图片
418
- /\[[^\]\n]+\]\([^)\n]+\)/, // 文字链接
419
- /<\/?[a-zA-Z][^>\n]*>/, // HTML / 飞书卡片标签
420
- ];
421
- /** 需要 Markdown 渲染的块级语法(按行判断)。 */
422
- const BLOCK_MARKDOWN = [
423
- /^\s{0,3}#{1,6}\s+/, // 标题
424
- /^\s{0,3}[-*+]\s+/, // 无序列表 / 任务列表
425
- /^\s{0,3}\d+[.)]\s+/, // 有序列表
426
- /^\s{0,3}>\s?/, // 引用
427
- /^\s*```/, // 代码块
428
- /^\s*\|.*\|\s*$/, // 表格行
429
- ];
430
- /**
431
- * 简单消息 = 单行、长度可控、完全不含 Markdown。
432
- * 这类内容走 text 消息渲染最稳,也避免卡片加载与宽度差异。
433
- */
434
- export function shouldSendAsText(text, maxLength = SIMPLE_MESSAGE_MAX) {
435
- const trimmed = text.trim();
436
- if (!trimmed || trimmed.length > maxLength)
437
- return false;
438
- if (trimmed.includes('\n'))
439
- return false;
440
- return !INLINE_MARKDOWN.some(pattern => pattern.test(trimmed)) && !BLOCK_MARKDOWN.some(pattern => pattern.test(trimmed));
441
- }
442
- /**
443
- * 卡片 markdown 元素(Card 1.0)只支持基础语法:不带标题、引用、表格的渲染,
444
- * 这里把不支持的写法降级为卡片能正常显示的形式,避免用户看到 Markdown 源码。
445
- */
446
- export function normalizeCardMarkdown(markdown) {
447
- const lines = markdown.replace(/\r\n?/g, '\n').replace(/<!--[\s\S]*?-->/g, '').split('\n');
448
- const out = [];
449
- let fence = null;
450
- for (let index = 0; index < lines.length; index += 1) {
451
- let line = lines[index];
452
- const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
453
- if (fenceMatch) {
454
- if (fence === null)
455
- fence = fenceMatch[1][0];
456
- else if (fenceMatch[1][0] === fence)
457
- fence = null;
458
- out.push(line);
459
- continue;
460
- }
461
- if (fence !== null) {
462
- out.push(line);
463
- continue;
464
- }
465
- // 换行标签与图片语法在卡片里都不成立:<br> 转真换行,图片转可点击链接。
466
- line = line.replace(/<br\s*\/?>/gi, '\n');
467
- line = line.replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g, (_match, alt, url) => alt.trim() ? `[${alt.trim()}](${url})` : `[图片](${url})`);
468
- // 表格:1.0 卡片不渲染表格,降级为「加粗表头 + 列表行」。
469
- if (isTableRow(line) && index + 1 < lines.length && isTableDivider(lines[index + 1])) {
470
- const header = parseTableCells(line);
471
- index += 1;
472
- const rows = [];
473
- while (index + 1 < lines.length && isTableRow(lines[index + 1])) {
474
- index += 1;
475
- rows.push(parseTableCells(lines[index]));
476
- }
477
- out.push(header.map(cell => `**${cell}**`).join(' · '));
478
- for (const row of rows)
479
- out.push(`- ${row.join(' · ')}`);
480
- continue;
481
- }
482
- // 标题:卡片不渲染 #,统一降级成加粗行。
483
- const heading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
484
- if (heading) {
485
- out.push(`**${heading[1]}**`);
486
- continue;
487
- }
488
- // 引用:卡片不渲染 >,用竖线前缀保留引用观感。
489
- const quote = /^\s{0,3}(?:>\s?)+(.*)$/.exec(line);
490
- if (quote) {
491
- out.push(`▍${quote[1]}`);
492
- continue;
493
- }
494
- // 任务列表:卡片不支持 [ ] / [x],换成勾选框字符。
495
- const task = /^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/.exec(line);
496
- if (task) {
497
- out.push(`${task[1]}- ${task[2] === ' ' ? '☐' : '☑'} ${task[3]}`);
498
- continue;
499
- }
500
- out.push(line);
501
- }
502
- return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
503
- }
504
- function isTableRow(line) {
505
- return /^\s*\|.*\|\s*$/.test(line);
506
- }
507
- function isTableDivider(line) {
508
- return /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/.test(line);
509
- }
510
- function parseTableCells(line) {
511
- const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
512
- return inner.split('|').map(cell => cell.trim());
513
- }
514
- /** 把 Markdown 包成互动卡片:正文用 card markdown 元素渲染,不再是源码。 */
515
- export function feishuCard(markdown) {
516
- const content = normalizeCardMarkdown(markdown) || markdown.trim();
517
- return JSON.stringify({
518
- config: { wide_screen_mode: true },
519
- elements: [{ tag: 'markdown', content }],
520
- });
521
- }
522
- /**
523
- * lark-cli 的 `im +messages-send` / `+messages-reply` 不支持从 stdin 读正文:
524
- * `--content` 只接受 JSON 字符串,`--markdown` 只接受 markdown 文本。
525
- * 正文必须直接落在参数值上,且用 `--flag=value` 形式,避免以 `-` 开头的正文被当成 flag。
526
- */
527
- export function feishuCardSendCommand(chatId, cardJson, idempotencyKey) {
528
- return ['im', '+messages-send', '--chat-id', chatId, '--msg-type', 'interactive', `--content=${cardJson}`, '--idempotency-key', idempotencyKey, '--as', 'bot', '--json'];
529
- }
530
- export function feishuCardReplyCommand(messageId, cardJson, idempotencyKey) {
531
- return ['im', '+messages-reply', '--message-id', messageId, '--msg-type', 'interactive', `--content=${cardJson}`, '--reply-in-thread', '--idempotency-key', idempotencyKey, '--as', 'bot', '--json'];
532
- }
533
- /** 卡片不可用时的降级:飞书 post 富文本,仍比纯文本更接近 Markdown 观感。 */
534
- export function feishuMarkdownSendCommand(chatId, markdown, idempotencyKey) {
535
- return ['im', '+messages-send', '--chat-id', chatId, `--markdown=${markdown}`, '--idempotency-key', idempotencyKey, '--as', 'bot', '--json'];
536
- }
537
- export function feishuMarkdownReplyCommand(messageId, markdown, idempotencyKey) {
538
- return ['im', '+messages-reply', '--message-id', messageId, `--markdown=${markdown}`, '--reply-in-thread', '--idempotency-key', idempotencyKey, '--as', 'bot', '--json'];
539
- }
540
- /** 用 Markdown 创建一篇飞书文档;正文走 stdin,避免超长参数与转义问题。 */
541
- export function feishuCreateDocCommand(title) {
542
- return ['docs', '+create', `--title=${title}`, '--content', '-', '--doc-format', 'markdown', '--as', 'bot'];
543
- }
544
- /** 从 `docs +create` 的 JSON 返回里取文档链接。 */
545
- export function parseDocumentUrl(stdout) {
546
- try {
547
- const doc = JSON.parse(stdout);
548
- return String(doc?.data?.document?.url ?? doc?.document?.url ?? doc?.data?.url ?? doc?.url ?? '');
549
- }
550
- catch {
551
- return '';
552
- }
553
- }
554
- /**
555
- * 飞书 --idempotency-key 上限 50 字符。超长时保留前缀并追加稳定哈希,
556
- * 既保证同一 key 仍然幂等,又不会因为长度被服务端拒绝。
557
- */
558
- export function fitIdempotencyKey(key, max = 50) {
559
- const clean = key.replace(/[^a-zA-Z0-9:_-]/g, '_');
560
- if (clean.length <= max)
561
- return clean;
562
- let hash = 0;
563
- for (let i = 0; i < clean.length; i += 1)
564
- hash = (hash * 31 + clean.charCodeAt(i)) | 0;
565
- const suffix = (hash >>> 0).toString(36);
566
- return `${clean.slice(0, max - suffix.length - 1)}_${suffix}`;
567
- }
568
- /** 通过 lark-cli 发消息;幂等 key 由 Outbox 提供。 */
569
- export class FeishuSender {
570
- runner;
571
- logger;
572
- constructor(runner, logger = noopLogger) {
573
- this.runner = runner;
574
- this.logger = logger;
575
- }
576
- async reply(messageId, text, idempotencyKey) {
577
- const res = await this.runner.run(feishuReplyCommand(messageId, text, fitIdempotencyKey(idempotencyKey)));
578
- if (res.code !== 0)
579
- throw new Error(`feishu:send-failed: ${res.stderr.slice(0, 300)}`);
580
- }
581
- async send(chatId, text, idempotencyKey) {
582
- const res = await this.runner.run(feishuSendCommand(chatId, text, fitIdempotencyKey(idempotencyKey)));
583
- if (res.code !== 0)
584
- throw new Error(`feishu:send-failed: ${res.stderr.slice(0, 300)}`);
585
- }
586
- /** 以互动卡片发送 Markdown;卡片失败时自动降级为 post 富文本。 */
587
- async sendCard(chatId, markdown, idempotencyKey) {
588
- const key = fitIdempotencyKey(idempotencyKey);
589
- const res = await this.runner.run(feishuCardSendCommand(chatId, feishuCard(markdown), key));
590
- if (res.code === 0)
591
- return;
592
- this.logger.warn('feishu:card-fallback', {
593
- target: chatId,
594
- key,
595
- code: res.code,
596
- message: res.stderr.slice(0, 200),
597
- });
598
- const fallback = await this.runner.run(feishuMarkdownSendCommand(chatId, markdown, key));
599
- if (fallback.code !== 0)
600
- throw new Error(`feishu:card-send-failed: ${fallback.stderr.slice(0, 300)}`);
601
- }
602
- async replyCard(messageId, markdown, idempotencyKey) {
603
- const key = fitIdempotencyKey(idempotencyKey);
604
- const res = await this.runner.run(feishuCardReplyCommand(messageId, feishuCard(markdown), key));
605
- if (res.code === 0)
606
- return;
607
- this.logger.warn('feishu:card-fallback', {
608
- target: messageId,
609
- key,
610
- code: res.code,
611
- message: res.stderr.slice(0, 200),
612
- });
613
- const fallback = await this.runner.run(feishuMarkdownReplyCommand(messageId, markdown, key));
614
- if (fallback.code !== 0)
615
- throw new Error(`feishu:card-reply-failed: ${fallback.stderr.slice(0, 300)}`);
616
- }
617
- /** 创建飞书文档并返回链接;失败时抛错,由调用方降级为长消息回复。 */
618
- async createDocument(title, markdown) {
619
- const res = await this.runner.run(feishuCreateDocCommand(title), { input: markdown, timeoutMs: 60_000 });
620
- if (res.code !== 0)
621
- throw new Error(`feishu:doc-create-failed: ${res.stderr.slice(0, 300)}`);
622
- const url = parseDocumentUrl(res.stdout);
623
- if (!url)
624
- throw new Error(`feishu:doc-url-missing: ${res.stdout.slice(0, 200)}`);
625
- return url;
626
- }
627
- /** 收到消息后立即加一个确认表情,返回 reaction_id;失败抛出。 */
628
- async react(messageId, emojiType) {
629
- if (!emojiType)
630
- return null;
631
- const res = await this.runner.run(feishuReactionCommand(messageId, emojiType));
632
- if (res.code !== 0)
633
- throw new Error(`feishu:react-failed: ${res.stderr.slice(0, 300)}`);
634
- return parseReactionId(res.stdout);
635
- }
636
- /** 撤销之前加的表情。 */
637
- async unreact(messageId, reactionId) {
638
- if (!reactionId)
639
- return;
640
- const res = await this.runner.run(feishuUnreactCommand(messageId, reactionId));
641
- if (res.code !== 0)
642
- throw new Error(`feishu:unreact-failed: ${res.stderr.slice(0, 300)}`);
643
- }
644
- }
645
- //# sourceMappingURL=feishu.js.map