@yaoyuanchao/dingtalk 1.5.10 → 1.6.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/src/monitor.ts CHANGED
@@ -1,10 +1,51 @@
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 { cacheInboundDownloadCode, getCachedDownloadCode } from "./quoted-msg-cache.js";
5
+ import { resolveQuotedFile } from "./quoted-file-service.js";
4
6
  import * as fs from "fs";
5
7
  import * as path from "path";
6
8
  import * as os from "os";
7
9
 
10
+ // ============================================================================
11
+ // Message Deduplication - prevent duplicate processing after Stream reconnect.
12
+ // DingTalk re-delivers messages that weren't ACK'd before disconnect using the
13
+ // same protocol messageId, so we track recently processed IDs.
14
+ // ============================================================================
15
+
16
+ const DEDUP_MAX_SIZE = 1000;
17
+ const DEDUP_TTL_MS = 10 * 60 * 1000; // 10 minutes
18
+
19
+ const processedMessageIds = new Map<string, number>(); // messageId → timestamp
20
+
21
+ function isDuplicateMessage(messageId: string): boolean {
22
+ if (!messageId) return false;
23
+ return processedMessageIds.has(messageId);
24
+ }
25
+
26
+ function markMessageProcessed(messageId: string): void {
27
+ if (!messageId) return;
28
+ processedMessageIds.set(messageId, Date.now());
29
+
30
+ // Evict expired entries when cache exceeds max size
31
+ if (processedMessageIds.size > DEDUP_MAX_SIZE) {
32
+ const cutoff = Date.now() - DEDUP_TTL_MS;
33
+ for (const [id, ts] of processedMessageIds) {
34
+ if (ts < cutoff) processedMessageIds.delete(id);
35
+ }
36
+ // If still over limit after TTL eviction, drop oldest
37
+ if (processedMessageIds.size > DEDUP_MAX_SIZE) {
38
+ const overflow = processedMessageIds.size - DEDUP_MAX_SIZE;
39
+ let removed = 0;
40
+ for (const id of processedMessageIds.keys()) {
41
+ if (removed >= overflow) break;
42
+ processedMessageIds.delete(id);
43
+ removed++;
44
+ }
45
+ }
46
+ }
47
+ }
48
+
8
49
  // ============================================================================
9
50
  // Message Cache - used to resolve quoted message content from originalMsgId
10
51
  // DingTalk Stream API does NOT include quoted content in reply callbacks;
@@ -26,6 +67,15 @@ const MSG_CACHE_FILE = path.join(os.homedir(), ".openclaw", "extensions", "dingt
26
67
 
27
68
  const msgCache = new Map<string, CachedMessage>();
28
69
 
70
+ /** Time-indexed outbound message cache for interactiveCard quote resolution.
71
+ * Key = send timestamp (ms), value = message text.
72
+ * When a quote comes in with msgType=interactiveCard (no content), we look up
73
+ * the closest outbound message by repliedMsg.createdAt within OUTBOUND_TIME_WINDOW_MS.
74
+ */
75
+ const outboundByTime: Array<{ ts: number; text: string }> = [];
76
+ const OUTBOUND_TIME_CACHE_MAX = 100;
77
+ const OUTBOUND_TIME_WINDOW_MS = 10_000; // ±10s tolerance
78
+
29
79
  /** Load persisted cache from disk on startup */
30
80
  function loadMsgCache(): void {
31
81
  try {
@@ -87,7 +137,34 @@ function msgCacheSet(msgId: string, entry: CachedMessage): void {
87
137
  /** Cache an outbound (bot-sent) message so it can be resolved when quoted */
88
138
  export function cacheOutboundMessage(key: string, text: string): void {
89
139
  if (!key || !text) return;
90
- msgCacheSet(key, { senderNick: 'Jax', text, msgtype: 'text', ts: Date.now() });
140
+ const now = Date.now();
141
+ msgCacheSet(key, { senderNick: 'Jax', text, msgtype: 'text', ts: now });
142
+ // Also cache by timestamp for interactiveCard quote resolution
143
+ outboundByTime.push({ ts: now, text });
144
+ if (outboundByTime.length > OUTBOUND_TIME_CACHE_MAX) outboundByTime.shift();
145
+ }
146
+
147
+ /** Cache an outbound message by timestamp only (for sessionWebhook sends that return no key) */
148
+ export function cacheOutboundMessageByTime(text: string): void {
149
+ if (!text) return;
150
+ outboundByTime.push({ ts: Date.now(), text });
151
+ if (outboundByTime.length > OUTBOUND_TIME_CACHE_MAX) outboundByTime.shift();
152
+ }
153
+
154
+ /** Look up an outbound message by DingTalk createdAt timestamp (for interactiveCard quotes) */
155
+ function resolveOutboundByTime(createdAt: number): string | undefined {
156
+ if (!createdAt || outboundByTime.length === 0) return undefined;
157
+ // Find the closest entry within the tolerance window
158
+ let best: { ts: number; text: string } | undefined;
159
+ let bestDiff = Infinity;
160
+ for (const entry of outboundByTime) {
161
+ const diff = Math.abs(entry.ts - createdAt);
162
+ if (diff < OUTBOUND_TIME_WINDOW_MS && diff < bestDiff) {
163
+ best = entry;
164
+ bestDiff = diff;
165
+ }
166
+ }
167
+ return best?.text;
91
168
  }
92
169
 
93
170
  // ============================================================================
@@ -166,15 +243,39 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
166
243
  const client = new DWClient({
167
244
  clientId: account.clientId,
168
245
  clientSecret: account.clientSecret,
246
+ keepAlive: true, // Enable SDK ping/pong for socket-level liveness
247
+ autoReconnect: false, // We manage reconnection with exponential backoff
169
248
  });
170
249
 
250
+ // Reconnection configuration
251
+ const HEARTBEAT_CHECK_MS = 30_000; // Check connectivity every 30s
252
+ const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 min no activity = force reconnect
253
+ const RECONNECT_BASE_MS = 1_000; // 1s initial backoff
254
+ const RECONNECT_CAP_MS = 30_000; // 30s max backoff
255
+ let reconnectAttempt = 0;
256
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
257
+ let lastActivityTime = Date.now();
258
+
259
+ // Track message activity to extend heartbeat window
260
+ const touchActivity = () => { lastActivityTime = Date.now(); };
261
+
171
262
  client.registerCallbackListener(TOPIC_ROBOT, async (downstream: any) => {
263
+ const protocolMsgId = downstream.headers?.messageId;
264
+
172
265
  // Immediately ACK to prevent DingTalk from retrying (60s timeout)
173
- // SDK method is socketCallBackResponse, not socketResponse
174
266
  try {
175
- client.socketCallBackResponse(downstream.headers.messageId, { status: 'SUCCESS' });
267
+ client.socketCallBackResponse(protocolMsgId, { status: 'SUCCESS' });
176
268
  } catch (_) { /* best-effort ACK */ }
177
269
 
270
+ touchActivity(); // Track message activity for heartbeat
271
+
272
+ // Deduplication: skip messages already processed (e.g. re-delivered after reconnect)
273
+ if (isDuplicateMessage(protocolMsgId)) {
274
+ log?.info?.("[dingtalk] Duplicate message skipped: " + protocolMsgId);
275
+ return { status: "SUCCESS", message: "OK" };
276
+ }
277
+ markMessageProcessed(protocolMsgId);
278
+
178
279
  try {
179
280
  const data: DingTalkRobotMessage = typeof downstream.data === "string"
180
281
  ? JSON.parse(downstream.data) : downstream.data;
@@ -190,28 +291,76 @@ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise
190
291
  return { status: "SUCCESS", message: "OK" };
191
292
  });
192
293
 
193
- const onAbort = () => {
194
- try { client.disconnect?.(); } catch {}
195
- setStatus?.({ running: false, lastStopAt: Date.now() });
196
- };
197
- if (abortSignal) {
198
- abortSignal.addEventListener("abort", onAbort, { once: true });
199
- }
200
-
201
- await client.connect();
202
- log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
203
- setStatus?.({ running: true, lastStartAt: Date.now() });
204
-
205
- // Keep this function alive until abort signal fires.
294
+ // ============================================================================
295
+ // Connection loop with custom heartbeat + exponential backoff reconnection.
296
+ // Keeps this function alive until abort signal fires.
206
297
  // If we return, OpenClaw considers the channel "stopped" and enters auto-restart loop.
207
- // The DingTalk SDK's connect() resolves immediately (before WebSocket opens),
208
- // so we must hold the Promise pending for the channel's entire lifetime.
209
- await new Promise<void>((resolve) => {
210
- if (abortSignal?.aborted) return resolve();
211
- if (abortSignal) {
212
- abortSignal.addEventListener("abort", () => resolve(), { once: true });
298
+ // ============================================================================
299
+ while (!abortSignal?.aborted) {
300
+ try {
301
+ await client.connect();
302
+ reconnectAttempt = 0;
303
+ lastActivityTime = Date.now();
304
+ log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
305
+ setStatus?.({ running: true, lastStartAt: Date.now() });
306
+
307
+ // Start heartbeat monitor: if no activity for 5 minutes, force disconnect to trigger reconnect.
308
+ // The SDK's keepAlive ping/pong (8s interval) handles socket-level liveness and sets
309
+ // client.connected=false on missed pongs, which our poll loop below detects.
310
+ // This heartbeat is a secondary safety net for higher-level silent failures where
311
+ // the socket stays open but DingTalk stops delivering messages.
312
+ heartbeatTimer = setInterval(() => {
313
+ const elapsed = Date.now() - lastActivityTime;
314
+ if (elapsed > HEARTBEAT_TIMEOUT_MS) {
315
+ log?.warn?.("[dingtalk] Heartbeat timeout (" + Math.round(elapsed / 1000) + "s since last activity), forcing reconnect");
316
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
317
+ try { client.disconnect?.(); } catch {}
318
+ }
319
+ }, HEARTBEAT_CHECK_MS);
320
+
321
+ // Wait for disconnect or abort
322
+ await new Promise<void>((resolve) => {
323
+ // Poll client.connected (SDK sets false on socket close / system disconnect)
324
+ const pollTimer = setInterval(() => {
325
+ if (!client.connected) { clearInterval(pollTimer); resolve(); }
326
+ }, 1000);
327
+ if (abortSignal) {
328
+ abortSignal.addEventListener("abort", () => {
329
+ clearInterval(pollTimer);
330
+ resolve();
331
+ }, { once: true });
332
+ }
333
+ });
334
+ } catch (err) {
335
+ log?.warn?.("[dingtalk] Connection error: " + (err instanceof Error ? err.message : String(err)));
213
336
  }
214
- });
337
+
338
+ // Clean up heartbeat
339
+ if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
340
+
341
+ if (abortSignal?.aborted) break; // Clean shutdown
342
+
343
+ setStatus?.({ running: false });
344
+
345
+ // Exponential backoff with jitter
346
+ reconnectAttempt++;
347
+ const backoff = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempt - 1), RECONNECT_CAP_MS);
348
+ const jitter = Math.random() * backoff * 0.3;
349
+ const delay = Math.round(backoff + jitter);
350
+ log?.info?.("[dingtalk] Reconnect attempt " + reconnectAttempt + " in " + delay + "ms");
351
+
352
+ // Sleep with abort-awareness
353
+ await new Promise<void>((resolve) => {
354
+ const timer = setTimeout(resolve, delay);
355
+ if (abortSignal) {
356
+ abortSignal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
357
+ }
358
+ });
359
+ }
360
+
361
+ // Final cleanup
362
+ try { client.disconnect?.(); } catch {}
363
+ setStatus?.({ running: false, lastStopAt: Date.now() });
215
364
  }
216
365
 
217
366
  /**
@@ -690,6 +839,15 @@ async function processInboundMessage(
690
839
  log?.info?.("[dingtalk] Audio ASR text available, skipping .amr download");
691
840
  }
692
841
 
842
+ // Cache inbound file/video/audio downloadCode for quoted-message fallback
843
+ if (extracted.mediaDownloadCode && msg.msgId && msg.conversationId &&
844
+ (extracted.messageType === 'file' || extracted.messageType === 'video' || extracted.messageType === 'audio')) {
845
+ cacheInboundDownloadCode(
846
+ account.accountId, msg.conversationId, msg.msgId,
847
+ extracted.mediaDownloadCode, extracted.messageType, msg.createAt,
848
+ );
849
+ }
850
+
693
851
  let rawBody = extracted.text;
694
852
 
695
853
  // Check if this might be a quote-only @mention (user quoted a message and @bot with no extra text)
@@ -821,6 +979,120 @@ async function processInboundMessage(
821
979
  const senderTag = quoteSender ? ` (${quoteSender})` : '';
822
980
  rawBody = `[引用回复${senderTag}: "${quotedContent.trim()}"]\n${rawBody}`;
823
981
  log?.info?.("[dingtalk] Added quoted message: " + quotedContent.slice(0, 50));
982
+ } else if (repliedMsg.msgType === 'interactiveCard' && repliedMsg.createdAt) {
983
+ // interactiveCard: DingTalk doesn't include content in quote payload.
984
+ // Try timestamp-based lookup against our outbound message cache.
985
+ const timeResolved = resolveOutboundByTime(repliedMsg.createdAt);
986
+ if (timeResolved) {
987
+ rawBody = `[引用回复 (Jax): "${timeResolved.trim().substring(0, 200)}"]\n${rawBody}`;
988
+ log?.info?.("[dingtalk] Resolved interactiveCard quote via timestamp (createdAt=" + repliedMsg.createdAt + "): " + timeResolved.slice(0, 50));
989
+ } else {
990
+ log?.warn?.("[dingtalk] interactiveCard quote: no timestamp match in outbound cache (createdAt=" + repliedMsg.createdAt + ", cache size=" + outboundByTime.length + ")");
991
+ }
992
+ } else if (['file', 'video', 'audio', 'unknownMsgType'].includes(repliedMsg.msgType)) {
993
+ // Quoted file/video/audio message — bot may not have seen the original.
994
+ // 'unknownMsgType' is returned by DingTalk for files sent via drag-and-drop
995
+ // (without @bot), so the bot never indexed the original message type.
996
+ // Fallback chain: inline downloadCode → cache → group file API
997
+ log?.info?.("[dingtalk] Quoted file-type message (msgType=" + repliedMsg.msgType + "), attempting fallback resolution");
998
+ const quoteSender = repliedMsg.senderNick || repliedMsg.senderName || '';
999
+ const quoteMsgId = repliedMsg.msgId;
1000
+ let resolvedFile = false;
1001
+
1002
+ // 1. Try inline downloadCode (rare — usually absent when bot didn't see the original)
1003
+ const inlineDownloadCode = repliedMsg.content?.downloadCode || repliedMsg.downloadCode;
1004
+ if (inlineDownloadCode && account.clientId && account.clientSecret) {
1005
+ try {
1006
+ const robotCode = account.robotCode || account.clientId;
1007
+ const dlResult = await downloadMediaFile(
1008
+ account.clientId, account.clientSecret, robotCode,
1009
+ inlineDownloadCode, repliedMsg.msgType,
1010
+ repliedMsg.content?.fileName,
1011
+ );
1012
+ if (dlResult.filePath) {
1013
+ if (!mediaPath) {
1014
+ mediaPath = dlResult.filePath;
1015
+ mediaType = dlResult.mimeType || repliedMsg.msgType;
1016
+ }
1017
+ const senderTag = quoteSender ? ` (${quoteSender})` : '';
1018
+ rawBody = `[引用文件${senderTag}: ${dlResult.filePath}]\n${rawBody}`;
1019
+ resolvedFile = true;
1020
+ log?.info?.("[dingtalk] Quoted file resolved via inline downloadCode: " + dlResult.filePath);
1021
+ }
1022
+ } catch (err) {
1023
+ log?.warn?.("[dingtalk] Quoted file inline download failed: " + err);
1024
+ }
1025
+ }
1026
+
1027
+ // 2. Try quoted-msg-cache
1028
+ if (!resolvedFile && quoteMsgId && account.clientId && account.clientSecret) {
1029
+ const cached = getCachedDownloadCode(account.accountId, msg.conversationId, quoteMsgId);
1030
+ if (cached?.downloadCode) {
1031
+ try {
1032
+ const robotCode = account.robotCode || account.clientId;
1033
+ const dlResult = await downloadMediaFile(
1034
+ account.clientId, account.clientSecret, robotCode,
1035
+ cached.downloadCode, cached.msgType as any,
1036
+ );
1037
+ if (dlResult.filePath) {
1038
+ if (!mediaPath) {
1039
+ mediaPath = dlResult.filePath;
1040
+ mediaType = dlResult.mimeType || cached.msgType;
1041
+ }
1042
+ const senderTag = quoteSender ? ` (${quoteSender})` : '';
1043
+ rawBody = `[引用文件${senderTag}: ${dlResult.filePath}]\n${rawBody}`;
1044
+ resolvedFile = true;
1045
+ log?.info?.("[dingtalk] Quoted file resolved via cache (downloadCode): " + dlResult.filePath);
1046
+ }
1047
+ } catch (err) {
1048
+ log?.warn?.("[dingtalk] Quoted file cache download failed: " + err);
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ // 3. Try group file API fallback
1054
+ if (!resolvedFile && account.clientId && account.clientSecret) {
1055
+ try {
1056
+ const fileResult = await resolveQuotedFile(
1057
+ { clientId: account.clientId, clientSecret: account.clientSecret },
1058
+ {
1059
+ openConversationId: msg.conversationId,
1060
+ // repliedMsg often lacks senderStaffId (DingTalk only provides encrypted senderId).
1061
+ // Fall back to the outer message sender's staffId — any group member works for space query.
1062
+ senderStaffId: repliedMsg.senderStaffId || msg.senderStaffId,
1063
+ fileCreatedAt: repliedMsg.createdAt || repliedMsg.createAt,
1064
+ },
1065
+ log,
1066
+ );
1067
+ if (fileResult) {
1068
+ if (!mediaPath) {
1069
+ mediaPath = fileResult.media.path;
1070
+ mediaType = fileResult.media.mimeType;
1071
+ }
1072
+ const senderTag = quoteSender ? ` (${quoteSender})` : '';
1073
+ const nameLabel = fileResult.name ? ` ${fileResult.name}` : '';
1074
+ rawBody = `[引用文件${senderTag}:${nameLabel} ${fileResult.media.path}]\n${rawBody}`;
1075
+ resolvedFile = true;
1076
+ // Write back to cache for future lookups
1077
+ if (quoteMsgId) {
1078
+ cacheInboundDownloadCode(
1079
+ account.accountId, msg.conversationId, quoteMsgId,
1080
+ undefined, repliedMsg.msgType, repliedMsg.createdAt || repliedMsg.createAt || Date.now(),
1081
+ { spaceId: fileResult.spaceId, fileId: fileResult.fileId },
1082
+ );
1083
+ }
1084
+ log?.info?.("[dingtalk] Quoted file resolved via group file API: " + fileResult.media.path);
1085
+ }
1086
+ } catch (err) {
1087
+ log?.warn?.("[dingtalk] Quoted file group API fallback failed: " + err);
1088
+ }
1089
+ }
1090
+
1091
+ if (!resolvedFile) {
1092
+ const senderTag = quoteSender ? ` (${quoteSender})` : '';
1093
+ rawBody = `[引用文件${senderTag},无法获取内容]\n${rawBody}`;
1094
+ log?.warn?.("[dingtalk] Quoted file could not be resolved, all fallbacks exhausted. msgType=" + repliedMsg.msgType + " msgId=" + (quoteMsgId || 'unknown'));
1095
+ }
824
1096
  } else {
825
1097
  log?.warn?.("[dingtalk] Reply message found but no content extracted, repliedMsg keys: " + Object.keys(repliedMsg || {}).join(',') + " | full: " + JSON.stringify(repliedMsg).substring(0, 500));
826
1098
  }
@@ -1260,7 +1532,7 @@ async function dispatchWithFullPipeline(params: {
1260
1532
  }) ?? rawBody;
1261
1533
 
1262
1534
  // 6. Finalize inbound context (includes media info)
1263
- const to = isDm ? `dingtalk:${senderId}` : `dingtalk:group:${conversationId}`;
1535
+ const to = isDm ? `dingtalk:dm:${senderId}` : `dingtalk:group:${conversationId}`;
1264
1536
 
1265
1537
  // 6a. Per-group system prompt (read from account.config.groups)
1266
1538
  const groupsConfig = account?.config?.groups ?? {};
@@ -1453,8 +1725,8 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1453
1725
  let webhookSuccess = false;
1454
1726
  const maxRetries = 2;
1455
1727
 
1456
- // Try sessionWebhook with retry
1457
- if (target.sessionWebhook && now < target.sessionWebhookExpiry) {
1728
+ // Try sessionWebhook with retry (60s safety buffer before expiry)
1729
+ if (target.sessionWebhook && now < (target.sessionWebhookExpiry - 60_000)) {
1458
1730
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1459
1731
  try {
1460
1732
  log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
@@ -1473,13 +1745,22 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1473
1745
  // Cache outbound message so it can be resolved when quoted
1474
1746
  if (sendResult.processQueryKey) {
1475
1747
  cacheOutboundMessage(sendResult.processQueryKey, chunk);
1748
+ } else {
1749
+ // sessionWebhook doesn't return processQueryKey — cache by timestamp only
1750
+ // so interactiveCard quotes can resolve via repliedMsg.createdAt
1751
+ cacheOutboundMessageByTime(chunk);
1476
1752
  }
1477
1753
  webhookSuccess = true;
1478
1754
  break;
1479
1755
  } catch (err) {
1480
- log?.info?.("[dingtalk] SessionWebhook attempt " + attempt + " failed: " + (err instanceof Error ? err.message : String(err)));
1756
+ const errMsg = err instanceof Error ? err.message : String(err);
1757
+ log?.info?.("[dingtalk] SessionWebhook attempt " + attempt + " failed: " + errMsg);
1758
+ // If webhook is definitively expired/invalid, skip remaining retries
1759
+ if (errMsg.includes('880001') || errMsg.includes('invalid session') || errMsg.includes('expired') || errMsg.includes('token is not exist')) {
1760
+ log?.info?.("[dingtalk] SessionWebhook expired/invalid, falling through to REST API");
1761
+ break;
1762
+ }
1481
1763
  if (attempt < maxRetries) {
1482
- // Wait 1 second before retry
1483
1764
  await new Promise(resolve => setTimeout(resolve, 1000));
1484
1765
  }
1485
1766
  }
@@ -1489,16 +1770,15 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
1489
1770
  // Fallback to REST API if webhook failed after all retries
1490
1771
  if (!webhookSuccess && target.account.clientId && target.account.clientSecret) {
1491
1772
  try {
1492
- log?.info?.("[dingtalk] SessionWebhook failed after " + maxRetries + " attempts, using REST API fallback");
1493
- // REST API only supports text format
1494
- const textChunk = messageFormat === "markdown" ? chunk : chunk;
1773
+ log?.info?.("[dingtalk] SessionWebhook unavailable, using REST API fallback");
1495
1774
  const restResult = await sendDingTalkRestMessage({
1496
1775
  clientId: target.account.clientId,
1497
1776
  clientSecret: target.account.clientSecret,
1498
1777
  robotCode: target.account.robotCode || target.account.clientId,
1499
1778
  userId: target.isDm ? target.senderId : undefined,
1500
1779
  conversationId: !target.isDm ? target.conversationId : undefined,
1501
- text: textChunk,
1780
+ text: chunk,
1781
+ format: isMarkdown ? 'markdown' : 'text',
1502
1782
  });
1503
1783
  log?.info?.("[dingtalk] REST API send OK" + (restResult.processQueryKey ? ` pqk=${restResult.processQueryKey}` : ''));
1504
1784
  // Cache outbound message so it can be resolved when quoted