@soimy/dingtalk 3.3.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.
- package/README.md +141 -12
- package/package.json +2 -2
- package/src/access-control.ts +65 -0
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
- package/src/ack-reaction-classifier.ts +17 -4
- package/src/ack-reaction-service.ts +66 -19
- package/src/attachment-text-extractor.ts +2 -1
- package/src/card-service.ts +145 -257
- package/src/channel.ts +60 -28
- package/src/config-schema.ts +28 -6
- package/src/config.ts +29 -5
- package/src/inbound-handler.ts +694 -520
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +221 -42
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +62 -5
- package/src/reply-strategy-card.ts +225 -0
- package/src/reply-strategy-markdown.ts +55 -0
- package/src/reply-strategy-with-reaction.ts +190 -0
- package/src/reply-strategy.ts +72 -0
- package/src/send-service.ts +140 -52
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +151 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +114 -9
- package/src/quote-journal.ts +0 -242
- package/src/quoted-msg-cache.ts +0 -226
package/src/message-utils.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { DingTalkInboundMessage, MessageContent, QuotedInfo, SendMessageOptions } from "./types";
|
|
1
|
+
import type { AtMention, DingTalkInboundMessage, MessageContent, QuotedInfo, SendMessageOptions } from "./types";
|
|
2
|
+
|
|
2
3
|
|
|
3
4
|
interface DingTalkDocMeta {
|
|
4
5
|
spaceId: string;
|
|
@@ -97,6 +98,136 @@ function extractRichTextQuoteParts(
|
|
|
97
98
|
};
|
|
98
99
|
}
|
|
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
|
+
}
|
|
230
|
+
|
|
100
231
|
/**
|
|
101
232
|
* Auto-detect markdown usage and derive message title.
|
|
102
233
|
* Title extraction follows DingTalk markdown card title constraints.
|
|
@@ -150,7 +281,7 @@ function parseMarkdownTableRow(line: string): string[] {
|
|
|
150
281
|
|
|
151
282
|
function renderMarkdownTable(lines: string[]): string {
|
|
152
283
|
const rows = lines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
|
|
153
|
-
return rows.map((cells) => cells.join(" | ")).join("\n");
|
|
284
|
+
return rows.map((cells) => cells.join(" | ")).join(" \n");
|
|
154
285
|
}
|
|
155
286
|
|
|
156
287
|
export function convertMarkdownTablesToPlainText(text: string): string {
|
|
@@ -193,49 +324,48 @@ export function convertMarkdownTablesToPlainText(text: string): string {
|
|
|
193
324
|
|
|
194
325
|
export function extractMessageContent(data: DingTalkInboundMessage): MessageContent {
|
|
195
326
|
const msgtype = data.msgtype || "text";
|
|
327
|
+
const atMentions: AtMention[] = [];
|
|
328
|
+
|
|
329
|
+
// 提取通过 @picker 选中的真实用户的 dingtalkId
|
|
330
|
+
// 这些是真实钉钉用户,不包含 agent 名(如 @frontend)
|
|
331
|
+
const atUserDingtalkIds = data.atUsers?.map((u) => u.dingtalkId).filter(Boolean);
|
|
196
332
|
|
|
197
333
|
const formatQuotedContent = (): QuotedInfo | null => {
|
|
198
334
|
const textField = data.text;
|
|
199
335
|
|
|
200
336
|
if (textField?.isReplyMsg && textField?.repliedMsg) {
|
|
201
337
|
const repliedMsg = textField.repliedMsg;
|
|
202
|
-
const repliedMsgType = repliedMsg.msgType;
|
|
338
|
+
const repliedMsgType = trimString(repliedMsg.msgType);
|
|
203
339
|
const content = repliedMsg.content;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return { prefix: `[引用消息: "${content.text.trim()}"]\n\n` };
|
|
207
|
-
}
|
|
340
|
+
const repliedMsgId = trimString(repliedMsg.msgId) || trimString(data.originalMsgId);
|
|
341
|
+
const repliedPreview = buildRepliedMessagePreview({ data, repliedMsg });
|
|
208
342
|
|
|
209
343
|
if (repliedMsgType === "picture" && content?.downloadCode) {
|
|
210
344
|
return {
|
|
211
|
-
prefix: "[引用图片]\n\n",
|
|
212
345
|
mediaDownloadCode: content.downloadCode,
|
|
213
346
|
mediaType: "image",
|
|
347
|
+
...repliedPreview,
|
|
214
348
|
};
|
|
215
349
|
}
|
|
216
350
|
|
|
217
351
|
if (repliedMsgType === "richText") {
|
|
218
352
|
const richTextQuote = extractRichTextQuoteParts(content?.richText);
|
|
219
353
|
if (richTextQuote) {
|
|
220
|
-
const quoteImageCount = richTextQuote.pictureDownloadCodes?.length || 0;
|
|
221
|
-
const prefix =
|
|
222
|
-
richTextQuote.summary && richTextQuote.summary !== "[图片]"
|
|
223
|
-
? `[引用消息: "${richTextQuote.summary}"]${quoteImageCount > 1 ? ` [含${quoteImageCount}张引用图片]` : ""}\n\n`
|
|
224
|
-
: "[引用图片]\n\n";
|
|
225
354
|
return {
|
|
226
|
-
|
|
355
|
+
msgId: repliedMsgId,
|
|
227
356
|
mediaDownloadCode: richTextQuote.pictureDownloadCode,
|
|
228
357
|
mediaType: richTextQuote.pictureDownloadCode ? "image" : undefined,
|
|
358
|
+
...repliedPreview,
|
|
229
359
|
};
|
|
230
360
|
}
|
|
231
361
|
}
|
|
232
362
|
|
|
233
363
|
if (repliedMsgType === "unknownMsgType") {
|
|
234
364
|
return {
|
|
235
|
-
prefix: "[引用文件]\n\n",
|
|
236
365
|
isQuotedFile: true,
|
|
237
366
|
fileCreatedAt: repliedMsg.createdAt,
|
|
238
|
-
msgId:
|
|
367
|
+
msgId: repliedMsgId,
|
|
368
|
+
...repliedPreview,
|
|
239
369
|
};
|
|
240
370
|
}
|
|
241
371
|
|
|
@@ -243,40 +373,39 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
243
373
|
const isBotCard = repliedMsg.senderId === data.chatbotUserId;
|
|
244
374
|
if (isBotCard) {
|
|
245
375
|
return {
|
|
246
|
-
prefix: "[引用了机器人的回复]\n\n",
|
|
247
376
|
isQuotedCard: true,
|
|
248
377
|
cardCreatedAt: repliedMsg.createdAt,
|
|
249
378
|
processQueryKey: data.originalProcessQueryKey,
|
|
250
|
-
msgId:
|
|
379
|
+
msgId: repliedMsgId,
|
|
380
|
+
...repliedPreview,
|
|
251
381
|
};
|
|
252
382
|
}
|
|
253
383
|
|
|
254
384
|
return {
|
|
255
|
-
prefix: "[引用了钉钉文档]\n\n",
|
|
256
385
|
isQuotedDocCard: true,
|
|
257
386
|
fileCreatedAt: repliedMsg.createdAt,
|
|
258
|
-
msgId:
|
|
387
|
+
msgId: repliedMsgId,
|
|
388
|
+
...repliedPreview,
|
|
259
389
|
};
|
|
260
390
|
}
|
|
261
391
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const idPart = repliedMsg.msgId ? `,原消息ID: ${repliedMsg.msgId}` : "";
|
|
265
|
-
return { prefix: `[引用消息不可见: msgType=${repliedMsgType}${idPart}]\n\n` };
|
|
392
|
+
if (repliedMsgType && repliedMsgId) {
|
|
393
|
+
return { msgId: repliedMsgId, ...repliedPreview };
|
|
266
394
|
}
|
|
267
395
|
|
|
268
396
|
// No msgType — backward compat: extract text or richText from content.
|
|
269
397
|
if (content?.text?.trim()) {
|
|
270
|
-
return {
|
|
398
|
+
return repliedMsgId ? { msgId: repliedMsgId, ...repliedPreview } : null;
|
|
271
399
|
}
|
|
272
400
|
|
|
273
401
|
if (content?.richText && Array.isArray(content.richText)) {
|
|
274
402
|
const richTextQuote = extractRichTextQuoteParts(content.richText);
|
|
275
|
-
if (richTextQuote
|
|
403
|
+
if (richTextQuote) {
|
|
276
404
|
return {
|
|
277
|
-
|
|
405
|
+
msgId: repliedMsgId,
|
|
278
406
|
mediaDownloadCode: richTextQuote.pictureDownloadCode,
|
|
279
407
|
mediaType: richTextQuote.pictureDownloadCode ? "image" : undefined,
|
|
408
|
+
...repliedPreview,
|
|
280
409
|
};
|
|
281
410
|
}
|
|
282
411
|
}
|
|
@@ -284,30 +413,42 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
284
413
|
|
|
285
414
|
if (textField?.isReplyMsg && !textField?.repliedMsg && data.originalMsgId) {
|
|
286
415
|
return {
|
|
287
|
-
prefix: `[这是一条引用消息,原消息ID: ${data.originalMsgId}]\n\n`,
|
|
288
416
|
msgId: data.originalMsgId,
|
|
289
417
|
};
|
|
290
418
|
}
|
|
291
419
|
|
|
292
420
|
if (data.quoteMessage) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
421
|
+
if (data.quoteMessage.msgId) {
|
|
422
|
+
return {
|
|
423
|
+
msgId: data.quoteMessage.msgId,
|
|
424
|
+
...buildLegacyQuoteMessagePreview(data.quoteMessage),
|
|
425
|
+
};
|
|
296
426
|
}
|
|
297
427
|
}
|
|
298
428
|
|
|
299
|
-
if (data.content?.quoteContent) {
|
|
300
|
-
return { prefix: `[引用消息: "${data.content.quoteContent}"]\n\n` };
|
|
301
|
-
}
|
|
302
|
-
|
|
303
429
|
return null;
|
|
304
430
|
};
|
|
305
431
|
|
|
306
432
|
const quoted = formatQuotedContent();
|
|
307
|
-
const quotedPrefix = quoted?.prefix || "";
|
|
308
433
|
|
|
309
434
|
if (msgtype === "text") {
|
|
310
|
-
|
|
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
|
+
};
|
|
311
452
|
}
|
|
312
453
|
|
|
313
454
|
if (msgtype === "richText") {
|
|
@@ -321,6 +462,11 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
321
462
|
}
|
|
322
463
|
if (part.type === "at" && part.atName) {
|
|
323
464
|
text += `@${part.atName} `;
|
|
465
|
+
// 提取 @ 提及信息,包括 atUserId
|
|
466
|
+
atMentions.push({
|
|
467
|
+
name: part.atName.trim(),
|
|
468
|
+
userId: part.atUserId,
|
|
469
|
+
});
|
|
324
470
|
}
|
|
325
471
|
if (part.type === "picture" && part.downloadCode) {
|
|
326
472
|
pictureDownloadCodes.push(part.downloadCode);
|
|
@@ -329,14 +475,15 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
329
475
|
const uniquePictureDownloadCodes = [...new Set(pictureDownloadCodes)];
|
|
330
476
|
const pictureDownloadCode = uniquePictureDownloadCodes[0];
|
|
331
477
|
return {
|
|
332
|
-
text:
|
|
333
|
-
quotedPrefix + (text.trim() || (pictureDownloadCode ? "<media:image>" : "[富文本消息]")),
|
|
478
|
+
text: text.trim() || (pictureDownloadCode ? "<media:image>" : "[富文本消息]"),
|
|
334
479
|
mediaPath: pictureDownloadCode,
|
|
335
480
|
mediaPaths: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
|
|
336
481
|
mediaType: pictureDownloadCode ? "image" : undefined,
|
|
337
482
|
mediaTypes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes.map(() => "image") : undefined,
|
|
338
483
|
messageType: "richText",
|
|
339
484
|
quoted: quoted ?? undefined,
|
|
485
|
+
atMentions,
|
|
486
|
+
atUserDingtalkIds,
|
|
340
487
|
};
|
|
341
488
|
}
|
|
342
489
|
|
|
@@ -346,6 +493,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
346
493
|
mediaPath: data.content?.downloadCode,
|
|
347
494
|
mediaType: "image",
|
|
348
495
|
messageType: "picture",
|
|
496
|
+
atMentions,
|
|
497
|
+
atUserDingtalkIds,
|
|
349
498
|
};
|
|
350
499
|
}
|
|
351
500
|
|
|
@@ -355,6 +504,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
355
504
|
mediaPath: data.content?.downloadCode,
|
|
356
505
|
mediaType: "audio",
|
|
357
506
|
messageType: "audio",
|
|
507
|
+
atMentions,
|
|
508
|
+
atUserDingtalkIds,
|
|
358
509
|
};
|
|
359
510
|
}
|
|
360
511
|
|
|
@@ -364,6 +515,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
364
515
|
mediaPath: data.content?.downloadCode,
|
|
365
516
|
mediaType: "video",
|
|
366
517
|
messageType: "video",
|
|
518
|
+
atMentions,
|
|
519
|
+
atUserDingtalkIds,
|
|
367
520
|
};
|
|
368
521
|
}
|
|
369
522
|
|
|
@@ -373,6 +526,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
373
526
|
mediaPath: data.content?.downloadCode,
|
|
374
527
|
mediaType: "file",
|
|
375
528
|
messageType: "file",
|
|
529
|
+
atMentions,
|
|
530
|
+
atUserDingtalkIds,
|
|
376
531
|
};
|
|
377
532
|
}
|
|
378
533
|
|
|
@@ -385,12 +540,16 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
385
540
|
docSpaceId: docMeta.spaceId,
|
|
386
541
|
docFileId: docMeta.fileId,
|
|
387
542
|
quoted: quoted ?? undefined,
|
|
543
|
+
atMentions,
|
|
544
|
+
atUserDingtalkIds,
|
|
388
545
|
};
|
|
389
546
|
}
|
|
390
547
|
return {
|
|
391
548
|
text: data.text?.content?.trim() || "[interactiveCard消息]",
|
|
392
549
|
messageType: msgtype,
|
|
393
550
|
quoted: quoted ?? undefined,
|
|
551
|
+
atMentions,
|
|
552
|
+
atUserDingtalkIds,
|
|
394
553
|
};
|
|
395
554
|
}
|
|
396
555
|
if (msgtype === "chatRecord") {
|
|
@@ -406,13 +565,33 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
406
565
|
text: "[系统提示] 没有读到引用记录(chatRecord 为空)。请改用逐条转发、复制原文,或重新转发非空聊天记录。",
|
|
407
566
|
messageType: "chatRecord",
|
|
408
567
|
quoted: quoted ?? undefined,
|
|
568
|
+
atMentions,
|
|
569
|
+
atUserDingtalkIds,
|
|
409
570
|
};
|
|
410
571
|
}
|
|
411
572
|
if (summary) {
|
|
412
|
-
return {
|
|
573
|
+
return {
|
|
574
|
+
text: `[聊天记录摘要] ${summary}`,
|
|
575
|
+
messageType: "chatRecord",
|
|
576
|
+
quoted: quoted ?? undefined,
|
|
577
|
+
atMentions,
|
|
578
|
+
atUserDingtalkIds,
|
|
579
|
+
};
|
|
413
580
|
}
|
|
414
|
-
return {
|
|
581
|
+
return {
|
|
582
|
+
text: "[chatRecord消息: 无可读内容]",
|
|
583
|
+
messageType: "chatRecord",
|
|
584
|
+
quoted: quoted ?? undefined,
|
|
585
|
+
atMentions,
|
|
586
|
+
atUserDingtalkIds,
|
|
587
|
+
};
|
|
415
588
|
}
|
|
416
589
|
|
|
417
|
-
return {
|
|
590
|
+
return {
|
|
591
|
+
text: data.text?.content?.trim() || `[${msgtype}消息]`,
|
|
592
|
+
messageType: msgtype,
|
|
593
|
+
quoted: quoted ?? undefined,
|
|
594
|
+
atMentions,
|
|
595
|
+
atUserDingtalkIds,
|
|
596
|
+
};
|
|
418
597
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { MessageRecord } from "../message-context-store";
|
|
2
|
+
import type { Logger, QuotedRef } from "../types";
|
|
3
|
+
import { resolveQuotedRecord } from "./quoted-ref";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
6
|
+
const DEFAULT_PER_HOP_BODY_LIMIT = 1200;
|
|
7
|
+
const DEFAULT_TOTAL_BODY_LIMIT = 3600;
|
|
8
|
+
|
|
9
|
+
export interface QuotedChainEntry {
|
|
10
|
+
depth: number;
|
|
11
|
+
direction: MessageRecord["direction"];
|
|
12
|
+
messageType: string;
|
|
13
|
+
sender?: string;
|
|
14
|
+
body: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResolvedQuotedRuntimeContext {
|
|
19
|
+
replyToId?: string;
|
|
20
|
+
replyToBody: string;
|
|
21
|
+
replyToSender?: string;
|
|
22
|
+
replyToIsQuote: true;
|
|
23
|
+
chain: QuotedChainEntry[];
|
|
24
|
+
untrustedContext?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QuotedRuntimePreview {
|
|
28
|
+
text?: string;
|
|
29
|
+
messageType?: string;
|
|
30
|
+
senderId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
|
|
34
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
35
|
+
? Math.floor(value)
|
|
36
|
+
: fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function trimmedString(value: string | undefined): string | undefined {
|
|
40
|
+
if (typeof value !== "string") {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function deriveReplyToId(record: MessageRecord): string | undefined {
|
|
48
|
+
if (record.direction === "inbound") {
|
|
49
|
+
return trimmedString(record.msgId);
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
trimmedString(record.delivery?.processQueryKey) ||
|
|
53
|
+
trimmedString(record.delivery?.messageId) ||
|
|
54
|
+
trimmedString(record.delivery?.outTrackId) ||
|
|
55
|
+
trimmedString(record.delivery?.cardInstanceId) ||
|
|
56
|
+
trimmedString(record.msgId)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deriveSender(record: MessageRecord): string | undefined {
|
|
61
|
+
return record.direction === "outbound" ? "assistant" : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function deriveMessageType(record: MessageRecord): string {
|
|
65
|
+
return trimmedString(record.messageType) || "unknown";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildBodyPlaceholder(record: MessageRecord): string {
|
|
69
|
+
const messageType = trimmedString(record.messageType) || "message";
|
|
70
|
+
return `[Quoted ${messageType}]`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildPreviewPlaceholder(preview: QuotedRuntimePreview | undefined): string {
|
|
74
|
+
const messageType = trimmedString(preview?.messageType) || "message";
|
|
75
|
+
return `[Quoted ${messageType}]`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveRecordBody(record: MessageRecord): string {
|
|
79
|
+
return (
|
|
80
|
+
trimmedString(record.attachmentText) ||
|
|
81
|
+
trimmedString(record.text) ||
|
|
82
|
+
buildBodyPlaceholder(record)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolvePreviewBody(preview: QuotedRuntimePreview | undefined): string | undefined {
|
|
87
|
+
return trimmedString(preview?.text) || (preview ? buildPreviewPlaceholder(preview) : undefined);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function deriveReplyToIdFromQuotedRef(quotedRef: QuotedRef | undefined): string | undefined {
|
|
91
|
+
if (!quotedRef?.key || !quotedRef.value) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return trimmedString(quotedRef.value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function derivePreviewSender(quotedRef?: QuotedRef): string | undefined {
|
|
98
|
+
if (quotedRef?.targetDirection === "outbound") {
|
|
99
|
+
return "assistant";
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function truncateBody(value: string, limit: number): string {
|
|
105
|
+
if (limit <= 0) {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
return value.length <= limit ? value : value.slice(0, limit);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fingerprintQuotedRef(quotedRef: QuotedRef | undefined): string | undefined {
|
|
112
|
+
if (!quotedRef) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const direction = quotedRef.targetDirection;
|
|
116
|
+
const key = quotedRef.key || "";
|
|
117
|
+
const value = quotedRef.value || "";
|
|
118
|
+
const fallbackCreatedAt =
|
|
119
|
+
typeof quotedRef.fallbackCreatedAt === "number" && Number.isFinite(quotedRef.fallbackCreatedAt)
|
|
120
|
+
? String(quotedRef.fallbackCreatedAt)
|
|
121
|
+
: "";
|
|
122
|
+
if (!direction) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
return JSON.stringify([direction, key, value, fallbackCreatedAt]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildChainEntry(params: {
|
|
129
|
+
record: MessageRecord;
|
|
130
|
+
depth: number;
|
|
131
|
+
remainingBodyBudget: number;
|
|
132
|
+
perHopBodyLimit: number;
|
|
133
|
+
}): QuotedChainEntry | null {
|
|
134
|
+
const limit = Math.min(params.perHopBodyLimit, params.remainingBodyBudget);
|
|
135
|
+
if (limit <= 0) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const body = truncateBody(resolveRecordBody(params.record), limit);
|
|
139
|
+
return {
|
|
140
|
+
depth: params.depth,
|
|
141
|
+
direction: params.record.direction,
|
|
142
|
+
messageType: deriveMessageType(params.record),
|
|
143
|
+
sender: deriveSender(params.record),
|
|
144
|
+
body,
|
|
145
|
+
createdAt: params.record.createdAt,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolveQuotedRuntimeContext(params: {
|
|
150
|
+
storePath?: string;
|
|
151
|
+
accountId: string;
|
|
152
|
+
conversationId: string | null;
|
|
153
|
+
quotedRef?: QuotedRef;
|
|
154
|
+
firstRecord?: MessageRecord | null;
|
|
155
|
+
firstPreview?: QuotedRuntimePreview;
|
|
156
|
+
log?: Logger;
|
|
157
|
+
maxDepth?: number;
|
|
158
|
+
perHopBodyLimit?: number;
|
|
159
|
+
totalBodyLimit?: number;
|
|
160
|
+
}): ResolvedQuotedRuntimeContext | null {
|
|
161
|
+
if (!params.quotedRef) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const maxDepth = normalizePositiveInteger(params.maxDepth, DEFAULT_MAX_DEPTH);
|
|
166
|
+
const perHopBodyLimit = normalizePositiveInteger(
|
|
167
|
+
params.perHopBodyLimit,
|
|
168
|
+
DEFAULT_PER_HOP_BODY_LIMIT,
|
|
169
|
+
);
|
|
170
|
+
const totalBodyLimit = normalizePositiveInteger(params.totalBodyLimit, DEFAULT_TOTAL_BODY_LIMIT);
|
|
171
|
+
|
|
172
|
+
const chain: QuotedChainEntry[] = [];
|
|
173
|
+
const seenRecordIds = new Set<string>();
|
|
174
|
+
const seenQuotedRefFingerprints = new Set<string>();
|
|
175
|
+
let firstResolvedRecord: MessageRecord | null = null;
|
|
176
|
+
let remainingBodyBudget = totalBodyLimit;
|
|
177
|
+
let currentQuotedRef: QuotedRef | undefined = params.quotedRef;
|
|
178
|
+
let currentRecord = params.firstRecord ?? null;
|
|
179
|
+
|
|
180
|
+
for (let depth = 1; depth <= maxDepth && currentQuotedRef; depth += 1) {
|
|
181
|
+
if (depth > 1 || !currentRecord) {
|
|
182
|
+
currentRecord = resolveQuotedRecord({
|
|
183
|
+
storePath: params.storePath,
|
|
184
|
+
accountId: params.accountId,
|
|
185
|
+
conversationId: params.conversationId,
|
|
186
|
+
quotedRef: currentQuotedRef,
|
|
187
|
+
log: params.log,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (!currentRecord) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
if (!firstResolvedRecord) {
|
|
194
|
+
firstResolvedRecord = currentRecord;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const recordId = `${currentRecord.direction}:${currentRecord.msgId}`;
|
|
198
|
+
if (seenRecordIds.has(recordId)) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
seenRecordIds.add(recordId);
|
|
202
|
+
|
|
203
|
+
const currentFingerprint = fingerprintQuotedRef(currentQuotedRef);
|
|
204
|
+
if (currentFingerprint) {
|
|
205
|
+
if (seenQuotedRefFingerprints.has(currentFingerprint)) {
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
seenQuotedRefFingerprints.add(currentFingerprint);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const entry = buildChainEntry({
|
|
212
|
+
record: currentRecord,
|
|
213
|
+
depth,
|
|
214
|
+
remainingBodyBudget,
|
|
215
|
+
perHopBodyLimit,
|
|
216
|
+
});
|
|
217
|
+
if (!entry) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
chain.push(entry);
|
|
221
|
+
remainingBodyBudget -= entry.body.length;
|
|
222
|
+
|
|
223
|
+
const nextQuotedRef = currentRecord.quotedRef;
|
|
224
|
+
if (!nextQuotedRef) {
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const nextFingerprint = fingerprintQuotedRef(nextQuotedRef);
|
|
229
|
+
if (nextFingerprint && seenQuotedRefFingerprints.has(nextFingerprint)) {
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
currentQuotedRef = nextQuotedRef;
|
|
234
|
+
currentRecord = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const replyToRecord = firstResolvedRecord ?? params.firstRecord ?? null;
|
|
238
|
+
const stableReplyToId = replyToRecord ? deriveReplyToId(replyToRecord) : undefined;
|
|
239
|
+
if (replyToRecord && stableReplyToId) {
|
|
240
|
+
return {
|
|
241
|
+
replyToId: stableReplyToId,
|
|
242
|
+
replyToBody: chain[0].body,
|
|
243
|
+
replyToSender: chain[0].sender,
|
|
244
|
+
replyToIsQuote: true,
|
|
245
|
+
chain,
|
|
246
|
+
untrustedContext:
|
|
247
|
+
chain.length > 1
|
|
248
|
+
? JSON.stringify({
|
|
249
|
+
quotedChain: chain.slice(1),
|
|
250
|
+
})
|
|
251
|
+
: undefined,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const previewBody = resolvePreviewBody(params.firstPreview);
|
|
256
|
+
const previewReplyToId = deriveReplyToIdFromQuotedRef(params.quotedRef);
|
|
257
|
+
if (!previewBody) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
replyToId: previewReplyToId,
|
|
263
|
+
replyToBody: truncateBody(previewBody, perHopBodyLimit),
|
|
264
|
+
replyToSender: derivePreviewSender(params.quotedRef),
|
|
265
|
+
replyToIsQuote: true,
|
|
266
|
+
chain: [],
|
|
267
|
+
untrustedContext: undefined,
|
|
268
|
+
};
|
|
269
|
+
}
|