@soimy/dingtalk 3.1.4 β†’ 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,14 +2,10 @@ import axios from "axios";
2
2
  import { normalizeAllowFrom, isSenderAllowed, isSenderGroupAllowed } from "./access-control";
3
3
  import { getAccessToken } from "./auth";
4
4
  import {
5
- cleanupCardCache,
6
5
  createAICard,
7
6
  finishAICard,
8
7
  formatContentForCard,
9
- getActiveCardIdByTarget,
10
- getCardById,
11
8
  isCardInTerminalState,
12
- streamAICard,
13
9
  } from "./card-service";
14
10
  import { resolveGroupConfig } from "./config";
15
11
  import { formatGroupMembers, noteGroupMember } from "./group-members-store";
@@ -24,6 +20,7 @@ import { getDingTalkRuntime } from "./runtime";
24
20
  import { sendBySession, sendMessage } from "./send-service";
25
21
  import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
26
22
  import { AICardStatus } from "./types";
23
+ import { acquireSessionLock } from "./session-lock";
27
24
  import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
28
25
 
29
26
  const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
@@ -148,8 +145,11 @@ export async function downloadMedia(
148
145
  const contentType = mediaResponse.headers["content-type"] || "application/octet-stream";
149
146
  const buffer = Buffer.from(mediaResponse.data as ArrayBuffer);
150
147
 
151
- // Keep inbound media handling consistent with other channels.
152
- const saved = await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
148
+ const maxBytes =
149
+ config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
150
+ const saved = maxBytes
151
+ ? await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes)
152
+ : await rt.channel.media.saveMediaBuffer(buffer, contentType, "inbound");
153
153
  log?.debug?.(`[DingTalk] Media saved: ${saved.path}`);
154
154
  return { path: saved.path, mimeType: saved.contentType ?? contentType };
155
155
  } catch (err: any) {
@@ -185,9 +185,6 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
185
185
 
186
186
  log?.debug?.("[DingTalk] Full Inbound Data:", JSON.stringify(maskSensitiveData(data)));
187
187
 
188
- // Clean up old terminal cards opportunistically on inbound traffic.
189
- cleanupCardCache();
190
-
191
188
  // 1) Ignore self messages from bot.
192
189
  if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
193
190
  log?.debug?.("[DingTalk] Ignoring robot self-message");
@@ -326,6 +323,35 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
326
323
  agentId: route.agentId,
327
324
  });
328
325
 
326
+ const to = isDirect ? senderId : groupId;
327
+
328
+ // 3) Select response mode (card vs markdown).
329
+ // Card creation runs BEFORE media download so the user sees immediate visual
330
+ // feedback while large files are still being downloaded.
331
+ const useCardMode = dingtalkConfig.messageType === "card";
332
+ let currentAICard = undefined;
333
+ let lastCardContent = "";
334
+
335
+ if (useCardMode) {
336
+ try {
337
+ log?.debug?.(
338
+ `[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${to}`,
339
+ );
340
+ const aiCard = await createAICard(dingtalkConfig, to, log);
341
+ if (aiCard) {
342
+ currentAICard = aiCard;
343
+ } else {
344
+ log?.warn?.(
345
+ "[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
346
+ );
347
+ }
348
+ } catch (err: any) {
349
+ log?.warn?.(
350
+ `[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
351
+ );
352
+ }
353
+ }
354
+
329
355
  let mediaPath: string | undefined;
330
356
  let mediaType: string | undefined;
331
357
  if (content.mediaPath && dingtalkConfig.robotCode) {
@@ -366,7 +392,6 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
366
392
  envelope: envelopeOptions,
367
393
  });
368
394
 
369
- const to = isDirect ? senderId : groupId;
370
395
  const ctx = rt.channel.reply.finalizeInboundContext({
371
396
  Body: body,
372
397
  RawBody: content.text,
@@ -407,199 +432,218 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
407
432
 
408
433
  log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
409
434
 
410
- // 3) Select response mode (card vs markdown).
411
- const useCardMode = dingtalkConfig.messageType === "card";
412
- let currentAICard = undefined;
413
- let lastCardContent = "";
414
-
415
- if (useCardMode) {
416
- const targetKey = `${accountId}:${to}`;
417
- const existingCardId = getActiveCardIdByTarget(targetKey);
418
- const existingCard = existingCardId ? getCardById(existingCardId) : undefined;
419
-
420
- // Reuse active non-terminal card to keep one card per conversation.
421
- if (existingCard && !isCardInTerminalState(existingCard.state)) {
422
- currentAICard = existingCard;
423
- log?.debug?.("[DingTalk] Reusing existing active AI card for this conversation.");
424
- } else {
435
+ // Serialize dispatchReply + card finalize per session to prevent the runtime
436
+ // from receiving concurrent dispatch calls on the same session key, which
437
+ // causes empty replies for all but the first caller.
438
+ const releaseSessionLock = await acquireSessionLock(route.sessionKey);
439
+ try {
440
+ // 4) Optional "thinking..." feedback for non-card mode.
441
+ if (dingtalkConfig.showThinking !== false) {
425
442
  try {
426
- const aiCard = await createAICard(dingtalkConfig, to, data, accountId, log);
427
- if (aiCard) {
428
- currentAICard = aiCard;
443
+ const thinkingText = "πŸ€” ζ€θ€ƒδΈ­οΌŒθ―·η¨ε€™...";
444
+ if (useCardMode && currentAICard) {
445
+ log?.debug?.("[DingTalk] AI Card in thinking state, skipping thinking message send.");
429
446
  } else {
430
- log?.warn?.(
431
- "[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.",
432
- );
447
+ lastCardContent = thinkingText;
448
+ const sendResult = await sendMessage(dingtalkConfig, to, thinkingText, {
449
+ sessionWebhook,
450
+ atUserId: !isDirect ? senderId : null,
451
+ log,
452
+ card: currentAICard,
453
+ });
454
+ if (!sendResult.ok) {
455
+ throw new Error(sendResult.error || "Thinking message send failed");
456
+ }
433
457
  }
434
458
  } catch (err: any) {
435
- log?.warn?.(
436
- `[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`,
437
- );
459
+ log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
460
+ if (err?.response?.data !== undefined) {
461
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
462
+ }
438
463
  }
439
464
  }
440
- }
441
465
 
442
- // 4) Optional "thinking..." feedback for non-card mode.
443
- if (dingtalkConfig.showThinking !== false) {
466
+ let queuedFinal: unknown;
444
467
  try {
445
- const thinkingText = "πŸ€” ζ€θ€ƒδΈ­οΌŒθ―·η¨ε€™...";
446
- if (useCardMode && currentAICard) {
447
- log?.debug?.("[DingTalk] AI Card in thinking state, skipping thinking message send.");
448
- } else {
449
- lastCardContent = thinkingText;
450
- await sendMessage(dingtalkConfig, to, thinkingText, {
451
- sessionWebhook,
452
- atUserId: !isDirect ? senderId : null,
453
- log,
454
- accountId,
455
- });
456
- }
457
- } catch (err: any) {
458
- log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
459
- if (err?.response?.data !== undefined) {
460
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingMessage", err.response.data));
461
- }
462
- }
463
- }
464
-
465
- const { queuedFinal } = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
466
- ctx,
467
- cfg,
468
- dispatcherOptions: {
469
- responsePrefix: "",
470
- deliver: async (payload: any, info?: { kind: string }) => {
471
- try {
472
- const textToSend = payload.markdown || payload.text;
473
- if (!textToSend) {
474
- return;
475
- }
476
-
477
- if (typeof textToSend === "string" && isUnhandledStopReasonText(textToSend)) {
478
- log?.warn?.(`[DingTalk] Suppressed stop reason from outbound chat content: ${textToSend}`);
479
- return;
480
- }
481
-
482
- if (useCardMode && currentAICard && info?.kind === "final") {
483
- lastCardContent = textToSend;
484
- return;
485
- }
486
-
487
- // Tool outputs are rendered into card stream as a separate formatted block.
488
- if (useCardMode && currentAICard && info?.kind === "tool") {
468
+ const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
469
+ ctx,
470
+ cfg,
471
+ dispatcherOptions: {
472
+ responsePrefix: "",
473
+ deliver: async (payload: any, info?: { kind: string }) => {
474
+ try {
475
+ const textToSend = payload.markdown || payload.text;
476
+ if (!textToSend) {
477
+ return;
478
+ }
479
+
480
+ if (typeof textToSend === "string" && isUnhandledStopReasonText(textToSend)) {
481
+ log?.warn?.(`[DingTalk] Suppressed stop reason from outbound chat content: ${textToSend}`);
482
+ return;
483
+ }
484
+
485
+ if (useCardMode && currentAICard && info?.kind === "final") {
486
+ lastCardContent = textToSend;
487
+ return;
488
+ }
489
+
490
+ if (useCardMode && currentAICard && info?.kind === "tool") {
491
+ if (isCardInTerminalState(currentAICard.state)) {
492
+ log?.debug?.(
493
+ `[DingTalk] Skipping tool stream update because card is terminal: state=${currentAICard.state}`,
494
+ );
495
+ return;
496
+ }
497
+
498
+ log?.info?.(
499
+ `[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`,
500
+ );
501
+ const toolText = formatContentForCard(textToSend, "tool");
502
+ if (toolText) {
503
+ const sendResult = await sendMessage(dingtalkConfig, to, toolText, {
504
+ sessionWebhook,
505
+ atUserId: !isDirect ? senderId : null,
506
+ log,
507
+ card: currentAICard,
508
+ cardUpdateMode: "append",
509
+ });
510
+ if (!sendResult.ok) {
511
+ throw new Error(sendResult.error || "Tool stream send failed");
512
+ }
513
+ lastCardContent = currentAICard.lastStreamedContent || toolText;
514
+ return;
515
+ }
516
+ }
517
+
518
+ lastCardContent = textToSend;
519
+ const sendResult = await sendMessage(dingtalkConfig, to, textToSend, {
520
+ sessionWebhook,
521
+ atUserId: !isDirect ? senderId : null,
522
+ log,
523
+ card: currentAICard,
524
+ });
525
+ if (!sendResult.ok) {
526
+ throw new Error(sendResult.error || "Reply send failed");
527
+ }
528
+ } catch (err: any) {
529
+ log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
530
+ if (err?.response?.data !== undefined) {
531
+ log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", err.response.data));
532
+ }
533
+ throw err;
534
+ }
535
+ },
536
+ },
537
+ replyOptions: {
538
+ onReasoningStream: async (payload: any) => {
539
+ if (!useCardMode || !currentAICard) {
540
+ return;
541
+ }
489
542
  if (isCardInTerminalState(currentAICard.state)) {
490
543
  log?.debug?.(
491
- `[DingTalk] Skipping tool stream update because card is terminal: state=${currentAICard.state}`,
544
+ `[DingTalk] Skipping thinking stream update because card is terminal: state=${currentAICard.state}`,
492
545
  );
493
546
  return;
494
547
  }
495
-
496
- log?.info?.(
497
- `[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`,
498
- );
499
- const toolText = formatContentForCard(textToSend, "tool");
500
- if (toolText) {
501
- await streamAICard(currentAICard, toolText, false, log);
548
+ const thinkingText = formatContentForCard(payload.text, "thinking");
549
+ if (!thinkingText) {
502
550
  return;
503
551
  }
504
- }
505
-
506
- lastCardContent = textToSend;
507
- await sendMessage(dingtalkConfig, to, textToSend, {
508
- sessionWebhook,
509
- atUserId: !isDirect ? senderId : null,
510
- log,
511
- accountId,
512
- });
513
- } catch (err: any) {
514
- log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
515
- if (err?.response?.data !== undefined) {
516
- log?.error?.(formatDingTalkErrorPayloadLog("inbound.replyDeliver", err.response.data));
517
- }
518
- throw err;
519
- }
520
- },
521
- },
522
- replyOptions: {
523
- // Real-time reasoning stream support for card mode.
524
- onReasoningStream: async (payload: any) => {
525
- if (!useCardMode || !currentAICard) {
526
- return;
552
+ try {
553
+ const sendResult = await sendMessage(dingtalkConfig, to, thinkingText, {
554
+ sessionWebhook,
555
+ atUserId: !isDirect ? senderId : null,
556
+ log,
557
+ card: currentAICard,
558
+ cardUpdateMode: "append",
559
+ });
560
+ if (!sendResult.ok) {
561
+ throw new Error(sendResult.error || "Thinking stream send failed");
562
+ }
563
+ } catch (err: any) {
564
+ log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
565
+ if (err?.response?.data !== undefined) {
566
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingStream", err.response.data));
567
+ }
568
+ }
569
+ },
570
+ },
571
+ });
572
+ queuedFinal = dispatchResult?.queuedFinal;
573
+ } catch (dispatchErr: any) {
574
+ if (useCardMode && currentAICard && !isCardInTerminalState(currentAICard.state)) {
575
+ try {
576
+ await finishAICard(currentAICard, "❌ 倄理倱θ΄₯", log);
577
+ } catch (cardCloseErr: any) {
578
+ log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${cardCloseErr.message}`);
579
+ currentAICard.state = AICardStatus.FAILED;
580
+ currentAICard.lastUpdated = Date.now();
527
581
  }
582
+ }
583
+ throw dispatchErr;
584
+ }
585
+
586
+ // 5) Finalize card stream if card mode is active.
587
+ if (useCardMode && currentAICard) {
588
+ try {
528
589
  if (isCardInTerminalState(currentAICard.state)) {
529
590
  log?.debug?.(
530
- `[DingTalk] Skipping thinking stream update because card is terminal: state=${currentAICard.state}`,
591
+ `[DingTalk] Skipping AI Card finalization because card is terminal: state=${currentAICard.state}`,
531
592
  );
532
593
  return;
533
594
  }
534
- const thinkingText = formatContentForCard(payload.text, "thinking");
535
- if (!thinkingText) {
536
- return;
537
- }
538
- try {
539
- await streamAICard(currentAICard, thinkingText, false, log);
540
- } catch (err: any) {
541
- log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
542
- if (err?.response?.data !== undefined) {
543
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.thinkingStream", err.response.data));
595
+
596
+ const isNonEmptyString = (value: any): boolean =>
597
+ typeof value === "string" && value.trim().length > 0;
598
+
599
+ const hasLastCardContent = isNonEmptyString(lastCardContent);
600
+ const hasQueuedFinalString = isNonEmptyString(queuedFinal);
601
+
602
+ if (hasLastCardContent || hasQueuedFinalString) {
603
+ const finalContentCandidate =
604
+ hasLastCardContent && typeof lastCardContent === "string"
605
+ ? lastCardContent
606
+ : typeof queuedFinal === "string"
607
+ ? queuedFinal
608
+ : "";
609
+ if (isUnhandledStopReasonText(finalContentCandidate)) {
610
+ log?.warn?.(
611
+ `[DingTalk] Suppressed stop reason from AI Card final content: ${finalContentCandidate}`,
612
+ );
613
+ currentAICard.state = AICardStatus.FINISHED;
614
+ currentAICard.lastUpdated = Date.now();
615
+ return;
616
+ }
617
+ const finalContent = finalContentCandidate;
618
+ await finishAICard(currentAICard, finalContent, log);
619
+ } else {
620
+ const lastStreamed = currentAICard.lastStreamedContent;
621
+ if (typeof lastStreamed === "string" && lastStreamed.trim().length > 0) {
622
+ await finishAICard(currentAICard, lastStreamed, log);
623
+ } else {
624
+ const defaultFinalContent = "βœ… Done";
625
+ log?.debug?.(
626
+ "[DingTalk] No textual content was produced; finalizing AI Card with default completion content.",
627
+ );
628
+ await finishAICard(currentAICard, defaultFinalContent, log);
544
629
  }
545
630
  }
546
- },
547
- },
548
- });
549
-
550
- // 5) Finalize card stream if card mode is active.
551
- if (useCardMode && currentAICard) {
552
- try {
553
- if (isCardInTerminalState(currentAICard.state)) {
554
- log?.debug?.(
555
- `[DingTalk] Skipping AI Card finalization because card is terminal: state=${currentAICard.state}`,
556
- );
557
- return;
558
- }
559
-
560
- const isNonEmptyString = (value: any): boolean =>
561
- typeof value === "string" && value.trim().length > 0;
562
-
563
- const hasLastCardContent = isNonEmptyString(lastCardContent);
564
- const hasQueuedFinalString = isNonEmptyString(queuedFinal);
565
-
566
- if (hasLastCardContent || hasQueuedFinalString) {
567
- const finalContentCandidate =
568
- hasLastCardContent && typeof lastCardContent === "string"
569
- ? lastCardContent
570
- : typeof queuedFinal === "string"
571
- ? queuedFinal
572
- : "";
573
- if (isUnhandledStopReasonText(finalContentCandidate)) {
574
- log?.warn?.(
575
- `[DingTalk] Suppressed stop reason from AI Card final content: ${finalContentCandidate}`,
576
- );
577
- currentAICard.state = AICardStatus.FINISHED;
578
- currentAICard.lastUpdated = Date.now();
579
- return;
631
+ } catch (err: any) {
632
+ log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
633
+ if (err?.response?.data !== undefined) {
634
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
580
635
  }
581
- const finalContent = finalContentCandidate;
582
- await finishAICard(currentAICard, finalContent, log);
583
- } else {
584
- const defaultFinalContent = "βœ… Done";
585
- log?.debug?.(
586
- "[DingTalk] No textual content was produced; finalizing AI Card with default completion content.",
587
- );
588
- await finishAICard(currentAICard, defaultFinalContent, log);
589
- }
590
- } catch (err: any) {
591
- log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
592
- if (err?.response?.data !== undefined) {
593
- log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", err.response.data));
594
- }
595
- try {
596
- if (currentAICard.state !== AICardStatus.FINISHED) {
597
- currentAICard.state = AICardStatus.FAILED;
598
- currentAICard.lastUpdated = Date.now();
636
+ try {
637
+ if (currentAICard.state !== AICardStatus.FINISHED) {
638
+ currentAICard.state = AICardStatus.FAILED;
639
+ currentAICard.lastUpdated = Date.now();
640
+ }
641
+ } catch (stateErr: any) {
642
+ log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
599
643
  }
600
- } catch (stateErr: any) {
601
- log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
602
644
  }
603
645
  }
646
+ } finally {
647
+ releaseSessionLock();
604
648
  }
605
649
  }