@soimy/dingtalk 3.5.3 → 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.
@@ -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,82 +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
- type InternalReplyStrategyConfig = DingTalkConfig & {
16
- /** @deprecated Internal compatibility only. Removed from public config surface. */
17
- cardStreamReasoning?: boolean;
18
- };
19
-
20
- export interface DeliverPayload {
21
- text?: string;
22
- mediaUrls: string[];
23
- /**
24
- * Shared reply-runtime voice hint. Strategies forward this unchanged into the
25
- * channel media delivery helper; inbound-handler is responsible for bridging
26
- * legacy aliases (for example `asVoice`) into this single field.
27
- */
28
- audioAsVoice?: boolean;
29
- kind: "block" | "final" | "tool";
30
- isReasoning?: boolean;
31
- }
32
-
33
- export interface ReplyOptions {
34
- disableBlockStreaming: boolean;
35
- onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
36
- onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
37
- onAssistantMessageStart?: () => void | Promise<void>;
38
- }
39
-
40
- export interface ReplyStrategy {
41
- /** Options forwarded to the runtime dispatcher. */
42
- getReplyOptions(): ReplyOptions;
43
-
44
- /** Called by the deliver callback for each payload chunk. */
45
- deliver(payload: DeliverPayload): Promise<void>;
46
-
47
- /** Called after dispatch completes successfully. */
48
- finalize(): Promise<void>;
49
-
50
- /** Called when dispatch throws an error. */
51
- abort(error: Error): Promise<void>;
52
-
53
- /** Last known final text (for external consumers such as logging). */
54
- getFinalText(): string | undefined;
55
- }
56
-
57
- /** Shared context passed to every strategy implementation. */
58
- export interface ReplyStrategyContext {
59
- config: InternalReplyStrategyConfig;
60
- to: string;
61
- sessionWebhook: string;
62
- senderId: string;
63
- isDirect: boolean;
64
- accountId: string;
65
- storePath: string;
66
- disableBlockStreaming?: boolean;
67
- sessionKey?: string;
68
- sessionAgentId?: string;
69
- groupId?: string;
70
- log?: Logger;
71
- replyQuotedRef?: QuotedRef;
72
- /**
73
- * Channel-level media delivery hook. The `audioAsVoice` option is the same
74
- * shared voice semantic carried on DeliverPayload, not a second independent
75
- * config knob.
76
- */
77
- deliverMedia: (urls: string[], options?: { audioAsVoice?: boolean }) => Promise<void>;
78
- isStopRequested?: () => boolean;
79
- }
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";
80
22
 
81
23
  // ---- Factory -----------------------------------------------------
82
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) {
@@ -453,7 +483,7 @@ export async function sendProactiveMedia(
453
483
  mediaPath: string,
454
484
  mediaType: "image" | "voice" | "video" | "file",
455
485
  options: SendMessageOptions & { accountId?: string } = {},
456
- ): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
486
+ ): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string; mediaId?: string }> {
457
487
  const log = options.log || getLogger();
458
488
 
459
489
  try {
@@ -541,7 +571,7 @@ export async function sendProactiveMedia(
541
571
  kind: "proactive-media",
542
572
  },
543
573
  });
544
- return { ok: true, data: result.data, messageId };
574
+ return { ok: true, data: result.data, messageId, mediaId };
545
575
  } catch (err: any) {
546
576
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
547
577
  const normalizedTarget = resolveOriginalPeerId(stripTargetPrefix(target).targetId);
@@ -605,6 +635,88 @@ export async function sendProactiveMedia(
605
635
  }
606
636
  }
607
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
+
608
720
  export async function sendBySession(
609
721
  config: DingTalkConfig,
610
722
  sessionWebhook: string,
@@ -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
+ }