@yaoyuanchao/dingtalk 1.4.18 → 1.4.20
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/config-schema.ts +9 -0
- package/src/monitor.ts +193 -2
- package/src/types.ts +2 -0
package/package.json
CHANGED
package/src/config-schema.ts
CHANGED
|
@@ -83,6 +83,15 @@ export const dingTalkConfigSchema = z.object({
|
|
|
83
83
|
),
|
|
84
84
|
longTextThreshold: z.number().int().positive().default(8000).optional()
|
|
85
85
|
.describe('Character threshold for longTextMode=file (default 8000)'),
|
|
86
|
+
|
|
87
|
+
// 消息聚合
|
|
88
|
+
messageAggregation: z.boolean().default(true)
|
|
89
|
+
.describe(
|
|
90
|
+
'Aggregate messages from the same sender within a short time window.\n' +
|
|
91
|
+
'Useful when DingTalk splits link cards into multiple messages.'
|
|
92
|
+
),
|
|
93
|
+
messageAggregationDelayMs: z.number().int().positive().default(2000).optional()
|
|
94
|
+
.describe('Time window in milliseconds to wait for additional messages (default 2000)'),
|
|
86
95
|
}).strict();
|
|
87
96
|
|
|
88
97
|
// 导出配置类型
|
package/src/monitor.ts
CHANGED
|
@@ -2,6 +2,35 @@ import type { DingTalkRobotMessage, ResolvedDingTalkAccount, ExtractedMessage }
|
|
|
2
2
|
import { sendViaSessionWebhook, sendMarkdownViaSessionWebhook, sendDingTalkRestMessage, batchGetUserInfo, downloadPicture, downloadMediaFile, cleanupOldMedia, uploadMediaFile, sendFileMessage, textToMarkdownFile } from "./api.js";
|
|
3
3
|
import { getDingTalkRuntime } from "./runtime.js";
|
|
4
4
|
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Message Aggregation Buffer
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// When users share links via DingTalk's "share link" feature, the message may
|
|
9
|
+
// arrive as multiple separate messages (text + URL). This buffer aggregates
|
|
10
|
+
// messages from the same sender within a short time window.
|
|
11
|
+
|
|
12
|
+
interface BufferedMessage {
|
|
13
|
+
messages: Array<{ text: string; timestamp: number; mediaPath?: string; mediaType?: string }>;
|
|
14
|
+
timer: ReturnType<typeof setTimeout>;
|
|
15
|
+
ctx: DingTalkMonitorContext;
|
|
16
|
+
msg: DingTalkRobotMessage; // Keep latest msg for reply target
|
|
17
|
+
replyTarget: any;
|
|
18
|
+
sessionKey: string;
|
|
19
|
+
isDm: boolean;
|
|
20
|
+
senderId: string;
|
|
21
|
+
senderName: string;
|
|
22
|
+
conversationId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const messageBuffer = new Map<string, BufferedMessage>();
|
|
26
|
+
const AGGREGATION_DELAY_MS = 2000; // 2 seconds - balance between UX and catching split messages
|
|
27
|
+
|
|
28
|
+
function getBufferKey(msg: DingTalkRobotMessage, accountId: string): string {
|
|
29
|
+
return `${accountId}:${msg.conversationId}:${msg.senderId || msg.senderStaffId}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
5
34
|
export interface DingTalkMonitorContext {
|
|
6
35
|
account: ResolvedDingTalkAccount;
|
|
7
36
|
cfg: any;
|
|
@@ -152,6 +181,40 @@ async function extractMessageContent(
|
|
|
152
181
|
};
|
|
153
182
|
}
|
|
154
183
|
|
|
184
|
+
case 'link': {
|
|
185
|
+
// Link card message - contains title, text, messageUrl, and optional picUrl
|
|
186
|
+
// Structure: msg.link = { title, text, messageUrl, picUrl }
|
|
187
|
+
const linkContent = msg.link || content;
|
|
188
|
+
log?.info?.("[dingtalk] link message received: " + JSON.stringify(linkContent));
|
|
189
|
+
|
|
190
|
+
if (linkContent) {
|
|
191
|
+
const title = linkContent.title || '';
|
|
192
|
+
const text = linkContent.text || '';
|
|
193
|
+
const messageUrl = linkContent.messageUrl || '';
|
|
194
|
+
const picUrl = linkContent.picUrl || '';
|
|
195
|
+
|
|
196
|
+
// Combine all parts into a readable format
|
|
197
|
+
const parts: string[] = [];
|
|
198
|
+
if (title) parts.push(`[链接] ${title}`);
|
|
199
|
+
if (text) parts.push(text);
|
|
200
|
+
if (messageUrl) parts.push(`链接: ${messageUrl}`);
|
|
201
|
+
if (picUrl) parts.push(`配图: ${picUrl}`);
|
|
202
|
+
|
|
203
|
+
const resultText = parts.join('\n') || '[链接卡片]';
|
|
204
|
+
log?.info?.("[dingtalk] Extracted link message: " + resultText.slice(0, 100));
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
text: resultText,
|
|
208
|
+
messageType: 'link',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
text: '[链接卡片]',
|
|
214
|
+
messageType: 'link',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
155
218
|
case 'chatRecord': {
|
|
156
219
|
// Chat record collection - contains multiple forwarded messages
|
|
157
220
|
// Structure: content.chatRecord is a JSON string containing an array of messages
|
|
@@ -662,10 +725,138 @@ async function processInboundMessage(
|
|
|
662
725
|
account,
|
|
663
726
|
};
|
|
664
727
|
|
|
728
|
+
// Check if message aggregation is enabled
|
|
729
|
+
const aggregationEnabled = account.config.messageAggregation !== false;
|
|
730
|
+
const aggregationDelayMs = account.config.messageAggregationDelayMs ?? AGGREGATION_DELAY_MS;
|
|
731
|
+
|
|
732
|
+
if (aggregationEnabled) {
|
|
733
|
+
// Buffer this message for aggregation
|
|
734
|
+
await bufferMessageForAggregation({
|
|
735
|
+
msg, ctx, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId,
|
|
736
|
+
mediaPath, mediaType,
|
|
737
|
+
});
|
|
738
|
+
return; // Actual dispatch happens when timer fires
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// No aggregation - dispatch immediately
|
|
742
|
+
await dispatchMessage({
|
|
743
|
+
ctx, msg, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId,
|
|
744
|
+
mediaPath, mediaType,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Buffer a message for aggregation with other messages from the same sender.
|
|
750
|
+
*/
|
|
751
|
+
async function bufferMessageForAggregation(params: {
|
|
752
|
+
msg: DingTalkRobotMessage;
|
|
753
|
+
ctx: DingTalkMonitorContext;
|
|
754
|
+
rawBody: string;
|
|
755
|
+
replyTarget: any;
|
|
756
|
+
sessionKey: string;
|
|
757
|
+
isDm: boolean;
|
|
758
|
+
senderId: string;
|
|
759
|
+
senderName: string;
|
|
760
|
+
conversationId: string;
|
|
761
|
+
mediaPath?: string;
|
|
762
|
+
mediaType?: string;
|
|
763
|
+
}): Promise<void> {
|
|
764
|
+
const { msg, ctx, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId, mediaPath, mediaType } = params;
|
|
765
|
+
const { account, log } = ctx;
|
|
766
|
+
const bufferKey = getBufferKey(msg, account.accountId);
|
|
767
|
+
const aggregationDelayMs = account.config.messageAggregationDelayMs ?? AGGREGATION_DELAY_MS;
|
|
768
|
+
|
|
769
|
+
const existing = messageBuffer.get(bufferKey);
|
|
770
|
+
|
|
771
|
+
if (existing) {
|
|
772
|
+
// Add to existing buffer
|
|
773
|
+
existing.messages.push({ text: rawBody, timestamp: Date.now(), mediaPath, mediaType });
|
|
774
|
+
// Update to latest msg for reply target (use latest sessionWebhook)
|
|
775
|
+
existing.msg = msg;
|
|
776
|
+
existing.replyTarget = replyTarget;
|
|
777
|
+
|
|
778
|
+
// Reset timer
|
|
779
|
+
clearTimeout(existing.timer);
|
|
780
|
+
existing.timer = setTimeout(() => {
|
|
781
|
+
flushMessageBuffer(bufferKey);
|
|
782
|
+
}, aggregationDelayMs);
|
|
783
|
+
|
|
784
|
+
log?.info?.(`[dingtalk] Message buffered, total: ${existing.messages.length} messages`);
|
|
785
|
+
} else {
|
|
786
|
+
// Create new buffer entry
|
|
787
|
+
const newEntry: BufferedMessage = {
|
|
788
|
+
messages: [{ text: rawBody, timestamp: Date.now(), mediaPath, mediaType }],
|
|
789
|
+
timer: setTimeout(() => {
|
|
790
|
+
flushMessageBuffer(bufferKey);
|
|
791
|
+
}, aggregationDelayMs),
|
|
792
|
+
ctx,
|
|
793
|
+
msg,
|
|
794
|
+
replyTarget,
|
|
795
|
+
sessionKey,
|
|
796
|
+
isDm,
|
|
797
|
+
senderId,
|
|
798
|
+
senderName,
|
|
799
|
+
conversationId,
|
|
800
|
+
};
|
|
801
|
+
messageBuffer.set(bufferKey, newEntry);
|
|
802
|
+
|
|
803
|
+
log?.info?.(`[dingtalk] Message buffered (new), waiting ${aggregationDelayMs}ms for more...`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Flush the message buffer and dispatch the combined message.
|
|
809
|
+
*/
|
|
810
|
+
async function flushMessageBuffer(bufferKey: string): Promise<void> {
|
|
811
|
+
const entry = messageBuffer.get(bufferKey);
|
|
812
|
+
if (!entry) return;
|
|
813
|
+
|
|
814
|
+
messageBuffer.delete(bufferKey);
|
|
815
|
+
|
|
816
|
+
const { messages, ctx, msg, replyTarget, sessionKey, isDm, senderId, senderName, conversationId } = entry;
|
|
817
|
+
const { log } = ctx;
|
|
818
|
+
|
|
819
|
+
// Combine all messages
|
|
820
|
+
const combinedText = messages.map(m => m.text).join('\n');
|
|
821
|
+
// Use the last media if any
|
|
822
|
+
const lastWithMedia = [...messages].reverse().find(m => m.mediaPath);
|
|
823
|
+
const mediaPath = lastWithMedia?.mediaPath;
|
|
824
|
+
const mediaType = lastWithMedia?.mediaType;
|
|
825
|
+
|
|
826
|
+
log?.info?.(`[dingtalk] Flushing buffer: ${messages.length} message(s) combined into ${combinedText.length} chars`);
|
|
827
|
+
|
|
828
|
+
// Dispatch the combined message
|
|
829
|
+
await dispatchMessage({
|
|
830
|
+
ctx, msg, rawBody: combinedText, replyTarget, sessionKey, isDm, senderId, senderName, conversationId,
|
|
831
|
+
mediaPath, mediaType,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Dispatch a message to the agent (after aggregation or immediately).
|
|
837
|
+
*/
|
|
838
|
+
async function dispatchMessage(params: {
|
|
839
|
+
ctx: DingTalkMonitorContext;
|
|
840
|
+
msg: DingTalkRobotMessage;
|
|
841
|
+
rawBody: string;
|
|
842
|
+
replyTarget: any;
|
|
843
|
+
sessionKey: string;
|
|
844
|
+
isDm: boolean;
|
|
845
|
+
senderId: string;
|
|
846
|
+
senderName: string;
|
|
847
|
+
conversationId: string;
|
|
848
|
+
mediaPath?: string;
|
|
849
|
+
mediaType?: string;
|
|
850
|
+
}): Promise<void> {
|
|
851
|
+
const { ctx, msg, rawBody, replyTarget, sessionKey, isDm, senderId, senderName, conversationId, mediaPath, mediaType } = params;
|
|
852
|
+
const { account, cfg, log, setStatus } = ctx;
|
|
853
|
+
const runtime = getDingTalkRuntime();
|
|
854
|
+
const isGroup = !isDm;
|
|
855
|
+
|
|
665
856
|
// Send thinking feedback (opt-in)
|
|
666
|
-
if (account.config.showThinking &&
|
|
857
|
+
if (account.config.showThinking && replyTarget.sessionWebhook) {
|
|
667
858
|
try {
|
|
668
|
-
await sendViaSessionWebhook(
|
|
859
|
+
await sendViaSessionWebhook(replyTarget.sessionWebhook, '正在思考...');
|
|
669
860
|
log?.info?.('[dingtalk] Sent thinking indicator');
|
|
670
861
|
} catch (_) {
|
|
671
862
|
// fire-and-forget, don't block processing
|
package/src/types.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface DingTalkRobotMessage {
|
|
|
18
18
|
text?: { content: string; isReplyMsg?: boolean; repliedMsg?: any };
|
|
19
19
|
richText?: unknown;
|
|
20
20
|
picture?: { downloadCode: string };
|
|
21
|
+
/** Link card message content */
|
|
22
|
+
link?: { title?: string; text?: string; messageUrl?: string; picUrl?: string };
|
|
21
23
|
/** Generic content field used by audio/video/file message types */
|
|
22
24
|
content?: any;
|
|
23
25
|
atUsers?: Array<{ dingtalkId: string; staffId?: string }>;
|