@soimy/dingtalk 3.5.3 → 3.6.1

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 (39) hide show
  1. package/README.md +4 -1
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +153 -5
  4. package/package.json +1 -1
  5. package/src/auth.ts +5 -2
  6. package/src/card/card-markdown-image-reroute.ts +106 -0
  7. package/src/card/card-run-registry.ts +54 -1
  8. package/src/card/card-stop-handler.ts +10 -20
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/statusline-renderer.ts +94 -0
  11. package/src/card-draft-controller.ts +245 -52
  12. package/src/card-service.ts +408 -23
  13. package/src/channel.ts +24 -1083
  14. package/src/config-schema.ts +21 -1
  15. package/src/config.ts +139 -66
  16. package/src/device-registration.ts +245 -0
  17. package/src/gateway/channel-gateway.ts +637 -0
  18. package/src/inbound-handler.ts +1276 -975
  19. package/src/media-utils.ts +6 -0
  20. package/src/message-context-store.ts +183 -85
  21. package/src/message-utils.ts +124 -16
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +174 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/onboarding.ts +333 -235
  26. package/src/path-utils.ts +49 -0
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/reply-strategy-card.ts +373 -64
  29. package/src/reply-strategy-markdown.ts +1 -1
  30. package/src/reply-strategy-types.ts +93 -0
  31. package/src/reply-strategy-with-reaction.ts +1 -1
  32. package/src/reply-strategy.ts +14 -72
  33. package/src/run-usage-store.ts +59 -0
  34. package/src/secret-input.ts +216 -0
  35. package/src/send-service.ts +115 -3
  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 +30 -5
  39. package/src/types.ts +48 -157
@@ -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
+ }
@@ -0,0 +1,216 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { promisify } from "node:util";
4
+ import { z } from "zod";
5
+ import { resolveRelativePath } from "./path-utils";
6
+
7
+ export type SecretInputRef = {
8
+ source: "env" | "file" | "exec";
9
+ provider: string;
10
+ id: string;
11
+ };
12
+
13
+ export type SecretInput = string | SecretInputRef;
14
+
15
+ export type SecretInputResolutionFailure = {
16
+ source: SecretInputRef["source"];
17
+ provider: string;
18
+ id: string;
19
+ reason: string;
20
+ };
21
+
22
+ export const SECRET_INPUT_EXEC_TIMEOUT_MS = 5000;
23
+
24
+ type SecretInputLog = {
25
+ warn?: (message: string, data?: unknown) => void;
26
+ };
27
+
28
+ const execFileAsync = promisify(execFile);
29
+ const SECRET_INPUT_PROVIDER_PATTERN = /^[^:>]+$/;
30
+ const SECRET_INPUT_ID_PATTERN = /^[^>]+$/;
31
+
32
+ function buildSecretInputFailure(
33
+ value: SecretInputRef,
34
+ reason: string,
35
+ ): SecretInputResolutionFailure {
36
+ return {
37
+ source: value.source,
38
+ provider: value.provider,
39
+ id: value.id,
40
+ reason,
41
+ };
42
+ }
43
+
44
+ export function formatSecretInputResolutionFailure(failure: SecretInputResolutionFailure): string {
45
+ return `${failure.source}:${failure.provider}:${failure.id} - ${failure.reason}`;
46
+ }
47
+
48
+ export function buildSecretInputSchema() {
49
+ return z.union([
50
+ z.string(),
51
+ z.object({
52
+ source: z.enum(["env", "file", "exec"]),
53
+ provider: z.string().min(1).max(1024).regex(SECRET_INPUT_PROVIDER_PATTERN),
54
+ id: z.string().min(1).max(1024).regex(SECRET_INPUT_ID_PATTERN),
55
+ }),
56
+ ]);
57
+ }
58
+
59
+ export function isSecretInputRef(value: unknown): value is SecretInputRef {
60
+ if (!value || typeof value !== "object") {
61
+ return false;
62
+ }
63
+ const ref = value as SecretInputRef;
64
+ return (
65
+ (ref.source === "env" || ref.source === "file" || ref.source === "exec") &&
66
+ typeof ref.provider === "string" &&
67
+ ref.provider.trim().length > 0 &&
68
+ SECRET_INPUT_PROVIDER_PATTERN.test(ref.provider) &&
69
+ typeof ref.id === "string" &&
70
+ ref.id.trim().length > 0 &&
71
+ SECRET_INPUT_ID_PATTERN.test(ref.id)
72
+ );
73
+ }
74
+
75
+ export function hasConfiguredSecretInput(value: unknown): boolean {
76
+ if (typeof value === "string") {
77
+ return value.trim().length > 0;
78
+ }
79
+ if (!isSecretInputRef(value)) {
80
+ return false;
81
+ }
82
+ if (value.source === "env") {
83
+ return Boolean(process.env[value.id]?.trim());
84
+ }
85
+ // file/exec references are considered configured when the reference shape is
86
+ // present. The actual filesystem/process lookup happens at runtime so status
87
+ // checks can stay side-effect free.
88
+ return true;
89
+ }
90
+
91
+ export function normalizeSecretInputString(value: unknown): string | undefined {
92
+ if (typeof value === "string") {
93
+ const trimmed = value.trim();
94
+ return trimmed || undefined;
95
+ }
96
+ if (!isSecretInputRef(value)) {
97
+ return undefined;
98
+ }
99
+ return `<${value.source}:${value.provider}:${value.id}>`;
100
+ }
101
+
102
+ export function parseSecretInputString(value: unknown): SecretInput | undefined {
103
+ if (typeof value !== "string") {
104
+ return undefined;
105
+ }
106
+ const trimmed = value.trim();
107
+ if (!trimmed) {
108
+ return undefined;
109
+ }
110
+ const match = trimmed.match(/^<(env|file|exec):([^:>]+):([^>]+)>$/);
111
+ if (!match) {
112
+ return trimmed;
113
+ }
114
+ return {
115
+ source: match[1] as SecretInputRef["source"],
116
+ provider: match[2],
117
+ id: match[3],
118
+ };
119
+ }
120
+
121
+ export async function resolveSecretInputString(
122
+ value: unknown,
123
+ log?: SecretInputLog,
124
+ ): Promise<string | undefined> {
125
+ return (await resolveSecretInputStringWithFailure(value, log)).value;
126
+ }
127
+
128
+ export async function resolveSecretInputStringWithFailure(
129
+ value: unknown,
130
+ log?: SecretInputLog,
131
+ ): Promise<{ value?: string; failure?: SecretInputResolutionFailure }> {
132
+ if (typeof value === "string") {
133
+ const trimmed = value.trim();
134
+ return { value: trimmed || undefined };
135
+ }
136
+ if (!isSecretInputRef(value)) {
137
+ return {};
138
+ }
139
+ if (value.source === "env") {
140
+ const envValue = process.env[value.id]?.trim();
141
+ if (envValue) {
142
+ return { value: envValue };
143
+ }
144
+ const failure = buildSecretInputFailure(value, "environment variable is not set or is empty");
145
+ log?.warn?.("[DingTalk][SecretInput] Failed to resolve env secret", {
146
+ provider: value.provider,
147
+ id: value.id,
148
+ error: failure.reason,
149
+ });
150
+ return { failure };
151
+ }
152
+ if (value.source === "file") {
153
+ try {
154
+ // Trust boundary: file SecretInput reads the configured local path. Use it
155
+ // only with trusted plugin configuration.
156
+ const filePath = resolveRelativePath(value.id);
157
+ const secret = (await readFile(filePath, "utf8")).trim();
158
+ if (secret) {
159
+ return { value: secret };
160
+ }
161
+ return { failure: buildSecretInputFailure(value, "file secret is empty") };
162
+ } catch (error) {
163
+ const failure = buildSecretInputFailure(
164
+ value,
165
+ error instanceof Error ? error.message : String(error),
166
+ );
167
+ log?.warn?.("[DingTalk][SecretInput] Failed to read file secret", {
168
+ provider: value.provider,
169
+ id: value.id,
170
+ error: failure.reason,
171
+ });
172
+ return { failure };
173
+ }
174
+ }
175
+ try {
176
+ // Trust boundary: exec SecretInput runs the configured provider binary with
177
+ // the secret id as its only argument. Use it only with trusted plugin
178
+ // configuration; execFile avoids shell interpolation but still executes the
179
+ // selected program.
180
+ const result = await execFileAsync(value.provider, [value.id], {
181
+ encoding: "utf8",
182
+ timeout: SECRET_INPUT_EXEC_TIMEOUT_MS,
183
+ windowsHide: true,
184
+ });
185
+ const secret = String(result.stdout).trim();
186
+ if (secret) {
187
+ return { value: secret };
188
+ }
189
+ return { failure: buildSecretInputFailure(value, "exec secret output is empty") };
190
+ } catch (error) {
191
+ const failure = buildSecretInputFailure(
192
+ value,
193
+ error instanceof Error ? error.message : String(error),
194
+ );
195
+ log?.warn?.("[DingTalk][SecretInput] Failed to resolve exec secret", {
196
+ provider: value.provider,
197
+ id: value.id,
198
+ error: failure.reason,
199
+ });
200
+ return { failure };
201
+ }
202
+ }
203
+
204
+ export async function resolveDingTalkSecretConfig<T extends { clientSecret?: unknown }>(
205
+ config: T,
206
+ log?: SecretInputLog,
207
+ ): Promise<
208
+ T & { clientSecret?: string; clientSecretResolutionFailure?: SecretInputResolutionFailure }
209
+ > {
210
+ const resolvedSecret = await resolveSecretInputStringWithFailure(config.clientSecret, log);
211
+ return {
212
+ ...config,
213
+ clientSecret: resolvedSecret.value,
214
+ clientSecretResolutionFailure: resolvedSecret.failure,
215
+ };
216
+ }
@@ -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
+ }