@xmanrui/dsh-im 4.5.0 → 4.7.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 (36) hide show
  1. package/README.en.md +2 -0
  2. package/README.md +2 -0
  3. package/lib/client.js +4 -1
  4. package/lib/index.js +249 -240
  5. package/package.json +1 -1
  6. package/src/channels/dingtalk/dingtalk-bridge.mjs +167 -20
  7. package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
  8. package/src/channels/dingtalk/state-store.mjs +98 -0
  9. package/src/channels/discord/discord-api.mjs +7 -0
  10. package/src/channels/discord/discord-runtime.mjs +97 -2
  11. package/src/channels/feishu/bridge.mjs +44 -13
  12. package/src/channels/feishu/feishu-cards.mjs +2 -0
  13. package/src/channels/feishu/message-utils.mjs +229 -0
  14. package/src/channels/qq/qq-bridge.mjs +49 -6
  15. package/src/channels/shared/batch-input.mjs +3 -3
  16. package/src/channels/shared/harness-client.mjs +82 -30
  17. package/src/channels/shared/i18n-en/feishu.mjs +2 -2
  18. package/src/channels/shared/i18n-en/shared-a.mjs +2 -0
  19. package/src/channels/shared/i18n-en/shared-b.mjs +2 -2
  20. package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
  21. package/src/channels/shared/image-prompt.mjs +51 -0
  22. package/src/channels/shared/semantic/reply-reference.mjs +153 -0
  23. package/src/channels/shared/session-reply-recovery.mjs +104 -0
  24. package/src/channels/shared/session-title.mjs +1 -1
  25. package/src/channels/shared/text-harness-bridge.mjs +13 -6
  26. package/src/channels/shared/workspace-command.mjs +22 -3
  27. package/src/channels/slack/manifest.mjs +3 -0
  28. package/src/channels/slack/slack-api.mjs +18 -0
  29. package/src/channels/slack/slack-runtime.mjs +56 -0
  30. package/src/channels/telegram/telegram-runtime.mjs +117 -2
  31. package/src/channels/wecom/wecom-bridge.mjs +50 -7
  32. package/src/channels/weixin/state-store.mjs +110 -0
  33. package/src/channels/weixin/weixin-api.mjs +86 -2
  34. package/src/channels/weixin/weixin-bridge.mjs +97 -9
  35. package/src/channels/weixin/weixin-runtime.mjs +26 -6
  36. package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
@@ -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,
@@ -44,7 +47,12 @@ import {
44
47
  isPresetCommand,
45
48
  runPresetCommand,
46
49
  } from '../shared/preset-command.mjs';
47
- import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
50
+ import {
51
+ parseSessionListArgument,
52
+ resolveSessionListWorkspace,
53
+ runWorkspaceCommand,
54
+ workspacePathSnapshot,
55
+ } from '../shared/workspace-command.mjs';
48
56
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
49
57
  import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
50
58
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
@@ -157,6 +165,7 @@ const WORKSPACE_HELP_LINES = [
157
165
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
158
166
  '/workspacelist 列出工作区绝对路径',
159
167
  '/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
168
+ '/sessionlist --limit N 仅列出当前工作区前 N 个会话',
160
169
  ];
161
170
 
162
171
  /** Safe user-facing text for bind/workspace failures (no raw messages). */
@@ -626,7 +635,9 @@ export class FeishuHarnessBridge {
626
635
  && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
627
636
  ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
628
637
  : this.#batchInputs.handle(key, batchText, {
629
- plainText: event.message.message_type === 'text' && Boolean(batchText),
638
+ plainText: event.message.message_type === 'text'
639
+ && Boolean(batchText)
640
+ && !hasReplyReference(commandMessage),
630
641
  });
631
642
  if (result.handled) {
632
643
  if (result.kind === 'submit') {
@@ -1032,11 +1043,12 @@ export class FeishuHarnessBridge {
1032
1043
  const text = message.content;
1033
1044
  const hasImages = hasInboundImages(message);
1034
1045
  const hasFiles = hasInboundFiles(message);
1046
+ const hasReply = hasReplyReference(message);
1035
1047
  // 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
1036
1048
  // 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
1037
1049
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
1038
1050
  const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
1039
- if (!text && !hasImages && !hasFiles) {
1051
+ if (!text && !hasImages && !hasFiles && !hasReply) {
1040
1052
  await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
1041
1053
  return;
1042
1054
  }
@@ -1079,8 +1091,18 @@ export class FeishuHarnessBridge {
1079
1091
  return;
1080
1092
  }
1081
1093
  if (SESSION_LIST_PREFIX.test(commandText)) {
1082
- const selector = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim() || null;
1083
- await this.#showSessions({ chatId: event.message.chat_id, key, replyTo: event.message.message_id }, selector, 0);
1094
+ const argument = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim();
1095
+ const request = parseSessionListArgument(argument);
1096
+ if (request.error) {
1097
+ await this.#send(event.message.chat_id, request.error, { replyTo: event.message.message_id });
1098
+ return;
1099
+ }
1100
+ await this.#showSessions(
1101
+ { chatId: event.message.chat_id, key, replyTo: event.message.message_id },
1102
+ request.selector || null,
1103
+ 0,
1104
+ { limit: request.limit },
1105
+ );
1084
1106
  return;
1085
1107
  }
1086
1108
  if (WORKSPACE_LIST_COMMAND.test(commandText)) {
@@ -1844,6 +1866,7 @@ export class FeishuHarnessBridge {
1844
1866
  messageId = null,
1845
1867
  sessionWorkspace = null,
1846
1868
  sessionPage = 0,
1869
+ sessionLimit = null,
1847
1870
  selections = [],
1848
1871
  }) {
1849
1872
  // Confirmations triggered by a card interaction stay anchored to the
@@ -1855,7 +1878,7 @@ export class FeishuHarnessBridge {
1855
1878
  { chatId, key, replyTo: messageId },
1856
1879
  sessionWorkspace,
1857
1880
  page,
1858
- { updateMessageId: messageId },
1881
+ { updateMessageId: messageId, limit: sessionLimit },
1859
1882
  );
1860
1883
  return;
1861
1884
  }
@@ -2034,7 +2057,7 @@ export class FeishuHarnessBridge {
2034
2057
  { chatId, key, replyTo: messageId },
2035
2058
  sessionWorkspace,
2036
2059
  sessionPage,
2037
- { updateMessageId: messageId },
2060
+ { updateMessageId: messageId, limit: sessionLimit },
2038
2061
  );
2039
2062
  }
2040
2063
  return;
@@ -2050,7 +2073,7 @@ export class FeishuHarnessBridge {
2050
2073
  { chatId, key, replyTo: messageId },
2051
2074
  sessionWorkspace,
2052
2075
  sessionPage,
2053
- { updateMessageId: messageId },
2076
+ { updateMessageId: messageId, limit: sessionLimit },
2054
2077
  );
2055
2078
  }
2056
2079
  }
@@ -2135,7 +2158,7 @@ export class FeishuHarnessBridge {
2135
2158
  { chatId, key, replyTo = null },
2136
2159
  selector,
2137
2160
  page = 0,
2138
- { updateMessageId = null } = {},
2161
+ { updateMessageId = null, limit = null } = {},
2139
2162
  ) {
2140
2163
  try {
2141
2164
  const signal = this.#cardDataSignal();
@@ -2145,7 +2168,11 @@ export class FeishuHarnessBridge {
2145
2168
  return;
2146
2169
  }
2147
2170
  const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal });
2148
- const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
2171
+ const visibleSessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
2172
+ const sessionLimit = Number.isSafeInteger(limit) && limit > 0 ? limit : null;
2173
+ const sessions = sessionLimit === null
2174
+ ? visibleSessions
2175
+ : visibleSessions.slice(0, sessionLimit);
2149
2176
  const workspace = listed?.workspace ?? resolved.workspace;
2150
2177
  if (sessions.length === 0) {
2151
2178
  await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }), { replyTo });
@@ -2172,6 +2199,7 @@ export class FeishuHarnessBridge {
2172
2199
  // list response's workspace is display data and is not authoritative.
2173
2200
  sessionWorkspace: resolved.workspace,
2174
2201
  sessionPage: safePage,
2202
+ sessionLimit,
2175
2203
  },
2176
2204
  );
2177
2205
  } catch (error) {
@@ -2240,6 +2268,9 @@ export class FeishuHarnessBridge {
2240
2268
  sessionPage: Number.isSafeInteger(options.sessionPage) && options.sessionPage >= 0
2241
2269
  ? options.sessionPage
2242
2270
  : 0,
2271
+ sessionLimit: Number.isSafeInteger(options.sessionLimit) && options.sessionLimit > 0
2272
+ ? options.sessionLimit
2273
+ : null,
2243
2274
  });
2244
2275
  if (this.#cardKeys.size > 200) {
2245
2276
  const oldest = this.#cardKeys.keys().next().value;
@@ -3289,8 +3320,8 @@ export class FeishuHarnessBridge {
3289
3320
  askCompleted = true;
3290
3321
  onAskComplete?.();
3291
3322
  };
3292
- let content = hasInboundImages(message)
3293
- ? await promptContentForMessage(message, { signal: this.#signal })
3323
+ let content = hasInboundImages(message) || hasReplyReference(message)
3324
+ ? await promptContentForInboundMessage(message, { signal: this.#signal })
3294
3325
  : undefined;
3295
3326
  const snapshot = this.#acceptedMessageIds.get(messageId);
3296
3327
  let contextEnhanced = false;
@@ -488,6 +488,7 @@ export function menuHelpText() {
488
488
  '',
489
489
  '📋 会话 / 工作区',
490
490
  '/sessionlist 或 /sessions 列出工作区会话',
491
+ '/sessionlist --limit N 仅列出当前工作区前 N 个会话',
491
492
  '/session ID 绑定已有会话',
492
493
  '/workspacelist 列出工作区',
493
494
  '/workspace 工作区序号或绝对路径 切换工作区',
@@ -554,6 +555,7 @@ const HELP_TEXT_COMMANDS = [
554
555
  '`/new` — 开启全新会话',
555
556
  '`/session ID` — 绑定已有会话',
556
557
  '`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话',
558
+ '`/sessionlist --limit N` 或 `/sessions --limit N` — 仅列出当前工作区前 N 个会话',
557
559
  '`/workspace 工作区序号或绝对路径` — 切换工作区',
558
560
  '`/workspacelist` — 列出工作区',
559
561
  '`/status` — 查看连接状态',
@@ -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
 
@@ -33,7 +33,6 @@ import {
33
33
  hasInboundImages,
34
34
  imagePromptDiagnostic,
35
35
  imagePromptUserMessage,
36
- promptContentForMessage,
37
36
  } from '../shared/image-prompt.mjs';
38
37
  import {
39
38
  hasInboundFiles,
@@ -44,6 +43,10 @@ import {
44
43
  trackOutboundArtifactProviderPromise,
45
44
  } from '../shared/semantic/artifact.mjs';
46
45
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
46
+ import {
47
+ hasReplyReference,
48
+ promptContentForInboundMessage,
49
+ } from '../shared/semantic/reply-reference.mjs';
47
50
  import {
48
51
  createDeliveryReceipt,
49
52
  providerMessageIdsFor,
@@ -88,6 +91,7 @@ function helpText() {
88
91
  t('/workspace 工作区序号或绝对路径 切换工作区'),
89
92
  t('/workspacelist 列出工作区绝对路径'),
90
93
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
94
+ t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
91
95
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
92
96
  t('/models 按序号列出所有可用模型'),
93
97
  t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
@@ -145,6 +149,37 @@ function hasQqFileAttachments(message) {
145
149
  && message.attachments.some((attachment) => !isQqImageAttachment(attachment));
146
150
  }
147
151
 
152
+ function qqAttachmentKind(attachment) {
153
+ const mediaType = attachmentMediaType(attachment);
154
+ if (mediaType?.startsWith('image/')) return 'image';
155
+ if (mediaType?.startsWith('audio/')) return 'audio';
156
+ if (mediaType?.startsWith('video/')) return 'video';
157
+ return 'file';
158
+ }
159
+
160
+ function qqReplyReference(message) {
161
+ const refMsgIdx = nonEmptyString(message?.refMsgIdx);
162
+ if (!refMsgIdx) return null;
163
+ const element = Array.isArray(message?.msgElements) ? message.msgElements[0] : null;
164
+ const sourceAttachments = Array.isArray(element?.attachments) ? element.attachments : [];
165
+ const attachments = sourceAttachments.map((attachment) => {
166
+ const name = nonEmptyString(attachment?.filename);
167
+ return { kind: qqAttachmentKind(attachment), ...(name ? { name } : {}) };
168
+ });
169
+ const asrText = sourceAttachments
170
+ .filter((attachment) => qqAttachmentKind(attachment) === 'audio')
171
+ .map((attachment) => nonEmptyString(attachment?.asr_refer_text))
172
+ .filter(Boolean)
173
+ .join('\n');
174
+ const content = asrText || nonEmptyString(element?.content);
175
+ return {
176
+ messageId: refMsgIdx,
177
+ ...(content ? { content } : {}),
178
+ ...(attachments.length > 0 ? { attachments } : {}),
179
+ ...(!content && attachments.length === 0 ? { unavailableReason: 'not-delivered' } : {}),
180
+ };
181
+ }
182
+
148
183
  async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
149
184
  const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
150
185
  const response = await fetchImpl(new URL(normalizedUrl), {
@@ -196,7 +231,13 @@ export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
196
231
  },
197
232
  });
198
233
  }
199
- return { content: safeText(message), images, files };
234
+ const replyTo = qqReplyReference(message);
235
+ return {
236
+ content: safeText(message),
237
+ images,
238
+ files,
239
+ ...(replyTo ? { replyTo } : {}),
240
+ };
200
241
  }
201
242
 
202
243
  function nonEmptyString(value) {
@@ -493,7 +534,8 @@ export class QqHarnessBridge {
493
534
  : this.#batchInputs.handle(key, commandText, {
494
535
  plainText: Boolean(commandText)
495
536
  && !hasQqImageAttachments(message)
496
- && !hasQqFileAttachments(message),
537
+ && !hasQqFileAttachments(message)
538
+ && !qqReplyReference(message),
497
539
  });
498
540
  if (result.handled) {
499
541
  if (result.kind === 'submit') {
@@ -785,10 +827,11 @@ export class QqHarnessBridge {
785
827
  const text = promptMessage.content;
786
828
  const hasImages = hasInboundImages(promptMessage);
787
829
  const hasFiles = hasInboundFiles(promptMessage);
830
+ const hasReply = hasReplyReference(promptMessage);
788
831
  let stream = null;
789
832
  let batchSettled = batchSubmission === null;
790
833
  try {
791
- if (!text && !hasImages && !hasFiles) {
834
+ if (!text && !hasImages && !hasFiles && !hasReply) {
792
835
  await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
793
836
  await markMessageSeen();
794
837
  return;
@@ -836,8 +879,8 @@ export class QqHarnessBridge {
836
879
  return;
837
880
  }
838
881
 
839
- let content = hasImages
840
- ? await promptContentForMessage(promptMessage, { signal: this.#signal })
882
+ let content = hasImages || hasReply
883
+ ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
841
884
  : undefined;
842
885
  const snapshot = this.#acceptedMessageIds.get(messageId);
843
886
  let contextEnhanced = false;
@@ -74,7 +74,7 @@ export class BatchInputManager {
74
74
  if (!batch) {
75
75
  if (!name) return { handled: false };
76
76
  if (!plainText) {
77
- return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片或文件后重试。'));
77
+ return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片、文件或引用消息后重试。'));
78
78
  }
79
79
  if (name === 'send') {
80
80
  return result('no-batch', t('当前没有待提交的批量内容,请先发送 /batch。'));
@@ -91,7 +91,7 @@ export class BatchInputManager {
91
91
  }
92
92
 
93
93
  if (!plainText && (batch.phase === 'collecting' || name)) {
94
- return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
94
+ return result('unsupported-content', t(`批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。
95
95
  请继续发送文字,或使用 /send、/cancel。`), {
96
96
  count: batch.messages.length,
97
97
  limit: BATCH_INPUT_LIMIT,
@@ -146,7 +146,7 @@ export class BatchInputManager {
146
146
  }
147
147
 
148
148
  if (typeof text !== 'string') {
149
- return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
149
+ return result('unsupported-content', t(`批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。
150
150
  请继续发送文字,或使用 /send、/cancel。`), {
151
151
  count: batch.messages.length,
152
152
  limit: BATCH_INPUT_LIMIT,