@soimy/dingtalk 2.7.0 → 3.0.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.
@@ -0,0 +1,453 @@
1
+ import axios from "axios";
2
+ import { normalizeAllowFrom, isSenderAllowed, isSenderGroupAllowed } from "./access-control";
3
+ import { getAccessToken } from "./auth";
4
+ import {
5
+ cleanupCardCache,
6
+ createAICard,
7
+ finishAICard,
8
+ formatContentForCard,
9
+ getActiveCardIdByTarget,
10
+ getCardById,
11
+ isCardInTerminalState,
12
+ streamAICard,
13
+ } from "./card-service";
14
+ import { resolveGroupConfig } from "./config";
15
+ import { formatGroupMembers, noteGroupMember } from "./group-members-store";
16
+ import { setCurrentLogger } from "./logger-context";
17
+ import { extractMessageContent } from "./message-utils";
18
+ import { registerPeerId } from "./peer-id-registry";
19
+ import { getDingTalkRuntime } from "./runtime";
20
+ import { sendBySession, sendMessage } from "./send-service";
21
+ import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
22
+ import { AICardStatus } from "./types";
23
+ import { maskSensitiveData } from "./utils";
24
+
25
+ /**
26
+ * Download DingTalk media file via runtime media service (sandbox-compatible).
27
+ * Files are stored in the global media inbound directory.
28
+ */
29
+ export async function downloadMedia(
30
+ config: DingTalkConfig,
31
+ downloadCode: string,
32
+ log?: any,
33
+ ): Promise<MediaFile | null> {
34
+ const rt = getDingTalkRuntime();
35
+ const formatAxiosErrorData = (value: unknown): string | undefined => {
36
+ if (value === null || value === undefined) {
37
+ return undefined;
38
+ }
39
+ if (Buffer.isBuffer(value)) {
40
+ return `<buffer ${value.length} bytes>`;
41
+ }
42
+ if (value instanceof ArrayBuffer) {
43
+ return `<arraybuffer ${value.byteLength} bytes>`;
44
+ }
45
+ if (typeof value === "string") {
46
+ return value.length > 500 ? `${value.slice(0, 500)}…` : value;
47
+ }
48
+ try {
49
+ return JSON.stringify(maskSensitiveData(value));
50
+ } catch {
51
+ return String(value);
52
+ }
53
+ };
54
+
55
+ if (!downloadCode) {
56
+ log?.error?.("[DingTalk] downloadMedia requires downloadCode to be provided.");
57
+ return null;
58
+ }
59
+ if (!config.robotCode) {
60
+ if (log?.error) {
61
+ log.error("[DingTalk] downloadMedia requires robotCode to be configured.");
62
+ }
63
+ return null;
64
+ }
65
+ try {
66
+ const token = await getAccessToken(config, log);
67
+ const response = await axios.post(
68
+ "https://api.dingtalk.com/v1.0/robot/messageFiles/download",
69
+ { downloadCode, robotCode: config.robotCode },
70
+ { headers: { "x-acs-dingtalk-access-token": token } },
71
+ );
72
+ const payload = response.data as Record<string, any>;
73
+ const downloadUrl = payload?.downloadUrl ?? payload?.data?.downloadUrl;
74
+ if (!downloadUrl) {
75
+ const payloadDetail = formatAxiosErrorData(payload);
76
+ log?.error?.(
77
+ `[DingTalk] downloadMedia missing downloadUrl. payload=${payloadDetail ?? "unknown"}`,
78
+ );
79
+ return null;
80
+ }
81
+ const mediaResponse = await axios.get(downloadUrl, { responseType: "arraybuffer" });
82
+ const contentType = mediaResponse.headers["content-type"] || "application/octet-stream";
83
+ const buffer = Buffer.from(mediaResponse.data as ArrayBuffer);
84
+
85
+ // Keep inbound media handling consistent with other channels.
86
+ const saved = await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
87
+ log?.debug?.(`[DingTalk] Media saved: ${saved.path}`);
88
+ return { path: saved.path, mimeType: saved.contentType ?? contentType };
89
+ } catch (err: any) {
90
+ if (log?.error) {
91
+ if (axios.isAxiosError(err)) {
92
+ const status = err.response?.status;
93
+ const statusText = err.response?.statusText;
94
+ const dataDetail = formatAxiosErrorData(err.response?.data);
95
+ const code = err.code ? ` code=${err.code}` : "";
96
+ const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
97
+ log.error(
98
+ `[DingTalk] Failed to download media:${statusLabel}${code} message=${err.message}`,
99
+ );
100
+ if (dataDetail) {
101
+ log.error(`[DingTalk] downloadMedia response data: ${dataDetail}`);
102
+ }
103
+ } else {
104
+ log.error(`[DingTalk] Failed to download media: ${err.message}`);
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ }
110
+
111
+ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
112
+ const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig } = params;
113
+ const rt = getDingTalkRuntime();
114
+
115
+ // Save logger globally so shared services can log consistently without threading log everywhere.
116
+ setCurrentLogger(log);
117
+
118
+ log?.debug?.("[DingTalk] Full Inbound Data:", JSON.stringify(maskSensitiveData(data)));
119
+
120
+ // Clean up old terminal cards opportunistically on inbound traffic.
121
+ cleanupCardCache();
122
+
123
+ // 1) Ignore self messages from bot.
124
+ if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
125
+ log?.debug?.("[DingTalk] Ignoring robot self-message");
126
+ return;
127
+ }
128
+
129
+ const content = extractMessageContent(data);
130
+ if (!content.text) {
131
+ return;
132
+ }
133
+
134
+ const isDirect = data.conversationType === "1";
135
+ const senderId = data.senderStaffId || data.senderId;
136
+ const senderName = data.senderNick || "Unknown";
137
+ const groupId = data.conversationId;
138
+ const groupName = data.conversationTitle || "Group";
139
+
140
+ // Register original peer IDs to preserve case-sensitive DingTalk conversation IDs.
141
+ if (groupId) {
142
+ registerPeerId(groupId);
143
+ }
144
+ if (senderId) {
145
+ registerPeerId(senderId);
146
+ }
147
+
148
+ // 2) Authorization guard (DM/group policy).
149
+ let commandAuthorized = true;
150
+ if (isDirect) {
151
+ const dmPolicy = dingtalkConfig.dmPolicy || "open";
152
+ const allowFrom = dingtalkConfig.allowFrom || [];
153
+
154
+ if (dmPolicy === "allowlist") {
155
+ const normalizedAllowFrom = normalizeAllowFrom(allowFrom);
156
+ const isAllowed = isSenderAllowed({ allow: normalizedAllowFrom, senderId });
157
+
158
+ if (!isAllowed) {
159
+ log?.debug?.(
160
+ `[DingTalk] DM blocked: senderId=${senderId} not in allowlist (dmPolicy=allowlist)`,
161
+ );
162
+ try {
163
+ await sendBySession(
164
+ dingtalkConfig,
165
+ sessionWebhook,
166
+ `⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到允许列表中。`,
167
+ { log },
168
+ );
169
+ } catch (err: any) {
170
+ log?.debug?.(`[DingTalk] Failed to send access denied message: ${err.message}`);
171
+ }
172
+
173
+ return;
174
+ }
175
+
176
+ log?.debug?.(`[DingTalk] DM authorized: senderId=${senderId} in allowlist`);
177
+ } else if (dmPolicy === "pairing") {
178
+ // SDK pairing flow performs actual authorization checks.
179
+ commandAuthorized = true;
180
+ } else {
181
+ commandAuthorized = true;
182
+ }
183
+ } else {
184
+ const groupPolicy = dingtalkConfig.groupPolicy || "open";
185
+ const allowFrom = dingtalkConfig.allowFrom || [];
186
+
187
+ if (groupPolicy === "allowlist") {
188
+ const normalizedAllowFrom = normalizeAllowFrom(allowFrom);
189
+ const isAllowed = isSenderGroupAllowed({ allow: normalizedAllowFrom, groupId });
190
+
191
+ if (!isAllowed) {
192
+ log?.debug?.(
193
+ `[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} not in allowlist (groupPolicy=allowlist)`,
194
+ );
195
+
196
+ try {
197
+ await sendBySession(
198
+ dingtalkConfig,
199
+ sessionWebhook,
200
+ `⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`,
201
+ { log, atUserId: senderId },
202
+ );
203
+ } catch (err: any) {
204
+ log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
205
+ }
206
+
207
+ return;
208
+ }
209
+
210
+ log?.debug?.(
211
+ `[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId} in allowlist`,
212
+ );
213
+ }
214
+ }
215
+
216
+ const route = rt.channel.routing.resolveAgentRoute({
217
+ cfg,
218
+ channel: "dingtalk",
219
+ accountId,
220
+ peer: { kind: isDirect ? "direct" : "group", id: isDirect ? senderId : groupId },
221
+ });
222
+
223
+ // Route resolved before media download for session context and routing metadata.
224
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
225
+ agentId: route.agentId,
226
+ });
227
+
228
+ let mediaPath: string | undefined;
229
+ let mediaType: string | undefined;
230
+ if (content.mediaPath && dingtalkConfig.robotCode) {
231
+ const media = await downloadMedia(dingtalkConfig, content.mediaPath, log);
232
+ if (media) {
233
+ mediaPath = media.path;
234
+ mediaType = media.mimeType;
235
+ }
236
+ }
237
+ const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
238
+ const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
239
+ storePath,
240
+ sessionKey: route.sessionKey,
241
+ });
242
+
243
+ const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
244
+ // GroupSystemPrompt is injected every turn (not only first-turn intro).
245
+ const groupSystemPrompt = !isDirect
246
+ ? [`DingTalk group context: conversationId=${groupId}`, groupConfig?.systemPrompt?.trim()]
247
+ .filter(Boolean)
248
+ .join("\n")
249
+ : undefined;
250
+
251
+ if (!isDirect) {
252
+ noteGroupMember(storePath, groupId, senderId, senderName);
253
+ }
254
+ const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
255
+
256
+ const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
257
+ const body = rt.channel.reply.formatInboundEnvelope({
258
+ channel: "DingTalk",
259
+ from: fromLabel,
260
+ timestamp: data.createAt,
261
+ body: content.text,
262
+ chatType: isDirect ? "direct" : "group",
263
+ sender: { name: senderName, id: senderId },
264
+ previousTimestamp,
265
+ envelope: envelopeOptions,
266
+ });
267
+
268
+ const to = isDirect ? senderId : groupId;
269
+ const ctx = rt.channel.reply.finalizeInboundContext({
270
+ Body: body,
271
+ RawBody: content.text,
272
+ CommandBody: content.text,
273
+ From: to,
274
+ To: to,
275
+ SessionKey: route.sessionKey,
276
+ AccountId: accountId,
277
+ ChatType: isDirect ? "direct" : "group",
278
+ ConversationLabel: fromLabel,
279
+ GroupSubject: isDirect ? undefined : groupName,
280
+ SenderName: senderName,
281
+ SenderId: senderId,
282
+ Provider: "dingtalk",
283
+ Surface: "dingtalk",
284
+ MessageSid: data.msgId,
285
+ Timestamp: data.createAt,
286
+ MediaPath: mediaPath,
287
+ MediaType: mediaType,
288
+ MediaUrl: mediaPath,
289
+ GroupMembers: groupMembers,
290
+ GroupSystemPrompt: groupSystemPrompt,
291
+ GroupChannel: isDirect ? undefined : route.sessionKey,
292
+ CommandAuthorized: commandAuthorized,
293
+ OriginatingChannel: "dingtalk",
294
+ OriginatingTo: to,
295
+ });
296
+
297
+ await rt.channel.session.recordInboundSession({
298
+ storePath,
299
+ sessionKey: ctx.SessionKey || route.sessionKey,
300
+ ctx,
301
+ updateLastRoute: { sessionKey: route.mainSessionKey, channel: "dingtalk", to, accountId },
302
+ onRecordError: (err: unknown) => {
303
+ log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
304
+ },
305
+ });
306
+
307
+ log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
308
+
309
+ // 3) Select response mode (card vs markdown).
310
+ const useCardMode = dingtalkConfig.messageType === "card";
311
+ let currentAICard = undefined;
312
+ let lastCardContent = "";
313
+
314
+ if (useCardMode) {
315
+ const targetKey = `${accountId}:${to}`;
316
+ const existingCardId = getActiveCardIdByTarget(targetKey);
317
+ const existingCard = existingCardId ? getCardById(existingCardId) : undefined;
318
+
319
+ // Reuse active non-terminal card to keep one card per conversation.
320
+ if (existingCard && !isCardInTerminalState(existingCard.state)) {
321
+ currentAICard = existingCard;
322
+ log?.debug?.("[DingTalk] Reusing existing active AI card for this conversation.");
323
+ } else {
324
+ try {
325
+ const aiCard = await createAICard(dingtalkConfig, to, data, accountId, log);
326
+ if (aiCard) {
327
+ currentAICard = aiCard;
328
+ } else {
329
+ log?.warn?.(
330
+ "[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
331
+ );
332
+ }
333
+ } catch (err: any) {
334
+ log?.warn?.(
335
+ `[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
336
+ );
337
+ }
338
+ }
339
+ }
340
+
341
+ // 4) Optional "thinking..." feedback for non-card mode.
342
+ if (dingtalkConfig.showThinking !== false) {
343
+ try {
344
+ const thinkingText = "🤔 思考中,请稍候...";
345
+ if (useCardMode && currentAICard) {
346
+ log?.debug?.("[DingTalk] AI Card in thinking state, skipping thinking message send.");
347
+ } else {
348
+ lastCardContent = thinkingText;
349
+ await sendMessage(dingtalkConfig, to, thinkingText, {
350
+ sessionWebhook,
351
+ atUserId: !isDirect ? senderId : null,
352
+ log,
353
+ accountId,
354
+ });
355
+ }
356
+ } catch (err: any) {
357
+ log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
358
+ }
359
+ }
360
+
361
+ const { queuedFinal } = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
362
+ ctx,
363
+ cfg,
364
+ dispatcherOptions: {
365
+ responsePrefix: "",
366
+ deliver: async (payload: any, info?: { kind: string }) => {
367
+ try {
368
+ const textToSend = payload.markdown || payload.text;
369
+ if (!textToSend) {
370
+ return;
371
+ }
372
+
373
+ // Tool outputs are rendered into card stream as a separate formatted block.
374
+ if (useCardMode && currentAICard && info?.kind === "tool") {
375
+ log?.info?.(
376
+ `[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`,
377
+ );
378
+ const toolText = formatContentForCard(textToSend, "tool");
379
+ if (toolText) {
380
+ await streamAICard(currentAICard, toolText, false, log);
381
+ return;
382
+ }
383
+ }
384
+
385
+ lastCardContent = textToSend;
386
+ await sendMessage(dingtalkConfig, to, textToSend, {
387
+ sessionWebhook,
388
+ atUserId: !isDirect ? senderId : null,
389
+ log,
390
+ accountId,
391
+ });
392
+ } catch (err: any) {
393
+ log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
394
+ throw err;
395
+ }
396
+ },
397
+ },
398
+ replyOptions: {
399
+ // Real-time reasoning stream support for card mode.
400
+ onReasoningStream: async (payload: any) => {
401
+ if (!useCardMode || !currentAICard) {
402
+ return;
403
+ }
404
+ const thinkingText = formatContentForCard(payload.text, "thinking");
405
+ if (!thinkingText) {
406
+ return;
407
+ }
408
+ try {
409
+ await streamAICard(currentAICard, thinkingText, false, log);
410
+ } catch (err: any) {
411
+ log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
412
+ }
413
+ },
414
+ },
415
+ });
416
+
417
+ // 5) Finalize card stream if card mode is active.
418
+ if (useCardMode && currentAICard) {
419
+ try {
420
+ const isNonEmptyString = (value: any): boolean =>
421
+ typeof value === "string" && value.trim().length > 0;
422
+
423
+ const hasLastCardContent = isNonEmptyString(lastCardContent);
424
+ const hasQueuedFinalString = isNonEmptyString(queuedFinal);
425
+
426
+ if (hasLastCardContent || hasQueuedFinalString) {
427
+ const finalContent =
428
+ hasLastCardContent && typeof lastCardContent === "string"
429
+ ? lastCardContent
430
+ : typeof queuedFinal === "string"
431
+ ? queuedFinal
432
+ : "";
433
+ await finishAICard(currentAICard, finalContent, log);
434
+ } else {
435
+ log?.debug?.(
436
+ "[DingTalk] Skipping AI Card finalization because no textual content was produced.",
437
+ );
438
+ currentAICard.state = AICardStatus.FINISHED;
439
+ currentAICard.lastUpdated = Date.now();
440
+ }
441
+ } catch (err: any) {
442
+ log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
443
+ try {
444
+ if (currentAICard.state !== AICardStatus.FINISHED) {
445
+ currentAICard.state = AICardStatus.FAILED;
446
+ currentAICard.lastUpdated = Date.now();
447
+ }
448
+ } catch (stateErr: any) {
449
+ log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
450
+ }
451
+ }
452
+ }
453
+ }
@@ -0,0 +1,17 @@
1
+ import type { Logger } from "./types";
2
+
3
+ let currentLogger: Logger | undefined;
4
+
5
+ /**
6
+ * Persist current request logger for shared services invoked outside handler scope.
7
+ */
8
+ export function setCurrentLogger(log?: Logger): void {
9
+ currentLogger = log;
10
+ }
11
+
12
+ /**
13
+ * Read current logger bound by inbound handler.
14
+ */
15
+ export function getLogger(): Logger | undefined {
16
+ return currentLogger;
17
+ }
@@ -5,14 +5,14 @@
5
5
  * Provides functions for media type detection and file upload to DingTalk media servers.
6
6
  */
7
7
 
8
- import * as path from 'path';
9
- import * as fs from 'fs';
10
- import { promises as fsPromises } from 'fs';
11
- import axios from 'axios';
12
- import FormData from 'form-data';
13
- import type { DingTalkConfig, Logger } from './types';
8
+ import * as fs from "fs";
9
+ import { promises as fsPromises } from "fs";
10
+ import * as path from "path";
11
+ import axios from "axios";
12
+ import FormData from "form-data";
13
+ import type { DingTalkConfig, Logger } from "./types";
14
14
 
15
- export type DingTalkMediaType = 'image' | 'voice' | 'video' | 'file';
15
+ export type DingTalkMediaType = "image" | "voice" | "video" | "file";
16
16
 
17
17
  /**
18
18
  * Detect media type from file extension
@@ -28,15 +28,15 @@ export type DingTalkMediaType = 'image' | 'voice' | 'video' | 'file';
28
28
  export function detectMediaTypeFromExtension(filePath: string): DingTalkMediaType {
29
29
  const ext = path.extname(filePath).toLowerCase();
30
30
 
31
- if (['.jpg', '.jpeg', '.png', '.gif', '.bmp'].includes(ext)) {
32
- return 'image';
33
- } else if (['.mp3', '.amr', '.wav'].includes(ext)) {
34
- return 'voice';
35
- } else if (['.mp4', '.avi', '.mov'].includes(ext)) {
36
- return 'video';
31
+ if ([".jpg", ".jpeg", ".png", ".gif", ".bmp"].includes(ext)) {
32
+ return "image";
33
+ } else if ([".mp3", ".amr", ".wav"].includes(ext)) {
34
+ return "voice";
35
+ } else if ([".mp4", ".avi", ".mov"].includes(ext)) {
36
+ return "video";
37
37
  }
38
38
 
39
- return 'file';
39
+ return "file";
40
40
  }
41
41
 
42
42
  /**
@@ -68,7 +68,7 @@ export async function uploadMedia(
68
68
  mediaPath: string,
69
69
  mediaType: DingTalkMediaType,
70
70
  getAccessToken: (config: DingTalkConfig, log?: Logger) => Promise<string>,
71
- log?: Logger
71
+ log?: Logger,
72
72
  ): Promise<string | null> {
73
73
  let fileStream: fs.ReadStream | null = null;
74
74
 
@@ -81,7 +81,9 @@ export async function uploadMedia(
81
81
  if (stats.size > sizeLimit) {
82
82
  const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
83
83
  const limitMB = (sizeLimit / (1024 * 1024)).toFixed(2);
84
- log?.error?.(`[DingTalk] Media file too large: ${sizeMB}MB exceeds ${limitMB}MB limit for ${mediaType}`);
84
+ log?.error?.(
85
+ `[DingTalk] Media file too large: ${sizeMB}MB exceeds ${limitMB}MB limit for ${mediaType}`,
86
+ );
85
87
  return null;
86
88
  }
87
89
 
@@ -91,7 +93,7 @@ export async function uploadMedia(
91
93
 
92
94
  // Upload to DingTalk's media server using form-data
93
95
  const form = new FormData();
94
- form.append('media', fileStream, { filename });
96
+ form.append("media", fileStream, { filename });
95
97
 
96
98
  const uploadUrl = `https://oapi.dingtalk.com/media/upload?access_token=${token}&type=${mediaType}`;
97
99
 
@@ -104,7 +106,9 @@ export async function uploadMedia(
104
106
  });
105
107
 
106
108
  if (response.data?.errcode === 0 && response.data?.media_id) {
107
- log?.debug?.(`[DingTalk] Media uploaded successfully: ${response.data.media_id} (${stats.size} bytes)`);
109
+ log?.debug?.(
110
+ `[DingTalk] Media uploaded successfully: ${response.data.media_id} (${stats.size} bytes)`,
111
+ );
108
112
  return response.data.media_id;
109
113
  } else {
110
114
  log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
@@ -112,9 +116,9 @@ export async function uploadMedia(
112
116
  }
113
117
  } catch (err: any) {
114
118
  // Handle file system errors (e.g., file not found, permission denied)
115
- if (err.code === 'ENOENT') {
119
+ if (err.code === "ENOENT") {
116
120
  log?.error?.(`[DingTalk] Media file not found: ${mediaPath}`);
117
- } else if (err.code === 'EACCES') {
121
+ } else if (err.code === "EACCES") {
118
122
  log?.error?.(`[DingTalk] Permission denied accessing media file: ${mediaPath}`);
119
123
  } else {
120
124
  log?.error?.(`[DingTalk] Failed to upload media: ${err.message}`);