@yaoyuanchao/dingtalk 1.5.8 → 1.5.10

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 +236 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaoyuanchao/dingtalk",
3
- "version": "1.5.8",
3
+ "version": "1.5.10",
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;
@@ -579,23 +692,73 @@ async function processInboundMessage(
579
692
 
580
693
  let rawBody = extracted.text;
581
694
 
582
- if (!rawBody && !mediaPath) {
695
+ // Check if this might be a quote-only @mention (user quoted a message and @bot with no extra text)
696
+ // DingTalk strips @mention from text, leaving rawBody empty. We must NOT early-return here
697
+ // because the quote resolution below will populate rawBody from the cache.
698
+ const _hasOriginalMsgId = !!(msg as any).originalMsgId || !!(msg as any).originalProcessQueryKey;
699
+ const _hasTopLevelQuote = !!(msg as any).quoteMsg || !!(msg as any).content?.quote || !!(msg as any).content?.referenceMessage;
700
+ const _isLikelyQuoteReply = ((msg.text as any)?.isReplyMsg) || _hasTopLevelQuote || _hasOriginalMsgId;
701
+
702
+ if (!rawBody && !mediaPath && !_isLikelyQuoteReply) {
583
703
  log?.info?.("[dingtalk] Empty message body after all attempts, skipping. msgtype=" + msg.msgtype);
584
704
  return;
585
705
  }
586
706
 
587
- // If we have media but no text, provide a placeholder
707
+ // If rawBody is still empty after quote resolution (e.g. cache miss and no inline quote),
708
+ // and there's no media, drop the message.
709
+ if (!rawBody?.trim() && !mediaPath) {
710
+ log?.info?.("[dingtalk] Empty message body after quote resolution, skipping.");
711
+ return;
712
+ }
713
+
714
+ // If media present but rawBody still empty, provide placeholder
588
715
  if (!rawBody && mediaPath) {
589
- rawBody = `[${extracted.messageType}] 媒体文件已下载: ${mediaPath}`;
716
+ const fileLabel = extracted.mediaFileName ? `${extracted.mediaFileName} ${mediaPath}` : mediaPath;
717
+ rawBody = `[${extracted.messageType}] 媒体文件已下载: ${fileLabel}`;
590
718
  }
591
719
 
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));
720
+ // Cache this message so quote replies can look it up later via originalMsgId
721
+ if (msg.msgId && rawBody) {
722
+ msgCacheSet(msg.msgId, {
723
+ senderNick: msg.senderNick || '',
724
+ text: rawBody,
725
+ msgtype: msg.msgtype,
726
+ ts: Date.now(),
727
+ });
728
+ }
595
729
 
596
- if ((msg.text as any).repliedMsg) {
730
+ // Handle quoted/replied messages: extract the quoted content and prepend it
731
+ // DingTalk uses at least three structures across API versions:
732
+ // v1 (older): msg.text.isReplyMsg=true + msg.text.repliedMsg
733
+ // v2 (stream newer): top-level msg.quoteMsg field
734
+ // v3 (richText): richText array with msgType="quote" items (handled in extractRichTextContent)
735
+ // v4 (content.quote): msg.content.quote or msg.content.referenceMessage
736
+ // v5 (cache lookup): msg.text.isReplyMsg=true + msg.originalMsgId → local cache lookup
737
+ const topLevelQuoteMsg = (msg as any).quoteMsg;
738
+ const contentQuote = (msg as any).content?.quote || (msg as any).content?.referenceMessage;
739
+ const isQuoteReply = (msg.text && (msg.text as any).isReplyMsg) || !!topLevelQuoteMsg || !!contentQuote;
740
+
741
+ if (isQuoteReply) {
742
+ log?.info?.("[dingtalk] Quote reply detected. isReplyMsg=" + !!(msg.text as any)?.isReplyMsg + " | has quoteMsg=" + !!topLevelQuoteMsg + " | has contentQuote=" + !!contentQuote);
743
+ log?.info?.("[dingtalk] Full message for quote debug: " + JSON.stringify(msg).substring(0, 1500));
744
+
745
+ const repliedMsgSource = (msg.text as any)?.repliedMsg || topLevelQuoteMsg || contentQuote;
746
+
747
+ // v5: cache lookup via originalMsgId (DingTalk Stream only provides originalMsgId, not content)
748
+ const originalMsgId = (msg as any).originalMsgId as string | undefined;
749
+ // v6: cache lookup via originalProcessQueryKey (for bot-sent outbound messages)
750
+ const originalProcessQueryKey = (msg as any).originalProcessQueryKey as string | undefined;
751
+ const cachedQuoted = (originalMsgId ? msgCache.get(originalMsgId) : undefined)
752
+ || (originalProcessQueryKey ? msgCache.get(originalProcessQueryKey) : undefined);
753
+
754
+ if (cachedQuoted) {
755
+ const resolvedBy = (originalMsgId && msgCache.has(originalMsgId)) ? `msgId=${originalMsgId}` : `pqk=${originalProcessQueryKey}`;
756
+ log?.info?.("[dingtalk] Quote reply resolved from cache (" + resolvedBy + ") sender=" + cachedQuoted.senderNick);
757
+ const senderTag = cachedQuoted.senderNick ? ` (${cachedQuoted.senderNick})` : '';
758
+ rawBody = `[引用回复${senderTag}: "${cachedQuoted.text.trim().substring(0, 200)}"]\n${rawBody}`;
759
+ } else if (repliedMsgSource) {
597
760
  try {
598
- const repliedMsg = (msg.text as any).repliedMsg;
761
+ const repliedMsg = repliedMsgSource;
599
762
  let quotedContent = "";
600
763
 
601
764
  // Extract quoted message content
@@ -637,19 +800,35 @@ async function processInboundMessage(
637
800
  quotedContent = repliedMsg.content.text;
638
801
  } else if (typeof repliedMsg.content === "string") {
639
802
  quotedContent = repliedMsg.content;
803
+ } else if (repliedMsg.text && typeof repliedMsg.text === "string") {
804
+ // Some DingTalk versions put quoted text directly in .text
805
+ quotedContent = repliedMsg.text;
806
+ } else if (repliedMsg.text?.content) {
807
+ // Or nested under .text.content
808
+ quotedContent = repliedMsg.text.content;
809
+ } else if (repliedMsg.body) {
810
+ // Yet another possible format
811
+ quotedContent = typeof repliedMsg.body === "string" ? repliedMsg.body : JSON.stringify(repliedMsg.body);
812
+ } else if (typeof repliedMsg === "string") {
813
+ // repliedMsgSource itself might be a plain string
814
+ quotedContent = repliedMsg;
640
815
  }
641
816
 
817
+ // Extract sender info if available
818
+ const quoteSender = repliedMsg.senderNick || repliedMsg.senderName || repliedMsg.content?.senderNick || '';
819
+
642
820
  if (quotedContent) {
643
- rawBody = `[引用回复: "${quotedContent.trim()}"]\n${rawBody}`;
821
+ const senderTag = quoteSender ? ` (${quoteSender})` : '';
822
+ rawBody = `[引用回复${senderTag}: "${quotedContent.trim()}"]\n${rawBody}`;
644
823
  log?.info?.("[dingtalk] Added quoted message: " + quotedContent.slice(0, 50));
645
824
  } else {
646
- log?.info?.("[dingtalk] Reply message found but no content extracted, repliedMsg: " + JSON.stringify(repliedMsg));
825
+ log?.warn?.("[dingtalk] Reply message found but no content extracted, repliedMsg keys: " + Object.keys(repliedMsg || {}).join(',') + " | full: " + JSON.stringify(repliedMsg).substring(0, 500));
647
826
  }
648
827
  } catch (err) {
649
828
  log?.info?.("[dingtalk] Failed to extract quoted message: " + err);
650
829
  }
651
830
  } else {
652
- log?.info?.("[dingtalk] Message marked as reply but no repliedMsg field found");
831
+ 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
832
  }
654
833
  }
655
834
 
@@ -1145,7 +1324,10 @@ async function dispatchWithFullPipeline(params: {
1145
1324
  await rt.channel.reply.dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyOptions });
1146
1325
  } finally {
1147
1326
  markDispatchIdle();
1148
- // Recall typing indicator if no reply was sent (queued or error)
1327
+ // Recall typing indicator if no reply was sent (queued or error).
1328
+ // Note: when a message is queued (session has active run), dispatch returns
1329
+ // quickly with no delivery — this is normal, not an error. The queued message
1330
+ // will be processed after the current run completes.
1149
1331
  if (!firstReplyFired && onFirstReply) {
1150
1332
  await onFirstReply().catch(() => {});
1151
1333
  }
@@ -1255,6 +1437,9 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1255
1437
  processedText = convertImageUrlsToMarkdown(processedText);
1256
1438
  }
1257
1439
 
1440
+ // Fix DingTalk emoji rendering bug: emojis adjacent to CJK chars swallow neighbors
1441
+ processedText = fixEmojiCjkSpacing(processedText);
1442
+
1258
1443
  const chunks: string[] = [];
1259
1444
  if (processedText.length <= chunkLimit) {
1260
1445
  chunks.push(processedText);
@@ -1274,7 +1459,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1274
1459
  try {
1275
1460
  log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
1276
1461
  log?.info?.("[dingtalk] Sending text (" + chunk.length + " chars): " + chunk.substring(0, 200));
1277
- let sendResult: { ok: boolean; errcode?: number; errmsg?: string };
1462
+ let sendResult: { ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string };
1278
1463
  if (isMarkdown) {
1279
1464
  const markdownTitle = buildMarkdownPreviewTitle(chunk, "Jax");
1280
1465
  sendResult = await sendMarkdownViaSessionWebhook(target.sessionWebhook, markdownTitle, chunk);
@@ -1284,7 +1469,11 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1284
1469
  if (!sendResult.ok) {
1285
1470
  throw new Error(`SessionWebhook rejected: errcode=${sendResult.errcode}, errmsg=${sendResult.errmsg}`);
1286
1471
  }
1287
- log?.info?.("[dingtalk] SessionWebhook send OK (errcode=" + (sendResult.errcode ?? 0) + ")");
1472
+ log?.info?.("[dingtalk] SessionWebhook send OK (errcode=" + (sendResult.errcode ?? 0) + (sendResult.processQueryKey ? ` pqk=${sendResult.processQueryKey}` : '') + ")");
1473
+ // Cache outbound message so it can be resolved when quoted
1474
+ if (sendResult.processQueryKey) {
1475
+ cacheOutboundMessage(sendResult.processQueryKey, chunk);
1476
+ }
1288
1477
  webhookSuccess = true;
1289
1478
  break;
1290
1479
  } catch (err) {
@@ -1303,7 +1492,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1303
1492
  log?.info?.("[dingtalk] SessionWebhook failed after " + maxRetries + " attempts, using REST API fallback");
1304
1493
  // REST API only supports text format
1305
1494
  const textChunk = messageFormat === "markdown" ? chunk : chunk;
1306
- await sendDingTalkRestMessage({
1495
+ const restResult = await sendDingTalkRestMessage({
1307
1496
  clientId: target.account.clientId,
1308
1497
  clientSecret: target.account.clientSecret,
1309
1498
  robotCode: target.account.robotCode || target.account.clientId,
@@ -1311,7 +1500,11 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1311
1500
  conversationId: !target.isDm ? target.conversationId : undefined,
1312
1501
  text: textChunk,
1313
1502
  });
1314
- log?.info?.("[dingtalk] REST API send OK");
1503
+ log?.info?.("[dingtalk] REST API send OK" + (restResult.processQueryKey ? ` pqk=${restResult.processQueryKey}` : ''));
1504
+ // Cache outbound message so it can be resolved when quoted
1505
+ if (restResult.processQueryKey) {
1506
+ cacheOutboundMessage(restResult.processQueryKey, textChunk);
1507
+ }
1315
1508
  } catch (err) {
1316
1509
  log?.info?.("[dingtalk] REST API also failed: " + (err instanceof Error ? err.stack : JSON.stringify(err)));
1317
1510
  }
@@ -1373,9 +1566,25 @@ async function sendTextAsFile(target: any, text: string, log?: any): Promise<boo
1373
1566
  }
1374
1567
 
1375
1568
  /**
1376
- * Convert bare image URLs to markdown image syntax
1377
- * Detects patterns like "图1: https://..." or "https://...png" and converts to ![](url)
1569
+ * Fix DingTalk emoji rendering bug: emojis adjacent to CJK characters cause
1570
+ * the renderer to "swallow" neighboring characters (e.g. "商✅化" eats "业").
1571
+ * Solution: ensure there's a space between emoji and CJK characters.
1378
1572
  */
1573
+ function fixEmojiCjkSpacing(text: string): string {
1574
+ // Emoji Unicode ranges (covers most common emoji blocks)
1575
+ const emojiRe = /(\p{Emoji_Presentation}|\p{Extended_Pictographic})/gu;
1576
+ const cjkRe = /[\u4e00-\u9fff\u3400-\u4dbf\uff00-\uffef\u3000-\u303f]/;
1577
+
1578
+ return text.replace(emojiRe, (emoji, _m, offset, str) => {
1579
+ const before = offset > 0 ? str[offset - 1] : '';
1580
+ const after = str[offset + emoji.length] ?? '';
1581
+ const padLeft = cjkRe.test(before) && before !== ' ' ? ' ' : '';
1582
+ const padRight = cjkRe.test(after) && after !== ' ' ? ' ' : '';
1583
+ return padLeft + emoji + padRight;
1584
+ });
1585
+ }
1586
+
1587
+
1379
1588
  function convertImageUrlsToMarkdown(text: string): string {
1380
1589
  // Pattern 1: "图X: https://..." format (common Agent output)
1381
1590
  text = text.replace(/图(\d+):\s*(https?:\/\/[^\s]+\.(png|jpg|jpeg|gif|webp)(\?[^\s]*)?)/gi, (match, num, url) => {