@soimy/dingtalk 3.6.11 → 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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +11684 -10583
- package/dist/src/{ack-reaction-service.d.ts → ack-reaction/ack-reaction-service.d.ts} +1 -1
- package/dist/src/ack-reaction/dynamic-ack-reaction-controller.d.ts +3 -3
- package/dist/src/card/ask-user-question-context.d.ts +1 -1
- package/dist/src/card/ask-user-question-store.d.ts +1 -1
- package/dist/src/card/ask-user-question.d.ts +1 -1
- package/dist/src/card/card-action-handler.d.ts +2 -2
- package/dist/src/{card-draft-controller.d.ts → card/card-draft-controller.d.ts} +5 -1
- package/dist/src/card/card-run-registry.d.ts +2 -2
- package/dist/src/{card-service.d.ts → card/card-service.d.ts} +1 -1
- package/dist/src/card/card-stop-handler.d.ts +1 -1
- package/dist/src/card/card-streaming-mode.d.ts +1 -1
- package/dist/src/card/card-task-progress.d.ts +60 -0
- package/dist/src/channel.d.ts +6 -6
- package/dist/src/command/card-stop-command.d.ts +1 -1
- package/dist/src/{feedback-learning-service.d.ts → command/feedback-learning-service.d.ts} +1 -1
- package/dist/src/command/inbound-command-dispatch-service.d.ts +2 -2
- package/dist/src/{learning-command-service.d.ts → command/learning-command-service.d.ts} +1 -1
- package/dist/src/gateway/channel-gateway.d.ts +1 -1
- package/dist/src/{connection-manager.d.ts → gateway/connection-manager.d.ts} +1 -1
- package/dist/src/{docs-service.d.ts → gateway/docs-service.d.ts} +1 -1
- package/dist/src/{inbound-handler.d.ts → gateway/inbound-handler.d.ts} +1 -1
- package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts +1 -1
- package/dist/src/messaging/attachment-text-extractor.d.ts +1 -1
- package/dist/src/messaging/btw-deliver.d.ts +1 -1
- package/dist/src/messaging/channel-outbound.d.ts +1 -1
- package/dist/src/{media-utils.d.ts → messaging/media-utils.d.ts} +1 -1
- package/dist/src/{message-context-store.d.ts → messaging/message-context-store.d.ts} +7 -1
- package/dist/src/{message-utils.d.ts → messaging/message-utils.d.ts} +1 -1
- package/dist/src/messaging/quoted-context.d.ts +2 -2
- package/dist/src/messaging/quoted-file-service.d.ts +1 -1
- package/dist/src/messaging/quoted-ref.d.ts +2 -2
- package/dist/src/{reply-strategy-card.d.ts → messaging/reply-strategy-card.d.ts} +1 -1
- package/dist/src/{reply-strategy-types.d.ts → messaging/reply-strategy-types.d.ts} +10 -1
- package/dist/src/{reply-strategy-with-reaction.d.ts → messaging/reply-strategy-with-reaction.d.ts} +1 -1
- package/dist/src/{reply-strategy.d.ts → messaging/reply-strategy.d.ts} +1 -1
- package/dist/src/{send-service.d.ts → messaging/send-service.d.ts} +1 -1
- package/dist/src/platform/channel-status.d.ts +1 -1
- package/dist/src/platform/config.d.ts +110 -0
- package/dist/src/platform/runtime-events.d.ts +67 -0
- package/dist/src/{types.d.ts → platform/types.d.ts} +74 -1
- package/dist/src/{persistence-store.d.ts → shared/persistence-store.d.ts} +1 -1
- package/dist/src/{utils.d.ts → shared/utils.d.ts} +1 -1
- package/dist/src/targeting/agent-name-matcher.d.ts +1 -1
- package/dist/src/targeting/agent-routing.d.ts +3 -3
- package/dist/src/{session-routing.d.ts → targeting/session-routing.d.ts} +1 -1
- package/index.ts +193 -69
- package/openclaw.plugin.json +158 -2
- package/package.json +16 -16
- package/src/{ack-reaction-classifier.ts → ack-reaction/ack-reaction-classifier.ts} +85 -13
- package/src/{ack-reaction-service.ts → ack-reaction/ack-reaction-service.ts} +13 -7
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +46 -31
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +4 -1
- package/src/card/ask-user-question-context.ts +1 -1
- package/src/card/ask-user-question-store.ts +2 -2
- package/src/card/ask-user-question.ts +11 -7
- package/src/card/card-action-handler.ts +2 -2
- package/src/{card-callback-service.ts → card/card-callback-service.ts} +7 -9
- package/src/card/card-draft-controller.ts +770 -0
- package/src/card/card-markdown-image-reroute.ts +8 -2
- package/src/card/card-run-registry.ts +17 -7
- package/src/{card-service.ts → card/card-service.ts} +91 -61
- package/src/card/card-stop-handler.ts +12 -8
- package/src/card/card-streaming-mode.ts +1 -1
- package/src/card/card-task-progress.ts +330 -0
- package/src/card/draft-stream-loop.ts +119 -0
- package/src/card/reasoning-answer-split.ts +125 -121
- package/src/card/reasoning-block-assembler.ts +122 -123
- package/src/{run-usage-store.ts → card/run-usage-store.ts} +18 -6
- package/src/card/statusline-renderer.ts +14 -5
- package/src/card/task-model-metadata.ts +8 -3
- package/src/channel.ts +14 -16
- package/src/command/card-stop-command.ts +2 -2
- package/src/{feedback-learning-service.ts → command/feedback-learning-service.ts} +36 -16
- package/src/{feedback-learning-store.ts → command/feedback-learning-store.ts} +133 -81
- package/src/command/inbound-command-dispatch-service.ts +12 -6
- package/src/{learning-command-service.ts → command/learning-command-service.ts} +36 -29
- package/src/{session-command-service.ts → command/session-command-service.ts} +3 -1
- package/src/gateway/channel-gateway.ts +106 -28
- package/src/{connection-manager.ts → gateway/connection-manager.ts} +45 -30
- package/src/{docs-service.ts → gateway/docs-service.ts} +5 -5
- package/src/{inbound-handler.ts → gateway/inbound-handler.ts} +139 -84
- package/src/gateway/inbound-session-queue-dispatcher.ts +18 -14
- package/src/{session-lock.ts → gateway/session-lock.ts} +14 -14
- package/src/messaging/attachment-text-extractor.ts +21 -7
- package/src/messaging/btw-deliver.ts +2 -2
- package/src/messaging/channel-actions.ts +15 -7
- package/src/messaging/channel-outbound.ts +16 -11
- package/src/{media-utils.ts → messaging/media-utils.ts} +276 -72
- package/src/{message-context-store.ts → messaging/message-context-store.ts} +11 -2
- package/src/{message-utils.ts → messaging/message-utils.ts} +46 -11
- package/src/messaging/quoted-context.ts +2 -2
- package/src/messaging/quoted-file-service.ts +311 -294
- package/src/messaging/quoted-ref.ts +11 -7
- package/src/{reply-strategy-card.ts → messaging/reply-strategy-card.ts} +178 -101
- package/src/{reply-strategy-markdown.ts → messaging/reply-strategy-markdown.ts} +8 -2
- package/src/{reply-strategy-types.ts → messaging/reply-strategy-types.ts} +11 -1
- package/src/{reply-strategy-with-reaction.ts → messaging/reply-strategy-with-reaction.ts} +6 -1
- package/src/{reply-strategy.ts → messaging/reply-strategy.ts} +2 -2
- package/src/{send-service.ts → messaging/send-service.ts} +100 -65
- package/src/{auth.ts → platform/auth.ts} +2 -2
- package/src/platform/channel-status.ts +3 -3
- package/src/{config-schema.ts → platform/config-schema.ts} +81 -4
- package/src/{config.ts → platform/config.ts} +167 -19
- package/src/{device-registration.ts → platform/device-registration.ts} +5 -4
- package/src/{onboarding.ts → platform/onboarding.ts} +1 -4
- package/src/{plugin-sdk-channel-actions-augment.ts → platform/plugin-sdk-channel-actions-augment.ts} +6 -5
- package/src/platform/runtime-events.ts +202 -0
- package/src/{secret-input.ts → platform/secret-input.ts} +117 -11
- package/src/{types.ts → platform/types.ts} +76 -1
- package/src/{http-client.ts → shared/http-client.ts} +1 -3
- package/src/{persistence-store.ts → shared/persistence-store.ts} +6 -6
- package/src/{utils.ts → shared/utils.ts} +30 -13
- package/src/targeting/agent-name-matcher.ts +1 -1
- package/src/targeting/agent-routing.ts +7 -7
- package/src/targeting/group-members-store.ts +1 -1
- package/src/{session-peer-store.ts → targeting/session-peer-store.ts} +9 -4
- package/src/{session-routing.ts → targeting/session-routing.ts} +1 -1
- package/src/targeting/target-directory-adapter.ts +3 -3
- package/src/targeting/target-directory-store.ts +1 -1
- package/src/targeting/target-input.ts +1 -1
- package/dist/src/ack-reaction/dynamic-ack-reaction-events.d.ts +0 -35
- package/dist/src/config.d.ts +0 -59
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +0 -123
- package/src/card-draft-controller.ts +0 -637
- package/src/draft-stream-loop.ts +0 -119
- /package/dist/src/{ack-reaction-classifier.d.ts → ack-reaction/ack-reaction-classifier.d.ts} +0 -0
- /package/dist/src/{card-callback-service.d.ts → card/card-callback-service.d.ts} +0 -0
- /package/dist/src/{draft-stream-loop.d.ts → card/draft-stream-loop.d.ts} +0 -0
- /package/dist/src/{run-usage-store.d.ts → card/run-usage-store.d.ts} +0 -0
- /package/dist/src/{feedback-learning-store.d.ts → command/feedback-learning-store.d.ts} +0 -0
- /package/dist/src/{session-command-service.d.ts → command/session-command-service.d.ts} +0 -0
- /package/dist/src/{session-lock.d.ts → gateway/session-lock.d.ts} +0 -0
- /package/dist/src/{proactive-risk-registry.d.ts → messaging/proactive-risk-registry.d.ts} +0 -0
- /package/dist/src/{reply-strategy-markdown.d.ts → messaging/reply-strategy-markdown.d.ts} +0 -0
- /package/dist/src/{access-control.d.ts → platform/access-control.d.ts} +0 -0
- /package/dist/src/{auth.d.ts → platform/auth.d.ts} +0 -0
- /package/dist/src/{config-schema.d.ts → platform/config-schema.d.ts} +0 -0
- /package/dist/src/{device-registration.d.ts → platform/device-registration.d.ts} +0 -0
- /package/dist/src/{logger-context.d.ts → platform/logger-context.d.ts} +0 -0
- /package/dist/src/{onboarding.d.ts → platform/onboarding.d.ts} +0 -0
- /package/dist/src/{plugin-sdk-channel-actions-augment.d.ts → platform/plugin-sdk-channel-actions-augment.d.ts} +0 -0
- /package/dist/src/{runtime.d.ts → platform/runtime.d.ts} +0 -0
- /package/dist/src/{secret-input.d.ts → platform/secret-input.d.ts} +0 -0
- /package/dist/src/{session-state.d.ts → platform/session-state.d.ts} +0 -0
- /package/dist/src/{signature.d.ts → platform/signature.d.ts} +0 -0
- /package/dist/src/{dedup.d.ts → shared/dedup.d.ts} +0 -0
- /package/dist/src/{http-client.d.ts → shared/http-client.d.ts} +0 -0
- /package/dist/src/{path-utils.d.ts → shared/path-utils.d.ts} +0 -0
- /package/dist/src/{peer-id-registry.d.ts → targeting/peer-id-registry.d.ts} +0 -0
- /package/dist/src/{session-peer-store.d.ts → targeting/session-peer-store.d.ts} +0 -0
- /package/src/{proactive-risk-registry.ts → messaging/proactive-risk-registry.ts} +0 -0
- /package/src/{access-control.ts → platform/access-control.ts} +0 -0
- /package/src/{logger-context.ts → platform/logger-context.ts} +0 -0
- /package/src/{runtime.ts → platform/runtime.ts} +0 -0
- /package/src/{session-state.ts → platform/session-state.ts} +0 -0
- /package/src/{signature.ts → platform/signature.ts} +0 -0
- /package/src/{dedup.ts → shared/dedup.ts} +0 -0
- /package/src/{path-utils.ts → shared/path-utils.ts} +0 -0
- /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
|
|
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
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
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(
|
|
43
|
+
export async function getMp3DurationSeconds(
|
|
44
|
+
filePathOrBuffer: string | Buffer,
|
|
45
|
+
log?: Logger,
|
|
46
|
+
): Promise<number> {
|
|
44
47
|
try {
|
|
45
|
-
const buffer =
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
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;
|
|
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?.(
|
|
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?.(
|
|
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?.(
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 (
|
|
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 =
|
|
314
|
-
|
|
315
|
-
|
|
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?.(
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
819
|
-
|
|
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 =
|
|
863
|
-
|
|
864
|
-
|
|
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?.(
|
|
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 {
|
|
3
|
-
import
|
|
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),
|