@soimy/dingtalk 3.6.10 → 3.7.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 (163) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +11655 -10324
  3. package/dist/src/{ack-reaction-service.d.ts → ack-reaction/ack-reaction-service.d.ts} +1 -1
  4. package/dist/src/ack-reaction/dynamic-ack-reaction-controller.d.ts +3 -3
  5. package/dist/src/card/ask-user-question-context.d.ts +1 -1
  6. package/dist/src/card/ask-user-question-store.d.ts +1 -1
  7. package/dist/src/card/ask-user-question.d.ts +1 -1
  8. package/dist/src/card/card-action-handler.d.ts +2 -2
  9. package/dist/src/{card-draft-controller.d.ts → card/card-draft-controller.d.ts} +5 -1
  10. package/dist/src/card/card-run-registry.d.ts +2 -2
  11. package/dist/src/{card-service.d.ts → card/card-service.d.ts} +1 -1
  12. package/dist/src/card/card-stop-handler.d.ts +1 -1
  13. package/dist/src/card/card-streaming-mode.d.ts +1 -1
  14. package/dist/src/card/card-task-progress.d.ts +60 -0
  15. package/dist/src/channel.d.ts +6 -6
  16. package/dist/src/command/card-stop-command.d.ts +1 -1
  17. package/dist/src/{feedback-learning-service.d.ts → command/feedback-learning-service.d.ts} +1 -1
  18. package/dist/src/command/inbound-command-dispatch-service.d.ts +2 -2
  19. package/dist/src/{learning-command-service.d.ts → command/learning-command-service.d.ts} +1 -1
  20. package/dist/src/gateway/channel-gateway.d.ts +1 -1
  21. package/dist/src/{connection-manager.d.ts → gateway/connection-manager.d.ts} +1 -1
  22. package/dist/src/{docs-service.d.ts → gateway/docs-service.d.ts} +5 -5
  23. package/dist/src/{inbound-handler.d.ts → gateway/inbound-handler.d.ts} +1 -1
  24. package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts +1 -1
  25. package/dist/src/messaging/attachment-text-extractor.d.ts +1 -1
  26. package/dist/src/messaging/btw-deliver.d.ts +1 -1
  27. package/dist/src/messaging/channel-outbound.d.ts +1 -1
  28. package/dist/src/messaging/inline-directives.d.ts +42 -0
  29. package/dist/src/{media-utils.d.ts → messaging/media-utils.d.ts} +1 -1
  30. package/dist/src/{message-context-store.d.ts → messaging/message-context-store.d.ts} +7 -1
  31. package/dist/src/{message-utils.d.ts → messaging/message-utils.d.ts} +1 -1
  32. package/dist/src/messaging/quoted-context.d.ts +2 -2
  33. package/dist/src/messaging/quoted-file-service.d.ts +1 -1
  34. package/dist/src/messaging/quoted-ref.d.ts +2 -2
  35. package/dist/src/{reply-strategy-card.d.ts → messaging/reply-strategy-card.d.ts} +1 -1
  36. package/dist/src/{reply-strategy-types.d.ts → messaging/reply-strategy-types.d.ts} +10 -1
  37. package/dist/src/{reply-strategy-with-reaction.d.ts → messaging/reply-strategy-with-reaction.d.ts} +1 -1
  38. package/dist/src/{reply-strategy.d.ts → messaging/reply-strategy.d.ts} +1 -1
  39. package/dist/src/{send-service.d.ts → messaging/send-service.d.ts} +1 -1
  40. package/dist/src/platform/channel-status.d.ts +1 -1
  41. package/dist/src/platform/config.d.ts +110 -0
  42. package/dist/src/platform/runtime-events.d.ts +67 -0
  43. package/dist/src/{types.d.ts → platform/types.d.ts} +75 -2
  44. package/dist/src/{persistence-store.d.ts → shared/persistence-store.d.ts} +1 -1
  45. package/dist/src/{utils.d.ts → shared/utils.d.ts} +1 -1
  46. package/dist/src/targeting/agent-name-matcher.d.ts +1 -1
  47. package/dist/src/targeting/agent-routing.d.ts +3 -3
  48. package/dist/src/{session-routing.d.ts → targeting/session-routing.d.ts} +1 -1
  49. package/index.ts +193 -69
  50. package/openclaw.plugin.json +158 -2
  51. package/package.json +16 -16
  52. package/src/{ack-reaction-classifier.ts → ack-reaction/ack-reaction-classifier.ts} +85 -13
  53. package/src/{ack-reaction-service.ts → ack-reaction/ack-reaction-service.ts} +13 -7
  54. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +46 -31
  55. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +4 -1
  56. package/src/card/ask-user-question-context.ts +1 -1
  57. package/src/card/ask-user-question-store.ts +2 -2
  58. package/src/card/ask-user-question.ts +11 -7
  59. package/src/card/card-action-handler.ts +2 -2
  60. package/src/{card-callback-service.ts → card/card-callback-service.ts} +7 -9
  61. package/src/card/card-draft-controller.ts +770 -0
  62. package/src/card/card-markdown-image-reroute.ts +8 -2
  63. package/src/card/card-run-registry.ts +17 -7
  64. package/src/{card-service.ts → card/card-service.ts} +91 -61
  65. package/src/card/card-stop-handler.ts +12 -8
  66. package/src/card/card-streaming-mode.ts +1 -1
  67. package/src/card/card-task-progress.ts +330 -0
  68. package/src/card/draft-stream-loop.ts +119 -0
  69. package/src/card/reasoning-answer-split.ts +125 -121
  70. package/src/card/reasoning-block-assembler.ts +122 -123
  71. package/src/{run-usage-store.ts → card/run-usage-store.ts} +18 -6
  72. package/src/card/statusline-renderer.ts +14 -5
  73. package/src/card/task-model-metadata.ts +8 -3
  74. package/src/channel.ts +14 -16
  75. package/src/command/card-stop-command.ts +2 -2
  76. package/src/{feedback-learning-service.ts → command/feedback-learning-service.ts} +36 -16
  77. package/src/{feedback-learning-store.ts → command/feedback-learning-store.ts} +133 -81
  78. package/src/command/inbound-command-dispatch-service.ts +12 -6
  79. package/src/{learning-command-service.ts → command/learning-command-service.ts} +36 -29
  80. package/src/{session-command-service.ts → command/session-command-service.ts} +3 -1
  81. package/src/gateway/channel-gateway.ts +106 -28
  82. package/src/{connection-manager.ts → gateway/connection-manager.ts} +45 -30
  83. package/src/{docs-service.ts → gateway/docs-service.ts} +5 -5
  84. package/src/{inbound-handler.ts → gateway/inbound-handler.ts} +144 -85
  85. package/src/gateway/inbound-session-queue-dispatcher.ts +18 -14
  86. package/src/{session-lock.ts → gateway/session-lock.ts} +14 -14
  87. package/src/messaging/attachment-text-extractor.ts +21 -7
  88. package/src/messaging/btw-deliver.ts +2 -2
  89. package/src/messaging/channel-actions.ts +15 -7
  90. package/src/messaging/channel-outbound.ts +16 -11
  91. package/src/messaging/inline-directives.ts +342 -0
  92. package/src/{media-utils.ts → messaging/media-utils.ts} +276 -72
  93. package/src/{message-context-store.ts → messaging/message-context-store.ts} +11 -2
  94. package/src/{message-utils.ts → messaging/message-utils.ts} +46 -11
  95. package/src/messaging/quoted-context.ts +2 -2
  96. package/src/messaging/quoted-file-service.ts +311 -294
  97. package/src/messaging/quoted-ref.ts +11 -7
  98. package/src/{reply-strategy-card.ts → messaging/reply-strategy-card.ts} +178 -101
  99. package/src/{reply-strategy-markdown.ts → messaging/reply-strategy-markdown.ts} +8 -2
  100. package/src/{reply-strategy-types.ts → messaging/reply-strategy-types.ts} +11 -1
  101. package/src/{reply-strategy-with-reaction.ts → messaging/reply-strategy-with-reaction.ts} +6 -1
  102. package/src/{reply-strategy.ts → messaging/reply-strategy.ts} +2 -2
  103. package/src/{send-service.ts → messaging/send-service.ts} +100 -65
  104. package/src/{auth.ts → platform/auth.ts} +2 -2
  105. package/src/platform/channel-status.ts +3 -3
  106. package/src/{config-schema.ts → platform/config-schema.ts} +81 -4
  107. package/src/{config.ts → platform/config.ts} +167 -19
  108. package/src/{device-registration.ts → platform/device-registration.ts} +5 -4
  109. package/src/{onboarding.ts → platform/onboarding.ts} +1 -4
  110. package/src/{plugin-sdk-channel-actions-augment.ts → platform/plugin-sdk-channel-actions-augment.ts} +6 -5
  111. package/src/platform/runtime-events.ts +202 -0
  112. package/src/{secret-input.ts → platform/secret-input.ts} +117 -11
  113. package/src/{types.ts → platform/types.ts} +77 -2
  114. package/src/{http-client.ts → shared/http-client.ts} +1 -3
  115. package/src/{persistence-store.ts → shared/persistence-store.ts} +6 -6
  116. package/src/{utils.ts → shared/utils.ts} +30 -13
  117. package/src/targeting/agent-name-matcher.ts +1 -1
  118. package/src/targeting/agent-routing.ts +7 -7
  119. package/src/targeting/group-members-store.ts +1 -1
  120. package/src/{session-peer-store.ts → targeting/session-peer-store.ts} +9 -4
  121. package/src/{session-routing.ts → targeting/session-routing.ts} +1 -1
  122. package/src/targeting/target-directory-adapter.ts +3 -3
  123. package/src/targeting/target-directory-store.ts +1 -1
  124. package/src/targeting/target-input.ts +1 -1
  125. package/dist/src/ack-reaction/dynamic-ack-reaction-events.d.ts +0 -35
  126. package/dist/src/config.d.ts +0 -59
  127. package/src/ack-reaction/dynamic-ack-reaction-events.ts +0 -123
  128. package/src/card-draft-controller.ts +0 -637
  129. package/src/draft-stream-loop.ts +0 -119
  130. /package/dist/src/{ack-reaction-classifier.d.ts → ack-reaction/ack-reaction-classifier.d.ts} +0 -0
  131. /package/dist/src/{card-callback-service.d.ts → card/card-callback-service.d.ts} +0 -0
  132. /package/dist/src/{draft-stream-loop.d.ts → card/draft-stream-loop.d.ts} +0 -0
  133. /package/dist/src/{run-usage-store.d.ts → card/run-usage-store.d.ts} +0 -0
  134. /package/dist/src/{feedback-learning-store.d.ts → command/feedback-learning-store.d.ts} +0 -0
  135. /package/dist/src/{session-command-service.d.ts → command/session-command-service.d.ts} +0 -0
  136. /package/dist/src/{session-lock.d.ts → gateway/session-lock.d.ts} +0 -0
  137. /package/dist/src/{proactive-risk-registry.d.ts → messaging/proactive-risk-registry.d.ts} +0 -0
  138. /package/dist/src/{reply-strategy-markdown.d.ts → messaging/reply-strategy-markdown.d.ts} +0 -0
  139. /package/dist/src/{access-control.d.ts → platform/access-control.d.ts} +0 -0
  140. /package/dist/src/{auth.d.ts → platform/auth.d.ts} +0 -0
  141. /package/dist/src/{config-schema.d.ts → platform/config-schema.d.ts} +0 -0
  142. /package/dist/src/{device-registration.d.ts → platform/device-registration.d.ts} +0 -0
  143. /package/dist/src/{logger-context.d.ts → platform/logger-context.d.ts} +0 -0
  144. /package/dist/src/{onboarding.d.ts → platform/onboarding.d.ts} +0 -0
  145. /package/dist/src/{plugin-sdk-channel-actions-augment.d.ts → platform/plugin-sdk-channel-actions-augment.d.ts} +0 -0
  146. /package/dist/src/{runtime.d.ts → platform/runtime.d.ts} +0 -0
  147. /package/dist/src/{secret-input.d.ts → platform/secret-input.d.ts} +0 -0
  148. /package/dist/src/{session-state.d.ts → platform/session-state.d.ts} +0 -0
  149. /package/dist/src/{signature.d.ts → platform/signature.d.ts} +0 -0
  150. /package/dist/src/{dedup.d.ts → shared/dedup.d.ts} +0 -0
  151. /package/dist/src/{http-client.d.ts → shared/http-client.d.ts} +0 -0
  152. /package/dist/src/{path-utils.d.ts → shared/path-utils.d.ts} +0 -0
  153. /package/dist/src/{peer-id-registry.d.ts → targeting/peer-id-registry.d.ts} +0 -0
  154. /package/dist/src/{session-peer-store.d.ts → targeting/session-peer-store.d.ts} +0 -0
  155. /package/src/{proactive-risk-registry.ts → messaging/proactive-risk-registry.ts} +0 -0
  156. /package/src/{access-control.ts → platform/access-control.ts} +0 -0
  157. /package/src/{logger-context.ts → platform/logger-context.ts} +0 -0
  158. /package/src/{runtime.ts → platform/runtime.ts} +0 -0
  159. /package/src/{session-state.ts → platform/session-state.ts} +0 -0
  160. /package/src/{signature.ts → platform/signature.ts} +0 -0
  161. /package/src/{dedup.ts → shared/dedup.ts} +0 -0
  162. /package/src/{path-utils.ts → shared/path-utils.ts} +0 -0
  163. /package/src/{peer-id-registry.ts → targeting/peer-id-registry.ts} +0 -0
@@ -1,4 +1,4 @@
1
- // src/media-utils.ts
1
+ // src/messaging/media-utils.ts
2
2
 
3
3
  /**
4
4
  * Media handling utilities for DingTalk channel plugin.
@@ -6,17 +6,17 @@
6
6
  */
7
7
 
8
8
  import { randomUUID } from "node:crypto";
9
- import * as os from "node:os";
10
- import * as path from "node:path";
11
- import { promises as fsPromises } from "node:fs";
12
9
  import { lookup as dnsLookup } from "node:dns/promises";
10
+ import { constants as fsConstants, promises as fsPromises } from "node:fs";
13
11
  import { BlockList, isIP } from "node:net";
14
- import axios from "./http-client";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
15
14
  import FormData from "form-data";
16
15
  import { runFfmpeg, runFfprobe } from "openclaw/plugin-sdk/media-runtime";
17
- import type { DingTalkConfig, Logger } from "./types";
18
- import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
19
- import { getDingTalkRuntime } from "./runtime";
16
+ import { getDingTalkRuntime } from "../platform/runtime";
17
+ import type { DingTalkConfig, Logger } from "../platform/types";
18
+ import axios from "../shared/http-client";
19
+ import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "../shared/utils";
20
20
 
21
21
  /**
22
22
  * Extended PluginRuntime with media bridge support.
@@ -40,11 +40,15 @@ interface PluginRuntimeWithMedia {
40
40
  * @param log Optional logger
41
41
  * @returns Duration in seconds (0 if parsing fails)
42
42
  */
43
- export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, log?: Logger): Promise<number> {
43
+ export async function getMp3DurationSeconds(
44
+ filePathOrBuffer: string | Buffer,
45
+ log?: Logger,
46
+ ): Promise<number> {
44
47
  try {
45
- const buffer = typeof filePathOrBuffer === "string"
46
- ? await fsPromises.readFile(filePathOrBuffer)
47
- : filePathOrBuffer;
48
+ const buffer =
49
+ typeof filePathOrBuffer === "string"
50
+ ? await fsPromises.readFile(filePathOrBuffer)
51
+ : filePathOrBuffer;
48
52
  let offset = 0;
49
53
 
50
54
  // Skip ID3v2 tag if present
@@ -57,7 +61,7 @@ export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, l
57
61
  (buffer[9] & 0x7f);
58
62
 
59
63
  // ID3 size excludes the 10-byte header; footer (if present) adds 10 bytes.
60
- const footerSize = (flags & 0x10) ? 10 : 0;
64
+ const footerSize = flags & 0x10 ? 10 : 0;
61
65
  offset = 10 + id3Size + footerSize;
62
66
  }
63
67
 
@@ -100,7 +104,7 @@ export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, l
100
104
  // Frame sync (11 bits set)
101
105
  if (buffer[offset] === 0xff && (buffer[offset + 1] & 0xe0) === 0xe0) {
102
106
  const versionBits = (buffer[offset + 1] >> 3) & 0x03; // 00=2.5, 01=reserved, 10=2, 11=1
103
- const layerBits = (buffer[offset + 1] >> 1) & 0x03; // 01=III, 10=II, 11=I, 00=reserved
107
+ const layerBits = (buffer[offset + 1] >> 1) & 0x03; // 01=III, 10=II, 11=I, 00=reserved
104
108
  const bitrateIndex = (buffer[offset + 2] >> 4) & 0x0f; // 0000/1111 invalid
105
109
  const sampleRateIndex = (buffer[offset + 2] >> 2) & 0x03; // 11 invalid
106
110
  const paddingBit = (buffer[offset + 2] >> 1) & 0x01;
@@ -199,10 +203,14 @@ export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, l
199
203
  return Math.floor(duration);
200
204
  }
201
205
 
202
- log?.warn?.(`[DingTalk] Could not parse MP3 duration from ${typeof filePathOrBuffer === "string" ? filePathOrBuffer : "<buffer>"} (found ${frameCount} frames)`);
206
+ log?.warn?.(
207
+ `[DingTalk] Could not parse MP3 duration from ${typeof filePathOrBuffer === "string" ? filePathOrBuffer : "<buffer>"} (found ${frameCount} frames)`,
208
+ );
203
209
  return 0;
204
210
  } catch (err: unknown) {
205
- log?.error?.(`[DingTalk] Failed to get MP3 duration: ${err instanceof Error ? err.message : String(err)}`);
211
+ log?.error?.(
212
+ `[DingTalk] Failed to get MP3 duration: ${err instanceof Error ? err.message : String(err)}`,
213
+ );
206
214
  return 0;
207
215
  }
208
216
  }
@@ -211,6 +219,26 @@ const DEFAULT_VOICE_DURATION_MS = 1000;
211
219
  const DINGTALK_VOICE_UPLOAD_EXTENSIONS = new Set([".ogg", ".amr"]);
212
220
  const DINGTALK_VOICE_INPUT_EXTENSIONS = new Set([".ogg", ".amr", ".mp3", ".wav"]);
213
221
 
222
+ /**
223
+ * Temp files this module created itself (remote media downloads and voice
224
+ * transcodes). They live outside `mediaLocalRoots` by design but are
225
+ * plugin-owned, so they may still be read directly instead of being rejected
226
+ * by the roots-gated runtime media bridge.
227
+ */
228
+ const trustedHostMediaPaths = new Set<string>();
229
+
230
+ function markTrustedHostMediaPath(filePath: string): void {
231
+ trustedHostMediaPaths.add(path.resolve(filePath));
232
+ }
233
+
234
+ function unmarkTrustedHostMediaPath(filePath: string): void {
235
+ trustedHostMediaPaths.delete(path.resolve(filePath));
236
+ }
237
+
238
+ function isTrustedHostMediaPath(filePath: string): boolean {
239
+ return trustedHostMediaPaths.has(path.resolve(filePath));
240
+ }
241
+
214
242
  async function getDurationMsWithFfprobe(filePath: string, log?: Logger): Promise<number> {
215
243
  try {
216
244
  const stdout = await runFfprobe([
@@ -228,13 +256,16 @@ async function getDurationMsWithFfprobe(filePath: string, log?: Logger): Promise
228
256
  }
229
257
  return Math.max(1, Math.round(duration * 1000));
230
258
  } catch (err: unknown) {
231
- log?.warn?.(`[DingTalk] Failed to probe voice duration: ${err instanceof Error ? err.message : String(err)}`);
259
+ log?.warn?.(
260
+ `[DingTalk] Failed to probe voice duration: ${err instanceof Error ? err.message : String(err)}`,
261
+ );
232
262
  return 0;
233
263
  }
234
264
  }
235
265
 
236
266
  async function prepareVoiceUploadPath(
237
267
  mediaPath: string,
268
+ options?: { mediaLocalRoots?: string[] },
238
269
  log?: Logger,
239
270
  ): Promise<{ path: string; cleanup?: () => Promise<void> }> {
240
271
  const ext = path.extname(mediaPath).toLowerCase();
@@ -242,30 +273,47 @@ async function prepareVoiceUploadPath(
242
273
  return { path: mediaPath };
243
274
  }
244
275
 
276
+ // ffmpeg must never open a caller path outside mediaLocalRoots, so stage the
277
+ // source through the roots-aware reader before transcoding.
278
+ const { buffer: sourceBuffer } = await readMediaBuffer(mediaPath, options, log);
279
+ const inputPath = path.join(os.tmpdir(), `dingtalk_voice_src_${randomUUID()}${ext}`);
280
+ await fsPromises.writeFile(inputPath, sourceBuffer);
281
+ markTrustedHostMediaPath(inputPath);
282
+
245
283
  const outputPath = path.join(os.tmpdir(), `dingtalk_voice_${randomUUID()}.ogg`);
246
- await runFfmpeg([
247
- "-y",
248
- "-i",
249
- mediaPath,
250
- "-vn",
251
- "-sn",
252
- "-dn",
253
- "-ar",
254
- "16000",
255
- "-ac",
256
- "1",
257
- "-c:a",
258
- "libopus",
259
- "-b:a",
260
- "24k",
261
- outputPath,
262
- ]);
284
+ try {
285
+ await runFfmpeg([
286
+ "-y",
287
+ "-i",
288
+ inputPath,
289
+ "-vn",
290
+ "-sn",
291
+ "-dn",
292
+ "-ar",
293
+ "16000",
294
+ "-ac",
295
+ "1",
296
+ "-c:a",
297
+ "libopus",
298
+ "-b:a",
299
+ "24k",
300
+ outputPath,
301
+ ]);
302
+ } catch (err) {
303
+ await fsPromises.rm(outputPath, { force: true }).catch(() => {});
304
+ throw err;
305
+ } finally {
306
+ unmarkTrustedHostMediaPath(inputPath);
307
+ await fsPromises.rm(inputPath, { force: true }).catch(() => {});
308
+ }
263
309
 
264
310
  log?.debug?.(`[DingTalk] Transcoded voice upload to OGG: ${mediaPath} -> ${outputPath}`);
311
+ markTrustedHostMediaPath(outputPath);
265
312
 
266
313
  return {
267
314
  path: outputPath,
268
315
  cleanup: async () => {
316
+ unmarkTrustedHostMediaPath(outputPath);
269
317
  await fsPromises.rm(outputPath, { force: true });
270
318
  },
271
319
  };
@@ -273,7 +321,11 @@ async function prepareVoiceUploadPath(
273
321
 
274
322
  function getWavDurationMsFromBuffer(buffer: Buffer, log?: Logger): number {
275
323
  try {
276
- if (buffer.length < 44 || buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") {
324
+ if (
325
+ buffer.length < 44 ||
326
+ buffer.toString("ascii", 0, 4) !== "RIFF" ||
327
+ buffer.toString("ascii", 8, 12) !== "WAVE"
328
+ ) {
277
329
  return 0;
278
330
  }
279
331
 
@@ -310,9 +362,11 @@ function getWavDurationMsFromBuffer(buffer: Buffer, log?: Logger): number {
310
362
  offset = chunkDataStart + paddedChunkSize;
311
363
  }
312
364
 
313
- const effectiveByteRate = byteRate || (sampleRate > 0 && channels > 0 && bitsPerSample > 0
314
- ? sampleRate * channels * (bitsPerSample / 8)
315
- : 0);
365
+ const effectiveByteRate =
366
+ byteRate ||
367
+ (sampleRate > 0 && channels > 0 && bitsPerSample > 0
368
+ ? sampleRate * channels * (bitsPerSample / 8)
369
+ : 0);
316
370
 
317
371
  if (effectiveByteRate <= 0 || dataSize <= 0) {
318
372
  return 0;
@@ -320,7 +374,9 @@ function getWavDurationMsFromBuffer(buffer: Buffer, log?: Logger): number {
320
374
 
321
375
  return Math.max(1, Math.round((dataSize / effectiveByteRate) * 1000));
322
376
  } catch (err: unknown) {
323
- log?.warn?.(`[DingTalk] Failed to parse WAV duration: ${err instanceof Error ? err.message : String(err)}`);
377
+ log?.warn?.(
378
+ `[DingTalk] Failed to parse WAV duration: ${err instanceof Error ? err.message : String(err)}`,
379
+ );
324
380
  return 0;
325
381
  }
326
382
  }
@@ -341,8 +397,8 @@ export async function getVoiceDurationMs(
341
397
  let durationSec: number;
342
398
  try {
343
399
  // Reuse pre-read buffer from uploadMedia when available to avoid double read
344
- const buffer = options?.preReadBuffer
345
- ?? (await readMediaBuffer(filePath, options, log)).buffer;
400
+ const buffer =
401
+ options?.preReadBuffer ?? (await readMediaBuffer(filePath, options, log)).buffer;
346
402
  durationSec = await getMp3DurationSeconds(buffer, log);
347
403
  } catch {
348
404
  durationSec = 0;
@@ -359,8 +415,8 @@ export async function getVoiceDurationMs(
359
415
 
360
416
  if (ext === ".wav") {
361
417
  try {
362
- const buffer = options?.preReadBuffer
363
- ?? (await readMediaBuffer(filePath, options, log)).buffer;
418
+ const buffer =
419
+ options?.preReadBuffer ?? (await readMediaBuffer(filePath, options, log)).buffer;
364
420
  const durationMs = getWavDurationMsFromBuffer(buffer, log);
365
421
  if (durationMs > 0) {
366
422
  return durationMs;
@@ -380,7 +436,6 @@ export async function getVoiceDurationMs(
380
436
  return DEFAULT_VOICE_DURATION_MS;
381
437
  }
382
438
 
383
-
384
439
  export type DingTalkMediaType = "image" | "voice" | "video" | "file";
385
440
  export type DingTalkOutboundMediaType = DingTalkMediaType;
386
441
 
@@ -503,7 +558,12 @@ function normalizeOutboundMediaType(value?: string | null): DingTalkOutboundMedi
503
558
  }
504
559
 
505
560
  const normalized = value.trim().toLowerCase();
506
- if (normalized === "image" || normalized === "voice" || normalized === "video" || normalized === "file") {
561
+ if (
562
+ normalized === "image" ||
563
+ normalized === "voice" ||
564
+ normalized === "video" ||
565
+ normalized === "file"
566
+ ) {
507
567
  return normalized;
508
568
  }
509
569
 
@@ -523,7 +583,10 @@ export function resolveOutboundMediaType(params: {
523
583
  throw new Error('asVoice requires mediaType="voice" when mediaType is provided.');
524
584
  }
525
585
 
526
- if (detectedType !== "voice" || !DINGTALK_VOICE_INPUT_EXTENSIONS.has(path.extname(params.mediaPath).toLowerCase())) {
586
+ if (
587
+ detectedType !== "voice" ||
588
+ !DINGTALK_VOICE_INPUT_EXTENSIONS.has(path.extname(params.mediaPath).toLowerCase())
589
+ ) {
527
590
  throw new Error("asVoice requires an audio file (ogg, amr, mp3, wav).");
528
591
  }
529
592
 
@@ -584,13 +647,17 @@ function isPrivateOrLocalHost(hostname: string): boolean {
584
647
  if (normalized === "::1") {
585
648
  return true;
586
649
  }
587
- return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80");
650
+ return (
651
+ normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80")
652
+ );
588
653
  }
589
654
 
590
655
  return false;
591
656
  }
592
657
 
593
- async function resolveHostname(hostname: string): Promise<Array<{ address: string; family: number }>> {
658
+ async function resolveHostname(
659
+ hostname: string,
660
+ ): Promise<Array<{ address: string; family: number }>> {
594
661
  const records = await dnsLookup(hostname, { all: true, verbatim: true });
595
662
  return Array.isArray(records) ? records : [records];
596
663
  }
@@ -747,15 +814,18 @@ export async function prepareMediaInput(
747
814
  : Buffer.from(response.data as ArrayBuffer);
748
815
 
749
816
  await fsPromises.writeFile(tempPath, buffer);
817
+ markTrustedHostMediaPath(tempPath);
750
818
  log?.debug?.(`[DingTalk] Downloaded remote media to temp file: ${tempPath}`);
751
819
 
752
820
  return {
753
821
  path: tempPath,
754
822
  cleanup: async () => {
823
+ unmarkTrustedHostMediaPath(tempPath);
755
824
  try {
756
825
  await fsPromises.unlink(tempPath);
757
826
  } catch (err: unknown) {
758
- const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined;
827
+ const code =
828
+ typeof err === "object" && err !== null && "code" in err ? err.code : undefined;
759
829
  if (code !== "ENOENT") {
760
830
  const message = err instanceof Error ? err.message : String(err);
761
831
  log?.warn?.(`[DingTalk] Failed to remove temp media ${tempPath}: ${message}`);
@@ -775,32 +845,163 @@ const FILE_SIZE_LIMITS: Record<DingTalkMediaType, number> = {
775
845
  file: 20 * 1024 * 1024, // 20MB
776
846
  };
777
847
 
848
+ /**
849
+ * Returns true when `target` is `root` itself or a descendant of it.
850
+ * Uses `path.relative` so sibling paths sharing a prefix (for example
851
+ * `/allowed` vs `/allowed-evil`) are not treated as contained.
852
+ */
853
+ function isPathWithinRoot(root: string, target: string): boolean {
854
+ const relative = path.relative(root, target);
855
+ return (
856
+ relative === "" ||
857
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
858
+ );
859
+ }
860
+
861
+ /**
862
+ * Resolve a path for boundary comparison while tolerating paths that do not
863
+ * exist yet on the host (sandbox/container paths). Mirrors the runtime media
864
+ * bridge: prefer the fully resolved real path, fall back to resolving the
865
+ * parent directory, and only then fall back to a plain path resolution.
866
+ */
867
+ async function resolveCanonicalMediaPath(mediaPath: string): Promise<string> {
868
+ try {
869
+ return await fsPromises.realpath(mediaPath);
870
+ } catch {
871
+ try {
872
+ const parent = await fsPromises.realpath(path.dirname(mediaPath));
873
+ return path.join(parent, path.basename(mediaPath));
874
+ } catch {
875
+ return path.resolve(mediaPath);
876
+ }
877
+ }
878
+ }
879
+
880
+ /**
881
+ * Canonicalize an allowed root, falling back to `path.resolve` when it is
882
+ * absent. Filesystem roots are rejected: authorizing `/` would allow every host
883
+ * path, so such an entry is treated as invalid (undefined).
884
+ */
885
+ async function resolveCanonicalRoot(root: string): Promise<string | undefined> {
886
+ const resolved = path.resolve(root);
887
+ if (resolved === path.parse(resolved).root) {
888
+ return undefined;
889
+ }
890
+ try {
891
+ const real = await fsPromises.realpath(resolved);
892
+ return real === path.parse(real).root ? undefined : real;
893
+ } catch {
894
+ return resolved;
895
+ }
896
+ }
897
+
898
+ /** A host path that passed the boundary check, plus roots to re-verify after open. */
899
+ type AllowedHostRead = {
900
+ path: string;
901
+ /** Canonical roots re-checked after opening, when a boundary is configured. */
902
+ roots?: readonly string[];
903
+ };
904
+
905
+ /**
906
+ * Resolve the host path a direct read may use, or undefined when the path falls
907
+ * outside the configured roots and is not a plugin-owned temp file. With no
908
+ * boundary configured the caller path is returned unchanged so the historical
909
+ * direct-read behavior is preserved.
910
+ */
911
+ async function resolveAllowedHostReadPath(
912
+ mediaPath: string,
913
+ mediaLocalRoots?: string[],
914
+ ): Promise<AllowedHostRead | undefined> {
915
+ if (!mediaLocalRoots) {
916
+ return { path: mediaPath };
917
+ }
918
+ // Plugin-generated temp media (remote download / voice transcode) lives
919
+ // outside the roots but is not caller-controlled, so allow reading it.
920
+ if (isTrustedHostMediaPath(mediaPath)) {
921
+ return { path: path.resolve(mediaPath) };
922
+ }
923
+ // Compare canonical paths so a symlink inside an allowed root cannot redirect
924
+ // the read outside of it.
925
+ const [canonicalPath, canonicalRoots] = await Promise.all([
926
+ resolveCanonicalMediaPath(mediaPath),
927
+ Promise.all(mediaLocalRoots.map((root) => resolveCanonicalRoot(root))),
928
+ ]);
929
+ const usableRoots = canonicalRoots.filter((root): root is string => Boolean(root));
930
+ if (usableRoots.some((root) => isPathWithinRoot(root, canonicalPath))) {
931
+ return { path: canonicalPath, roots: usableRoots };
932
+ }
933
+ return undefined;
934
+ }
935
+
936
+ /**
937
+ * Read a boundary-validated host file without following a final-component
938
+ * symlink, then re-check containment while the handle is held. `O_NOFOLLOW`
939
+ * alone leaves a check-to-open race on intermediate directories, so the path is
940
+ * re-resolved against the frozen roots after opening.
941
+ */
942
+ async function readVerifiedHostFile(allowed: AllowedHostRead): Promise<Buffer> {
943
+ const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
944
+ const handle = await fsPromises.open(allowed.path, fsConstants.O_RDONLY | noFollow);
945
+ try {
946
+ if (allowed.roots) {
947
+ const recheck = await fsPromises.realpath(allowed.path);
948
+ if (!allowed.roots.some((root) => isPathWithinRoot(root, recheck))) {
949
+ throw Object.assign(
950
+ new Error(`Media path escaped the allowed roots during read: ${allowed.path}`),
951
+ { code: "EACCES" },
952
+ );
953
+ }
954
+ }
955
+ return await handle.readFile();
956
+ } finally {
957
+ await handle.close().catch(() => {});
958
+ }
959
+ }
960
+
778
961
  /**
779
962
  * Read a media file, resolving sandbox/container paths via the runtime bridge
780
963
  * when direct host filesystem access fails.
781
964
  *
782
965
  * Precedence:
783
- * 1. Direct fs.readFile (works for host-local paths)
966
+ * 1. Direct host read (works for host-local paths inside the allowed roots)
784
967
  * 2. rt.media.loadWebMedia (resolves sandbox workspace paths via bridge)
968
+ *
969
+ * When `mediaLocalRoots` is configured, a direct host read is only allowed for
970
+ * paths whose canonical (symlink-resolved) location lives inside one of those
971
+ * roots, plus plugin-owned temp files. Everything else goes through the runtime
972
+ * media bridge so the host boundary stays enforced.
785
973
  */
786
974
  async function readMediaBuffer(
787
975
  mediaPath: string,
788
976
  options?: { mediaLocalRoots?: string[] },
789
977
  log?: Logger,
790
978
  ): Promise<{ buffer: Buffer; size: number }> {
791
- // Try direct host filesystem first
792
- try {
793
- const buffer = await fsPromises.readFile(mediaPath);
794
- return { buffer, size: buffer.length };
795
- } catch (err: unknown) {
796
- const errno = err as NodeJS.ErrnoException;
797
- if (errno.code !== "ENOENT") {
798
- throw err; // Permission errors etc. should propagate immediately
979
+ // Direct host reads are allowed only when no boundary is configured, the path
980
+ // is a plugin-owned temp file, or it canonicalizes inside an allowed root.
981
+ const allowed = await resolveAllowedHostReadPath(mediaPath, options?.mediaLocalRoots);
982
+
983
+ if (allowed) {
984
+ try {
985
+ const buffer = options?.mediaLocalRoots
986
+ ? await readVerifiedHostFile(allowed)
987
+ : await fsPromises.readFile(allowed.path);
988
+ return { buffer, size: buffer.length };
989
+ } catch (err: unknown) {
990
+ const errno = err as NodeJS.ErrnoException;
991
+ // ELOOP/ENOTDIR cover dangling symlinks and non-directory path
992
+ // components; treat them like the historical ENOENT fallback.
993
+ if (errno.code !== "ENOENT" && errno.code !== "ELOOP" && errno.code !== "ENOTDIR") {
994
+ throw err; // Permission errors etc. should propagate immediately
995
+ }
996
+ log?.debug?.(`[DingTalk] File not found on host, trying runtime media bridge: ${mediaPath}`);
799
997
  }
998
+ } else {
999
+ log?.debug?.(
1000
+ `[DingTalk] Media path is outside configured local roots, using runtime media bridge: ${mediaPath}`,
1001
+ );
800
1002
  }
801
1003
 
802
- // File not found on host — try runtime media bridge (sandbox/container paths)
803
- log?.debug?.(`[DingTalk] File not found on host, trying runtime media bridge: ${mediaPath}`);
1004
+ // Fall back to runtime media bridge (sandbox/container paths)
804
1005
  const rt = getDingTalkRuntime() as PluginRuntimeWithMedia;
805
1006
  if (!rt.media?.loadWebMedia) {
806
1007
  throw Object.assign(
@@ -814,15 +1015,12 @@ async function readMediaBuffer(
814
1015
  });
815
1016
 
816
1017
  if (!media || !media.buffer) {
817
- throw Object.assign(
818
- new Error(`Runtime media bridge returned no data for: ${mediaPath}`),
819
- { code: "ENOENT" },
820
- );
1018
+ throw Object.assign(new Error(`Runtime media bridge returned no data for: ${mediaPath}`), {
1019
+ code: "ENOENT",
1020
+ });
821
1021
  }
822
1022
 
823
- const buffer = Buffer.isBuffer(media.buffer)
824
- ? media.buffer
825
- : Buffer.from(media.buffer);
1023
+ const buffer = Buffer.isBuffer(media.buffer) ? media.buffer : Buffer.from(media.buffer);
826
1024
  return { buffer, size: buffer.length };
827
1025
  }
828
1026
 
@@ -852,16 +1050,20 @@ export async function uploadMedia(
852
1050
  let resolvedMediaPath = mediaPath;
853
1051
 
854
1052
  if (mediaType === "voice") {
855
- const prepared = await prepareVoiceUploadPath(mediaPath, log);
1053
+ const prepared = await prepareVoiceUploadPath(mediaPath, options, log);
856
1054
  resolvedMediaPath = prepared.path;
857
1055
  voicePreparedCleanup = prepared.cleanup;
858
1056
  }
859
1057
 
860
1058
  // Read file via sandbox-aware bridge (falls back to direct fs for host paths)
861
1059
  const { buffer, size } = await readMediaBuffer(resolvedMediaPath, options, log);
862
- const durationMs = mediaType === "voice"
863
- ? await getVoiceDurationMs(resolvedMediaPath, mediaType, log, { ...options, preReadBuffer: buffer })
864
- : undefined;
1060
+ const durationMs =
1061
+ mediaType === "voice"
1062
+ ? await getVoiceDurationMs(resolvedMediaPath, mediaType, log, {
1063
+ ...options,
1064
+ preReadBuffer: buffer,
1065
+ })
1066
+ : undefined;
865
1067
 
866
1068
  // Check file size
867
1069
  const sizeLimit = FILE_SIZE_LIMITS[mediaType];
@@ -908,7 +1110,9 @@ export async function uploadMedia(
908
1110
  } else if (errno.code === "EACCES") {
909
1111
  log?.error?.(`[DingTalk] Permission denied accessing media file: ${mediaPath}`);
910
1112
  } else {
911
- log?.error?.(`[DingTalk] Failed to upload media: ${err instanceof Error ? err.message : String(err)}`);
1113
+ log?.error?.(
1114
+ `[DingTalk] Failed to upload media: ${err instanceof Error ? err.message : String(err)}`,
1115
+ );
912
1116
  if (axios.isAxiosError(err) && err.response) {
913
1117
  const status = err.response.status;
914
1118
  const statusText = err.response.statusText;
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
3
- import type { AttachmentTextSource, Logger, QuotedRef } from "./types";
2
+ import type { AttachmentTextSource, Logger, QuotedRef } from "../platform/types";
3
+ import { readNamespaceJson, writeNamespaceJsonAtomic } from "../shared/persistence-store";
4
4
 
5
5
  const MESSAGE_CONTEXT_NAMESPACE = "messages.context";
6
6
  const MESSAGE_CONTEXT_VERSION = 1;
@@ -50,7 +50,10 @@ export interface MessageRecord {
50
50
  quotedRef?: QuotedRef;
51
51
  senderId?: string;
52
52
  senderName?: string;
53
+ /** Display names parsed from mentions; not positionally aligned with mentionUserIds. */
53
54
  mentions?: string[];
55
+ /** Raw DingTalk user IDs from structured @mention payloads (atUsers / richText atUserId). Not positionally aligned with mentions. */
56
+ mentionUserIds?: string[];
54
57
  chatType?: "direct" | "group";
55
58
  /** Flat quoted target for summary/history lookups; quotedRef remains the authoritative structured link. */
56
59
  quotedMessageId?: string;
@@ -101,7 +104,10 @@ interface BaseUpsertParams {
101
104
  quotedRef?: QuotedRef;
102
105
  senderId?: string;
103
106
  senderName?: string;
107
+ /** Display names parsed from mentions; not positionally aligned with mentionUserIds. */
104
108
  mentions?: string[];
109
+ /** Raw DingTalk user IDs from structured @mention payloads (atUsers / richText atUserId). Not positionally aligned with mentions. */
110
+ mentionUserIds?: string[];
105
111
  chatType?: "direct" | "group";
106
112
  quotedMessageId?: string;
107
113
  media?: {
@@ -340,6 +346,7 @@ function normalizeMessageRecord(value: unknown): MessageRecord | null {
340
346
  ? candidate.senderName.trim()
341
347
  : undefined,
342
348
  mentions: normalizeMentions(candidate.mentions),
349
+ mentionUserIds: normalizeMentions(candidate.mentionUserIds),
343
350
  chatType:
344
351
  candidate.chatType === "direct" || candidate.chatType === "group"
345
352
  ? candidate.chatType
@@ -703,6 +710,7 @@ function upsertRecord(
703
710
  senderId?: string;
704
711
  senderName?: string;
705
712
  mentions?: string[];
713
+ mentionUserIds?: string[];
706
714
  chatType?: "direct" | "group";
707
715
  quotedMessageId?: string;
708
716
  media?: MessageRecord["media"];
@@ -765,6 +773,7 @@ function upsertRecord(
765
773
  senderId: mergeStringField(existing?.senderId, params.senderId),
766
774
  senderName: mergeStringField(existing?.senderName, params.senderName),
767
775
  mentions: mergeMentions(existing?.mentions, params.mentions),
776
+ mentionUserIds: mergeMentions(existing?.mentionUserIds, params.mentionUserIds),
768
777
  chatType: params.chatType || existing?.chatType,
769
778
  quotedMessageId: mergeStringField(existing?.quotedMessageId, params.quotedMessageId),
770
779
  media: mergeMedia(existing?.media, params.media),