@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/CHANGELOG.md +149 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/clawdbot.plugin.json +9 -0
- package/index.ts +16 -0
- package/package.json +55 -0
- package/src/accounts.ts +73 -0
- package/src/api.ts +382 -0
- package/src/channel.ts +271 -0
- package/src/config-schema.ts +115 -0
- package/src/monitor.ts +622 -0
- package/src/onboarding.ts +152 -0
- package/src/probe.ts +36 -0
- package/src/runtime.ts +10 -0
- package/src/types.ts +56 -0
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 
|
|
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 ``;
|
|
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] : '') + `})`;
|
|
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
|
+
}
|