@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.
- package/package.json +1 -1
- package/src/api.ts +25 -17
- package/src/monitor.ts +236 -27
package/package.json
CHANGED
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
|
|
455
|
+
// Determine file extension: prefer original filename, then content type, then media type
|
|
455
456
|
const contentType = response.contentType || '';
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
716
|
+
const fileLabel = extracted.mediaFileName ? `${extracted.mediaFileName} → ${mediaPath}` : mediaPath;
|
|
717
|
+
rawBody = `[${extracted.messageType}] 媒体文件已下载: ${fileLabel}`;
|
|
590
718
|
}
|
|
591
719
|
|
|
592
|
-
//
|
|
593
|
-
if (msg.
|
|
594
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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?.
|
|
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]
|
|
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
|
-
*
|
|
1377
|
-
*
|
|
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) => {
|