@soimy/dingtalk 3.4.0 → 3.4.2
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 +71 -66
- package/package.json +4 -4
- package/src/channel.ts +47 -20
- package/src/config.ts +1 -1
- package/src/connection-manager.ts +16 -5
- package/src/media-utils.ts +99 -36
- package/src/message-utils.ts +1 -1
- package/src/onboarding.ts +372 -317
- package/src/runtime.ts +5 -7
- package/src/send-service.ts +24 -10
- package/src/targeting/agent-name-matcher.ts +1 -1
- package/src/targeting/agent-routing.ts +1 -1
- package/src/targeting/target-directory-adapter.ts +2 -1
- package/src/types.ts +11 -13
package/index.ts
CHANGED
|
@@ -1,80 +1,85 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
api
|
|
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.
|
|
3
|
+
"version": "3.4.2",
|
|
4
4
|
"description": "DingTalk (钉钉) channel plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bot",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"vitest": "^3.2.4"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"openclaw": ">=2026.
|
|
61
|
+
"openclaw": ">=2026.3.14"
|
|
62
62
|
},
|
|
63
63
|
"openclaw": {
|
|
64
64
|
"extensions": [
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
"id": "dingtalk",
|
|
73
73
|
"label": "DingTalk",
|
|
74
74
|
"selectionLabel": "DingTalk (钉钉)",
|
|
75
|
-
"docsPath": "/
|
|
76
|
-
"docsLabel": "
|
|
75
|
+
"docsPath": "https://github.com/soimy/openclaw-channel-dingtalk",
|
|
76
|
+
"docsLabel": "plugin docs",
|
|
77
77
|
"blurb": "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
|
|
78
78
|
"order": 70,
|
|
79
79
|
"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
|
|
4
|
-
import
|
|
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 {
|
|
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
|
-
|
|
233
|
+
describeMessageTool: ({ cfg }) => describeDingTalkMessageTool(cfg),
|
|
211
234
|
supportsAction: ({ action }) => action === "send",
|
|
212
|
-
extractToolSend: ({ args }) =>
|
|
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 =
|
|
241
|
+
const to = readStringParam(params, "to", { required: true });
|
|
219
242
|
const mediaInput =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 =
|
|
249
|
+
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
|
|
227
250
|
let message =
|
|
228
|
-
|
|
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 =
|
|
261
|
+
const requestedMediaType = readStringParam(params, "mediaType");
|
|
239
262
|
|
|
240
263
|
const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
|
|
241
264
|
|
|
242
265
|
if (dryRun) {
|
|
243
|
-
return
|
|
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
|
|
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
|
|
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: "/
|
|
349
|
+
docsPath: "https://github.com/soimy/openclaw-channel-dingtalk",
|
|
326
350
|
blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
|
|
327
351
|
aliases: ["dd", "ding"],
|
|
328
352
|
},
|
|
329
|
-
configSchema:
|
|
330
|
-
|
|
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
|
-
|
|
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) => {
|
package/src/media-utils.ts
CHANGED
|
@@ -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
|
|
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(
|
|
42
|
+
export async function getMp3DurationSeconds(filePathOrBuffer: string | Buffer, log?: Logger): Promise<number> {
|
|
28
43
|
try {
|
|
29
|
-
const buffer =
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
*
|
|
611
|
-
*
|
|
636
|
+
* Read a media file, resolving sandbox/container paths via the runtime bridge
|
|
637
|
+
* when direct host filesystem access fails.
|
|
612
638
|
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
698
|
+
options?: { mediaLocalRoots?: string[] },
|
|
699
|
+
): Promise<UploadMediaResult | null> {
|
|
632
700
|
try {
|
|
633
701
|
const token = await getAccessToken(config, log);
|
|
634
702
|
|
|
635
|
-
//
|
|
636
|
-
const
|
|
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 (
|
|
639
|
-
const sizeMB = (
|
|
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",
|
|
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} (${
|
|
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} (${
|
|
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
|
}
|
package/src/message-utils.ts
CHANGED
|
@@ -443,7 +443,7 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
|
|
|
443
443
|
}
|
|
444
444
|
|
|
445
445
|
return {
|
|
446
|
-
text: textContent,
|
|
446
|
+
text: textContent || quoted?.previewText || "",
|
|
447
447
|
messageType: "text",
|
|
448
448
|
quoted: quoted ?? undefined,
|
|
449
449
|
atMentions,
|