@soimy/dingtalk 3.2.0 → 3.4.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +796 -42
  3. package/index.ts +62 -0
  4. package/package.json +4 -2
  5. package/src/access-control.ts +83 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  8. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  9. package/src/ack-reaction-classifier.ts +75 -0
  10. package/src/ack-reaction-service.ts +182 -0
  11. package/src/attachment-text-extractor.ts +148 -0
  12. package/src/card-callback-service.ts +119 -0
  13. package/src/card-draft-controller.ts +114 -0
  14. package/src/card-service.ts +666 -26
  15. package/src/channel.ts +455 -150
  16. package/src/config-schema.ts +64 -6
  17. package/src/config.ts +161 -5
  18. package/src/connection-manager.ts +354 -47
  19. package/src/dedup.ts +1 -0
  20. package/src/docs-service.ts +198 -0
  21. package/src/draft-stream-loop.ts +119 -0
  22. package/src/feedback-learning-service.ts +643 -0
  23. package/src/feedback-learning-store.ts +543 -0
  24. package/src/group-members-store.ts +48 -14
  25. package/src/inbound-handler.ts +1374 -259
  26. package/src/learning-command-service.ts +339 -0
  27. package/src/media-utils.ts +94 -50
  28. package/src/message-context-store.ts +787 -0
  29. package/src/message-utils.ts +487 -46
  30. package/src/messaging/quoted-context.ts +269 -0
  31. package/src/messaging/quoted-ref.ts +97 -0
  32. package/src/onboarding.ts +96 -1
  33. package/src/peer-id-registry.ts +102 -0
  34. package/src/persistence-store.ts +131 -0
  35. package/src/quoted-file-service.ts +385 -0
  36. package/src/reply-strategy-card.ts +225 -0
  37. package/src/reply-strategy-markdown.ts +55 -0
  38. package/src/reply-strategy-with-reaction.ts +190 -0
  39. package/src/reply-strategy.ts +72 -0
  40. package/src/send-service.ts +267 -45
  41. package/src/session-command-service.ts +147 -0
  42. package/src/session-lock.ts +2 -0
  43. package/src/session-peer-store.ts +77 -0
  44. package/src/session-routing.ts +33 -0
  45. package/src/targeting/agent-name-matcher.ts +148 -0
  46. package/src/targeting/agent-routing.ts +181 -0
  47. package/src/targeting/target-directory-adapter.ts +151 -0
  48. package/src/targeting/target-directory-store.ts +396 -0
  49. package/src/targeting/target-input.ts +62 -0
  50. package/src/types.ts +261 -28
  51. package/src/utils.ts +231 -12
@@ -1,4 +1,232 @@
1
- import type { DingTalkInboundMessage, MessageContent, SendMessageOptions } from "./types";
1
+ import type { AtMention, DingTalkInboundMessage, MessageContent, QuotedInfo, SendMessageOptions } from "./types";
2
+
3
+
4
+ interface DingTalkDocMeta {
5
+ spaceId: string;
6
+ fileId: string;
7
+ }
8
+
9
+ function parseBizCustomActionUrl(url: string | undefined): DingTalkDocMeta | null {
10
+ if (!url || typeof url !== "string") {
11
+ return null;
12
+ }
13
+
14
+ const queryIndex = url.indexOf("?");
15
+ if (queryIndex < 0 || queryIndex === url.length - 1) {
16
+ return null;
17
+ }
18
+
19
+ try {
20
+ const params = new URLSearchParams(url.slice(queryIndex + 1));
21
+ const route = params.get("route");
22
+ const type = params.get("type");
23
+ const spaceId = params.get("spaceId");
24
+ const fileId = params.get("fileId");
25
+ if (route !== "previewDentry" || type !== "file" || !spaceId || !fileId) {
26
+ return null;
27
+ }
28
+ return { spaceId, fileId };
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function extractRichTextQuoteParts(
35
+ richText: Array<Record<string, unknown>> | undefined,
36
+ ): { summary: string; pictureDownloadCode?: string; pictureDownloadCodes?: string[] } | null {
37
+ if (!Array.isArray(richText) || richText.length === 0) {
38
+ return null;
39
+ }
40
+
41
+ const textParts: string[] = [];
42
+ const pictureDownloadCodes: string[] = [];
43
+
44
+ for (const part of richText) {
45
+ const partType =
46
+ typeof part.msgType === "string"
47
+ ? part.msgType
48
+ : typeof part.type === "string"
49
+ ? part.type
50
+ : undefined;
51
+ const textValue =
52
+ typeof part.content === "string"
53
+ ? part.content
54
+ : typeof part.text === "string"
55
+ ? part.text
56
+ : undefined;
57
+
58
+ if ((partType === "text" || partType === undefined) && textValue) {
59
+ textParts.push(textValue);
60
+ continue;
61
+ }
62
+ if (partType === "emoji" && textValue) {
63
+ textParts.push(textValue);
64
+ continue;
65
+ }
66
+ if (partType === "picture") {
67
+ textParts.push("[图片]");
68
+ if (typeof part.downloadCode === "string" && part.downloadCode.trim()) {
69
+ pictureDownloadCodes.push(part.downloadCode.trim());
70
+ }
71
+ continue;
72
+ }
73
+ if (partType === "at") {
74
+ const atName =
75
+ typeof part.atName === "string"
76
+ ? part.atName
77
+ : typeof textValue === "string"
78
+ ? textValue
79
+ : "某人";
80
+ textParts.push(`@${atName}`);
81
+ continue;
82
+ }
83
+ if (textValue) {
84
+ textParts.push(textValue);
85
+ }
86
+ }
87
+
88
+ const summary = textParts.join("").trim();
89
+ const uniquePictureDownloadCodes = [...new Set(pictureDownloadCodes)];
90
+ const pictureDownloadCode = uniquePictureDownloadCodes[0];
91
+ if (!summary && !pictureDownloadCode) {
92
+ return null;
93
+ }
94
+ return {
95
+ summary,
96
+ pictureDownloadCode,
97
+ pictureDownloadCodes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
98
+ };
99
+ }
100
+
101
+ function trimString(value: string | undefined): string | undefined {
102
+ if (typeof value !== "string") {
103
+ return undefined;
104
+ }
105
+ const trimmed = value.trim();
106
+ return trimmed.length > 0 ? trimmed : undefined;
107
+ }
108
+
109
+ function buildQuotedMessageTypePlaceholder(messageType: string | undefined, fileName?: string): string | undefined {
110
+ switch (messageType) {
111
+ case "text":
112
+ return undefined;
113
+ case "picture":
114
+ return "<media:image>";
115
+ case "audio":
116
+ return "<media:voice>";
117
+ case "video":
118
+ return "<media:video>";
119
+ case "file":
120
+ case "unknownMsgType":
121
+ return fileName ? `<media:file> (${fileName})` : "<media:file>";
122
+ case "interactiveCardFile":
123
+ return "[钉钉文档]";
124
+ case "interactiveCard":
125
+ return "[interactiveCard消息]";
126
+ case "richText":
127
+ return "[富文本消息]";
128
+ default:
129
+ return messageType ? `[Quoted ${messageType}]` : undefined;
130
+ }
131
+ }
132
+
133
+ function buildLegacyQuoteMessagePreview(message: DingTalkInboundMessage["quoteMessage"]): {
134
+ previewText?: string;
135
+ previewMessageType?: string;
136
+ previewSenderId?: string;
137
+ } {
138
+ const previewMessageType = trimString(message?.msgtype);
139
+ return {
140
+ previewText:
141
+ trimString(message?.text?.content) ||
142
+ buildQuotedMessageTypePlaceholder(previewMessageType),
143
+ previewMessageType,
144
+ previewSenderId: trimString(message?.senderId),
145
+ };
146
+ }
147
+
148
+ function buildRepliedMessagePreview(params: {
149
+ data: DingTalkInboundMessage;
150
+ repliedMsg: NonNullable<NonNullable<DingTalkInboundMessage["text"]>["repliedMsg"]>;
151
+ }): Partial<QuotedInfo> {
152
+ const { data, repliedMsg } = params;
153
+ const repliedMsgType = trimString(repliedMsg.msgType);
154
+ const content = repliedMsg.content;
155
+ const fileName = trimString(content?.fileName);
156
+ const richTextQuote = extractRichTextQuoteParts(content?.richText);
157
+ const docMeta = parseBizCustomActionUrl(content?.biz_custom_action_url);
158
+
159
+ if (repliedMsgType === "picture") {
160
+ return {
161
+ mediaDownloadCode: trimString(content?.downloadCode),
162
+ mediaType: trimString(content?.downloadCode) ? "image" : undefined,
163
+ previewText: buildQuotedMessageTypePlaceholder("picture"),
164
+ previewMessageType: "picture",
165
+ previewSenderId: trimString(repliedMsg.senderId),
166
+ };
167
+ }
168
+
169
+ if (repliedMsgType === "richText") {
170
+ return {
171
+ mediaDownloadCode: richTextQuote?.pictureDownloadCode,
172
+ mediaType: richTextQuote?.pictureDownloadCode ? "image" : undefined,
173
+ previewText:
174
+ trimString(richTextQuote?.summary) || buildQuotedMessageTypePlaceholder("richText"),
175
+ previewMessageType: "richText",
176
+ previewSenderId: trimString(repliedMsg.senderId),
177
+ };
178
+ }
179
+
180
+ if (repliedMsgType === "unknownMsgType") {
181
+ return {
182
+ isQuotedFile: true,
183
+ fileCreatedAt: repliedMsg.createdAt,
184
+ previewText: buildQuotedMessageTypePlaceholder("unknownMsgType", fileName),
185
+ previewMessageType: "unknownMsgType",
186
+ previewFileName: fileName,
187
+ previewSenderId: trimString(repliedMsg.senderId),
188
+ };
189
+ }
190
+
191
+ if (repliedMsgType === "interactiveCard") {
192
+ const isBotCard = repliedMsg.senderId === data.chatbotUserId;
193
+ if (isBotCard) {
194
+ return {
195
+ isQuotedCard: true,
196
+ cardCreatedAt: repliedMsg.createdAt,
197
+ processQueryKey: trimString(data.originalProcessQueryKey),
198
+ previewText:
199
+ trimString(content?.text) || buildQuotedMessageTypePlaceholder("interactiveCard"),
200
+ previewMessageType: "interactiveCard",
201
+ previewSenderId: trimString(repliedMsg.senderId),
202
+ };
203
+ }
204
+
205
+ return {
206
+ isQuotedDocCard: true,
207
+ fileCreatedAt: repliedMsg.createdAt,
208
+ previewText:
209
+ trimString(content?.text) ||
210
+ buildQuotedMessageTypePlaceholder(docMeta ? "interactiveCardFile" : "interactiveCard"),
211
+ previewMessageType: docMeta ? "interactiveCardFile" : "interactiveCard",
212
+ previewSenderId: trimString(repliedMsg.senderId),
213
+ };
214
+ }
215
+
216
+ const textPreview =
217
+ trimString(content?.text) ||
218
+ trimString(richTextQuote?.summary) ||
219
+ buildQuotedMessageTypePlaceholder(repliedMsgType, fileName);
220
+
221
+ return {
222
+ mediaDownloadCode: richTextQuote?.pictureDownloadCode,
223
+ mediaType: richTextQuote?.pictureDownloadCode ? "image" : undefined,
224
+ previewText: textPreview,
225
+ previewMessageType: repliedMsgType,
226
+ previewFileName: fileName,
227
+ previewSenderId: trimString(repliedMsg.senderId),
228
+ };
229
+ }
2
230
 
3
231
  /**
4
232
  * Auto-detect markdown usage and derive message title.
@@ -24,76 +252,209 @@ export function detectMarkdownAndExtractTitle(
24
252
  return { useMarkdown, title };
25
253
  }
26
254
 
255
+ function isMarkdownTableSeparator(line: string): boolean {
256
+ const normalized = line.trim();
257
+ if (!normalized.includes("-")) {
258
+ return false;
259
+ }
260
+ const cells = normalized
261
+ .replace(/^\|/, "")
262
+ .replace(/\|$/, "")
263
+ .split("|")
264
+ .map((cell) => cell.trim());
265
+ return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
266
+ }
267
+
268
+ function isMarkdownTableRow(line: string): boolean {
269
+ const trimmed = line.trim();
270
+ return trimmed.includes("|") && !trimmed.startsWith("```");
271
+ }
272
+
273
+ function parseMarkdownTableRow(line: string): string[] {
274
+ return line
275
+ .trim()
276
+ .replace(/^\|/, "")
277
+ .replace(/\|$/, "")
278
+ .split("|")
279
+ .map((cell) => cell.trim());
280
+ }
281
+
282
+ function renderMarkdownTable(lines: string[]): string {
283
+ const rows = lines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
284
+ return rows.map((cells) => cells.join(" | ")).join(" \n");
285
+ }
286
+
287
+ export function convertMarkdownTablesToPlainText(text: string): string {
288
+ const lines = text.split("\n");
289
+ const output: string[] = [];
290
+ let index = 0;
291
+ let inCodeFence = false;
292
+
293
+ while (index < lines.length) {
294
+ const line = lines[index] || "";
295
+ if (line.trim().startsWith("```")) {
296
+ inCodeFence = !inCodeFence;
297
+ output.push(line);
298
+ index += 1;
299
+ continue;
300
+ }
301
+
302
+ if (
303
+ !inCodeFence &&
304
+ index + 1 < lines.length &&
305
+ isMarkdownTableRow(line) &&
306
+ isMarkdownTableSeparator(lines[index + 1] || "")
307
+ ) {
308
+ const tableLines = [line];
309
+ index += 2;
310
+ while (index < lines.length && isMarkdownTableRow(lines[index] || "")) {
311
+ tableLines.push(lines[index] || "");
312
+ index += 1;
313
+ }
314
+ output.push(renderMarkdownTable(tableLines));
315
+ continue;
316
+ }
317
+
318
+ output.push(line);
319
+ index += 1;
320
+ }
321
+
322
+ return output.join("\n");
323
+ }
324
+
27
325
  export function extractMessageContent(data: DingTalkInboundMessage): MessageContent {
28
326
  const msgtype = data.msgtype || "text";
327
+ const atMentions: AtMention[] = [];
29
328
 
30
- // Normalize quote/reply metadata into a readable text prefix so the agent can understand message context.
31
- const formatQuotedContent = (): string => {
32
- const textField = data.text as any;
329
+ // 提取通过 @picker 选中的真实用户的 dingtalkId
330
+ // 这些是真实钉钉用户,不包含 agent 名(如 @frontend)
331
+ const atUserDingtalkIds = data.atUsers?.map((u) => u.dingtalkId).filter(Boolean);
332
+
333
+ const formatQuotedContent = (): QuotedInfo | null => {
334
+ const textField = data.text;
33
335
 
34
336
  if (textField?.isReplyMsg && textField?.repliedMsg) {
35
337
  const repliedMsg = textField.repliedMsg;
36
- const content = repliedMsg?.content;
338
+ const repliedMsgType = trimString(repliedMsg.msgType);
339
+ const content = repliedMsg.content;
340
+ const repliedMsgId = trimString(repliedMsg.msgId) || trimString(data.originalMsgId);
341
+ const repliedPreview = buildRepliedMessagePreview({ data, repliedMsg });
37
342
 
38
- if (content?.text) {
39
- const quoteText = content.text.trim();
40
- if (quoteText) {
41
- return `[引用消息: "${quoteText}"]\n\n`;
343
+ if (repliedMsgType === "picture" && content?.downloadCode) {
344
+ return {
345
+ mediaDownloadCode: content.downloadCode,
346
+ mediaType: "image",
347
+ ...repliedPreview,
348
+ };
349
+ }
350
+
351
+ if (repliedMsgType === "richText") {
352
+ const richTextQuote = extractRichTextQuoteParts(content?.richText);
353
+ if (richTextQuote) {
354
+ return {
355
+ msgId: repliedMsgId,
356
+ mediaDownloadCode: richTextQuote.pictureDownloadCode,
357
+ mediaType: richTextQuote.pictureDownloadCode ? "image" : undefined,
358
+ ...repliedPreview,
359
+ };
42
360
  }
43
361
  }
44
362
 
45
- if (content?.richText && Array.isArray(content.richText)) {
46
- const textParts: string[] = [];
47
- for (const part of content.richText) {
48
- if (part.msgType === "text" && part.content) {
49
- textParts.push(part.content);
50
- } else if (part.msgType === "emoji" || part.type === "emoji") {
51
- textParts.push(part.content || "[表情]");
52
- } else if (part.msgType === "picture" || part.type === "picture") {
53
- textParts.push("[图片]");
54
- } else if (part.msgType === "at" || part.type === "at") {
55
- textParts.push(`@${part.content || part.atName || "某人"}`);
56
- } else if (part.text) {
57
- textParts.push(part.text);
58
- }
363
+ if (repliedMsgType === "unknownMsgType") {
364
+ return {
365
+ isQuotedFile: true,
366
+ fileCreatedAt: repliedMsg.createdAt,
367
+ msgId: repliedMsgId,
368
+ ...repliedPreview,
369
+ };
370
+ }
371
+
372
+ if (repliedMsgType === "interactiveCard") {
373
+ const isBotCard = repliedMsg.senderId === data.chatbotUserId;
374
+ if (isBotCard) {
375
+ return {
376
+ isQuotedCard: true,
377
+ cardCreatedAt: repliedMsg.createdAt,
378
+ processQueryKey: data.originalProcessQueryKey,
379
+ msgId: repliedMsgId,
380
+ ...repliedPreview,
381
+ };
59
382
  }
60
- const quoteText = textParts.join("").trim();
61
- if (quoteText) {
62
- return `[引用消息: "${quoteText}"]\n\n`;
383
+
384
+ return {
385
+ isQuotedDocCard: true,
386
+ fileCreatedAt: repliedMsg.createdAt,
387
+ msgId: repliedMsgId,
388
+ ...repliedPreview,
389
+ };
390
+ }
391
+
392
+ if (repliedMsgType && repliedMsgId) {
393
+ return { msgId: repliedMsgId, ...repliedPreview };
394
+ }
395
+
396
+ // No msgType — backward compat: extract text or richText from content.
397
+ if (content?.text?.trim()) {
398
+ return repliedMsgId ? { msgId: repliedMsgId, ...repliedPreview } : null;
399
+ }
400
+
401
+ if (content?.richText && Array.isArray(content.richText)) {
402
+ const richTextQuote = extractRichTextQuoteParts(content.richText);
403
+ if (richTextQuote) {
404
+ return {
405
+ msgId: repliedMsgId,
406
+ mediaDownloadCode: richTextQuote.pictureDownloadCode,
407
+ mediaType: richTextQuote.pictureDownloadCode ? "image" : undefined,
408
+ ...repliedPreview,
409
+ };
63
410
  }
64
411
  }
65
412
  }
66
413
 
67
- // Some clients only send originalMsgId for rich media reply messages.
68
414
  if (textField?.isReplyMsg && !textField?.repliedMsg && data.originalMsgId) {
69
- return `[这是一条引用消息,原消息ID: ${data.originalMsgId}]\n\n`;
415
+ return {
416
+ msgId: data.originalMsgId,
417
+ };
70
418
  }
71
419
 
72
420
  if (data.quoteMessage) {
73
- const quoteText = data.quoteMessage.text?.content?.trim() || "";
74
- if (quoteText) {
75
- return `[引用消息: "${quoteText}"]\n\n`;
421
+ if (data.quoteMessage.msgId) {
422
+ return {
423
+ msgId: data.quoteMessage.msgId,
424
+ ...buildLegacyQuoteMessagePreview(data.quoteMessage),
425
+ };
76
426
  }
77
427
  }
78
428
 
79
- if (data.content?.quoteContent) {
80
- return `[引用消息: "${data.content.quoteContent}"]\n\n`;
81
- }
82
-
83
- return "";
429
+ return null;
84
430
  };
85
431
 
86
- const quotedPrefix = formatQuotedContent();
432
+ const quoted = formatQuotedContent();
87
433
 
88
- // Unified extraction by DingTalk msgtype for downstream routing/agent processing.
89
434
  if (msgtype === "text") {
90
- return { text: quotedPrefix + (data.text?.content?.trim() || ""), messageType: "text" };
435
+ const textContent = data.text?.content?.trim() || "";
436
+
437
+ // Strip quoted prefix before extracting @mentions to avoid matching @names inside quotes.
438
+ const textForAtExtraction = textContent.replace(/^\[引用[^\]]*\]\s*/, "");
439
+ // Match @name but exclude email-like patterns (user@domain.com) and emoji (@_@).
440
+ const atMatches = textForAtExtraction.matchAll(/(?<!\w)@([^\s@.]+)(?!\.\w)/g);
441
+ for (const match of atMatches) {
442
+ atMentions.push({ name: match[1].trim() });
443
+ }
444
+
445
+ return {
446
+ text: textContent,
447
+ messageType: "text",
448
+ quoted: quoted ?? undefined,
449
+ atMentions,
450
+ atUserDingtalkIds,
451
+ };
91
452
  }
92
453
 
93
454
  if (msgtype === "richText") {
94
455
  const richTextParts = data.content?.richText || [];
95
456
  let text = "";
96
- let pictureDownloadCode: string | undefined;
457
+ const pictureDownloadCodes: string[] = [];
97
458
  // Keep first image downloadCode while preserving readable text and @mention parts.
98
459
  for (const part of richTextParts) {
99
460
  if (part.text && (part.type === "text" || part.type === undefined)) {
@@ -101,17 +462,28 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
101
462
  }
102
463
  if (part.type === "at" && part.atName) {
103
464
  text += `@${part.atName} `;
465
+ // 提取 @ 提及信息,包括 atUserId
466
+ atMentions.push({
467
+ name: part.atName.trim(),
468
+ userId: part.atUserId,
469
+ });
104
470
  }
105
- if (part.type === "picture" && part.downloadCode && !pictureDownloadCode) {
106
- pictureDownloadCode = part.downloadCode;
471
+ if (part.type === "picture" && part.downloadCode) {
472
+ pictureDownloadCodes.push(part.downloadCode);
107
473
  }
108
474
  }
475
+ const uniquePictureDownloadCodes = [...new Set(pictureDownloadCodes)];
476
+ const pictureDownloadCode = uniquePictureDownloadCodes[0];
109
477
  return {
110
- text:
111
- quotedPrefix + (text.trim() || (pictureDownloadCode ? "<media:image>" : "[富文本消息]")),
478
+ text: text.trim() || (pictureDownloadCode ? "<media:image>" : "[富文本消息]"),
112
479
  mediaPath: pictureDownloadCode,
480
+ mediaPaths: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
113
481
  mediaType: pictureDownloadCode ? "image" : undefined,
482
+ mediaTypes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes.map(() => "image") : undefined,
114
483
  messageType: "richText",
484
+ quoted: quoted ?? undefined,
485
+ atMentions,
486
+ atUserDingtalkIds,
115
487
  };
116
488
  }
117
489
 
@@ -121,6 +493,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
121
493
  mediaPath: data.content?.downloadCode,
122
494
  mediaType: "image",
123
495
  messageType: "picture",
496
+ atMentions,
497
+ atUserDingtalkIds,
124
498
  };
125
499
  }
126
500
 
@@ -130,6 +504,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
130
504
  mediaPath: data.content?.downloadCode,
131
505
  mediaType: "audio",
132
506
  messageType: "audio",
507
+ atMentions,
508
+ atUserDingtalkIds,
133
509
  };
134
510
  }
135
511
 
@@ -139,6 +515,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
139
515
  mediaPath: data.content?.downloadCode,
140
516
  mediaType: "video",
141
517
  messageType: "video",
518
+ atMentions,
519
+ atUserDingtalkIds,
142
520
  };
143
521
  }
144
522
 
@@ -148,9 +526,72 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
148
526
  mediaPath: data.content?.downloadCode,
149
527
  mediaType: "file",
150
528
  messageType: "file",
529
+ atMentions,
530
+ atUserDingtalkIds,
151
531
  };
152
532
  }
153
533
 
154
- // Fallback: preserve unknown msgtype as readable marker.
155
- return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype };
534
+ if (msgtype === "interactiveCard") {
535
+ const docMeta = parseBizCustomActionUrl(data.content?.biz_custom_action_url);
536
+ if (docMeta) {
537
+ return {
538
+ text: "[钉钉文档]\n\n",
539
+ messageType: "interactiveCardFile",
540
+ docSpaceId: docMeta.spaceId,
541
+ docFileId: docMeta.fileId,
542
+ quoted: quoted ?? undefined,
543
+ atMentions,
544
+ atUserDingtalkIds,
545
+ };
546
+ }
547
+ return {
548
+ text: data.text?.content?.trim() || "[interactiveCard消息]",
549
+ messageType: msgtype,
550
+ quoted: quoted ?? undefined,
551
+ atMentions,
552
+ atUserDingtalkIds,
553
+ };
554
+ }
555
+ if (msgtype === "chatRecord") {
556
+ const content = data.content as Record<string, unknown> | undefined;
557
+ const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
558
+ const rawRecord = content?.chatRecord;
559
+ if (
560
+ summary === "[]" ||
561
+ (typeof rawRecord === "string" && rawRecord.trim() === "[]") ||
562
+ (Array.isArray(rawRecord) && rawRecord.length === 0)
563
+ ) {
564
+ return {
565
+ text: "[系统提示] 没有读到引用记录(chatRecord 为空)。请改用逐条转发、复制原文,或重新转发非空聊天记录。",
566
+ messageType: "chatRecord",
567
+ quoted: quoted ?? undefined,
568
+ atMentions,
569
+ atUserDingtalkIds,
570
+ };
571
+ }
572
+ if (summary) {
573
+ return {
574
+ text: `[聊天记录摘要] ${summary}`,
575
+ messageType: "chatRecord",
576
+ quoted: quoted ?? undefined,
577
+ atMentions,
578
+ atUserDingtalkIds,
579
+ };
580
+ }
581
+ return {
582
+ text: "[chatRecord消息: 无可读内容]",
583
+ messageType: "chatRecord",
584
+ quoted: quoted ?? undefined,
585
+ atMentions,
586
+ atUserDingtalkIds,
587
+ };
588
+ }
589
+
590
+ return {
591
+ text: data.text?.content?.trim() || `[${msgtype}消息]`,
592
+ messageType: msgtype,
593
+ quoted: quoted ?? undefined,
594
+ atMentions,
595
+ atUserDingtalkIds,
596
+ };
156
597
  }