@yaoyuanchao/dingtalk 1.2.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 ADDED
@@ -0,0 +1,622 @@
1
+ import type { DingTalkRobotMessage, ResolvedDingTalkAccount } from "./types.js";
2
+ import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, cleanupOldPictures } from "./api.js";
3
+ import { getDingTalkRuntime } from "./runtime.js";
4
+
5
+ export interface DingTalkMonitorContext {
6
+ account: ResolvedDingTalkAccount;
7
+ cfg: any;
8
+ abortSignal: AbortSignal;
9
+ log?: any;
10
+ setStatus?: (update: Record<string, unknown>) => void;
11
+ }
12
+
13
+ export async function startDingTalkMonitor(ctx: DingTalkMonitorContext): Promise<void> {
14
+ const { account, cfg, abortSignal, log, setStatus } = ctx;
15
+
16
+ if (!account.clientId || !account.clientSecret) {
17
+ throw new Error("DingTalk clientId/clientSecret not configured");
18
+ }
19
+
20
+ // Clean up old pictures on startup
21
+ cleanupOldPictures();
22
+
23
+ // Schedule periodic cleanup every hour
24
+ const cleanupInterval = setInterval(() => {
25
+ cleanupOldPictures();
26
+ }, 60 * 60 * 1000); // 1 hour
27
+
28
+ // Clean up on abort (only if abortSignal is provided)
29
+ if (abortSignal) {
30
+ abortSignal.addEventListener('abort', () => {
31
+ clearInterval(cleanupInterval);
32
+ });
33
+ }
34
+
35
+ let DWClient: any;
36
+ let TOPIC_ROBOT: any;
37
+ try {
38
+ const mod = await import("dingtalk-stream");
39
+ DWClient = mod.DWClient || mod.default?.DWClient || mod.default;
40
+ TOPIC_ROBOT = mod.TOPIC_ROBOT || mod.default?.TOPIC_ROBOT || "/v1.0/im/bot/messages/get";
41
+ } catch (err) {
42
+ throw new Error("Failed to import dingtalk-stream SDK: " + err);
43
+ }
44
+
45
+ if (!DWClient) throw new Error("DWClient not found in dingtalk-stream");
46
+
47
+ log?.info?.("[dingtalk:" + account.accountId + "] Starting Stream...");
48
+
49
+ const client = new DWClient({
50
+ clientId: account.clientId,
51
+ clientSecret: account.clientSecret,
52
+ });
53
+
54
+ client.registerCallbackListener(TOPIC_ROBOT, async (downstream: any) => {
55
+ try {
56
+ const data: DingTalkRobotMessage = typeof downstream.data === "string"
57
+ ? JSON.parse(downstream.data) : downstream.data;
58
+ setStatus?.({ lastInboundAt: Date.now() });
59
+ await processInboundMessage(data, ctx);
60
+ } catch (err) {
61
+ log?.info?.("[dingtalk] Message error: " + err);
62
+ }
63
+ return { status: "SUCCESS", message: "OK" };
64
+ });
65
+
66
+ client.registerAllEventListener((msg: any) => {
67
+ return { status: "SUCCESS", message: "OK" };
68
+ });
69
+
70
+ const onAbort = () => {
71
+ try { client.disconnect?.(); } catch {}
72
+ setStatus?.({ running: false, lastStopAt: Date.now() });
73
+ };
74
+ if (abortSignal) {
75
+ abortSignal.addEventListener("abort", onAbort, { once: true });
76
+ }
77
+
78
+ await client.connect();
79
+ log?.info?.("[dingtalk:" + account.accountId + "] Stream connected");
80
+ setStatus?.({ running: true, lastStartAt: Date.now() });
81
+ }
82
+
83
+ async function processInboundMessage(
84
+ msg: DingTalkRobotMessage,
85
+ ctx: DingTalkMonitorContext,
86
+ ): Promise<void> {
87
+ const { account, cfg, log, setStatus } = ctx;
88
+ const runtime = getDingTalkRuntime();
89
+
90
+ const isDm = msg.conversationType === "1";
91
+ const isGroup = msg.conversationType === "2";
92
+
93
+ // Debug: log full message structure for debugging
94
+ if (msg.msgtype === 'richText' || msg.picture || (msg.atUsers && msg.atUsers.length > 0)) {
95
+ log?.info?.("[dingtalk-debug] Full message structure:");
96
+ log?.info?.("[dingtalk-debug] msgtype: " + msg.msgtype);
97
+ log?.info?.("[dingtalk-debug] text: " + JSON.stringify(msg.text));
98
+ log?.info?.("[dingtalk-debug] richText: " + JSON.stringify(msg.richText));
99
+ log?.info?.("[dingtalk-debug] picture: " + JSON.stringify(msg.picture));
100
+ log?.info?.("[dingtalk-debug] atUsers: " + JSON.stringify(msg.atUsers));
101
+ log?.info?.("[dingtalk-debug] RAW MESSAGE: " + JSON.stringify(msg).substring(0, 500));
102
+ }
103
+
104
+ // Extract message content from text or richText
105
+ let rawBody = msg.text?.content?.trim() ?? "";
106
+
107
+ // If text is empty, try to extract from richText
108
+ if (!rawBody && msg.richText) {
109
+ try {
110
+ const richTextStr = typeof msg.richText === 'string'
111
+ ? msg.richText
112
+ : JSON.stringify(msg.richText);
113
+ log?.info?.("[dingtalk] Received richText message (full): " + richTextStr);
114
+
115
+ const rt = msg.richText as any;
116
+
117
+ // Try multiple possible fields for text content
118
+ if (typeof msg.richText === 'string') {
119
+ // If it's a string, use it directly
120
+ rawBody = msg.richText.trim();
121
+ } else if (rt) {
122
+ // Try various possible field names
123
+ rawBody = rt.text?.trim()
124
+ || rt.content?.trim()
125
+ || rt.richText?.trim()
126
+ || "";
127
+
128
+ // If still empty, try to extract from richText array structure
129
+ if (!rawBody && Array.isArray(rt.richText)) {
130
+ const textParts: string[] = [];
131
+ for (const item of rt.richText) {
132
+ // Handle different types of richText elements
133
+ if (item.text) {
134
+ textParts.push(item.text);
135
+ } else if (item.content) {
136
+ textParts.push(item.content);
137
+ }
138
+ // Note: @mention text should be included in item.text by DingTalk
139
+ }
140
+ rawBody = textParts.join('').trim();
141
+ }
142
+ }
143
+
144
+ if (rawBody) {
145
+ log?.info?.("[dingtalk] Extracted from richText: " + rawBody.slice(0, 100));
146
+ }
147
+ } catch (err) {
148
+ log?.info?.("[dingtalk] Failed to parse richText: " + err);
149
+ }
150
+ }
151
+
152
+ // Additional fallback: try to get content from text.content even for richText messages
153
+ if (!rawBody && msg.text?.content) {
154
+ rawBody = msg.text.content.trim();
155
+ log?.info?.("[dingtalk] Using text.content as fallback: " + rawBody.slice(0, 100));
156
+ }
157
+
158
+ // Handle richText messages (when msgtype === 'richText', data is in msg.content.richText)
159
+ if (!rawBody && msg.msgtype === 'richText') {
160
+ const content = (msg as any).content;
161
+ log?.info?.("[dingtalk] RichText message - msg.content: " + JSON.stringify(content).substring(0, 200));
162
+
163
+ if (content?.richText && Array.isArray(content.richText)) {
164
+ const parts: string[] = [];
165
+
166
+ for (const item of content.richText) {
167
+ if (item.msgType === "text" && item.content) {
168
+ parts.push(item.content);
169
+ } else if ((item.msgType === "picture" || item.pictureDownloadCode || item.downloadCode) && (item.downloadCode || item.pictureDownloadCode)) {
170
+ // Handle picture: msgType may be absent, check for downloadCode fields
171
+ const downloadCode = item.downloadCode || item.pictureDownloadCode;
172
+ // Download the picture from richText message
173
+ try {
174
+ const robotCode = account.robotCode || account.clientId;
175
+ const pictureResult = await downloadPicture(
176
+ account.clientId,
177
+ account.clientSecret,
178
+ robotCode,
179
+ downloadCode,
180
+ );
181
+
182
+ if (pictureResult.filePath) {
183
+ parts.push(`[图片: ${pictureResult.filePath}]`);
184
+ log?.info?.("[dingtalk] Downloaded picture from richText: " + pictureResult.filePath);
185
+ } else if (pictureResult.error) {
186
+ parts.push(`[图片下载失败: ${pictureResult.error}]`);
187
+ } else {
188
+ parts.push("[图片]");
189
+ }
190
+ } catch (err) {
191
+ parts.push(`[图片下载出错: ${err}]`);
192
+ log?.warn?.("[dingtalk] Error downloading picture from richText: " + err);
193
+ }
194
+ }
195
+ }
196
+
197
+ rawBody = parts.join("");
198
+ if (rawBody) {
199
+ log?.info?.("[dingtalk] Extracted from msg.content.richText: " + rawBody.substring(0, 100));
200
+ }
201
+ }
202
+ }
203
+
204
+ // Handle picture messages
205
+ if (!rawBody && msg.msgtype === 'picture') {
206
+ log?.info?.("[dingtalk] Picture message - msg.picture: " + JSON.stringify(msg.picture));
207
+ log?.info?.("[dingtalk] Picture message - msg.content: " + JSON.stringify((msg as any).content));
208
+ log?.info?.("[dingtalk] Full msg keys: " + Object.keys(msg).join(', '));
209
+
210
+ const content = (msg as any).content;
211
+ let downloadCode: string | undefined;
212
+
213
+ if (msg.picture?.downloadCode) {
214
+ downloadCode = msg.picture.downloadCode;
215
+ } else if (content?.downloadCode) {
216
+ downloadCode = content.downloadCode;
217
+ }
218
+
219
+ if (downloadCode) {
220
+ log?.info?.("[dingtalk] Picture detected, downloadCode: " + downloadCode);
221
+
222
+ // Try to download the picture
223
+ try {
224
+ const robotCode = account.robotCode || account.clientId;
225
+ const pictureResult = await downloadPicture(
226
+ account.clientId,
227
+ account.clientSecret,
228
+ robotCode,
229
+ downloadCode,
230
+ );
231
+
232
+ if (pictureResult.error) {
233
+ rawBody = `[用户发送了图片,但下载失败: ${pictureResult.error}]`;
234
+ log?.warn?.("[dingtalk] Picture download failed: " + pictureResult.error);
235
+ } else if (pictureResult.filePath) {
236
+ rawBody = `[用户发送了图片]\n图片已保存到: ${pictureResult.filePath}`;
237
+ log?.info?.("[dingtalk] Picture downloaded successfully: " + pictureResult.filePath);
238
+
239
+ // Note: If Agent supports multimodal input, we could pass the base64 or file path
240
+ // For now, we just notify the agent that a picture was sent
241
+ } else {
242
+ rawBody = "[用户发送了图片,但无法获取下载链接]";
243
+ }
244
+ } catch (err) {
245
+ rawBody = `[用户发送了图片,下载时出错: ${err}]`;
246
+ log?.warn?.("[dingtalk] Error downloading picture: " + err);
247
+ }
248
+ } else {
249
+ // Even if we can't get picture info, allow the message through
250
+ rawBody = "[用户发送了图片(无法获取下载码)]";
251
+ log?.info?.("[dingtalk] Picture msgtype but no downloadCode found");
252
+ }
253
+ }
254
+
255
+ if (!rawBody) {
256
+ log?.info?.("[dingtalk] Empty message body after all attempts, skipping. msgtype=" + msg.msgtype + ", hasText=" + !!msg.text + ", hasRichText=" + !!msg.richText + ", hasPicture=" + !!msg.picture);
257
+ return;
258
+ }
259
+
260
+ // Handle quoted/replied messages: extract the quoted content and prepend it
261
+ if (msg.text && (msg.text as any).isReplyMsg) {
262
+ log?.info?.("[dingtalk] Message is a reply, full text object: " + JSON.stringify(msg.text));
263
+
264
+ if ((msg.text as any).repliedMsg) {
265
+ try {
266
+ const repliedMsg = (msg.text as any).repliedMsg;
267
+ let quotedContent = "";
268
+
269
+ // Extract quoted message content
270
+ if (repliedMsg.content?.richText && Array.isArray(repliedMsg.content.richText)) {
271
+ // richText format: array of {msgType, content} or {msgType, downloadCode}
272
+ const parts: string[] = [];
273
+
274
+ for (const item of repliedMsg.content.richText) {
275
+ if (item.msgType === "text" && item.content) {
276
+ parts.push(item.content);
277
+ } else if (item.msgType === "picture" && item.downloadCode) {
278
+ // Download the picture from quoted message
279
+ try {
280
+ const robotCode = account.robotCode || account.clientId;
281
+ const pictureResult = await downloadPicture(
282
+ account.clientId,
283
+ account.clientSecret,
284
+ robotCode,
285
+ item.downloadCode,
286
+ );
287
+
288
+ if (pictureResult.filePath) {
289
+ parts.push(`[图片: ${pictureResult.filePath}]`);
290
+ log?.info?.("[dingtalk] Downloaded picture from quoted message: " + pictureResult.filePath);
291
+ } else if (pictureResult.error) {
292
+ parts.push(`[图片下载失败: ${pictureResult.error}]`);
293
+ } else {
294
+ parts.push("[图片]");
295
+ }
296
+ } catch (err) {
297
+ parts.push(`[图片下载出错: ${err}]`);
298
+ log?.warn?.("[dingtalk] Error downloading picture from quoted message: " + err);
299
+ }
300
+ }
301
+ }
302
+
303
+ quotedContent = parts.join("");
304
+ } else if (repliedMsg.content?.text) {
305
+ quotedContent = repliedMsg.content.text;
306
+ } else if (typeof repliedMsg.content === "string") {
307
+ quotedContent = repliedMsg.content;
308
+ }
309
+
310
+ if (quotedContent) {
311
+ rawBody = `[引用回复: "${quotedContent.trim()}"]\n${rawBody}`;
312
+ log?.info?.("[dingtalk] Added quoted message: " + quotedContent.slice(0, 50));
313
+ } else {
314
+ log?.info?.("[dingtalk] Reply message found but no content extracted, repliedMsg: " + JSON.stringify(repliedMsg));
315
+ }
316
+ } catch (err) {
317
+ log?.info?.("[dingtalk] Failed to extract quoted message: " + err);
318
+ }
319
+ } else {
320
+ log?.info?.("[dingtalk] Message marked as reply but no repliedMsg field found");
321
+ }
322
+ }
323
+
324
+ // Handle @mentions: DingTalk removes @username from text.content
325
+ // Query user info for mentioned users (those with staffId)
326
+ if (msg.atUsers && msg.atUsers.length > 0) {
327
+ log?.info?.("[dingtalk] Message has @mentions: " + JSON.stringify(msg.atUsers));
328
+
329
+ // Filter users with staffId (exclude bots which don't have staffId)
330
+ const userIds = msg.atUsers
331
+ .filter(u => u.staffId)
332
+ .map(u => u.staffId as string)
333
+ .slice(0, 5); // Limit to 5 users to avoid too many API calls
334
+
335
+ if (userIds.length > 0 && account.clientId && account.clientSecret) {
336
+ try {
337
+ // Batch query user info with 500ms timeout
338
+ const userInfoMap = await batchGetUserInfo(account.clientId, account.clientSecret, userIds, 500);
339
+
340
+ if (userInfoMap.size > 0) {
341
+ // Build mention list: [@张三 @李四]
342
+ const mentions = Array.from(userInfoMap.values()).map(name => `@${name}`).join(" ");
343
+ rawBody = `[${mentions}] ${rawBody}`;
344
+ log?.info?.("[dingtalk] Added user mentions: " + mentions);
345
+ } else {
346
+ // Fallback if no user info retrieved
347
+ rawBody = `[有${msg.atUsers.length}人被@] ${rawBody}`;
348
+ log?.info?.("[dingtalk] User info fetch failed, using count fallback");
349
+ }
350
+ } catch (err) {
351
+ // Fallback on error
352
+ rawBody = `[有${msg.atUsers.length}人被@] ${rawBody}`;
353
+ log?.info?.("[dingtalk] Error fetching user info: " + err + ", using count fallback");
354
+ }
355
+ } else {
356
+ // No staffId or credentials - use count fallback
357
+ rawBody = `[有${msg.atUsers.length}人被@] ${rawBody}`;
358
+ log?.info?.("[dingtalk] No staffId or credentials, using count fallback");
359
+ }
360
+ }
361
+
362
+ const senderId = msg.senderStaffId || msg.senderId;
363
+ const senderName = msg.senderNick || "";
364
+ const conversationId = msg.conversationId;
365
+
366
+ log?.info?.("[dingtalk] " + (isDm ? "DM" : "Group") + " from " + senderName + ": " + rawBody.slice(0, 50));
367
+
368
+ // DM access control
369
+ if (isDm) {
370
+ const dmConfig = account.config.dm ?? {};
371
+ if (dmConfig.enabled === false) return;
372
+ const dmPolicy = dmConfig.policy ?? "pairing";
373
+ if (dmPolicy === "disabled") return;
374
+ if (dmPolicy !== "open") {
375
+ const allowFrom = (dmConfig.allowFrom ?? []).map(String);
376
+ if (!isSenderAllowed(senderId, allowFrom)) {
377
+ log?.info?.("[dingtalk] DM denied for " + senderId);
378
+ if (dmPolicy === "pairing" && msg.sessionWebhook) {
379
+ await sendViaSessionWebhook(
380
+ msg.sessionWebhook,
381
+ "Access denied. Your staffId: " + senderId + "\nAsk admin to add you.",
382
+ ).catch(() => {});
383
+ }
384
+ return;
385
+ }
386
+ }
387
+ }
388
+
389
+ // Group access control
390
+ if (isGroup) {
391
+ const groupPolicy = account.config.groupPolicy ?? "allowlist";
392
+ if (groupPolicy === "disabled") return;
393
+
394
+ // Check group whitelist
395
+ if (groupPolicy === "allowlist") {
396
+ const groupAllowlist = (account.config.groupAllowlist ?? []).map(String);
397
+ if (groupAllowlist.length > 0 && !isGroupAllowed(conversationId, groupAllowlist)) {
398
+ log?.info?.("[dingtalk] Group not in allowlist: " + conversationId);
399
+ return;
400
+ }
401
+ }
402
+
403
+ // Check @mention requirement
404
+ const requireMention = account.config.requireMention !== false;
405
+ if (requireMention && !msg.isInAtList) return;
406
+ }
407
+
408
+ const sessionKey = "dingtalk:" + account.accountId + ":" + (isDm ? "dm" : "group") + ":" + conversationId;
409
+
410
+ const replyTarget = {
411
+ sessionWebhook: msg.sessionWebhook,
412
+ sessionWebhookExpiry: msg.sessionWebhookExpiredTime,
413
+ conversationId,
414
+ senderId,
415
+ isDm,
416
+ account,
417
+ };
418
+
419
+ // Load actual config if cfg is a config manager
420
+ let actualCfg = cfg;
421
+ if (cfg && typeof cfg.loadConfig === "function") {
422
+ try {
423
+ actualCfg = await cfg.loadConfig();
424
+ console.warn("[dingtalk-debug] Loaded actual config, agents.defaults.model:", JSON.stringify(actualCfg?.agents?.defaults?.model, null, 2));
425
+ } catch (err) {
426
+ console.warn("[dingtalk-debug] Failed to load config:", err);
427
+ }
428
+ }
429
+
430
+ try {
431
+ if (runtime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
432
+ const ctxPayload = {
433
+ Body: rawBody,
434
+ RawBody: rawBody,
435
+ CommandBody: rawBody,
436
+ From: "dingtalk:" + senderId,
437
+ To: isDm ? ("dingtalk:dm:" + senderId) : ("dingtalk:group:" + conversationId),
438
+ SessionKey: sessionKey,
439
+ AccountId: account.accountId,
440
+ ChatType: isDm ? "direct" : "group",
441
+ ConversationLabel: isDm ? senderName : (msg.conversationTitle ?? conversationId),
442
+ SenderName: senderName || undefined,
443
+ SenderId: senderId,
444
+ WasMentioned: isGroup ? msg.isInAtList : undefined,
445
+ Provider: "dingtalk",
446
+ Surface: "dingtalk",
447
+ MessageSid: msg.msgId,
448
+ OriginatingChannel: "dingtalk",
449
+ OriginatingTo: "dingtalk:" + conversationId,
450
+ };
451
+
452
+ // Fire-and-forget: don't await to avoid blocking SDK callback during long agent runs
453
+ runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
454
+ ctx: ctxPayload,
455
+ cfg: actualCfg,
456
+ dispatcherOptions: {
457
+ deliver: async (payload: any) => {
458
+ if (payload.text) {
459
+ await deliverReply(replyTarget, payload.text, log);
460
+ setStatus?.({ lastOutboundAt: Date.now() });
461
+ }
462
+ },
463
+ onError: (err: any) => {
464
+ log?.info?.("[dingtalk] Reply error: " + err);
465
+ },
466
+ },
467
+ }).catch((err) => {
468
+ log?.info?.("[dingtalk] Dispatch failed: " + err);
469
+ });
470
+ } else {
471
+ log?.info?.("[dingtalk] Runtime dispatch not available");
472
+ }
473
+ } catch (err) {
474
+ log?.info?.("[dingtalk] Dispatch error: " + err);
475
+ }
476
+ }
477
+
478
+ async function deliverReply(target: any, text: string, log?: any): Promise<void> {
479
+ const now = Date.now();
480
+ const chunkLimit = 2000;
481
+ const messageFormat = target.account.config.messageFormat ?? "text";
482
+ // Support both "markdown" and "richtext" (they're equivalent for DingTalk)
483
+ const isMarkdown = messageFormat === "markdown" || messageFormat === "richtext";
484
+
485
+ // Convert markdown tables to text format (DingTalk doesn't support tables)
486
+ let processedText = text;
487
+ if (isMarkdown) {
488
+ processedText = convertMarkdownTables(text);
489
+ // Convert bare image URLs to markdown syntax for proper display
490
+ processedText = convertImageUrlsToMarkdown(processedText);
491
+ }
492
+
493
+ const chunks: string[] = [];
494
+ if (processedText.length <= chunkLimit) {
495
+ chunks.push(processedText);
496
+ } else {
497
+ for (let i = 0; i < processedText.length; i += chunkLimit) {
498
+ chunks.push(processedText.slice(i, i + chunkLimit));
499
+ }
500
+ }
501
+
502
+ for (const chunk of chunks) {
503
+ let webhookSuccess = false;
504
+ const maxRetries = 2;
505
+
506
+ // Try sessionWebhook with retry
507
+ if (target.sessionWebhook && now < target.sessionWebhookExpiry) {
508
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
509
+ try {
510
+ log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
511
+ log?.info?.("[dingtalk] Sending text: " + chunk.substring(0, 200));
512
+ if (isMarkdown) {
513
+ await sendMarkdownViaSessionWebhook(target.sessionWebhook, "Reply", chunk);
514
+ } else {
515
+ await sendViaSessionWebhook(target.sessionWebhook, chunk);
516
+ }
517
+ log?.info?.("[dingtalk] SessionWebhook send OK");
518
+ webhookSuccess = true;
519
+ break;
520
+ } catch (err) {
521
+ log?.info?.("[dingtalk] SessionWebhook attempt " + attempt + " failed: " + (err instanceof Error ? err.message : String(err)));
522
+ if (attempt < maxRetries) {
523
+ // Wait 1 second before retry
524
+ await new Promise(resolve => setTimeout(resolve, 1000));
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ // Fallback to REST API if webhook failed after all retries
531
+ if (!webhookSuccess && target.account.clientId && target.account.clientSecret) {
532
+ try {
533
+ log?.info?.("[dingtalk] SessionWebhook failed after " + maxRetries + " attempts, using REST API fallback");
534
+ // REST API only supports text format
535
+ const textChunk = messageFormat === "markdown" ? chunk : chunk;
536
+ await sendDingTalkRestMessage({
537
+ clientId: target.account.clientId,
538
+ clientSecret: target.account.clientSecret,
539
+ robotCode: target.account.robotCode || target.account.clientId,
540
+ userId: target.isDm ? target.senderId : undefined,
541
+ conversationId: !target.isDm ? target.conversationId : undefined,
542
+ text: textChunk,
543
+ });
544
+ log?.info?.("[dingtalk] REST API send OK");
545
+ } catch (err) {
546
+ log?.info?.("[dingtalk] REST API also failed: " + (err instanceof Error ? err.stack : JSON.stringify(err)));
547
+ }
548
+ } else if (!webhookSuccess) {
549
+ log?.info?.("[dingtalk] No delivery method available!");
550
+ }
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Convert bare image URLs to markdown image syntax
556
+ * Detects patterns like "图1: https://..." or "https://...png" and converts to ![](url)
557
+ */
558
+ function convertImageUrlsToMarkdown(text: string): string {
559
+ // Pattern 1: "图X: https://..." format (common Agent output)
560
+ text = text.replace(/图(\d+):\s*(https?:\/\/[^\s]+\.(png|jpg|jpeg|gif|webp)(\?[^\s]*)?)/gi, (match, num, url) => {
561
+ return `![图${num}](${url})`;
562
+ });
563
+
564
+ // Pattern 2: Bare image URLs on their own line or preceded by space
565
+ // But avoid converting URLs that are already in markdown syntax
566
+ text = text.replace(/(?<!\]\()(?:^|\s)(https?:\/\/[^\s]+\.(png|jpg|jpeg|gif|webp)(\?[^\s]*)?)/gim, (match, url) => {
567
+ // Check if this URL is already part of markdown image syntax
568
+ if (match.startsWith('](')) return match;
569
+ const leadingSpace = match.match(/^\s/);
570
+ return (leadingSpace ? leadingSpace[0] : '') + `![image](${url.trim()})`;
571
+ });
572
+
573
+ return text;
574
+ }
575
+
576
+ /**
577
+ * Convert markdown tables to plain text format
578
+ * DingTalk doesn't support markdown tables, so we convert them to readable text
579
+ */
580
+ function convertMarkdownTables(text: string): string {
581
+ // Match markdown tables (| col1 | col2 |\n|------|------|\n| val1 | val2 |)
582
+ const tableRegex = /(\|.+\|\n)+/g;
583
+
584
+ return text.replace(tableRegex, (match) => {
585
+ const lines = match.trim().split('\n');
586
+ if (lines.length < 2) return match;
587
+
588
+ // Check if it's a valid table (has separator line)
589
+ const hasSeparator = lines.some(line => /^[\s|:-]+$/.test(line.replace(/\|/g, '')));
590
+ if (!hasSeparator) return match;
591
+
592
+ // Convert to plain text format
593
+ let result = '\n```\n';
594
+ for (const line of lines) {
595
+ // Skip separator lines (|---|---|)
596
+ if (/^[\s|:-]+$/.test(line.replace(/\|/g, ''))) continue;
597
+
598
+ const cells = line.split('|').map(c => c.trim()).filter(c => c);
599
+ result += cells.join(' | ') + '\n';
600
+ }
601
+ result += '```\n';
602
+ return result;
603
+ });
604
+ }
605
+
606
+ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
607
+ if (allowFrom.includes("*")) return true;
608
+ const normalized = senderId.trim().toLowerCase();
609
+ return allowFrom.some((entry) => {
610
+ const e = String(entry).trim().toLowerCase();
611
+ return e === normalized;
612
+ });
613
+ }
614
+
615
+ function isGroupAllowed(conversationId: string, allowlist: string[]): boolean {
616
+ if (allowlist.includes("*")) return true;
617
+ const normalized = conversationId.trim().toLowerCase();
618
+ return allowlist.some((entry) => {
619
+ const e = String(entry).trim().toLowerCase();
620
+ return e === normalized;
621
+ });
622
+ }