@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.
@@ -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
- if (repliedMsgType === "text" && content?.text?.trim()) {
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
- prefix,
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: repliedMsg.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: repliedMsg.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: repliedMsg.msgId,
387
+ msgId: repliedMsgId,
388
+ ...repliedPreview,
259
389
  };
260
390
  }
261
391
 
262
- // Has msgType but not one we handle — generic fallback.
263
- if (repliedMsgType) {
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 { prefix: `[引用消息: "${content.text.trim()}"]\n\n` };
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?.summary) {
403
+ if (richTextQuote) {
276
404
  return {
277
- prefix: `[引用消息: "${richTextQuote.summary}"]\n\n`,
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
- const quoteText = data.quoteMessage.text?.content?.trim() || "";
294
- if (quoteText) {
295
- return { prefix: `[引用消息: "${quoteText}"]\n\n` };
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
- return { text: quotedPrefix + (data.text?.content?.trim() || ""), messageType: "text", quoted: quoted ?? undefined };
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 { text: `[聊天记录摘要] ${summary}`, messageType: "chatRecord", quoted: quoted ?? undefined };
573
+ return {
574
+ text: `[聊天记录摘要] ${summary}`,
575
+ messageType: "chatRecord",
576
+ quoted: quoted ?? undefined,
577
+ atMentions,
578
+ atUserDingtalkIds,
579
+ };
413
580
  }
414
- return { text: "[chatRecord消息: 无可读内容]", messageType: "chatRecord", quoted: quoted ?? undefined };
581
+ return {
582
+ text: "[chatRecord消息: 无可读内容]",
583
+ messageType: "chatRecord",
584
+ quoted: quoted ?? undefined,
585
+ atMentions,
586
+ atUserDingtalkIds,
587
+ };
415
588
  }
416
589
 
417
- return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype, quoted: quoted ?? undefined };
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
+ }