@soimy/dingtalk 3.5.2 → 3.6.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.
Files changed (40) hide show
  1. package/README.md +6 -23
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +799 -0
  4. package/package.json +5 -5
  5. package/src/card/card-markdown-image-reroute.ts +106 -0
  6. package/src/card/card-run-registry.ts +54 -1
  7. package/src/card/card-stop-handler.ts +10 -20
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/reasoning-answer-split.ts +162 -0
  11. package/src/card/statusline-renderer.ts +94 -0
  12. package/src/card-draft-controller.ts +326 -54
  13. package/src/card-service.ts +479 -8
  14. package/src/channel.ts +19 -1062
  15. package/src/config-schema.ts +81 -38
  16. package/src/config.ts +142 -4
  17. package/src/device-registration.ts +245 -0
  18. package/src/gateway/channel-gateway.ts +636 -0
  19. package/src/inbound-handler.ts +489 -49
  20. package/src/media-utils.ts +169 -7
  21. package/src/message-utils.ts +153 -17
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +173 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/messaging/quoted-file-service.ts +9 -4
  26. package/src/onboarding.ts +323 -205
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +568 -44
  30. package/src/reply-strategy-markdown.ts +2 -2
  31. package/src/reply-strategy-types.ts +93 -0
  32. package/src/reply-strategy-with-reaction.ts +1 -1
  33. package/src/reply-strategy.ts +14 -56
  34. package/src/run-usage-store.ts +59 -0
  35. package/src/send-service.ts +225 -7
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +44 -28
  39. package/src/types.ts +49 -117
  40. package/src/utils.ts +25 -0
@@ -6,7 +6,7 @@
6
6
  * Reasoning display is intentionally unsupported on DingTalk markdown.
7
7
  */
8
8
 
9
- import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
9
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
10
10
  import { sendMessage } from "./send-service";
11
11
 
12
12
  const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
@@ -119,7 +119,7 @@ export function createMarkdownReplyStrategy(
119
119
 
120
120
  async deliver(payload: DeliverPayload): Promise<void> {
121
121
  if (payload.mediaUrls.length > 0) {
122
- await ctx.deliverMedia(payload.mediaUrls);
122
+ await ctx.deliverMedia(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
123
123
  sentVisibleContent = true;
124
124
  }
125
125
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shared type definitions for reply strategy implementations.
3
+ *
4
+ * Extracted into a leaf module so that the factory (reply-strategy.ts) and
5
+ * concrete strategies (reply-strategy-card.ts, reply-strategy-markdown.ts)
6
+ * can share these interfaces without circular imports.
7
+ */
8
+
9
+ import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
10
+ import type { DingTalkConfig, Logger, QuotedRef } from "./types";
11
+
12
+ // ---- Internal helper type ----
13
+
14
+ export type InternalReplyStrategyConfig = DingTalkConfig & {
15
+ /** @deprecated Internal compatibility only. Removed from public config surface. */
16
+ cardStreamReasoning?: boolean;
17
+ };
18
+
19
+ // ---- Public interfaces ----
20
+
21
+ export interface DeliverPayload {
22
+ text?: string;
23
+ mediaUrls: string[];
24
+ /**
25
+ * Shared reply-runtime voice hint. Strategies forward this unchanged into the
26
+ * channel media delivery helper; inbound-handler is responsible for bridging
27
+ * legacy aliases (for example `asVoice`) into this single field.
28
+ */
29
+ audioAsVoice?: boolean;
30
+ kind: "block" | "final" | "tool";
31
+ isReasoning?: boolean;
32
+ }
33
+
34
+ export interface ReplyOptions {
35
+ disableBlockStreaming: boolean;
36
+ onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
37
+ onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
38
+ onAssistantMessageStart?: () => void | Promise<void>;
39
+ onAgentRunStart?: GetReplyOptions["onAgentRunStart"];
40
+ onModelSelected?: GetReplyOptions["onModelSelected"];
41
+ }
42
+
43
+ export interface ReplyStrategy {
44
+ /** Options forwarded to the runtime dispatcher. */
45
+ getReplyOptions(): ReplyOptions;
46
+
47
+ /** Called by the deliver callback for each payload chunk. */
48
+ deliver(payload: DeliverPayload): Promise<void>;
49
+
50
+ /** Called after dispatch completes successfully. */
51
+ finalize(): Promise<void>;
52
+
53
+ /** Called when dispatch throws an error. */
54
+ abort(error: Error): Promise<void>;
55
+
56
+ /** Last known final text (for external consumers such as logging). */
57
+ getFinalText(): string | undefined;
58
+ }
59
+
60
+ /** Shared context passed to every strategy implementation. */
61
+ export interface TaskMeta {
62
+ model?: string;
63
+ effort?: string;
64
+ usage?: number;
65
+ elapsedMs?: number;
66
+ agent?: string;
67
+ runIds?: Set<string>;
68
+ }
69
+
70
+ export interface ReplyStrategyContext {
71
+ config: InternalReplyStrategyConfig;
72
+ to: string;
73
+ sessionWebhook: string;
74
+ senderId: string;
75
+ isDirect: boolean;
76
+ accountId: string;
77
+ storePath: string;
78
+ disableBlockStreaming?: boolean;
79
+ sessionKey?: string;
80
+ sessionAgentId?: string;
81
+ groupId?: string;
82
+ log?: Logger;
83
+ replyQuotedRef?: QuotedRef;
84
+ /**
85
+ * Channel-level media delivery hook. The `audioAsVoice` option is the same
86
+ * shared voice semantic carried on DeliverPayload, not a second independent
87
+ * config knob.
88
+ */
89
+ deliverMedia: (urls: string[], options?: { audioAsVoice?: boolean }) => Promise<void>;
90
+ isStopRequested?: () => boolean;
91
+ inboundText?: string;
92
+ taskMeta?: TaskMeta;
93
+ }
@@ -24,7 +24,7 @@
24
24
  * concurrently. The `sessionFilter` parameter is required for this reason.
25
25
  */
26
26
 
27
- import type { DeliverPayload, ReplyOptions, ReplyStrategy } from "./reply-strategy";
27
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy } from "./reply-strategy-types";
28
28
  import type { DingTalkConfig, Logger } from "./types";
29
29
 
30
30
  const TOOL_REACTION_SILENCE_MS = 55_000;
@@ -1,66 +1,24 @@
1
1
  /**
2
- * Reply strategy interface for DingTalk message delivery.
2
+ * Reply strategy factory for DingTalk message delivery.
3
3
  *
4
- * Abstracts the "how to deliver a reply" concern away from
5
- * handleDingTalkMessage, so card and markdown modes each manage
6
- * their own state and lifecycle independently.
4
+ * Delegates the "how to deliver a reply" concern to card or markdown
5
+ * strategy implementations. Type definitions live in reply-strategy-types.ts.
7
6
  */
8
7
 
9
- import type { AICardInstance, DingTalkConfig, Logger, QuotedRef } from "./types";
8
+ import type { AICardInstance } from "./types";
9
+ import type { ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
10
10
  import { createCardReplyStrategy } from "./reply-strategy-card";
11
11
  import { createMarkdownReplyStrategy } from "./reply-strategy-markdown";
12
12
 
13
- // ---- Public types ------------------------------------------------
14
-
15
- export interface DeliverPayload {
16
- text?: string;
17
- mediaUrls: string[];
18
- kind: "block" | "final" | "tool";
19
- isReasoning?: boolean;
20
- }
21
-
22
- export interface ReplyOptions {
23
- disableBlockStreaming: boolean;
24
- onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
25
- onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
26
- onAssistantMessageStart?: () => void | Promise<void>;
27
- }
28
-
29
- export interface ReplyStrategy {
30
- /** Options forwarded to the runtime dispatcher. */
31
- getReplyOptions(): ReplyOptions;
32
-
33
- /** Called by the deliver callback for each payload chunk. */
34
- deliver(payload: DeliverPayload): Promise<void>;
35
-
36
- /** Called after dispatch completes successfully. */
37
- finalize(): Promise<void>;
38
-
39
- /** Called when dispatch throws an error. */
40
- abort(error: Error): Promise<void>;
41
-
42
- /** Last known final text (for external consumers such as logging). */
43
- getFinalText(): string | undefined;
44
- }
45
-
46
- /** Shared context passed to every strategy implementation. */
47
- export interface ReplyStrategyContext {
48
- config: DingTalkConfig;
49
- to: string;
50
- sessionWebhook: string;
51
- senderId: string;
52
- isDirect: boolean;
53
- accountId: string;
54
- storePath: string;
55
- disableBlockStreaming?: boolean;
56
- sessionKey?: string;
57
- sessionAgentId?: string;
58
- groupId?: string;
59
- log?: Logger;
60
- replyQuotedRef?: QuotedRef;
61
- deliverMedia: (urls: string[]) => Promise<void>;
62
- isStopRequested?: () => boolean;
63
- }
13
+ // Re-export all types so existing consumers that import from "./reply-strategy"
14
+ // continue to work without changes beyond the ones we explicitly migrate.
15
+ export type {
16
+ DeliverPayload,
17
+ ReplyOptions,
18
+ ReplyStrategy,
19
+ ReplyStrategyContext,
20
+ TaskMeta,
21
+ } from "./reply-strategy-types";
64
22
 
65
23
  // ---- Factory -----------------------------------------------------
66
24
 
@@ -0,0 +1,59 @@
1
+ export interface UsageAccumulation {
2
+ input?: number;
3
+ output?: number;
4
+ cacheRead?: number;
5
+ cacheWrite?: number;
6
+ total?: number;
7
+ }
8
+
9
+ const usageStore = new Map<string, UsageAccumulation>();
10
+
11
+ export function recordRunStart(runId: string): void {
12
+ if (!usageStore.has(runId)) {
13
+ usageStore.set(runId, {});
14
+ }
15
+ }
16
+
17
+ export function accumulateUsage(runId: string, usage: UsageAccumulation): void {
18
+ const existing = usageStore.get(runId);
19
+ if (!existing) { return; }
20
+
21
+ for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
22
+ const value = usage[field];
23
+ if (typeof value === "number") {
24
+ existing[field] = (existing[field] ?? 0) + value;
25
+ }
26
+ }
27
+ }
28
+
29
+ export function getUsageByRunId(runId: string): UsageAccumulation | undefined {
30
+ return usageStore.get(runId);
31
+ }
32
+
33
+ export function getAggregatedUsage(runIds: Set<string> | undefined): UsageAccumulation {
34
+ const result: UsageAccumulation = {};
35
+ if (!runIds) { return result; }
36
+ for (const runId of runIds) {
37
+ const usage = usageStore.get(runId);
38
+ if (!usage) { continue; }
39
+ for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
40
+ if (typeof usage[field] === "number") {
41
+ result[field] = (result[field] ?? 0) + usage[field];
42
+ }
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+
48
+ export function clearRun(runId: string | undefined): void {
49
+ if (runId) { usageStore.delete(runId); }
50
+ }
51
+
52
+ export function clearRuns(runIds: Set<string> | undefined): void {
53
+ if (!runIds) { return; }
54
+ for (const runId of runIds) { usageStore.delete(runId); }
55
+ }
56
+
57
+ export function clearAllForTest(): void {
58
+ usageStore.clear();
59
+ }
@@ -1,13 +1,20 @@
1
1
  import * as path from "node:path";
2
2
  import axios from "./http-client";
3
3
  import { getAccessToken } from "./auth";
4
+ import { resolveCardRunByConversation, resolveCardRunByOwner } from "./card/card-run-registry";
5
+
4
6
  import {
5
7
  isCardInTerminalState,
6
8
  sendProactiveCardText,
7
9
  } from "./card-service";
8
10
  import { resolveRobotCode, stripTargetPrefix } from "./config";
9
11
  import { getLogger } from "./logger-context";
10
- import { getVoiceDurationMs, uploadMedia as uploadMediaUtil } from "./media-utils";
12
+ import {
13
+ getVoiceDurationMs,
14
+ prepareMediaInput,
15
+ resolveOutboundMediaType,
16
+ uploadMedia as uploadMediaUtil,
17
+ } from "./media-utils";
11
18
  import { convertMarkdownTablesToPlainText, detectMarkdownAndExtractTitle } from "./message-utils";
12
19
  import {
13
20
  DEFAULT_MESSAGE_CONTEXT_TTL_DAYS,
@@ -176,6 +183,29 @@ function buildPersistedOutboundText(text: string, options: SendMessageOptions):
176
183
  }
177
184
 
178
185
  const DINGTALK_TEXT_CHUNK_LIMIT = 3800;
186
+ const CARD_MEDIA_CONTROLLER_ATTACH_WAIT_MS = 150;
187
+ const CARD_MEDIA_CONTROLLER_ATTACH_POLL_MS = 25;
188
+
189
+ async function waitForCardControllerAttachment(
190
+ activeRun: ReturnType<typeof resolveCardRunByConversation>,
191
+ ): Promise<NonNullable<ReturnType<typeof resolveCardRunByConversation>>["controller"] | null> {
192
+ if (!activeRun) {
193
+ return null;
194
+ }
195
+ if (activeRun.controller?.appendImageBlock) {
196
+ return activeRun.controller;
197
+ }
198
+
199
+ const deadline = Date.now() + CARD_MEDIA_CONTROLLER_ATTACH_WAIT_MS;
200
+ while (Date.now() < deadline) {
201
+ await new Promise((resolve) => setTimeout(resolve, CARD_MEDIA_CONTROLLER_ATTACH_POLL_MS));
202
+ if (activeRun.controller?.appendImageBlock) {
203
+ return activeRun.controller;
204
+ }
205
+ }
206
+
207
+ return activeRun.controller?.appendImageBlock ? activeRun.controller : null;
208
+ }
179
209
 
180
210
  function splitMarkdownChunks(text: string, limit = DINGTALK_TEXT_CHUNK_LIMIT): string[] {
181
211
  if (!text || text.length <= limit) {
@@ -212,6 +242,14 @@ function extractErrorCodeFromResponseData(data: unknown): string | null {
212
242
  }
213
243
 
214
244
  const payload = data as Record<string, unknown>;
245
+ const errcode = payload.errcode;
246
+ if (typeof errcode === "number" && Number.isFinite(errcode)) {
247
+ return String(errcode);
248
+ }
249
+ if (typeof errcode === "string" && errcode.trim()) {
250
+ return errcode.trim();
251
+ }
252
+
215
253
  const code = payload.code;
216
254
  if (typeof code === "string" && code.trim()) {
217
255
  return code.trim();
@@ -225,6 +263,62 @@ function extractErrorCodeFromResponseData(data: unknown): string | null {
225
263
  return null;
226
264
  }
227
265
 
266
+ function summarizeSessionWebhookResponse(data: unknown): string {
267
+ if (!data || typeof data !== "object") {
268
+ return `type=${typeof data}`;
269
+ }
270
+ const payload = data as Record<string, unknown>;
271
+ const code = extractErrorCodeFromResponseData(payload) || "(none)";
272
+ const message = firstTrimmedString(
273
+ payload.message,
274
+ payload.errmsg,
275
+ payload.msg,
276
+ payload.errorMessage,
277
+ ) || "(none)";
278
+ const success =
279
+ typeof payload.success === "boolean"
280
+ ? String(payload.success)
281
+ : typeof payload.result === "boolean"
282
+ ? String(payload.result)
283
+ : "(none)";
284
+ const delivery = extractOutboundDeliveryMetadata(payload);
285
+ return (
286
+ `success=${success} code=${code} message=${message} ` +
287
+ `messageId=${delivery.messageId || "(none)"} ` +
288
+ `processQueryKey=${delivery.processQueryKey || "(none)"} ` +
289
+ `outTrackId=${delivery.outTrackId || "(none)"}`
290
+ );
291
+ }
292
+
293
+ function ensureSessionWebhookBusinessSuccess(
294
+ data: unknown,
295
+ context: { msgtype: string },
296
+ ): void {
297
+ if (!data || typeof data !== "object") {
298
+ return;
299
+ }
300
+ const payload = data as Record<string, unknown>;
301
+ const code = extractErrorCodeFromResponseData(payload);
302
+ const message = firstTrimmedString(
303
+ payload.message,
304
+ payload.errmsg,
305
+ payload.msg,
306
+ payload.errorMessage,
307
+ ) || "unknown error";
308
+
309
+ const hasFailureSuccessFlag = payload.success === false || payload.result === false;
310
+ const hasFailureCode = typeof code === "string" && code !== "" && code !== "0";
311
+ if (!hasFailureSuccessFlag && !hasFailureCode) {
312
+ return;
313
+ }
314
+
315
+ const reason = [
316
+ code && code !== "0" ? `code=${code}` : "",
317
+ message !== "unknown error" ? `message=${message}` : "",
318
+ ].filter(Boolean).join(" ");
319
+ throw new Error(`Session webhook ${context.msgtype} send failed${reason ? `: ${reason}` : ""}`);
320
+ }
321
+
228
322
  function isProactivePermissionOrScopeError(code: string | null): boolean {
229
323
  if (!code) {
230
324
  return false;
@@ -389,7 +483,7 @@ export async function sendProactiveMedia(
389
483
  mediaPath: string,
390
484
  mediaType: "image" | "voice" | "video" | "file",
391
485
  options: SendMessageOptions & { accountId?: string } = {},
392
- ): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
486
+ ): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string; mediaId?: string }> {
393
487
  const log = options.log || getLogger();
394
488
 
395
489
  try {
@@ -400,7 +494,7 @@ export async function sendProactiveMedia(
400
494
  if (!uploadResult) {
401
495
  return { ok: false, error: "Failed to upload media" };
402
496
  }
403
- const { mediaId, buffer } = uploadResult;
497
+ const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
404
498
 
405
499
  const token = await getAccessToken(config, log);
406
500
  const { targetId, isExplicitUser } = stripTargetPrefix(target);
@@ -421,7 +515,8 @@ export async function sendProactiveMedia(
421
515
  msgParam = JSON.stringify({ photoURL: mediaId });
422
516
  } else if (mediaType === "voice") {
423
517
  msgKey = "sampleAudio";
424
- const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log, { preReadBuffer: buffer });
518
+ const durationMs = uploadedDurationMs
519
+ ?? await getVoiceDurationMs(mediaPath, mediaType, log, { preReadBuffer: buffer });
425
520
  msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
426
521
  } else {
427
522
  // sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
@@ -476,7 +571,7 @@ export async function sendProactiveMedia(
476
571
  kind: "proactive-media",
477
572
  },
478
573
  });
479
- return { ok: true, data: result.data, messageId };
574
+ return { ok: true, data: result.data, messageId, mediaId };
480
575
  } catch (err: any) {
481
576
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
482
577
  const normalizedTarget = resolveOriginalPeerId(stripTargetPrefix(target).targetId);
@@ -540,6 +635,88 @@ export async function sendProactiveMedia(
540
635
  }
541
636
  }
542
637
 
638
+ export async function sendMedia(
639
+ config: DingTalkConfig,
640
+ target: string,
641
+ mediaInput: string,
642
+ options: SendMessageOptions & {
643
+ mediaType?: "image" | "voice" | "video" | "file";
644
+ audioAsVoice?: boolean;
645
+ expectedCardOwnerId?: string;
646
+ } = {},
647
+ ): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string; mediaId?: string }> {
648
+ const log = options.log || getLogger();
649
+ let preparedMedia: Awaited<ReturnType<typeof prepareMediaInput>> | undefined;
650
+
651
+ try {
652
+ preparedMedia = await prepareMediaInput(mediaInput, log, config.mediaUrlAllowlist);
653
+ const mediaPath = preparedMedia.cleanup
654
+ ? preparedMedia.path
655
+ : path.resolve(process.cwd(), preparedMedia.path);
656
+ const mediaType = resolveOutboundMediaType({
657
+ mediaType: options.mediaType,
658
+ mediaPath,
659
+ asVoice: options.audioAsVoice === true,
660
+ });
661
+
662
+ if (config.messageType === "card" && mediaType === "image") {
663
+ const accountId = options.accountId ?? "default";
664
+
665
+ // Three-tier lookup strategy to handle sessionKey=- from runtime:
666
+ // 1. If conversationId is available: try owner-filtered then conversation-only
667
+ // 2. If conversationId is undefined but owner is provided: try owner-only lookup
668
+ // 3. Otherwise: no active card found
669
+ let activeRun = null;
670
+
671
+ if (options.conversationId) {
672
+ // conversationId explicitly provided (parsed from sessionKey)
673
+ activeRun = options.expectedCardOwnerId
674
+ ? resolveCardRunByConversation(accountId, options.conversationId, {
675
+ ownerUserId: options.expectedCardOwnerId,
676
+ }) ?? resolveCardRunByConversation(accountId, options.conversationId, undefined)
677
+ : resolveCardRunByConversation(accountId, options.conversationId, undefined);
678
+ } else if (options.expectedCardOwnerId) {
679
+ // Fallback: when sessionKey=- causes conversationId to be undefined,
680
+ // try owner-only matching as last resort
681
+ activeRun = resolveCardRunByOwner(accountId, options.expectedCardOwnerId);
682
+ if (activeRun) {
683
+ log?.debug?.(
684
+ `[DingTalk] Matched active card by owner-only lookup (conversationId unavailable) ` +
685
+ `accountId=${accountId} ownerUserId=${options.expectedCardOwnerId} outTrackId=${activeRun.outTrackId}`,
686
+ );
687
+ }
688
+ }
689
+
690
+ const uploadResult = await uploadMedia(config, mediaPath, "image", log, {
691
+ mediaLocalRoots: options.mediaLocalRoots,
692
+ });
693
+ if (!uploadResult?.mediaId) {
694
+ return { ok: false, error: "Failed to upload media" };
695
+ }
696
+
697
+ const activeController = await waitForCardControllerAttachment(activeRun);
698
+ if (activeController?.appendImageBlock) {
699
+ await activeController.appendImageBlock(uploadResult.mediaId);
700
+ return { ok: true, mediaId: uploadResult.mediaId };
701
+ }
702
+
703
+ if (activeRun) {
704
+ log?.debug?.(
705
+ `[DingTalk] Active card matched but controller was not attached in time; fallback to proactive media send`,
706
+ );
707
+ } else {
708
+ log?.debug?.(
709
+ `[DingTalk] No active card found for media embedding; fallback to proactive media send`,
710
+ );
711
+ }
712
+ }
713
+
714
+ return await sendProactiveMedia(config, target, mediaPath, mediaType, options);
715
+ } finally {
716
+ await preparedMedia?.cleanup?.();
717
+ }
718
+ }
719
+
543
720
  export async function sendBySession(
544
721
  config: DingTalkConfig,
545
722
  sessionWebhook: string,
@@ -555,14 +732,18 @@ export async function sendBySession(
555
732
  mediaLocalRoots: options.mediaLocalRoots,
556
733
  });
557
734
  if (uploadResult) {
558
- const { mediaId, buffer } = uploadResult;
735
+ const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
559
736
  let body: any;
560
737
 
561
738
  if (options.mediaType === "image") {
562
739
  body = { msgtype: "image", image: { media_id: mediaId } };
563
740
  } else if (options.mediaType === "voice") {
564
- const durationMs = await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
741
+ const durationMs = uploadedDurationMs
742
+ ?? await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
565
743
  body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
744
+ log?.debug?.(
745
+ `[DingTalk] Sending session voice message mediaId=${mediaId} durationMs=${durationMs}`,
746
+ );
566
747
  } else if (options.mediaType === "video") {
567
748
  body = { msgtype: "video", video: { media_id: mediaId } };
568
749
  } else if (options.mediaType === "file") {
@@ -577,6 +758,17 @@ export async function sendBySession(
577
758
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
578
759
  ...getProxyBypassOption(config),
579
760
  });
761
+ log?.debug?.(
762
+ `[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
763
+ );
764
+ ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
765
+ const delivery = extractOutboundDeliveryMetadata(result.data);
766
+ if (!delivery.messageId && !delivery.processQueryKey && !delivery.outTrackId) {
767
+ log?.warn?.(
768
+ `[DingTalk] Session webhook ${body.msgtype} response missing delivery metadata; ` +
769
+ summarizeSessionWebhookResponse(result.data),
770
+ );
771
+ }
580
772
  return result.data;
581
773
  }
582
774
  } else {
@@ -624,6 +816,10 @@ export async function sendBySession(
624
816
  headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
625
817
  ...getProxyBypassOption(config),
626
818
  });
819
+ log?.debug?.(
820
+ `[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
821
+ );
822
+ ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
627
823
  lastResult = result.data;
628
824
  }
629
825
  return lastResult;
@@ -662,6 +858,28 @@ export async function sendMessage(
662
858
  }
663
859
  }
664
860
 
861
+ if (options.sessionWebhook && options.mediaPath && options.mediaType === "voice") {
862
+ log?.debug?.(
863
+ "[DingTalk] Session webhook does not support voice replies reliably; " +
864
+ "using proactive media API for this voice response",
865
+ );
866
+ const proactiveVoiceResult = await sendProactiveMedia(
867
+ config,
868
+ conversationId,
869
+ options.mediaPath,
870
+ options.mediaType,
871
+ options,
872
+ );
873
+ if (!proactiveVoiceResult.ok) {
874
+ return { ok: false, error: proactiveVoiceResult.error || "Voice reply send failed" };
875
+ }
876
+ return {
877
+ ok: true,
878
+ data: proactiveVoiceResult.data,
879
+ messageId: proactiveVoiceResult.messageId,
880
+ };
881
+ }
882
+
665
883
  if (options.sessionWebhook) {
666
884
  const data = await sendBySession(config, options.sessionWebhook, text, options);
667
885
  const delivery = extractOutboundDeliveryMetadata(data);
@@ -0,0 +1,62 @@
1
+ interface SessionState {
2
+ model?: string;
3
+ effort?: string;
4
+ taskStartTime: number;
5
+ }
6
+
7
+ const sessionStore = new Map<string, SessionState>();
8
+
9
+ function sessionKey(accountId: string, conversationId: string): string {
10
+ return `${accountId}:${conversationId}`;
11
+ }
12
+
13
+ export function initSessionState(accountId: string, conversationId: string): SessionState {
14
+ const key = sessionKey(accountId, conversationId);
15
+ const existing = sessionStore.get(key);
16
+ if (existing) {
17
+ existing.taskStartTime = Date.now();
18
+ return existing;
19
+ }
20
+ const state: SessionState = {
21
+ taskStartTime: Date.now(),
22
+ };
23
+ sessionStore.set(key, state);
24
+ return state;
25
+ }
26
+
27
+ export function getSessionState(accountId: string, conversationId: string): SessionState | undefined {
28
+ return sessionStore.get(sessionKey(accountId, conversationId));
29
+ }
30
+
31
+ export function updateSessionState(
32
+ accountId: string,
33
+ conversationId: string,
34
+ patch: Partial<Pick<SessionState, "model" | "effort">>,
35
+ ): void {
36
+ const state = sessionStore.get(sessionKey(accountId, conversationId));
37
+ if (!state) {
38
+ return;
39
+ }
40
+ if (patch.model !== undefined) {
41
+ state.model = patch.model;
42
+ }
43
+ if (patch.effort !== undefined) {
44
+ state.effort = patch.effort;
45
+ }
46
+ }
47
+
48
+ export function getTaskTimeSeconds(accountId: string, conversationId: string): number | undefined {
49
+ const state = sessionStore.get(sessionKey(accountId, conversationId));
50
+ if (!state) {
51
+ return undefined;
52
+ }
53
+ return Math.round((Date.now() - state.taskStartTime) / 1000);
54
+ }
55
+
56
+ export function clearSessionState(accountId: string, conversationId: string): void {
57
+ sessionStore.delete(sessionKey(accountId, conversationId));
58
+ }
59
+
60
+ export function clearAllSessionStatesForTest(): void {
61
+ sessionStore.clear();
62
+ }
@@ -146,3 +146,31 @@ export function resolveAtAgents(
146
146
  hasInvalidAgentNames,
147
147
  };
148
148
  }
149
+
150
+ /**
151
+ * Get agent display name for statusLine in card template.
152
+ *
153
+ * Priority:
154
+ * 1. subAgentOptions.matchedName - user-friendly name from @mention (e.g., "代码专家")
155
+ * 2. agents.list lookup - name field from agent config
156
+ * 3. agentId fallback - technical identifier
157
+ */
158
+ export function getAgentDisplayName(params: {
159
+ subAgentOptions?: { matchedName?: string };
160
+ agentId: string;
161
+ agentsList?: Array<{ id: string; name?: string }>;
162
+ }): string {
163
+ // Priority 1: sub-agent matchedName (user-friendly)
164
+ if (params.subAgentOptions?.matchedName) {
165
+ return params.subAgentOptions.matchedName;
166
+ }
167
+ // Priority 2: lookup from agents.list
168
+ if (params.agentsList) {
169
+ const agent = params.agentsList.find((a) => a.id === params.agentId);
170
+ if (agent?.name) {
171
+ return agent.name;
172
+ }
173
+ }
174
+ // Priority 3: fallback to agentId
175
+ return params.agentId;
176
+ }