@xmanrui/dsh-im 4.4.0 → 4.6.0

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.
Files changed (43) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/client.js +29 -9
  4. package/lib/index.js +252 -241
  5. package/package.json +1 -1
  6. package/plugin-src/client/context-enhancement.js +19 -5
  7. package/plugin-src/client/i18n.js +5 -1
  8. package/plugin-src/host/modern-harness-api.mjs +3 -0
  9. package/src/channels/dingtalk/dingtalk-bridge.mjs +175 -23
  10. package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
  11. package/src/channels/dingtalk/state-store.mjs +98 -0
  12. package/src/channels/discord/discord-api.mjs +7 -0
  13. package/src/channels/discord/discord-runtime.mjs +97 -2
  14. package/src/channels/feishu/bridge.mjs +30 -7
  15. package/src/channels/feishu/feishu-cards.mjs +2 -2
  16. package/src/channels/feishu/feishu-channel.mjs +36 -6
  17. package/src/channels/feishu/message-utils.mjs +229 -0
  18. package/src/channels/qq/qq-bridge.mjs +56 -9
  19. package/src/channels/shared/batch-input.mjs +3 -3
  20. package/src/channels/shared/bot-workspace-store.mjs +5 -0
  21. package/src/channels/shared/context-enhancement.mjs +9 -4
  22. package/src/channels/shared/harness-client.mjs +88 -30
  23. package/src/channels/shared/i18n-en/feishu.mjs +3 -3
  24. package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
  25. package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
  26. package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
  27. package/src/channels/shared/image-prompt.mjs +51 -0
  28. package/src/channels/shared/semantic/reply-reference.mjs +153 -0
  29. package/src/channels/shared/session-reply-recovery.mjs +104 -0
  30. package/src/channels/shared/session-title.mjs +74 -0
  31. package/src/channels/shared/text-harness-bridge.mjs +19 -8
  32. package/src/channels/shared/workspace-command.mjs +16 -4
  33. package/src/channels/shared/workspace-session.mjs +28 -2
  34. package/src/channels/slack/manifest.mjs +3 -0
  35. package/src/channels/slack/slack-api.mjs +18 -0
  36. package/src/channels/slack/slack-runtime.mjs +56 -0
  37. package/src/channels/telegram/telegram-runtime.mjs +118 -2
  38. package/src/channels/wecom/wecom-bridge.mjs +55 -9
  39. package/src/channels/weixin/state-store.mjs +110 -0
  40. package/src/channels/weixin/weixin-api.mjs +86 -2
  41. package/src/channels/weixin/weixin-bridge.mjs +104 -12
  42. package/src/channels/weixin/weixin-runtime.mjs +26 -6
  43. package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
@@ -206,12 +206,101 @@ function discordFileSource(attachment, fetchImpl) {
206
206
  };
207
207
  }
208
208
 
209
- export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
209
+ function discordReplyAttachment(attachment) {
210
+ if (!attachment || typeof attachment !== 'object') return null;
211
+ const mediaType = typeof attachment.content_type === 'string'
212
+ ? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
213
+ const kind = mediaType.startsWith('image/') ? 'image'
214
+ : mediaType.startsWith('audio/') ? 'audio'
215
+ : mediaType.startsWith('video/') ? 'video' : 'file';
216
+ const name = typeof attachment.filename === 'string' && attachment.filename
217
+ ? attachment.filename : undefined;
218
+ return { kind, ...(name ? { name } : {}) };
219
+ }
220
+
221
+ function discordReplySnapshot(message, fallbackMessageId) {
222
+ if (!message || typeof message !== 'object') return null;
223
+ const messageId = typeof message.id === 'string' && message.id
224
+ ? message.id : fallbackMessageId;
225
+ const authorId = typeof message.author?.id === 'string' && message.author.id
226
+ ? message.author.id : undefined;
227
+ const authorName = [message.member?.nick, message.author?.global_name, message.author?.username]
228
+ .find((value) => typeof value === 'string' && value.trim());
229
+ const attachments = Array.isArray(message.attachments)
230
+ ? message.attachments.map(discordReplyAttachment).filter(Boolean)
231
+ : [];
232
+ if (Array.isArray(message.sticker_items)) {
233
+ attachments.push(...message.sticker_items.map((sticker) => ({
234
+ kind: 'image',
235
+ ...(typeof sticker?.name === 'string' && sticker.name ? { name: sticker.name } : {}),
236
+ })));
237
+ }
238
+ return {
239
+ ...(messageId ? { messageId: String(messageId) } : {}),
240
+ ...(authorId ? { authorId } : {}),
241
+ ...(authorName ? { authorName } : {}),
242
+ content: typeof message.content === 'string' ? message.content : '',
243
+ attachments,
244
+ };
245
+ }
246
+
247
+ function discordReplyReference(message, loadReply) {
248
+ const channelId = String(message?.channel_id ?? '');
249
+ const referenceId = typeof message?.message_reference?.message_id === 'string'
250
+ && message.message_reference.message_id
251
+ ? message.message_reference.message_id : undefined;
252
+ const referenceChannelId = message?.message_reference?.channel_id;
253
+ if (referenceChannelId !== undefined && String(referenceChannelId) !== channelId) {
254
+ return {
255
+ ...(referenceId ? { messageId: referenceId } : {}),
256
+ unavailableReason: 'not-found',
257
+ };
258
+ }
259
+ if (Object.hasOwn(message ?? {}, 'referenced_message')) {
260
+ if (message.referenced_message === null) {
261
+ return {
262
+ ...(referenceId ? { messageId: referenceId } : {}),
263
+ unavailableReason: 'deleted',
264
+ };
265
+ }
266
+ if (message.referenced_message && typeof message.referenced_message === 'object') {
267
+ const snapshotId = typeof message.referenced_message.id === 'string'
268
+ && message.referenced_message.id ? message.referenced_message.id : undefined;
269
+ if (String(message.referenced_message.channel_id ?? '') !== channelId
270
+ || !snapshotId || (referenceId && snapshotId !== referenceId)) {
271
+ return {
272
+ ...(referenceId ? { messageId: referenceId } : {}),
273
+ unavailableReason: 'not-found',
274
+ };
275
+ }
276
+ return discordReplySnapshot(message.referenced_message, referenceId) ?? undefined;
277
+ }
278
+ }
279
+ if (!referenceId) return undefined;
280
+ if (typeof loadReply !== 'function') {
281
+ return { messageId: referenceId, unavailableReason: 'not-delivered' };
282
+ }
283
+ return {
284
+ messageId: referenceId,
285
+ load: async ({ signal } = {}) => {
286
+ const referenced = await loadReply({ channelId, messageId: referenceId, signal });
287
+ if (!referenced || String(referenced.id ?? '') !== referenceId
288
+ || String(referenced.channel_id ?? '') !== channelId) return null;
289
+ return discordReplySnapshot(referenced, referenceId);
290
+ },
291
+ };
292
+ }
293
+
294
+ export function normalizeDiscordMessage(message, botId, {
295
+ fetchImpl = fetch,
296
+ loadReply,
297
+ } = {}) {
210
298
  if (!message?.id || !message?.channel_id || !message?.author?.id
211
299
  || Number(message.type) === 21) return null;
212
300
  const direct = !message.guild_id;
213
301
  const addressed = direct
214
302
  || message.mentions?.some((mention) => String(mention?.id) === String(botId));
303
+ const replyTo = discordReplyReference(message, loadReply);
215
304
  return {
216
305
  messageId: String(message.id),
217
306
  senderId: String(message.author.id),
@@ -232,6 +321,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
232
321
  files: Array.isArray(message.attachments)
233
322
  ? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
234
323
  : [],
324
+ ...(replyTo ? { replyTo } : {}),
235
325
  addressed,
236
326
  replyTarget: {
237
327
  channelId: String(message.channel_id),
@@ -252,7 +342,12 @@ export async function resolveDiscordMessageRoute(message, botId, {
252
342
  signal,
253
343
  onChannel,
254
344
  } = {}) {
255
- const normalized = normalizeDiscordMessage(message, botId, { fetchImpl });
345
+ const normalized = normalizeDiscordMessage(message, botId, {
346
+ fetchImpl,
347
+ loadReply: typeof api?.getMessage === 'function'
348
+ ? (options) => api.getMessage(options)
349
+ : undefined,
350
+ });
256
351
  if (!normalized || normalized.senderIsBot) return normalized;
257
352
  signal?.throwIfAborted();
258
353
  if (normalized.kind === 'direct') {
@@ -11,8 +11,11 @@ import {
11
11
  hasInboundImages,
12
12
  imagePromptDiagnostic,
13
13
  imagePromptUserMessage,
14
- promptContentForMessage,
15
14
  } from '../shared/image-prompt.mjs';
15
+ import {
16
+ hasReplyReference,
17
+ promptContentForInboundMessage,
18
+ } from '../shared/semantic/reply-reference.mjs';
16
19
  import {
17
20
  hasInboundFiles,
18
21
  inboundFileUserMessage,
@@ -153,6 +156,7 @@ function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}
153
156
 
154
157
  /** Canonical workspace/session help advertised by every bridge family. */
155
158
  const WORKSPACE_HELP_LINES = [
159
+ '/workspace 工作区序号或绝对路径 切换工作区',
156
160
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
157
161
  '/workspacelist 列出工作区绝对路径',
158
162
  '/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
@@ -625,7 +629,9 @@ export class FeishuHarnessBridge {
625
629
  && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
626
630
  ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
627
631
  : this.#batchInputs.handle(key, batchText, {
628
- plainText: event.message.message_type === 'text' && Boolean(batchText),
632
+ plainText: event.message.message_type === 'text'
633
+ && Boolean(batchText)
634
+ && !hasReplyReference(commandMessage),
629
635
  });
630
636
  if (result.handled) {
631
637
  if (result.kind === 'submit') {
@@ -1031,11 +1037,12 @@ export class FeishuHarnessBridge {
1031
1037
  const text = message.content;
1032
1038
  const hasImages = hasInboundImages(message);
1033
1039
  const hasFiles = hasInboundFiles(message);
1040
+ const hasReply = hasReplyReference(message);
1034
1041
  // 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
1035
1042
  // 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
1036
1043
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
1037
1044
  const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
1038
- if (!text && !hasImages && !hasFiles) {
1045
+ if (!text && !hasImages && !hasFiles && !hasReply) {
1039
1046
  await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
1040
1047
  return;
1041
1048
  }
@@ -3288,15 +3295,18 @@ export class FeishuHarnessBridge {
3288
3295
  askCompleted = true;
3289
3296
  onAskComplete?.();
3290
3297
  };
3291
- let content = hasInboundImages(message)
3292
- ? await promptContentForMessage(message, { signal: this.#signal })
3298
+ let content = hasInboundImages(message) || hasReplyReference(message)
3299
+ ? await promptContentForInboundMessage(message, { signal: this.#signal })
3293
3300
  : undefined;
3294
3301
  const snapshot = this.#acceptedMessageIds.get(messageId);
3302
+ let contextEnhanced = false;
3295
3303
  if (snapshot) {
3296
- content = enhanceContextContent(content ?? text, snapshot, () => ({
3304
+ const originalContent = content ?? text;
3305
+ content = enhanceContextContent(originalContent, snapshot, () => ({
3297
3306
  channel: 'feishu',
3298
3307
  senderId: senderOpenId(event),
3299
3308
  }));
3309
+ contextEnhanced = content !== originalContent;
3300
3310
  }
3301
3311
  if (!this.#channel?.stream) {
3302
3312
  const { answer, artifacts = [] } = await askInWorkspaceSession({
@@ -3305,6 +3315,7 @@ export class FeishuHarnessBridge {
3305
3315
  key,
3306
3316
  text,
3307
3317
  content,
3318
+ contextEnhanced,
3308
3319
  createOptions: { signal: this.#signal },
3309
3320
  existsOptions: { signal: this.#signal },
3310
3321
  askOptions: this.#interactionAskOptions(event, key, message.files),
@@ -3351,8 +3362,18 @@ export class FeishuHarnessBridge {
3351
3362
  stream = await this.#channel.stream(chatId, {
3352
3363
  markdown: async (controller) => {
3353
3364
  promptStarted = true;
3365
+ const baseAskOptions = this.#interactionAskOptions(event, key, message.files);
3354
3366
  const askOptions = {
3355
- ...this.#interactionAskOptions(event, key, message.files),
3367
+ ...baseAskOptions,
3368
+ // issue #86:独立交互消息(提问/审批)会落在占位卡下方,呈现前
3369
+ // 先换卡,让最终答案落在交互消息之后的新流式卡上。
3370
+ onInteraction: async (interaction) => {
3371
+ if ((interaction?.kind === 'question' || interaction?.kind === 'approval')
3372
+ && typeof controller?.rotate === 'function') {
3373
+ await controller.rotate();
3374
+ }
3375
+ await baseAskOptions.onInteraction(interaction);
3376
+ },
3356
3377
  onUpdate: async (update) => {
3357
3378
  await controller.setContent(this.#progressText(update));
3358
3379
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
@@ -3364,6 +3385,7 @@ export class FeishuHarnessBridge {
3364
3385
  key,
3365
3386
  text,
3366
3387
  content,
3388
+ contextEnhanced,
3367
3389
  createOptions: { signal: this.#signal },
3368
3390
  existsOptions: { signal: this.#signal },
3369
3391
  askOptions,
@@ -3427,6 +3449,7 @@ export class FeishuHarnessBridge {
3427
3449
  key,
3428
3450
  text,
3429
3451
  content,
3452
+ contextEnhanced,
3430
3453
  createOptions: { signal: this.#signal },
3431
3454
  existsOptions: { signal: this.#signal },
3432
3455
  askOptions: this.#interactionAskOptions(event, key, message.files),
@@ -490,7 +490,7 @@ export function menuHelpText() {
490
490
  '/sessionlist 或 /sessions 列出工作区会话',
491
491
  '/session ID 绑定已有会话',
492
492
  '/workspacelist 列出工作区',
493
- '/workspace 路径 切换工作区',
493
+ '/workspace 工作区序号或绝对路径 切换工作区',
494
494
  '/new 开启全新会话',
495
495
  '',
496
496
  '📊 状态 / 压缩',
@@ -554,7 +554,7 @@ const HELP_TEXT_COMMANDS = [
554
554
  '`/new` — 开启全新会话',
555
555
  '`/session ID` — 绑定已有会话',
556
556
  '`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话',
557
- '`/workspace 路径` — 切换工作区',
557
+ '`/workspace 工作区序号或绝对路径` — 切换工作区',
558
558
  '`/workspacelist` — 列出工作区',
559
559
  '`/status` — 查看连接状态',
560
560
  '`/compact` — 压缩上下文',
@@ -205,15 +205,45 @@ export class VerifiedFeishuChannel {
205
205
  }
206
206
 
207
207
  const cards = [];
208
+ let activeCard = null;
209
+ let rotating = false;
208
210
  try {
209
- const firstCard = await this.#createStreamCard(chatId, options.replyTo);
210
- cards.push(firstCard);
211
+ activeCard = await this.#createStreamCard(chatId, options.replyTo);
212
+ cards.push(activeCard);
211
213
  let lastContent = this.#initialText;
214
+ // issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
215
+ // 回写旧卡。rotate() 把旧卡定格为「过程记录 + 指引行」并标记换卡态;
216
+ // 下一次 setContent(过程更新或最终答案)才创建新卡——新卡必然创建于
217
+ // 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
218
+ const ensureActiveCard = async () => {
219
+ if (!rotating) return activeCard;
220
+ activeCard = await this.#createStreamCard(chatId, options.replyTo);
221
+ cards.push(activeCard);
222
+ rotating = false;
223
+ return activeCard;
224
+ };
212
225
  const controller = {
213
- messageId: firstCard.messageId,
226
+ get messageId() {
227
+ return activeCard.messageId;
228
+ },
229
+ rotate: async () => {
230
+ if (rotating) return;
231
+ rotating = true;
232
+ try {
233
+ await this.#updateStreamCard(
234
+ activeCard,
235
+ `${streamPreview(lastContent)}\n\n${t('⤵️ 最终结果见下方')}`,
236
+ );
237
+ await this.#finishStreamCard(activeCard);
238
+ } catch (error) {
239
+ // 明确降级:定格失败不阻塞交互呈现,旧卡保留原内容。
240
+ console.warn('[dsh-feishu] unable to finalize the superseded stream card:', error.message);
241
+ }
242
+ },
214
243
  setContent: async (content) => {
215
244
  const next = String(content ?? '') || '…';
216
- await this.#updateStreamCard(firstCard, streamPreview(next));
245
+ const card = await ensureActiveCard();
246
+ await this.#updateStreamCard(card, streamPreview(next));
217
247
  // Updates are replaceable snapshots, including progress/tool text.
218
248
  // Retain the full latest snapshot even when its preview is unchanged.
219
249
  lastContent = next;
@@ -224,14 +254,14 @@ export class VerifiedFeishuChannel {
224
254
  const chunks = splitStreamContent(lastContent);
225
255
  for (const [index, chunk] of chunks.entries()) {
226
256
  const card = index === 0
227
- ? firstCard
257
+ ? await ensureActiveCard()
228
258
  : await this.#createStreamCard(chatId, options.replyTo);
229
259
  if (index > 0) cards.push(card);
230
260
  await this.#updateStreamCard(card, chunk);
231
261
  await this.#finishStreamCard(card);
232
262
  }
233
263
  return {
234
- messageId: firstCard.messageId,
264
+ messageId: cards[0].messageId,
235
265
  providerMessageIds: cards.map((card) => card.messageId),
236
266
  };
237
267
  } catch (error) {
@@ -2,8 +2,14 @@ import { ImagePromptError } from '../shared/image-prompt.mjs';
2
2
  import { t } from '../shared/i18n.mjs';
3
3
 
4
4
  const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
5
+ const FEISHU_CARD_MESSAGE_CONTENT_TYPE = 'raw_card_content';
5
6
  const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
6
7
  const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
8
+ const FEISHU_CARD_TEXT_MAX_DEPTH = 12;
9
+ const FEISHU_CARD_TEXT_MAX_NODES = 1_000;
10
+ const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
11
+ '请升级至最新版本客户端,以查看内容',
12
+ ]);
7
13
  const FEISHU_IMAGE_PERMISSION_MESSAGE =
8
14
  '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
9
15
 
@@ -56,6 +62,124 @@ function nonEmptyString(value) {
56
62
  return typeof value === 'string' && value.trim() ? value.trim() : null;
57
63
  }
58
64
 
65
+ function objectRecord(value) {
66
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
67
+ }
68
+
69
+ function jsonRecord(value) {
70
+ if (objectRecord(value)) return value;
71
+ if (typeof value !== 'string') return null;
72
+ try {
73
+ return objectRecord(JSON.parse(value));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function interactiveCardRoot(parsed) {
80
+ const root = objectRecord(parsed);
81
+ if (!root) return null;
82
+ // raw_card_content wraps CardKit entities in json_card. Keep direct Card
83
+ // 1.0/2.0 payloads readable as well for historical messages and fixtures.
84
+ return jsonRecord(root.json_card) ?? jsonRecord(root.card) ?? root;
85
+ }
86
+
87
+ function cardProperty(value) {
88
+ const record = objectRecord(value);
89
+ return objectRecord(record?.property) ?? record;
90
+ }
91
+
92
+ function cardTextContent(property) {
93
+ const i18n = objectRecord(property?.i18nContent);
94
+ const content = nonEmptyString(i18n?.zh_cn)
95
+ ?? nonEmptyString(i18n?.en_us)
96
+ ?? nonEmptyString(i18n?.ja_jp)
97
+ ?? nonEmptyString(property?.content)
98
+ ?? nonEmptyString(property?.text);
99
+ return content && !FEISHU_CARD_UNAVAILABLE_TEXTS.has(content) ? content : null;
100
+ }
101
+
102
+ function cardElementText(
103
+ element,
104
+ { depth = 0, inline = false, budget = { remaining: FEISHU_CARD_TEXT_MAX_NODES } } = {},
105
+ ) {
106
+ if (depth > FEISHU_CARD_TEXT_MAX_DEPTH || budget.remaining <= 0) return '';
107
+ budget.remaining -= 1;
108
+ if (Array.isArray(element)) {
109
+ return element
110
+ .map((part) => cardElementText(part, {
111
+ depth: depth + 1,
112
+ inline: Array.isArray(part),
113
+ budget,
114
+ }))
115
+ .filter(Boolean)
116
+ .join(inline ? ' ' : '\n');
117
+ }
118
+ const value = objectRecord(element);
119
+ if (!value) return '';
120
+ const property = cardProperty(value);
121
+ if (!property) return '';
122
+ const tag = String(value.tag ?? '').toLowerCase();
123
+
124
+ if (
125
+ tag === 'markdown'
126
+ || tag === 'markdown_v1'
127
+ || tag === 'lark_md'
128
+ || tag === 'plain_text'
129
+ || tag === 'text'
130
+ ) {
131
+ const content = cardTextContent(property);
132
+ if (content) return content;
133
+ const nested = cardElementText(property.elements, {
134
+ depth: depth + 1,
135
+ inline: true,
136
+ budget,
137
+ });
138
+ if (nested || tag !== 'markdown_v1') return nested;
139
+ return cardElementText(value.fallback ?? property.fallback, {
140
+ depth: depth + 1,
141
+ inline: true,
142
+ budget,
143
+ });
144
+ }
145
+ if (tag === 'a' || tag === 'link' || tag === 'button') {
146
+ if (typeof property.text === 'string') return nonEmptyString(property.text) ?? '';
147
+ return cardElementText(property.text, { depth: depth + 1, inline: true, budget });
148
+ }
149
+ if (tag === 'div') {
150
+ return [
151
+ cardElementText(property.text, { depth: depth + 1, budget }),
152
+ cardElementText(property.fields, { depth: depth + 1, budget }),
153
+ ].filter(Boolean).join('\n');
154
+ }
155
+
156
+ // Traverse visible layout containers only. Deliberately ignore callback
157
+ // values, form state, URLs, ids, template variables and other hidden data.
158
+ return ['elements', 'columns', 'fields', 'children', 'actions']
159
+ .map((key) => cardElementText(property[key], { depth: depth + 1, budget }))
160
+ .filter(Boolean)
161
+ .join('\n');
162
+ }
163
+
164
+ function interactiveCardText(parsed) {
165
+ const card = interactiveCardRoot(parsed);
166
+ if (!card) return '';
167
+ const budget = { remaining: FEISHU_CARD_TEXT_MAX_NODES };
168
+ const header = cardProperty(card.header);
169
+ const title = [
170
+ cardElementText(header?.title, { inline: true, budget }),
171
+ cardElementText(header?.subtitle, { inline: true, budget }),
172
+ ].filter(Boolean).join('\n') || nonEmptyString(card.title) || '';
173
+ const body = cardProperty(card.body);
174
+ const elements = Array.isArray(body?.elements)
175
+ ? body.elements
176
+ : Array.isArray(card.elements)
177
+ ? card.elements
178
+ : null;
179
+ const content = cardElementText(elements, { budget });
180
+ return [title, content].filter(Boolean).join('\n');
181
+ }
182
+
59
183
  function postContent(event, parsed = parsedMessageContent(event)) {
60
184
  if (event?.message?.message_type !== 'post') return null;
61
185
  if (!parsed) return null;
@@ -283,6 +407,109 @@ function feishuFileSource(event, client, file) {
283
407
  };
284
408
  }
285
409
 
410
+ function feishuReplyTargetId(event) {
411
+ const parentId = nonEmptyString(event?.message?.parent_id);
412
+ if (parentId) return parentId;
413
+ const rootId = nonEmptyString(event?.message?.root_id);
414
+ const messageId = nonEmptyString(event?.message?.message_id);
415
+ return rootId && rootId !== messageId ? rootId : null;
416
+ }
417
+
418
+ function feishuReplyAttachments(messageType, parsed, post) {
419
+ if (messageType === 'post') {
420
+ return (post?.imageKeys ?? []).map(() => ({ kind: 'image' }));
421
+ }
422
+ if (messageType === 'image') return [{ kind: 'image' }];
423
+ if (messageType === 'file') {
424
+ return [{
425
+ kind: 'file',
426
+ ...(nonEmptyString(parsed?.file_name) ? { name: nonEmptyString(parsed.file_name) } : {}),
427
+ }];
428
+ }
429
+ if (messageType === 'audio') return [{ kind: 'audio' }];
430
+ if (messageType === 'media') {
431
+ return [{
432
+ kind: 'video',
433
+ ...(nonEmptyString(parsed?.file_name) ? { name: nonEmptyString(parsed.file_name) } : {}),
434
+ }];
435
+ }
436
+ if (messageType === 'sticker') return [{ kind: 'other' }];
437
+ return [];
438
+ }
439
+
440
+ function feishuReplyReference(event, client) {
441
+ const messageId = feishuReplyTargetId(event);
442
+ if (!messageId) return null;
443
+ const chatId = nonEmptyString(event?.message?.chat_id);
444
+ return {
445
+ messageId,
446
+ async load({ signal } = {}) {
447
+ signal?.throwIfAborted();
448
+ let response;
449
+ try {
450
+ response = await client?.im?.v1?.message?.get?.({
451
+ path: { message_id: messageId },
452
+ params: {
453
+ with_sender_name: true,
454
+ card_msg_content_type: FEISHU_CARD_MESSAGE_CONTENT_TYPE,
455
+ },
456
+ });
457
+ } catch (error) {
458
+ signal?.throwIfAborted();
459
+ if (await feishuProviderCode(error, signal) === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
460
+ return { messageId, unavailableReason: 'permission-denied' };
461
+ }
462
+ throw error;
463
+ }
464
+ signal?.throwIfAborted();
465
+ if (providerCode(response) === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
466
+ return { messageId, unavailableReason: 'permission-denied' };
467
+ }
468
+ if (providerCode(response) !== null && providerCode(response) !== 0) {
469
+ return { messageId, unavailableReason: 'not-delivered' };
470
+ }
471
+ const item = response?.data?.items?.find?.(
472
+ (candidate) => nonEmptyString(candidate?.message_id) === messageId,
473
+ );
474
+ if (!item) return { messageId, unavailableReason: 'not-found' };
475
+ if (item.deleted) return { messageId, unavailableReason: 'deleted' };
476
+ const itemChatId = nonEmptyString(item.chat_id);
477
+ if (!chatId || !itemChatId || itemChatId !== chatId) {
478
+ return { messageId, unavailableReason: 'not-found' };
479
+ }
480
+
481
+ const messageType = nonEmptyString(item.msg_type) ?? '';
482
+ const quotedEvent = {
483
+ message: {
484
+ message_id: messageId,
485
+ message_type: messageType,
486
+ content: item.body?.content,
487
+ mentions: item.mentions ?? [],
488
+ },
489
+ };
490
+ const parsed = parsedMessageContent(quotedEvent);
491
+ const post = postContent(quotedEvent, parsed);
492
+ const quoted = extractInboundMessage(quotedEvent, client);
493
+ const content = messageType === 'interactive'
494
+ ? interactiveCardText(parsed)
495
+ : quoted.content;
496
+ const attachments = feishuReplyAttachments(messageType, parsed, post);
497
+ return {
498
+ messageId,
499
+ ...(nonEmptyString(item.sender?.id) ? { authorId: nonEmptyString(item.sender.id) } : {}),
500
+ ...(nonEmptyString(item.sender?.sender_name)
501
+ ? { authorName: nonEmptyString(item.sender.sender_name) }
502
+ : {}),
503
+ ...(content ? { content } : {}),
504
+ attachments,
505
+ ...(messageType === 'interactive' && !content && attachments.length === 0
506
+ ? { unavailableReason: 'unsupported' }
507
+ : {}),
508
+ };
509
+ },
510
+ };
511
+ }
512
+
286
513
  export function extractInboundMessage(event, client) {
287
514
  const messageType = event?.message?.message_type;
288
515
  const parsed = parsedMessageContent(event);
@@ -292,10 +519,12 @@ export function extractInboundMessage(event, client) {
292
519
  : null;
293
520
  const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
294
521
  const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
522
+ const replyTo = feishuReplyReference(event, client);
295
523
  return {
296
524
  content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
297
525
  images: imageKeys.map((key) => feishuImageSource(event, client, key)),
298
526
  files: file ? [file] : [],
527
+ ...(replyTo ? { replyTo } : {}),
299
528
  };
300
529
  }
301
530