@soimy/dingtalk 3.4.0 → 3.4.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.
package/index.ts CHANGED
@@ -1,80 +1,85 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import * as pluginSdk from "openclaw/plugin-sdk";
3
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import { defineChannelPluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import { readStringParam } from "openclaw/plugin-sdk/param-readers";
4
3
  import { dingtalkPlugin } from "./src/channel";
5
4
  import { getConfig } from "./src/config";
6
5
  import { appendToDoc, createDoc, DocCreateAppendError, listDocs, searchDocs } from "./src/docs-service";
7
6
  import { setDingTalkRuntime } from "./src/runtime";
8
- import type { DingtalkPluginModule } from "./src/types";
9
7
 
10
8
  type GatewayMethodContext = Pick<
11
9
  Parameters<Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]>[0],
12
10
  "params" | "respond"
13
11
  >;
14
12
 
15
- const plugin: DingtalkPluginModule = {
13
+ function registerDingTalkDocsGatewayMethods(api: OpenClawPluginApi): void {
14
+ api.registerGatewayMethod("dingtalk.docs.create", async ({ respond, params }: GatewayMethodContext) => {
15
+ const accountId = readStringParam(params, "accountId");
16
+ const spaceId = readStringParam(params, "spaceId", { required: true });
17
+ const title = readStringParam(params, "title", { required: true });
18
+ const content = readStringParam(params, "content", { allowEmpty: true });
19
+ const parentId = readStringParam(params, "parentId");
20
+ const config = getConfig(api.config, accountId ?? undefined);
21
+ try {
22
+ const doc = await createDoc(
23
+ config,
24
+ spaceId,
25
+ title,
26
+ content ?? undefined,
27
+ api.logger,
28
+ parentId ?? undefined,
29
+ );
30
+ return respond(true, doc);
31
+ } catch (error) {
32
+ if (error instanceof DocCreateAppendError) {
33
+ return respond(true, {
34
+ partialSuccess: true,
35
+ initContentAppended: false,
36
+ docId: error.doc.docId,
37
+ doc: error.doc,
38
+ appendError: error.message,
39
+ });
40
+ }
41
+ throw error;
42
+ }
43
+ });
44
+
45
+ api.registerGatewayMethod("dingtalk.docs.append", async ({ respond, params }: GatewayMethodContext) => {
46
+ const accountId = readStringParam(params, "accountId");
47
+ const docId = readStringParam(params, "docId", { required: true });
48
+ const content = readStringParam(params, "content", { required: true, allowEmpty: false });
49
+ const config = getConfig(api.config, accountId ?? undefined);
50
+ const result = await appendToDoc(config, docId, content, api.logger);
51
+ return respond(true, result);
52
+ });
53
+
54
+ api.registerGatewayMethod("dingtalk.docs.search", async ({ respond, params }: GatewayMethodContext) => {
55
+ const accountId = readStringParam(params, "accountId");
56
+ const keyword = readStringParam(params, "keyword", { required: true });
57
+ const spaceId = readStringParam(params, "spaceId");
58
+ const config = getConfig(api.config, accountId ?? undefined);
59
+ const docs = await searchDocs(config, keyword, spaceId, api.logger);
60
+ return respond(true, { docs });
61
+ });
62
+
63
+ api.registerGatewayMethod("dingtalk.docs.list", async ({ respond, params }: GatewayMethodContext) => {
64
+ const accountId = readStringParam(params, "accountId");
65
+ const spaceId = readStringParam(params, "spaceId", { required: true });
66
+ const parentId = readStringParam(params, "parentId");
67
+ const config = getConfig(api.config, accountId ?? undefined);
68
+ const docs = await listDocs(config, spaceId, parentId, api.logger);
69
+ return respond(true, { docs });
70
+ });
71
+ }
72
+
73
+ export { dingtalkPlugin } from "./src/channel";
74
+ export { setDingTalkRuntime } from "./src/runtime";
75
+
76
+ export default defineChannelPluginEntry({
16
77
  id: "dingtalk",
17
78
  name: "DingTalk Channel",
18
79
  description: "DingTalk (钉钉) messaging channel via Stream mode",
19
- configSchema: emptyPluginConfigSchema(),
20
- register(api: OpenClawPluginApi): void {
21
- setDingTalkRuntime(api.runtime);
22
- api.registerChannel({ plugin: dingtalkPlugin });
23
- api.registerGatewayMethod("dingtalk.docs.create", async ({ respond, params }: GatewayMethodContext) => {
24
- const accountId = pluginSdk.readStringParam(params, "accountId");
25
- const spaceId = pluginSdk.readStringParam(params, "spaceId", { required: true });
26
- const title = pluginSdk.readStringParam(params, "title", { required: true });
27
- const content = pluginSdk.readStringParam(params, "content", { allowEmpty: true });
28
- const parentId = pluginSdk.readStringParam(params, "parentId");
29
- const config = getConfig(api.config, accountId ?? undefined);
30
- try {
31
- const doc = await createDoc(
32
- config,
33
- spaceId,
34
- title,
35
- content ?? undefined,
36
- api.logger,
37
- parentId ?? undefined,
38
- );
39
- return respond(true, doc);
40
- } catch (error) {
41
- if (error instanceof DocCreateAppendError) {
42
- return respond(true, {
43
- partialSuccess: true,
44
- initContentAppended: false,
45
- docId: error.doc.docId,
46
- doc: error.doc,
47
- appendError: error.message,
48
- });
49
- }
50
- throw error;
51
- }
52
- });
53
- api.registerGatewayMethod("dingtalk.docs.append", async ({ respond, params }: GatewayMethodContext) => {
54
- const accountId = pluginSdk.readStringParam(params, "accountId");
55
- const docId = pluginSdk.readStringParam(params, "docId", { required: true });
56
- const content = pluginSdk.readStringParam(params, "content", { required: true, allowEmpty: false });
57
- const config = getConfig(api.config, accountId ?? undefined);
58
- const result = await appendToDoc(config, docId, content, api.logger);
59
- return respond(true, result);
60
- });
61
- api.registerGatewayMethod("dingtalk.docs.search", async ({ respond, params }: GatewayMethodContext) => {
62
- const accountId = pluginSdk.readStringParam(params, "accountId");
63
- const keyword = pluginSdk.readStringParam(params, "keyword", { required: true });
64
- const spaceId = pluginSdk.readStringParam(params, "spaceId");
65
- const config = getConfig(api.config, accountId ?? undefined);
66
- const docs = await searchDocs(config, keyword, spaceId, api.logger);
67
- return respond(true, { docs });
68
- });
69
- api.registerGatewayMethod("dingtalk.docs.list", async ({ respond, params }: GatewayMethodContext) => {
70
- const accountId = pluginSdk.readStringParam(params, "accountId");
71
- const spaceId = pluginSdk.readStringParam(params, "spaceId", { required: true });
72
- const parentId = pluginSdk.readStringParam(params, "parentId");
73
- const config = getConfig(api.config, accountId ?? undefined);
74
- const docs = await listDocs(config, spaceId, parentId, api.logger);
75
- return respond(true, { docs });
76
- });
80
+ plugin: dingtalkPlugin,
81
+ setRuntime: setDingTalkRuntime,
82
+ registerFull(api) {
83
+ registerDingTalkDocsGatewayMethods(api);
77
84
  },
78
- };
79
-
80
- export default plugin;
85
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -51,6 +51,7 @@
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.2.0",
53
53
  "@vitest/coverage-v8": "^3.2.4",
54
+ "openclaw": "2026.3.22",
54
55
  "oxfmt": "0.34.0",
55
56
  "oxlint": "^1.49.0",
56
57
  "oxlint-tsgolint": "^0.15.0",
@@ -58,7 +59,7 @@
58
59
  "vitest": "^3.2.4"
59
60
  },
60
61
  "peerDependencies": {
61
- "openclaw": ">=2026.2.13"
62
+ "openclaw": ">=2026.3.14"
62
63
  },
63
64
  "openclaw": {
64
65
  "extensions": [
@@ -72,8 +73,8 @@
72
73
  "id": "dingtalk",
73
74
  "label": "DingTalk",
74
75
  "selectionLabel": "DingTalk (钉钉)",
75
- "docsPath": "/channels/dingtalk",
76
- "docsLabel": "dingtalk",
76
+ "docsPath": "https://github.com/soimy/openclaw-channel-dingtalk",
77
+ "docsLabel": "plugin docs",
77
78
  "blurb": "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
78
79
  "order": 70,
79
80
  "aliases": [
package/src/channel.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
3
- import type { ChannelMessageActionAdapter, OpenClawConfig } from "openclaw/plugin-sdk";
4
- import * as pluginSdk from "openclaw/plugin-sdk";
3
+ import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
4
+ import { buildChannelConfigSchema, type OpenClawConfig } from "openclaw/plugin-sdk/core";
5
+ import { jsonResult } from "openclaw/plugin-sdk/telegram-core";
6
+ import { readStringParam } from "openclaw/plugin-sdk/param-readers";
7
+ import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
5
8
  import { getAccessToken } from "./auth";
6
9
  import { analyzeCardCallback } from "./card-callback-service";
7
10
  import {
@@ -30,7 +33,7 @@ import {
30
33
  import { handleDingTalkMessage } from "./inbound-handler";
31
34
  import { getLogger } from "./logger-context";
32
35
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
33
- import { dingtalkOnboardingAdapter } from "./onboarding.js";
36
+ import { dingtalkSetupAdapter, dingtalkSetupWizard } from "./onboarding.js";
34
37
  import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
35
38
  import { getDingTalkRuntime } from "./runtime";
36
39
  import {
@@ -206,26 +209,46 @@ function readBooleanLikeParam(params: Record<string, unknown>, key: string): boo
206
209
  return undefined;
207
210
  }
208
211
 
212
+ function describeDingTalkMessageTool(cfg: OpenClawConfig): {
213
+ actions: readonly ["send"] | readonly [];
214
+ capabilities: readonly ["cards"] | readonly [];
215
+ schema: null;
216
+ } {
217
+ const config = getConfig(cfg);
218
+ const configured = Boolean(config.clientId && config.clientSecret);
219
+ if (!configured && !(config.accounts && Object.keys(config.accounts).length > 0)) {
220
+ return { actions: [], capabilities: [], schema: null };
221
+ }
222
+ const hasCardMode =
223
+ config.messageType === "card" ||
224
+ (config.accounts && Object.values(config.accounts).some((a) => a?.messageType === "card"));
225
+ return {
226
+ actions: ["send"] as const,
227
+ capabilities: hasCardMode ? (["cards"] as const) : [],
228
+ schema: null,
229
+ };
230
+ }
231
+
209
232
  const dingtalkMessageActions: ChannelMessageActionAdapter = {
210
- listActions: () => ["send"],
233
+ describeMessageTool: ({ cfg }) => describeDingTalkMessageTool(cfg),
211
234
  supportsAction: ({ action }) => action === "send",
212
- extractToolSend: ({ args }) => pluginSdk.extractToolSend(args, "sendMessage"),
213
- handleAction: async ({ action, params, cfg, accountId, dryRun }) => {
235
+ extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
236
+ handleAction: async ({ action, params, cfg, accountId, dryRun, mediaLocalRoots }) => {
214
237
  if (action !== "send") {
215
238
  throw new Error(`Action ${action} is not supported for provider dingtalk.`);
216
239
  }
217
240
 
218
- const to = pluginSdk.readStringParam(params, "to", { required: true });
241
+ const to = readStringParam(params, "to", { required: true });
219
242
  const mediaInput =
220
- pluginSdk.readStringParam(params, "media", { trim: false }) ??
221
- pluginSdk.readStringParam(params, "path", { trim: false }) ??
222
- pluginSdk.readStringParam(params, "filePath", { trim: false }) ??
223
- pluginSdk.readStringParam(params, "mediaUrl", { trim: false });
243
+ readStringParam(params, "media", { trim: false }) ??
244
+ readStringParam(params, "path", { trim: false }) ??
245
+ readStringParam(params, "filePath", { trim: false }) ??
246
+ readStringParam(params, "mediaUrl", { trim: false });
224
247
 
225
248
  const hasMedia = Boolean(mediaInput && mediaInput.trim());
226
- const caption = pluginSdk.readStringParam(params, "caption", { allowEmpty: true }) ?? "";
249
+ const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
227
250
  let message =
228
- pluginSdk.readStringParam(params, "message", {
251
+ readStringParam(params, "message", {
229
252
  required: !hasMedia,
230
253
  allowEmpty: true,
231
254
  }) ?? "";
@@ -235,12 +258,12 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
235
258
  }
236
259
 
237
260
  const asVoice = readBooleanLikeParam(params, "asVoice") === true;
238
- const requestedMediaType = pluginSdk.readStringParam(params, "mediaType");
261
+ const requestedMediaType = readStringParam(params, "mediaType");
239
262
 
240
263
  const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
241
264
 
242
265
  if (dryRun) {
243
- return pluginSdk.jsonResult({
266
+ return jsonResult({
244
267
  ok: true,
245
268
  dryRun: true,
246
269
  to: target,
@@ -267,13 +290,14 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
267
290
  const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
268
291
  log,
269
292
  accountId: accountId ?? undefined,
293
+ mediaLocalRoots: mediaLocalRoots ? [...mediaLocalRoots] : undefined,
270
294
  });
271
295
 
272
296
  if (!result.ok) {
273
297
  throw new Error(result.error || "send media failed");
274
298
  }
275
299
 
276
- return pluginSdk.jsonResult({
300
+ return jsonResult({
277
301
  ok: true,
278
302
  to: target,
279
303
  mediaType,
@@ -305,7 +329,7 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
305
329
  }
306
330
 
307
331
  const data = result.data as any;
308
- return pluginSdk.jsonResult({
332
+ return jsonResult({
309
333
  ok: true,
310
334
  to: target,
311
335
  messageId: data?.processQueryKey || data?.messageId || null,
@@ -322,12 +346,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
322
346
  id: "dingtalk",
323
347
  label: "DingTalk",
324
348
  selectionLabel: "DingTalk (钉钉)",
325
- docsPath: "/channels/dingtalk",
349
+ docsPath: "https://github.com/soimy/openclaw-channel-dingtalk",
326
350
  blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
327
351
  aliases: ["dd", "ding"],
328
352
  },
329
- configSchema: pluginSdk.buildChannelConfigSchema(DingTalkConfigSchema),
330
- onboarding: dingtalkOnboardingAdapter,
353
+ configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
354
+ setup: dingtalkSetupAdapter,
355
+ setupWizard: dingtalkSetupWizard,
331
356
  capabilities: {
332
357
  chatTypes: ["direct", "group"] as Array<"direct" | "group">,
333
358
  reactions: false,
@@ -479,6 +504,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
479
504
  mediaType: providedMediaType,
480
505
  asVoice,
481
506
  accountId,
507
+ mediaLocalRoots,
482
508
  log,
483
509
  }: any) => {
484
510
  const config = getConfig(cfg, accountId);
@@ -547,6 +573,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
547
573
  accountId,
548
574
  storePath,
549
575
  conversationId: to,
576
+ mediaLocalRoots,
550
577
  });
551
578
  } catch (err: any) {
552
579
  if (err?.response?.data !== undefined) {
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
4
  import type { DingTalkConfig } from "./types";
5
5
 
6
6
  const WINDOWS_ROOT_DIRECTORIES = new Set([
@@ -112,6 +112,18 @@ export class ConnectionManager {
112
112
  }
113
113
  }
114
114
 
115
+ /**
116
+ * Resolve all pending waitForStop() callers without performing full stop().
117
+ * Called by stop() and terminal FAILED states where the manager will never
118
+ * reconnect again, so startAccount must be allowed to exit.
119
+ */
120
+ private resolveStopWaiters(): void {
121
+ for (const resolve of this.stopPromiseResolvers) {
122
+ resolve();
123
+ }
124
+ this.stopPromiseResolvers = [];
125
+ }
126
+
115
127
  private logRuntimeCounters(reason: string): void {
116
128
  const c = this.runtimeCounters;
117
129
  this.log?.info?.(
@@ -722,6 +734,7 @@ export class ConnectionManager {
722
734
  this.notifyStateChange(
723
735
  `Max consecutive deadline timeouts (${ConnectionManager.MAX_CONSECUTIVE_DEADLINE_TIMEOUTS}) reached`,
724
736
  );
737
+ this.resolveStopWaiters();
725
738
  return;
726
739
  }
727
740
 
@@ -755,6 +768,7 @@ export class ConnectionManager {
755
768
  this.consecutiveUnhealthyChecks = 0;
756
769
  this.reconnectDeadline = undefined;
757
770
  this.notifyStateChange(`Max runtime reconnect cycles (${maxCycles}) reached`);
771
+ this.resolveStopWaiters();
758
772
  return;
759
773
  }
760
774
 
@@ -822,10 +836,7 @@ export class ConnectionManager {
822
836
  this.log?.info?.(`[${this.accountId}] Connection manager stopped`);
823
837
 
824
838
  // Resolve all pending waitForStop() promises
825
- for (const resolve of this.stopPromiseResolvers) {
826
- resolve();
827
- }
828
- this.stopPromiseResolvers = [];
839
+ this.resolveStopWaiters();
829
840
  }
830
841
 
831
842
  /**
@@ -835,7 +846,7 @@ export class ConnectionManager {
835
846
  * Safe to call concurrently; all pending callers are resolved when stop() is called.
836
847
  */
837
848
  public waitForStop(): Promise<void> {
838
- if (this.stopped) {
849
+ if (this.stopped || this.state === ConnectionStateEnum.FAILED) {
839
850
  return Promise.resolve();
840
851
  }
841
852
  return new Promise<void>((resolve) => {
@@ -5,7 +5,6 @@
5
5
  * Provides functions for media type detection and file upload to DingTalk media servers.
6
6
  */
7
7
 
8
- import * as fs from "node:fs";
9
8
  import { randomUUID } from "node:crypto";
10
9
  import * as os from "node:os";
11
10
  import * as path from "node:path";
@@ -16,17 +15,35 @@ import axios from "axios";
16
15
  import FormData from "form-data";
17
16
  import type { DingTalkConfig, Logger } from "./types";
18
17
  import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
18
+ import { getDingTalkRuntime } from "./runtime";
19
+
20
+ /**
21
+ * Extended PluginRuntime with media bridge support.
22
+ * The `media.loadWebMedia` method resolves sandbox/container paths
23
+ * through the runtime bridge when direct host filesystem access fails.
24
+ */
25
+ interface PluginRuntimeWithMedia {
26
+ media?: {
27
+ loadWebMedia(
28
+ mediaPath: string,
29
+ options?: { mediaLocalRoots?: string[] },
30
+ ): Promise<{ buffer: Buffer | ArrayBuffer; fileName?: string; contentType?: string } | null>;
31
+ };
32
+ [key: string]: unknown;
33
+ }
19
34
 
20
35
  /**
21
36
  * Calculate MP3 duration in seconds by parsing MPEG frame headers
22
37
  * Supports CBR and VBR MP3 files
23
- * @param filePath Path to the MP3 file
38
+ * @param filePathOrBuffer Path to the MP3 file, or a pre-read Buffer
24
39
  * @param log Optional logger
25
40
  * @returns Duration in seconds (0 if parsing fails)
26
41
  */
27
- export async function getMp3DurationSeconds(filePath: string, log?: Logger): Promise<number> {
42
+ export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, log?: Logger): Promise<number> {
28
43
  try {
29
- const buffer = await fsPromises.readFile(filePath);
44
+ const buffer = typeof filePathOrBuffer === "string"
45
+ ? await fsPromises.readFile(filePathOrBuffer)
46
+ : filePathOrBuffer;
30
47
  let offset = 0;
31
48
 
32
49
  // Skip ID3v2 tag if present
@@ -181,7 +198,7 @@ export async function getMp3DurationSeconds(filePath: string, log?: Logger): Pro
181
198
  return Math.floor(duration);
182
199
  }
183
200
 
184
- log?.warn?.(`[DingTalk] Could not parse MP3 duration from ${filePath} (found ${frameCount} frames)`);
201
+ log?.warn?.(`[DingTalk] Could not parse MP3 duration from ${typeof filePathOrBuffer === "string" ? filePathOrBuffer : "<buffer>"} (found ${frameCount} frames)`);
185
202
  return 0;
186
203
  } catch (err: unknown) {
187
204
  log?.error?.(`[DingTalk] Failed to get MP3 duration: ${err instanceof Error ? err.message : String(err)}`);
@@ -195,6 +212,7 @@ export async function getVoiceDurationMs(
195
212
  filePath: string,
196
213
  mediaType: DingTalkMediaType,
197
214
  log?: Logger,
215
+ options?: { mediaLocalRoots?: string[]; preReadBuffer?: Buffer },
198
216
  ): Promise<number> {
199
217
  if (mediaType !== "voice") {
200
218
  return DEFAULT_VOICE_DURATION_MS;
@@ -203,7 +221,15 @@ export async function getVoiceDurationMs(
203
221
  const ext = path.extname(filePath).toLowerCase();
204
222
 
205
223
  if (ext === ".mp3") {
206
- const durationSec = await getMp3DurationSeconds(filePath, log);
224
+ let durationSec: number;
225
+ try {
226
+ // Reuse pre-read buffer from uploadMedia when available to avoid double read
227
+ const buffer = options?.preReadBuffer
228
+ ?? (await readMediaBuffer(filePath, options, log)).buffer;
229
+ durationSec = await getMp3DurationSeconds(buffer, log);
230
+ } catch {
231
+ durationSec = 0;
232
+ }
207
233
  if (durationSec > 0) {
208
234
  return Math.max(1, Math.round(durationSec * 1000));
209
235
  }
@@ -607,36 +633,80 @@ const FILE_SIZE_LIMITS: Record<DingTalkMediaType, number> = {
607
633
  };
608
634
 
609
635
  /**
610
- * Upload media file to DingTalk and get media_id
611
- * Uses DingTalk's media upload API: https://oapi.dingtalk.com/media/upload
636
+ * Read a media file, resolving sandbox/container paths via the runtime bridge
637
+ * when direct host filesystem access fails.
612
638
  *
613
- * Note: Media files are stored temporarily by DingTalk (not in permanent storage).
614
- * The media_id can be used in subsequent message sends.
615
- *
616
- * @param config DingTalk configuration
617
- * @param mediaPath Local path to the media file
618
- * @param mediaType Type of media: 'image' | 'voice' | 'video' | 'file'
619
- * @param getAccessToken Function to get DingTalk access token
620
- * @param log Optional logger
621
- * @returns media_id on success, null on failure
639
+ * Precedence:
640
+ * 1. Direct fs.readFile (works for host-local paths)
641
+ * 2. rt.media.loadWebMedia (resolves sandbox workspace paths via bridge)
622
642
  */
643
+ async function readMediaBuffer(
644
+ mediaPath: string,
645
+ options?: { mediaLocalRoots?: string[] },
646
+ log?: Logger,
647
+ ): Promise<{ buffer: Buffer; size: number }> {
648
+ // Try direct host filesystem first
649
+ try {
650
+ const buffer = await fsPromises.readFile(mediaPath);
651
+ return { buffer, size: buffer.length };
652
+ } catch (err: unknown) {
653
+ const errno = err as NodeJS.ErrnoException;
654
+ if (errno.code !== "ENOENT") {
655
+ throw err; // Permission errors etc. should propagate immediately
656
+ }
657
+ }
658
+
659
+ // File not found on host — try runtime media bridge (sandbox/container paths)
660
+ log?.debug?.(`[DingTalk] File not found on host, trying runtime media bridge: ${mediaPath}`);
661
+ const rt = getDingTalkRuntime() as PluginRuntimeWithMedia;
662
+ if (!rt.media?.loadWebMedia) {
663
+ throw Object.assign(
664
+ new Error(`File not found and runtime media bridge unavailable: ${mediaPath}`),
665
+ { code: "ENOENT" },
666
+ );
667
+ }
668
+
669
+ const media = await rt.media.loadWebMedia(mediaPath, {
670
+ mediaLocalRoots: options?.mediaLocalRoots,
671
+ });
672
+
673
+ if (!media || !media.buffer) {
674
+ throw Object.assign(
675
+ new Error(`Runtime media bridge returned no data for: ${mediaPath}`),
676
+ { code: "ENOENT" },
677
+ );
678
+ }
679
+
680
+ const buffer = Buffer.isBuffer(media.buffer)
681
+ ? media.buffer
682
+ : Buffer.from(media.buffer);
683
+ return { buffer, size: buffer.length };
684
+ }
685
+
686
+ export interface UploadMediaResult {
687
+ mediaId: string;
688
+ /** The file buffer read during upload, reusable for voice duration parsing etc. */
689
+ buffer: Buffer;
690
+ }
691
+
623
692
  export async function uploadMedia(
624
693
  config: DingTalkConfig,
625
694
  mediaPath: string,
626
695
  mediaType: DingTalkMediaType,
627
696
  getAccessToken: (config: DingTalkConfig, log?: Logger) => Promise<string>,
628
697
  log?: Logger,
629
- ): Promise<string | null> {
630
- let fileStream: fs.ReadStream | null = null;
631
-
698
+ options?: { mediaLocalRoots?: string[] },
699
+ ): Promise<UploadMediaResult | null> {
632
700
  try {
633
701
  const token = await getAccessToken(config, log);
634
702
 
635
- // Check file size (stat will throw if file doesn't exist)
636
- const stats = await fsPromises.stat(mediaPath);
703
+ // Read file via sandbox-aware bridge (falls back to direct fs for host paths)
704
+ const { buffer, size } = await readMediaBuffer(mediaPath, options, log);
705
+
706
+ // Check file size
637
707
  const sizeLimit = FILE_SIZE_LIMITS[mediaType];
638
- if (stats.size > sizeLimit) {
639
- const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
708
+ if (size > sizeLimit) {
709
+ const sizeMB = (size / (1024 * 1024)).toFixed(2);
640
710
  const limitMB = (sizeLimit / (1024 * 1024)).toFixed(2);
641
711
  log?.error?.(
642
712
  `[DingTalk] Media file too large: ${sizeMB}MB exceeds ${limitMB}MB limit for ${mediaType}`,
@@ -644,17 +714,15 @@ export async function uploadMedia(
644
714
  return null;
645
715
  }
646
716
 
647
- // Read file as a stream for better memory efficiency
648
- fileStream = fs.createReadStream(mediaPath);
649
717
  const filename = path.basename(mediaPath);
650
718
 
651
719
  // Upload to DingTalk's media server using form-data
652
720
  const form = new FormData();
653
- form.append("media", fileStream, { filename });
721
+ form.append("media", buffer, { filename });
654
722
 
655
723
  const uploadUrl = `https://oapi.dingtalk.com/media/upload?access_token=${token}&type=${mediaType}`;
656
724
 
657
- log?.debug?.(`[DingTalk] Uploading media: ${filename} (${stats.size} bytes) as ${mediaType}`);
725
+ log?.debug?.(`[DingTalk] Uploading media: ${filename} (${size} bytes) as ${mediaType}`);
658
726
 
659
727
  const response = await axios.post(uploadUrl, form, {
660
728
  headers: form.getHeaders(),
@@ -665,9 +733,9 @@ export async function uploadMedia(
665
733
 
666
734
  if (response.data?.errcode === 0 && response.data?.media_id) {
667
735
  log?.debug?.(
668
- `[DingTalk] Media uploaded successfully: ${response.data.media_id} (${stats.size} bytes)`,
736
+ `[DingTalk] Media uploaded successfully: ${response.data.media_id} (${size} bytes)`,
669
737
  );
670
- return response.data.media_id;
738
+ return { mediaId: response.data.media_id, buffer };
671
739
  } else {
672
740
  log?.error?.(`[DingTalk] Media upload failed: ${JSON.stringify(response.data)}`);
673
741
  return null;
@@ -676,7 +744,7 @@ export async function uploadMedia(
676
744
  // Handle file system errors (e.g., file not found, permission denied)
677
745
  const errno = err as NodeJS.ErrnoException;
678
746
  if (errno.code === "ENOENT") {
679
- log?.error?.(`[DingTalk] Media file not found: ${mediaPath}`);
747
+ log?.error?.(`[DingTalk] Media file not found (host and sandbox): ${mediaPath}`);
680
748
  } else if (errno.code === "EACCES") {
681
749
  log?.error?.(`[DingTalk] Permission denied accessing media file: ${mediaPath}`);
682
750
  } else {
@@ -690,10 +758,5 @@ export async function uploadMedia(
690
758
  }
691
759
  }
692
760
  return null;
693
- } finally {
694
- // Ensure file stream is closed even on error
695
- if (fileStream) {
696
- fileStream.destroy();
697
- }
698
761
  }
699
762
  }