@soimy/dingtalk 3.1.3 → 3.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.
@@ -2,15 +2,13 @@ import * as path from "node:path";
2
2
  import axios from "axios";
3
3
  import { getAccessToken } from "./auth";
4
4
  import {
5
- deleteActiveCardByTarget,
6
- getActiveCardIdByTarget,
7
- getCardById,
8
5
  isCardInTerminalState,
6
+ sendProactiveCardText,
9
7
  streamAICard,
10
8
  } from "./card-service";
11
9
  import { stripTargetPrefix } from "./config";
12
10
  import { getLogger } from "./logger-context";
13
- import { uploadMedia as uploadMediaUtil } from "./media-utils";
11
+ import { getVoiceDurationMs, uploadMedia as uploadMediaUtil } from "./media-utils";
14
12
  import { detectMarkdownAndExtractTitle } from "./message-utils";
15
13
  import { resolveOriginalPeerId } from "./peer-id-registry";
16
14
  import {
@@ -20,6 +18,7 @@ import {
20
18
  } from "./proactive-risk-registry";
21
19
  import { formatDingTalkErrorPayloadLog } from "./utils";
22
20
  import type {
21
+ AICardInstance,
23
22
  AxiosResponse,
24
23
  DingTalkConfig,
25
24
  Logger,
@@ -31,6 +30,26 @@ import { AICardStatus } from "./types";
31
30
 
32
31
  export { detectMediaTypeFromExtension } from "./media-utils";
33
32
 
33
+ function composeCardContentForAppend(previous: string | undefined, incoming: string): string {
34
+ const prev = previous ?? "";
35
+ if (!prev) {
36
+ return incoming;
37
+ }
38
+ if (!incoming) {
39
+ return prev;
40
+ }
41
+ if (incoming.startsWith(prev)) {
42
+ return incoming;
43
+ }
44
+ if (prev.endsWith(incoming)) {
45
+ return prev;
46
+ }
47
+ if (prev.endsWith("\n") || incoming.startsWith("\n")) {
48
+ return `${prev}${incoming}`;
49
+ }
50
+ return `${prev}${incoming}`;
51
+ }
52
+
34
53
  function extractErrorCodeFromResponseData(data: unknown): string | null {
35
54
  if (!data || typeof data !== "object") {
36
55
  return null;
@@ -82,7 +101,6 @@ export async function sendProactiveTextOrMarkdown(
82
101
  options: SendMessageOptions = {},
83
102
  ): Promise<AxiosResponse> {
84
103
  const log = options.log || getLogger();
85
- const token = await getAccessToken(config, log);
86
104
 
87
105
  // Support group:/user: prefix and restore original case-sensitive conversationId.
88
106
  const { targetId, isExplicitUser } = stripTargetPrefix(target);
@@ -95,6 +113,22 @@ export async function sendProactiveTextOrMarkdown(
95
113
  ? ` proactiveRisk=${proactiveRisk.level}:${proactiveRisk.reason}`
96
114
  : "";
97
115
 
116
+ // In card mode, use card API to avoid oToMessages/batchSend permission requirement.
117
+ const messageType = config.messageType || "markdown";
118
+ if (messageType === "card" && config.cardTemplateId) {
119
+ log?.debug?.(
120
+ `[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
121
+ );
122
+ const result = await sendProactiveCardText(config, resolvedTarget, text, log);
123
+ if (result.ok) {
124
+ return {} as AxiosResponse; // Return empty response for compatibility
125
+ }
126
+ log?.warn?.(
127
+ `[DingTalk] Proactive card send failed, fallback to proactive template API: ${result.error || "unknown"}`,
128
+ );
129
+ }
130
+
131
+ const token = await getAccessToken(config, log);
98
132
  const url = isGroup
99
133
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
100
134
  : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
@@ -207,7 +241,8 @@ export async function sendProactiveMedia(
207
241
  msgParam = JSON.stringify({ photoURL: mediaId });
208
242
  } else if (mediaType === "voice") {
209
243
  msgKey = "sampleAudio";
210
- msgParam = JSON.stringify({ mediaId, duration: "0" });
244
+ const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log);
245
+ msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
211
246
  } else {
212
247
  // sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
213
248
  const filename = path.basename(mediaPath);
@@ -293,7 +328,8 @@ export async function sendBySession(
293
328
  if (options.mediaType === "image") {
294
329
  body = { msgtype: "image", image: { media_id: mediaId } };
295
330
  } else if (options.mediaType === "voice") {
296
- body = { msgtype: "voice", voice: { media_id: mediaId } };
331
+ const durationMs = await getVoiceDurationMs(options.mediaPath, options.mediaType, log);
332
+ body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
297
333
  } else if (options.mediaType === "video") {
298
334
  body = { msgtype: "video", video: { media_id: mediaId } };
299
335
  } else if (options.mediaType === "file") {
@@ -345,32 +381,42 @@ export async function sendMessage(
345
381
  config: DingTalkConfig,
346
382
  conversationId: string,
347
383
  text: string,
348
- options: SendMessageOptions & { sessionWebhook?: string; accountId?: string } = {},
384
+ options: SendMessageOptions & { sessionWebhook?: string; card?: AICardInstance } = {},
349
385
  ): Promise<{ ok: boolean; error?: string; data?: AxiosResponse }> {
350
386
  try {
351
387
  const messageType = config.messageType || "markdown";
352
388
  const log = options.log || getLogger();
353
389
 
354
- // Card mode: stream into active card if exists; otherwise fallback to markdown/session send.
355
- if (messageType === "card" && options.accountId) {
356
- const targetKey = `${options.accountId}:${conversationId}`;
357
- const activeCardId = getActiveCardIdByTarget(targetKey);
358
- if (activeCardId) {
359
- const activeCard = getCardById(activeCardId);
360
- if (activeCard && !isCardInTerminalState(activeCard.state)) {
361
- try {
362
- await streamAICard(activeCard, text, false, log);
363
- return { ok: true };
364
- } catch (err: any) {
365
- // Mark failed and continue to markdown fallback to avoid message loss.
366
- log?.warn?.(
367
- `[DingTalk] AI Card streaming failed, fallback to markdown: ${err.message}`,
368
- );
369
- activeCard.state = AICardStatus.FAILED;
370
- activeCard.lastUpdated = Date.now();
390
+ if (messageType === "card" && options.card) {
391
+ const card = options.card;
392
+ if (isCardInTerminalState(card.state)) {
393
+ if (options.sessionWebhook) {
394
+ await sendBySession(config, options.sessionWebhook, text, options);
395
+ return { ok: true };
396
+ }
397
+
398
+ if (config.cardTemplateId) {
399
+ const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
400
+ if (!proactiveResult.ok) {
401
+ return { ok: false, error: proactiveResult.error || "Card send failed" };
371
402
  }
372
- } else {
373
- deleteActiveCardByTarget(targetKey);
403
+ return { ok: true };
404
+ }
405
+ } else {
406
+ try {
407
+ const mode = options.cardUpdateMode || "replace";
408
+ const shouldFinalize = mode === "finalize" || options.cardFinalize === true;
409
+ const nextContent =
410
+ mode === "append"
411
+ ? composeCardContentForAppend(card.lastStreamedContent, text)
412
+ : text;
413
+ await streamAICard(card, nextContent, shouldFinalize, log);
414
+ return { ok: true };
415
+ } catch (err: any) {
416
+ log?.warn?.(`[DingTalk] AI Card streaming failed: ${err.message}`);
417
+ card.state = AICardStatus.FAILED;
418
+ card.lastUpdated = Date.now();
419
+ return { ok: false, error: err.message };
374
420
  }
375
421
  }
376
422
  }
@@ -0,0 +1,32 @@
1
+ // Per-session mutex to serialize dispatchReply calls for the same session.
2
+ // Prevents concurrent dispatch from producing empty replies when the runtime
3
+ // cannot handle overlapping requests on the same session key.
4
+
5
+ const locks = new Map<string, Promise<void>>();
6
+
7
+ /**
8
+ * Acquire a per-session lock. Returns a release function that MUST be called
9
+ * (typically in a `finally` block) to unblock the next queued caller.
10
+ *
11
+ * Different session keys run in parallel; same key runs serially.
12
+ */
13
+ export async function acquireSessionLock(sessionKey: string): Promise<() => void> {
14
+ let release!: () => void;
15
+ const gate = new Promise<void>((r) => {
16
+ release = r;
17
+ });
18
+ const prev = locks.get(sessionKey) ?? Promise.resolve();
19
+ locks.set(sessionKey, gate);
20
+ await prev;
21
+ return () => {
22
+ if (locks.get(sessionKey) === gate) {
23
+ locks.delete(sessionKey);
24
+ }
25
+ release();
26
+ };
27
+ }
28
+
29
+ /** Visible for testing only. */
30
+ export function _getLocksMapForTest(): Map<string, Promise<void>> {
31
+ return locks;
32
+ }
package/src/types.ts CHANGED
@@ -40,6 +40,7 @@ export interface DingTalkConfig extends OpenClawConfig {
40
40
  dmPolicy?: "open" | "pairing" | "allowlist";
41
41
  groupPolicy?: "open" | "allowlist";
42
42
  allowFrom?: string[];
43
+ mediaUrlAllowlist?: string[];
43
44
  showThinking?: boolean;
44
45
  debug?: boolean;
45
46
  messageType?: "markdown" | "card";
@@ -54,6 +55,10 @@ export interface DingTalkConfig extends OpenClawConfig {
54
55
  reconnectJitter?: number;
55
56
  /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
56
57
  maxReconnectCycles?: number;
58
+ /** Whether to use ConnectionManager; when false, use DWClient native keepAlive+autoReconnect */
59
+ useConnectionManager?: boolean;
60
+ /** Maximum inbound media file size in MB (overrides runtime default when set) */
61
+ mediaMaxMb?: number;
57
62
  proactivePermissionHint?: {
58
63
  enabled?: boolean;
59
64
  cooldownHours?: number;
@@ -74,6 +79,7 @@ export interface DingTalkChannelConfig {
74
79
  dmPolicy?: "open" | "pairing" | "allowlist";
75
80
  groupPolicy?: "open" | "allowlist";
76
81
  allowFrom?: string[];
82
+ mediaUrlAllowlist?: string[];
77
83
  showThinking?: boolean;
78
84
  debug?: boolean;
79
85
  messageType?: "markdown" | "card";
@@ -87,6 +93,10 @@ export interface DingTalkChannelConfig {
87
93
  reconnectJitter?: number;
88
94
  /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
89
95
  maxReconnectCycles?: number;
96
+ /** Whether to use ConnectionManager; when false, use DWClient native keepAlive+autoReconnect */
97
+ useConnectionManager?: boolean;
98
+ /** Maximum inbound media file size in MB (overrides runtime default when set) */
99
+ mediaMaxMb?: number;
90
100
  proactivePermissionHint?: {
91
101
  enabled?: boolean;
92
102
  cooldownHours?: number;
@@ -214,6 +224,8 @@ export interface SendMessageOptions {
214
224
  mediaUrl?: string;
215
225
  mediaType?: "image" | "voice" | "video" | "file";
216
226
  accountId?: string;
227
+ cardUpdateMode?: "replace" | "append" | "finalize";
228
+ cardFinalize?: boolean;
217
229
  }
218
230
 
219
231
  /**
@@ -446,6 +458,7 @@ export interface AICardInstance {
446
458
  lastUpdated: number;
447
459
  state: AICardState; // Current card state: PROCESSING, INPUTING, FINISHED, FAILED
448
460
  config?: DingTalkConfig; // Store config reference for token refresh
461
+ lastStreamedContent?: string;
449
462
  }
450
463
 
451
464
  /**
@@ -567,6 +580,8 @@ export function resolveDingTalkAccount(
567
580
  maxReconnectDelay: dingtalk?.maxReconnectDelay,
568
581
  reconnectJitter: dingtalk?.reconnectJitter,
569
582
  maxReconnectCycles: dingtalk?.maxReconnectCycles,
583
+ useConnectionManager: dingtalk?.useConnectionManager,
584
+ mediaMaxMb: dingtalk?.mediaMaxMb,
570
585
  proactivePermissionHint: dingtalk?.proactivePermissionHint,
571
586
  };
572
587
  return {