@yaoyuanchao/dingtalk 1.5.8 → 1.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.ts +25 -17
- package/src/monitor.ts +208 -22
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;
|
|
@@ -586,16 +699,52 @@ async function processInboundMessage(
|
|
|
586
699
|
|
|
587
700
|
// If we have media but no text, provide a placeholder
|
|
588
701
|
if (!rawBody && mediaPath) {
|
|
589
|
-
|
|
702
|
+
const fileLabel = extracted.mediaFileName ? `${extracted.mediaFileName} → ${mediaPath}` : mediaPath;
|
|
703
|
+
rawBody = `[${extracted.messageType}] 媒体文件已下载: ${fileLabel}`;
|
|
590
704
|
}
|
|
591
705
|
|
|
592
|
-
//
|
|
593
|
-
if (msg.
|
|
594
|
-
|
|
706
|
+
// Cache this message so quote replies can look it up later via originalMsgId
|
|
707
|
+
if (msg.msgId && rawBody) {
|
|
708
|
+
msgCacheSet(msg.msgId, {
|
|
709
|
+
senderNick: msg.senderNick || '',
|
|
710
|
+
text: rawBody,
|
|
711
|
+
msgtype: msg.msgtype,
|
|
712
|
+
ts: Date.now(),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
595
715
|
|
|
596
|
-
|
|
716
|
+
// Handle quoted/replied messages: extract the quoted content and prepend it
|
|
717
|
+
// DingTalk uses at least three structures across API versions:
|
|
718
|
+
// v1 (older): msg.text.isReplyMsg=true + msg.text.repliedMsg
|
|
719
|
+
// v2 (stream newer): top-level msg.quoteMsg field
|
|
720
|
+
// v3 (richText): richText array with msgType="quote" items (handled in extractRichTextContent)
|
|
721
|
+
// v4 (content.quote): msg.content.quote or msg.content.referenceMessage
|
|
722
|
+
// v5 (cache lookup): msg.text.isReplyMsg=true + msg.originalMsgId → local cache lookup
|
|
723
|
+
const topLevelQuoteMsg = (msg as any).quoteMsg;
|
|
724
|
+
const contentQuote = (msg as any).content?.quote || (msg as any).content?.referenceMessage;
|
|
725
|
+
const isQuoteReply = (msg.text && (msg.text as any).isReplyMsg) || !!topLevelQuoteMsg || !!contentQuote;
|
|
726
|
+
|
|
727
|
+
if (isQuoteReply) {
|
|
728
|
+
log?.info?.("[dingtalk] Quote reply detected. isReplyMsg=" + !!(msg.text as any)?.isReplyMsg + " | has quoteMsg=" + !!topLevelQuoteMsg + " | has contentQuote=" + !!contentQuote);
|
|
729
|
+
log?.info?.("[dingtalk] Full message for quote debug: " + JSON.stringify(msg).substring(0, 1500));
|
|
730
|
+
|
|
731
|
+
const repliedMsgSource = (msg.text as any)?.repliedMsg || topLevelQuoteMsg || contentQuote;
|
|
732
|
+
|
|
733
|
+
// v5: cache lookup via originalMsgId (DingTalk Stream only provides originalMsgId, not content)
|
|
734
|
+
const originalMsgId = (msg as any).originalMsgId as string | undefined;
|
|
735
|
+
// v6: cache lookup via originalProcessQueryKey (for bot-sent outbound messages)
|
|
736
|
+
const originalProcessQueryKey = (msg as any).originalProcessQueryKey as string | undefined;
|
|
737
|
+
const cachedQuoted = (originalMsgId ? msgCache.get(originalMsgId) : undefined)
|
|
738
|
+
|| (originalProcessQueryKey ? msgCache.get(originalProcessQueryKey) : undefined);
|
|
739
|
+
|
|
740
|
+
if (cachedQuoted) {
|
|
741
|
+
const resolvedBy = (originalMsgId && msgCache.has(originalMsgId)) ? `msgId=${originalMsgId}` : `pqk=${originalProcessQueryKey}`;
|
|
742
|
+
log?.info?.("[dingtalk] Quote reply resolved from cache (" + resolvedBy + ") sender=" + cachedQuoted.senderNick);
|
|
743
|
+
const senderTag = cachedQuoted.senderNick ? ` (${cachedQuoted.senderNick})` : '';
|
|
744
|
+
rawBody = `[引用回复${senderTag}: "${cachedQuoted.text.trim().substring(0, 200)}"]\n${rawBody}`;
|
|
745
|
+
} else if (repliedMsgSource) {
|
|
597
746
|
try {
|
|
598
|
-
const repliedMsg =
|
|
747
|
+
const repliedMsg = repliedMsgSource;
|
|
599
748
|
let quotedContent = "";
|
|
600
749
|
|
|
601
750
|
// Extract quoted message content
|
|
@@ -637,19 +786,35 @@ async function processInboundMessage(
|
|
|
637
786
|
quotedContent = repliedMsg.content.text;
|
|
638
787
|
} else if (typeof repliedMsg.content === "string") {
|
|
639
788
|
quotedContent = repliedMsg.content;
|
|
789
|
+
} else if (repliedMsg.text && typeof repliedMsg.text === "string") {
|
|
790
|
+
// Some DingTalk versions put quoted text directly in .text
|
|
791
|
+
quotedContent = repliedMsg.text;
|
|
792
|
+
} else if (repliedMsg.text?.content) {
|
|
793
|
+
// Or nested under .text.content
|
|
794
|
+
quotedContent = repliedMsg.text.content;
|
|
795
|
+
} else if (repliedMsg.body) {
|
|
796
|
+
// Yet another possible format
|
|
797
|
+
quotedContent = typeof repliedMsg.body === "string" ? repliedMsg.body : JSON.stringify(repliedMsg.body);
|
|
798
|
+
} else if (typeof repliedMsg === "string") {
|
|
799
|
+
// repliedMsgSource itself might be a plain string
|
|
800
|
+
quotedContent = repliedMsg;
|
|
640
801
|
}
|
|
641
802
|
|
|
803
|
+
// Extract sender info if available
|
|
804
|
+
const quoteSender = repliedMsg.senderNick || repliedMsg.senderName || repliedMsg.content?.senderNick || '';
|
|
805
|
+
|
|
642
806
|
if (quotedContent) {
|
|
643
|
-
|
|
807
|
+
const senderTag = quoteSender ? ` (${quoteSender})` : '';
|
|
808
|
+
rawBody = `[引用回复${senderTag}: "${quotedContent.trim()}"]\n${rawBody}`;
|
|
644
809
|
log?.info?.("[dingtalk] Added quoted message: " + quotedContent.slice(0, 50));
|
|
645
810
|
} else {
|
|
646
|
-
log?.
|
|
811
|
+
log?.warn?.("[dingtalk] Reply message found but no content extracted, repliedMsg keys: " + Object.keys(repliedMsg || {}).join(',') + " | full: " + JSON.stringify(repliedMsg).substring(0, 500));
|
|
647
812
|
}
|
|
648
813
|
} catch (err) {
|
|
649
814
|
log?.info?.("[dingtalk] Failed to extract quoted message: " + err);
|
|
650
815
|
}
|
|
651
816
|
} else {
|
|
652
|
-
log?.info?.("[dingtalk]
|
|
817
|
+
log?.info?.("[dingtalk] Quote reply: no inline content and originalMsgId=" + (originalMsgId || 'none') + " not in cache (cache size=" + msgCache.size + "). Full msg: " + JSON.stringify(msg).substring(0, 800));
|
|
653
818
|
}
|
|
654
819
|
}
|
|
655
820
|
|
|
@@ -1141,6 +1306,7 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1141
1306
|
});
|
|
1142
1307
|
|
|
1143
1308
|
// 9. Dispatch reply from config
|
|
1309
|
+
const dispatchStartMs = Date.now();
|
|
1144
1310
|
try {
|
|
1145
1311
|
await rt.channel.reply.dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyOptions });
|
|
1146
1312
|
} finally {
|
|
@@ -1148,6 +1314,18 @@ async function dispatchWithFullPipeline(params: {
|
|
|
1148
1314
|
// Recall typing indicator if no reply was sent (queued or error)
|
|
1149
1315
|
if (!firstReplyFired && onFirstReply) {
|
|
1150
1316
|
await onFirstReply().catch(() => {});
|
|
1317
|
+
// Fast-fail detection: if dispatch finished < 2s with no delivery,
|
|
1318
|
+
// it's likely a transient internal error (e.g. session store lock timeout).
|
|
1319
|
+
// Send a visible fallback so the user knows to resend.
|
|
1320
|
+
const elapsedMs = Date.now() - dispatchStartMs;
|
|
1321
|
+
if (elapsedMs < 2000) {
|
|
1322
|
+
log?.warn?.(`[dingtalk] Fast-fail detected: dispatch completed in ${elapsedMs}ms with no delivery`);
|
|
1323
|
+
try {
|
|
1324
|
+
await deliverReply(replyTarget, '⚠️ 消息处理异常,请重发(内部错误,已记录)', log);
|
|
1325
|
+
} catch (notifyErr: any) {
|
|
1326
|
+
log?.warn?.('[dingtalk] Fast-fail notification delivery failed: ' + notifyErr?.message);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1151
1329
|
}
|
|
1152
1330
|
}
|
|
1153
1331
|
|
|
@@ -1274,7 +1452,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1274
1452
|
try {
|
|
1275
1453
|
log?.info?.("[dingtalk] Using sessionWebhook (attempt " + attempt + "/" + maxRetries + "), format=" + messageFormat);
|
|
1276
1454
|
log?.info?.("[dingtalk] Sending text (" + chunk.length + " chars): " + chunk.substring(0, 200));
|
|
1277
|
-
let sendResult: { ok: boolean; errcode?: number; errmsg?: string };
|
|
1455
|
+
let sendResult: { ok: boolean; errcode?: number; errmsg?: string; processQueryKey?: string };
|
|
1278
1456
|
if (isMarkdown) {
|
|
1279
1457
|
const markdownTitle = buildMarkdownPreviewTitle(chunk, "Jax");
|
|
1280
1458
|
sendResult = await sendMarkdownViaSessionWebhook(target.sessionWebhook, markdownTitle, chunk);
|
|
@@ -1284,7 +1462,11 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1284
1462
|
if (!sendResult.ok) {
|
|
1285
1463
|
throw new Error(`SessionWebhook rejected: errcode=${sendResult.errcode}, errmsg=${sendResult.errmsg}`);
|
|
1286
1464
|
}
|
|
1287
|
-
log?.info?.("[dingtalk] SessionWebhook send OK (errcode=" + (sendResult.errcode ?? 0) + ")");
|
|
1465
|
+
log?.info?.("[dingtalk] SessionWebhook send OK (errcode=" + (sendResult.errcode ?? 0) + (sendResult.processQueryKey ? ` pqk=${sendResult.processQueryKey}` : '') + ")");
|
|
1466
|
+
// Cache outbound message so it can be resolved when quoted
|
|
1467
|
+
if (sendResult.processQueryKey) {
|
|
1468
|
+
cacheOutboundMessage(sendResult.processQueryKey, chunk);
|
|
1469
|
+
}
|
|
1288
1470
|
webhookSuccess = true;
|
|
1289
1471
|
break;
|
|
1290
1472
|
} catch (err) {
|
|
@@ -1303,7 +1485,7 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1303
1485
|
log?.info?.("[dingtalk] SessionWebhook failed after " + maxRetries + " attempts, using REST API fallback");
|
|
1304
1486
|
// REST API only supports text format
|
|
1305
1487
|
const textChunk = messageFormat === "markdown" ? chunk : chunk;
|
|
1306
|
-
await sendDingTalkRestMessage({
|
|
1488
|
+
const restResult = await sendDingTalkRestMessage({
|
|
1307
1489
|
clientId: target.account.clientId,
|
|
1308
1490
|
clientSecret: target.account.clientSecret,
|
|
1309
1491
|
robotCode: target.account.robotCode || target.account.clientId,
|
|
@@ -1311,7 +1493,11 @@ async function deliverReply(target: any, text: string, log?: any): Promise<void>
|
|
|
1311
1493
|
conversationId: !target.isDm ? target.conversationId : undefined,
|
|
1312
1494
|
text: textChunk,
|
|
1313
1495
|
});
|
|
1314
|
-
log?.info?.("[dingtalk] REST API send OK");
|
|
1496
|
+
log?.info?.("[dingtalk] REST API send OK" + (restResult.processQueryKey ? ` pqk=${restResult.processQueryKey}` : ''));
|
|
1497
|
+
// Cache outbound message so it can be resolved when quoted
|
|
1498
|
+
if (restResult.processQueryKey) {
|
|
1499
|
+
cacheOutboundMessage(restResult.processQueryKey, textChunk);
|
|
1500
|
+
}
|
|
1315
1501
|
} catch (err) {
|
|
1316
1502
|
log?.info?.("[dingtalk] REST API also failed: " + (err instanceof Error ? err.stack : JSON.stringify(err)));
|
|
1317
1503
|
}
|