@yaoyuanchao/dingtalk 1.5.8 → 1.5.9

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/api.ts +25 -17
  3. package/src/monitor.ts +208 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.5.8",
3
+ "version": "1.5.9",
4
4
  "type": "module",
5
5
  "description": "DingTalk channel plugin for ClawdBot/OpenClaw with Stream Mode support",
6
6
  "license": "MIT",
package/src/api.ts CHANGED
@@ -116,7 +116,7 @@ export async function getDingTalkAccessToken(clientId: string, clientSecret: str
116
116
  export async function sendViaSessionWebhook(
117
117
  sessionWebhook: string,
118
118
  text: string,
119
- ): Promise<{ ok: boolean; errcode?: number; errmsg?: string }> {
119
+ ): Promise<{ ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string }> {
120
120
  const res = await jsonPost(sessionWebhook, {
121
121
  msgtype: "text",
122
122
  text: { content: text },
@@ -125,7 +125,7 @@ export async function sendViaSessionWebhook(
125
125
  if (!ok) {
126
126
  console.warn(`[dingtalk] SessionWebhook text error: errcode=${res?.errcode}, errmsg=${res?.errmsg}`);
127
127
  }
128
- return { ok, errcode: res?.errcode, errmsg: res?.errmsg };
128
+ return { ok, errcode: res?.errcode, errmsg: res?.errmsg, processQueryKey: res?.processQueryKey || res?.requestId };
129
129
  }
130
130
 
131
131
  /** Send markdown via sessionWebhook */
@@ -133,7 +133,7 @@ export async function sendMarkdownViaSessionWebhook(
133
133
  sessionWebhook: string,
134
134
  title: string,
135
135
  text: string,
136
- ): Promise<{ ok: boolean; errcode?: number; errmsg?: string }> {
136
+ ): Promise<{ ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string }> {
137
137
  const res = await jsonPost(sessionWebhook, {
138
138
  msgtype: "markdown",
139
139
  markdown: { title, text },
@@ -142,7 +142,7 @@ export async function sendMarkdownViaSessionWebhook(
142
142
  if (!ok) {
143
143
  console.warn(`[dingtalk] SessionWebhook markdown error: errcode=${res?.errcode}, errmsg=${res?.errmsg}`);
144
144
  }
145
- return { ok, errcode: res?.errcode, errmsg: res?.errmsg };
145
+ return { ok, errcode: res?.errcode, errmsg: res?.errmsg, processQueryKey: res?.processQueryKey || res?.requestId };
146
146
  }
147
147
 
148
148
  /** Send image via sessionWebhook using markdown format */
@@ -168,7 +168,7 @@ export async function sendDingTalkRestMessage(params: {
168
168
  conversationId?: string;
169
169
  text: string;
170
170
  format?: 'text' | 'markdown';
171
- }): Promise<{ ok: boolean }> {
171
+ }): Promise<{ ok: boolean; processQueryKey?: string }> {
172
172
  const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
173
173
  const headers = { "x-acs-dingtalk-access-token": token };
174
174
 
@@ -193,7 +193,7 @@ export async function sendDingTalkRestMessage(params: {
193
193
  if (res?.errcode && res.errcode !== 0) {
194
194
  throw new Error(`DingTalk DM send error: ${JSON.stringify(res)}`);
195
195
  }
196
- return { ok: !!res?.processQueryKey || !res?.code };
196
+ return { ok: !!res?.processQueryKey || !res?.code, processQueryKey: res?.processQueryKey || res?.result?.processQueryKey };
197
197
  }
198
198
 
199
199
  if (params.conversationId) {
@@ -210,7 +210,7 @@ export async function sendDingTalkRestMessage(params: {
210
210
  if (res?.errcode && res.errcode !== 0) {
211
211
  throw new Error(`DingTalk group send error: ${JSON.stringify(res)}`);
212
212
  }
213
- return { ok: !!res?.processQueryKey || !res?.code };
213
+ return { ok: !!res?.processQueryKey || !res?.code, processQueryKey: res?.processQueryKey || res?.result?.processQueryKey };
214
214
  }
215
215
 
216
216
  throw new Error("Either userId or conversationId required");
@@ -424,6 +424,7 @@ export async function downloadMediaFile(
424
424
  robotCode: string,
425
425
  downloadCode: string,
426
426
  mediaType?: string,
427
+ originalFileName?: string,
427
428
  ): Promise<{ filePath?: string; mimeType?: string; error?: string }> {
428
429
  try {
429
430
  const token = await getDingTalkAccessToken(clientId, clientSecret);
@@ -451,17 +452,24 @@ export async function downloadMediaFile(
451
452
  fs.mkdirSync(TEMP_DIR, { recursive: true });
452
453
  }
453
454
 
454
- // Determine file extension from content type or media type hint
455
+ // Determine file extension: prefer original filename, then content type, then media type
455
456
  const contentType = response.contentType || '';
456
- const ext = MEDIA_EXTENSIONS[contentType]
457
- || (mediaType === 'audio' ? '.amr' : undefined)
458
- || (mediaType === 'video' ? '.mp4' : undefined)
459
- || (mediaType === 'image' ? '.jpg' : undefined)
460
- || '.bin';
461
-
462
- const timestamp = Date.now();
463
- const prefix = mediaType || 'media';
464
- const filename = `${prefix}_${timestamp}${ext}`;
457
+ let filename: string;
458
+ if (originalFileName && path.extname(originalFileName)) {
459
+ // Use original filename with timestamp prefix to avoid collisions
460
+ const ext = path.extname(originalFileName);
461
+ const base = path.basename(originalFileName, ext).replace(/[^\w\u4e00-\u9fa5.-]/g, '_').slice(0, 60);
462
+ filename = `${base}_${Date.now()}${ext}`;
463
+ } else {
464
+ const ext = MEDIA_EXTENSIONS[contentType]
465
+ || (mediaType === 'audio' ? '.amr' : undefined)
466
+ || (mediaType === 'video' ? '.mp4' : undefined)
467
+ || (mediaType === 'image' ? '.jpg' : undefined)
468
+ || (originalFileName ? path.extname(originalFileName) : undefined)
469
+ || '.bin';
470
+ const prefix = mediaType || 'media';
471
+ filename = `${prefix}_${Date.now()}${ext}`;
472
+ }
465
473
  const filePath = path.join(TEMP_DIR, filename);
466
474
 
467
475
  fs.writeFileSync(filePath, mediaBuffer);
package/src/monitor.ts CHANGED
@@ -1,6 +1,94 @@
1
1
  import type { DingTalkRobotMessage, ResolvedDingTalkAccount, ExtractedMessage } from "./types.js";
2
2
  import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile, sendTypingIndicator } from "./api.js";
3
3
  import { getDingTalkRuntime } from "./runtime.js";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+
8
+ // ============================================================================
9
+ // Message Cache - used to resolve quoted message content from originalMsgId
10
+ // DingTalk Stream API does NOT include quoted content in reply callbacks;
11
+ // it only provides originalMsgId. We cache incoming/outgoing messages to look them up.
12
+ // Cache is persisted to disk so it survives gateway restarts.
13
+ // ============================================================================
14
+
15
+ interface CachedMessage {
16
+ senderNick: string;
17
+ text: string; // human-readable content
18
+ msgtype: string;
19
+ ts: number; // Date.now() at receive time
20
+ }
21
+
22
+ const MSG_CACHE_MAX = 500; // max entries to keep
23
+ const MSG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
24
+
25
+ const MSG_CACHE_FILE = path.join(os.homedir(), ".openclaw", "extensions", "dingtalk", ".cache", "msg-cache.json");
26
+
27
+ const msgCache = new Map<string, CachedMessage>();
28
+
29
+ /** Load persisted cache from disk on startup */
30
+ function loadMsgCache(): void {
31
+ try {
32
+ if (fs.existsSync(MSG_CACHE_FILE)) {
33
+ const raw = fs.readFileSync(MSG_CACHE_FILE, "utf-8");
34
+ const entries: [string, CachedMessage][] = JSON.parse(raw);
35
+ const cutoff = Date.now() - MSG_CACHE_TTL_MS;
36
+ let loaded = 0;
37
+ for (const [k, v] of entries) {
38
+ if (v.ts > cutoff) {
39
+ msgCache.set(k, v);
40
+ loaded++;
41
+ }
42
+ }
43
+ console.info(`[dingtalk] Loaded ${loaded} entries from msg cache (${MSG_CACHE_FILE})`);
44
+ }
45
+ } catch (err) {
46
+ console.warn(`[dingtalk] Failed to load msg cache: ${err}`);
47
+ }
48
+ }
49
+
50
+ /** Persist cache to disk (debounced write) */
51
+ let _saveCacheTimer: ReturnType<typeof setTimeout> | null = null;
52
+ function scheduleSaveCache(): void {
53
+ if (_saveCacheTimer) return;
54
+ _saveCacheTimer = setTimeout(() => {
55
+ _saveCacheTimer = null;
56
+ try {
57
+ const dir = path.dirname(MSG_CACHE_FILE);
58
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
59
+ const entries = [...msgCache.entries()];
60
+ fs.writeFileSync(MSG_CACHE_FILE, JSON.stringify(entries), "utf-8");
61
+ } catch (err) {
62
+ console.warn(`[dingtalk] Failed to save msg cache: ${err}`);
63
+ }
64
+ }, 2000); // debounce 2s
65
+ }
66
+
67
+ // Load cache on module init
68
+ loadMsgCache();
69
+
70
+ function msgCacheSet(msgId: string, entry: CachedMessage): void {
71
+ // Evict expired entries if cache is full
72
+ if (msgCache.size >= MSG_CACHE_MAX) {
73
+ const cutoff = Date.now() - MSG_CACHE_TTL_MS;
74
+ for (const [k, v] of msgCache) {
75
+ if (v.ts < cutoff) msgCache.delete(k);
76
+ }
77
+ // If still full, evict oldest
78
+ if (msgCache.size >= MSG_CACHE_MAX) {
79
+ const oldest = [...msgCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0];
80
+ if (oldest) msgCache.delete(oldest[0]);
81
+ }
82
+ }
83
+ msgCache.set(msgId, entry);
84
+ scheduleSaveCache();
85
+ }
86
+
87
+ /** Cache an outbound (bot-sent) message so it can be resolved when quoted */
88
+ export function cacheOutboundMessage(key: string, text: string): void {
89
+ if (!key || !text) return;
90
+ msgCacheSet(key, { senderNick: 'Jax', text, msgtype: 'text', ts: Date.now() });
91
+ }
4
92
 
5
93
  // ============================================================================
6
94
  // Message Aggregation Buffer
@@ -455,6 +543,19 @@ async function extractRichTextContent(
455
543
  } else if (item.text) {
456
544
  // DingTalk sometimes sends richText items as {text: "..."} without msgType wrapper
457
545
  parts.push(item.text);
546
+ } else if (item.msgType === "quote" || item.type === "quote") {
547
+ // Quoted/referenced message item in richText array (DingTalk v3 quote reply structure)
548
+ const quotedText = item.content?.text
549
+ || (typeof item.content === 'string' ? item.content : null)
550
+ || item.text || '';
551
+ const quotedSender = item.content?.senderNick || item.senderNick || '';
552
+ if (quotedText) {
553
+ const senderPrefix = quotedSender ? `${quotedSender}: ` : '';
554
+ parts.push(`[引用: "${senderPrefix}${String(quotedText).trim().substring(0, 120)}"]`);
555
+ log?.info?.("[dingtalk] Extracted quote item from richText array: " + String(quotedText).substring(0, 60));
556
+ } else {
557
+ log?.info?.("[dingtalk] Quote item in richText but no text content: " + JSON.stringify(item).substring(0, 200));
558
+ }
458
559
  } else if ((item.msgType === "picture" || item.pictureDownloadCode || item.downloadCode) && (item.downloadCode || item.pictureDownloadCode)) {
459
560
  const downloadCode = item.downloadCode || item.pictureDownloadCode;
460
561
  try {
@@ -530,15 +631,26 @@ async function processInboundMessage(
530
631
  const isDm = msg.conversationType === "1";
531
632
  const isGroup = msg.conversationType === "2";
532
633
 
533
- // Debug: log full message structure for debugging
534
- if (msg.msgtype === 'richText' || msg.picture || (msg.atUsers && msg.atUsers.length > 0)) {
535
- log?.info?.("[dingtalk-debug] Full message structure:");
536
- log?.info?.("[dingtalk-debug] msgtype: " + msg.msgtype);
537
- log?.info?.("[dingtalk-debug] text: " + JSON.stringify(msg.text));
538
- log?.info?.("[dingtalk-debug] richText: " + JSON.stringify(msg.richText));
539
- log?.info?.("[dingtalk-debug] picture: " + JSON.stringify(msg.picture));
540
- log?.info?.("[dingtalk-debug] atUsers: " + JSON.stringify(msg.atUsers));
541
- log?.info?.("[dingtalk-debug] RAW MESSAGE: " + JSON.stringify(msg).substring(0, 500));
634
+ // Debug: log full message structure for all inbound messages
635
+ // Especially important for catching unknown/quote reply structures
636
+ {
637
+ const hasQuoteIndicators = (msg.text as any)?.isReplyMsg
638
+ || !!(msg as any).quoteMsg
639
+ || !!(msg as any).content?.quote
640
+ || msg.msgtype === 'richText';
641
+ if (msg.msgtype === 'richText' || msg.picture || (msg.atUsers && msg.atUsers.length > 0) || hasQuoteIndicators) {
642
+ log?.info?.("[dingtalk-debug] Full message structure:");
643
+ log?.info?.("[dingtalk-debug] msgtype: " + msg.msgtype);
644
+ log?.info?.("[dingtalk-debug] text: " + JSON.stringify(msg.text));
645
+ log?.info?.("[dingtalk-debug] richText: " + JSON.stringify(msg.richText));
646
+ log?.info?.("[dingtalk-debug] picture: " + JSON.stringify(msg.picture));
647
+ log?.info?.("[dingtalk-debug] atUsers: " + JSON.stringify(msg.atUsers));
648
+ log?.info?.("[dingtalk-debug] RAW MESSAGE: " + JSON.stringify(msg).substring(0, 800));
649
+ } else {
650
+ // For regular text messages, still log a condensed version to catch unexpected fields
651
+ const msgKeys = Object.keys(msg as any).filter(k => !['conversationId','chatbotCorpId','chatbotUserId','msgId','senderNick','isAdmin','senderStaffId','sessionWebhookExpiredTime','createAt','senderCorpId','conversationType','senderId','sessionWebhook','robotCode'].includes(k));
652
+ log?.info?.("[dingtalk-debug] text msg extra fields: " + JSON.stringify(msgKeys) + " | " + JSON.stringify(msg).substring(0, 400));
653
+ }
542
654
  }
543
655
 
544
656
  // Extract message content using structured extractor
@@ -562,6 +674,7 @@ async function processInboundMessage(
562
674
  robotCode,
563
675
  extracted.mediaDownloadCode,
564
676
  extracted.mediaType,
677
+ extracted.mediaFileName, // preserve original filename for PDFs/Excel/etc
565
678
  );
566
679
  if (result.filePath) {
567
680
  mediaPath = result.filePath;
@@ -586,16 +699,52 @@ async function processInboundMessage(
586
699
 
587
700
  // If we have media but no text, provide a placeholder
588
701
  if (!rawBody && mediaPath) {
589
- rawBody = `[${extracted.messageType}] 媒体文件已下载: ${mediaPath}`;
702
+ const fileLabel = extracted.mediaFileName ? `${extracted.mediaFileName} ${mediaPath}` : mediaPath;
703
+ rawBody = `[${extracted.messageType}] 媒体文件已下载: ${fileLabel}`;
590
704
  }
591
705
 
592
- // Handle quoted/replied messages: extract the quoted content and prepend it
593
- if (msg.text && (msg.text as any).isReplyMsg) {
594
- log?.info?.("[dingtalk] Message is a reply, full text object: " + JSON.stringify(msg.text));
706
+ // Cache this message so quote replies can look it up later via originalMsgId
707
+ if (msg.msgId && rawBody) {
708
+ msgCacheSet(msg.msgId, {
709
+ senderNick: msg.senderNick || '',
710
+ text: rawBody,
711
+ msgtype: msg.msgtype,
712
+ ts: Date.now(),
713
+ });
714
+ }
595
715
 
596
- if ((msg.text as any).repliedMsg) {
716
+ // Handle quoted/replied messages: extract the quoted content and prepend it
717
+ // DingTalk uses at least three structures across API versions:
718
+ // v1 (older): msg.text.isReplyMsg=true + msg.text.repliedMsg
719
+ // v2 (stream newer): top-level msg.quoteMsg field
720
+ // v3 (richText): richText array with msgType="quote" items (handled in extractRichTextContent)
721
+ // v4 (content.quote): msg.content.quote or msg.content.referenceMessage
722
+ // v5 (cache lookup): msg.text.isReplyMsg=true + msg.originalMsgId → local cache lookup
723
+ const topLevelQuoteMsg = (msg as any).quoteMsg;
724
+ const contentQuote = (msg as any).content?.quote || (msg as any).content?.referenceMessage;
725
+ const isQuoteReply = (msg.text && (msg.text as any).isReplyMsg) || !!topLevelQuoteMsg || !!contentQuote;
726
+
727
+ if (isQuoteReply) {
728
+ log?.info?.("[dingtalk] Quote reply detected. isReplyMsg=" + !!(msg.text as any)?.isReplyMsg + " | has quoteMsg=" + !!topLevelQuoteMsg + " | has contentQuote=" + !!contentQuote);
729
+ log?.info?.("[dingtalk] Full message for quote debug: " + JSON.stringify(msg).substring(0, 1500));
730
+
731
+ const repliedMsgSource = (msg.text as any)?.repliedMsg || topLevelQuoteMsg || contentQuote;
732
+
733
+ // v5: cache lookup via originalMsgId (DingTalk Stream only provides originalMsgId, not content)
734
+ const originalMsgId = (msg as any).originalMsgId as string | undefined;
735
+ // v6: cache lookup via originalProcessQueryKey (for bot-sent outbound messages)
736
+ const originalProcessQueryKey = (msg as any).originalProcessQueryKey as string | undefined;
737
+ const cachedQuoted = (originalMsgId ? msgCache.get(originalMsgId) : undefined)
738
+ || (originalProcessQueryKey ? msgCache.get(originalProcessQueryKey) : undefined);
739
+
740
+ if (cachedQuoted) {
741
+ const resolvedBy = (originalMsgId && msgCache.has(originalMsgId)) ? `msgId=${originalMsgId}` : `pqk=${originalProcessQueryKey}`;
742
+ log?.info?.("[dingtalk] Quote reply resolved from cache (" + resolvedBy + ") sender=" + cachedQuoted.senderNick);
743
+ const senderTag = cachedQuoted.senderNick ? ` (${cachedQuoted.senderNick})` : '';
744
+ rawBody = `[引用回复${senderTag}: "${cachedQuoted.text.trim().substring(0, 200)}"]\n${rawBody}`;
745
+ } else if (repliedMsgSource) {
597
746
  try {
598
- const repliedMsg = (msg.text as any).repliedMsg;
747
+ const repliedMsg = repliedMsgSource;
599
748
  let quotedContent = "";
600
749
 
601
750
  // Extract quoted message content
@@ -637,19 +786,35 @@ async function processInboundMessage(
637
786
  quotedContent = repliedMsg.content.text;
638
787
  } else if (typeof repliedMsg.content === "string") {
639
788
  quotedContent = repliedMsg.content;
789
+ } else if (repliedMsg.text && typeof repliedMsg.text === "string") {
790
+ // Some DingTalk versions put quoted text directly in .text
791
+ quotedContent = repliedMsg.text;
792
+ } else if (repliedMsg.text?.content) {
793
+ // Or nested under .text.content
794
+ quotedContent = repliedMsg.text.content;
795
+ } else if (repliedMsg.body) {
796
+ // Yet another possible format
797
+ quotedContent = typeof repliedMsg.body === "string" ? repliedMsg.body : JSON.stringify(repliedMsg.body);
798
+ } else if (typeof repliedMsg === "string") {
799
+ // repliedMsgSource itself might be a plain string
800
+ quotedContent = repliedMsg;
640
801
  }
641
802
 
803
+ // Extract sender info if available
804
+ const quoteSender = repliedMsg.senderNick || repliedMsg.senderName || repliedMsg.content?.senderNick || '';
805
+
642
806
  if (quotedContent) {
643
- rawBody = `[引用回复: "${quotedContent.trim()}"]\n${rawBody}`;
807
+ const senderTag = quoteSender ? ` (${quoteSender})` : '';
808
+ rawBody = `[引用回复${senderTag}: "${quotedContent.trim()}"]\n${rawBody}`;
644
809
  log?.info?.("[dingtalk] Added quoted message: " + quotedContent.slice(0, 50));
645
810
  } else {
646
- log?.info?.("[dingtalk] Reply message found but no content extracted, repliedMsg: " + JSON.stringify(repliedMsg));
811
+ log?.warn?.("[dingtalk] Reply message found but no content extracted, repliedMsg keys: " + Object.keys(repliedMsg || {}).join(',') + " | full: " + JSON.stringify(repliedMsg).substring(0, 500));
647
812
  }
648
813
  } catch (err) {
649
814
  log?.info?.("[dingtalk] Failed to extract quoted message: " + err);
650
815
  }
651
816
  } else {
652
- log?.info?.("[dingtalk] Message marked as reply but no repliedMsg field found");
817
+ log?.info?.("[dingtalk] Quote reply: no inline content and originalMsgId=" + (originalMsgId || 'none') + " not in cache (cache size=" + msgCache.size + "). Full msg: " + JSON.stringify(msg).substring(0, 800));
653
818
  }
654
819
  }
655
820
 
@@ -1141,6 +1306,7 @@ async function dispatchWithFullPipeline(params: {
1141
1306
  });
1142
1307
 
1143
1308
  // 9. Dispatch reply from config
1309
+ const dispatchStartMs = Date.now();
1144
1310
  try {
1145
1311
  await rt.channel.reply.dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyOptions });
1146
1312
  } finally {
@@ -1148,6 +1314,18 @@ async function dispatchWithFullPipeline(params: {
1148
1314
  // Recall typing indicator if no reply was sent (queued or error)
1149
1315
  if (!firstReplyFired && onFirstReply) {
1150
1316
  await onFirstReply().catch(() => {});
1317
+ // Fast-fail detection: if dispatch finished < 2s with no delivery,
1318
+ // it's likely a transient internal error (e.g. session store lock timeout).
1319
+ // Send a visible fallback so the user knows to resend.
1320
+ const elapsedMs = Date.now() - dispatchStartMs;
1321
+ if (elapsedMs < 2000) {
1322
+ log?.warn?.(`[dingtalk] Fast-fail detected: dispatch completed in ${elapsedMs}ms with no delivery`);
1323
+ try {
1324
+ await deliverReply(replyTarget, '⚠️ 消息处理异常,请重发(内部错误,已记录)', log);
1325
+ } catch (notifyErr: any) {
1326
+ log?.warn?.('[dingtalk] Fast-fail notification delivery failed: ' + notifyErr?.message);
1327
+ }
1328
+ }
1151
1329
  }
1152
1330
  }
1153
1331
 
@@ -1274,7 +1452,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1274
1452
  try {
1275
1453
  log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
1276
1454
  log?.info?.("[dingtalk] Sending text (" + chunk.length + " chars): " + chunk.substring(0, 200));
1277
- let sendResult: { ok: boolean; errcode?: number; errmsg?: string };
1455
+ let sendResult: { ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string };
1278
1456
  if (isMarkdown) {
1279
1457
  const markdownTitle = buildMarkdownPreviewTitle(chunk, "Jax");
1280
1458
  sendResult = await sendMarkdownViaSessionWebhook(target.sessionWebhook, markdownTitle, chunk);
@@ -1284,7 +1462,11 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1284
1462
  if (!sendResult.ok) {
1285
1463
  throw new Error(`SessionWebhook rejected: errcode=${sendResult.errcode}, errmsg=${sendResult.errmsg}`);
1286
1464
  }
1287
- log?.info?.("[dingtalk] SessionWebhook send OK (errcode=" + (sendResult.errcode ?? 0) + ")");
1465
+ log?.info?.("[dingtalk] SessionWebhook send OK (errcode=" + (sendResult.errcode ?? 0) + (sendResult.processQueryKey ? ` pqk=${sendResult.processQueryKey}` : '') + ")");
1466
+ // Cache outbound message so it can be resolved when quoted
1467
+ if (sendResult.processQueryKey) {
1468
+ cacheOutboundMessage(sendResult.processQueryKey, chunk);
1469
+ }
1288
1470
  webhookSuccess = true;
1289
1471
  break;
1290
1472
  } catch (err) {
@@ -1303,7 +1485,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1303
1485
  log?.info?.("[dingtalk] SessionWebhook failed after " + maxRetries + " attempts, using REST API fallback");
1304
1486
  // REST API only supports text format
1305
1487
  const textChunk = messageFormat === "markdown" ? chunk : chunk;
1306
- await sendDingTalkRestMessage({
1488
+ const restResult = await sendDingTalkRestMessage({
1307
1489
  clientId: target.account.clientId,
1308
1490
  clientSecret: target.account.clientSecret,
1309
1491
  robotCode: target.account.robotCode || target.account.clientId,
@@ -1311,7 +1493,11 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1311
1493
  conversationId: !target.isDm ? target.conversationId : undefined,
1312
1494
  text: textChunk,
1313
1495
  });
1314
- log?.info?.("[dingtalk] REST API send OK");
1496
+ log?.info?.("[dingtalk] REST API send OK" + (restResult.processQueryKey ? ` pqk=${restResult.processQueryKey}` : ''));
1497
+ // Cache outbound message so it can be resolved when quoted
1498
+ if (restResult.processQueryKey) {
1499
+ cacheOutboundMessage(restResult.processQueryKey, textChunk);
1500
+ }
1315
1501
  } catch (err) {
1316
1502
  log?.info?.("[dingtalk] REST API also failed: " + (err instanceof Error ? err.stack : JSON.stringify(err)));
1317
1503
  }