@soimy/dingtalk 3.5.2 → 3.6.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/README.md +6 -23
- package/index.ts +7 -0
- package/openclaw.plugin.json +799 -0
- package/package.json +5 -5
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/card-template.ts +14 -3
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +326 -54
- package/src/card-service.ts +479 -8
- package/src/channel.ts +19 -1062
- package/src/config-schema.ts +81 -38
- package/src/config.ts +142 -4
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +489 -49
- package/src/media-utils.ts +169 -7
- package/src/message-utils.ts +153 -17
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/messaging/quoted-file-service.ts +9 -4
- package/src/onboarding.ts +323 -205
- package/src/platform/channel-status.ts +81 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +568 -44
- package/src/reply-strategy-markdown.ts +2 -2
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -56
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +225 -7
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +44 -28
- package/src/types.ts +49 -117
- package/src/utils.ts +25 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
|
|
2
|
+
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
4
|
+
import { readStringParam } from "openclaw/plugin-sdk/param-readers";
|
|
5
|
+
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
|
6
|
+
import { getConfig, stripTargetPrefix } from "../config";
|
|
7
|
+
import { getLogger } from "../logger-context";
|
|
8
|
+
import { resolveOriginalPeerId } from "../peer-id-registry";
|
|
9
|
+
import { sendMedia, sendMessage } from "../send-service";
|
|
10
|
+
import { parseBooleanLike } from "../utils";
|
|
11
|
+
|
|
12
|
+
function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
|
13
|
+
return parseBooleanLike(params[key]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readSharedAudioAsVoiceParam(params: Record<string, unknown>): boolean {
|
|
17
|
+
const sharedValue = readBooleanLikeParam(params, "audioAsVoice");
|
|
18
|
+
if (sharedValue !== undefined) {
|
|
19
|
+
return sharedValue;
|
|
20
|
+
}
|
|
21
|
+
return readBooleanLikeParam(params, "asVoice") === true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveConversationIdFromSessionKey(sessionKey?: string | null): string | undefined {
|
|
25
|
+
const trimmed = sessionKey?.trim();
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const markers = [":group:", ":channel:", ":direct:"] as const;
|
|
31
|
+
const marker = markers.find((candidate) => trimmed.includes(candidate));
|
|
32
|
+
if (!marker) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const suffix = trimmed.slice(trimmed.indexOf(marker) + marker.length);
|
|
37
|
+
const threadMarker = ":topic:";
|
|
38
|
+
const threadIndex = suffix.indexOf(threadMarker);
|
|
39
|
+
const conversationId = (threadIndex >= 0 ? suffix.slice(0, threadIndex) : suffix).trim();
|
|
40
|
+
return conversationId || undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function inferCardOwnerIdFromTarget(target: string): string | undefined {
|
|
44
|
+
const trimmed = target.trim();
|
|
45
|
+
if (!trimmed || trimmed.startsWith("cid")) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
return trimmed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function describeDingTalkMessageTool(cfg: OpenClawConfig) {
|
|
52
|
+
const config = getConfig(cfg);
|
|
53
|
+
const configured = Boolean(config.clientId && config.clientSecret);
|
|
54
|
+
if (!configured && !(config.accounts && Object.keys(config.accounts).length > 0)) {
|
|
55
|
+
return { actions: [], capabilities: [], schema: null };
|
|
56
|
+
}
|
|
57
|
+
const hasCardMode =
|
|
58
|
+
config.messageType === "card" ||
|
|
59
|
+
(config.accounts && Object.values(config.accounts).some((account) => account?.messageType === "card"));
|
|
60
|
+
return {
|
|
61
|
+
actions: ["send"] as const,
|
|
62
|
+
capabilities: hasCardMode ? (["cards"] as const) : [],
|
|
63
|
+
schema: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createDingTalkMessageActions(): ChannelMessageActionAdapter {
|
|
68
|
+
return {
|
|
69
|
+
describeMessageTool: ({ cfg }) => describeDingTalkMessageTool(cfg),
|
|
70
|
+
supportsAction: ({ action }) => action === "send",
|
|
71
|
+
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
|
72
|
+
handleAction: async ({ action, params, cfg, accountId, dryRun, mediaLocalRoots, sessionKey }) => {
|
|
73
|
+
if (action !== "send") {
|
|
74
|
+
throw new Error(`Action ${action} is not supported for provider dingtalk.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const to = readStringParam(params, "to", { required: true });
|
|
78
|
+
const mediaInput =
|
|
79
|
+
readStringParam(params, "media", { trim: false }) ??
|
|
80
|
+
readStringParam(params, "path", { trim: false }) ??
|
|
81
|
+
readStringParam(params, "filePath", { trim: false }) ??
|
|
82
|
+
readStringParam(params, "mediaUrl", { trim: false });
|
|
83
|
+
|
|
84
|
+
const hasMedia = Boolean(mediaInput && mediaInput.trim());
|
|
85
|
+
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
|
|
86
|
+
let message =
|
|
87
|
+
readStringParam(params, "message", {
|
|
88
|
+
required: !hasMedia,
|
|
89
|
+
allowEmpty: true,
|
|
90
|
+
}) ?? "";
|
|
91
|
+
|
|
92
|
+
if (!message.trim() && caption.trim()) {
|
|
93
|
+
message = caption;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const asVoice = readSharedAudioAsVoiceParam(params);
|
|
97
|
+
const requestedMediaType = readStringParam(params, "mediaType") as
|
|
98
|
+
| "image"
|
|
99
|
+
| "voice"
|
|
100
|
+
| "video"
|
|
101
|
+
| "file"
|
|
102
|
+
| undefined;
|
|
103
|
+
|
|
104
|
+
const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
|
|
105
|
+
|
|
106
|
+
if (dryRun) {
|
|
107
|
+
return jsonResult({
|
|
108
|
+
ok: true,
|
|
109
|
+
dryRun: true,
|
|
110
|
+
to: target,
|
|
111
|
+
hasMedia,
|
|
112
|
+
asVoice,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const log = getLogger();
|
|
117
|
+
const config = getConfig(cfg, accountId ?? undefined);
|
|
118
|
+
|
|
119
|
+
if (hasMedia && mediaInput) {
|
|
120
|
+
const conversationId = resolveConversationIdFromSessionKey(sessionKey) ?? target;
|
|
121
|
+
const expectedCardOwnerId =
|
|
122
|
+
readStringParam(params, "expectedCardOwnerId") ?? inferCardOwnerIdFromTarget(target);
|
|
123
|
+
const result = await sendMedia(config, target, mediaInput, {
|
|
124
|
+
log,
|
|
125
|
+
accountId: accountId ?? undefined,
|
|
126
|
+
conversationId,
|
|
127
|
+
mediaType: requestedMediaType ?? undefined,
|
|
128
|
+
audioAsVoice: asVoice,
|
|
129
|
+
mediaLocalRoots: mediaLocalRoots ? [...mediaLocalRoots] : undefined,
|
|
130
|
+
expectedCardOwnerId: expectedCardOwnerId ?? undefined,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!result.ok) {
|
|
134
|
+
throw new Error(result.error || "send media failed");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return jsonResult({
|
|
138
|
+
ok: true,
|
|
139
|
+
to: target,
|
|
140
|
+
messageId: result.messageId ?? null,
|
|
141
|
+
result: result.data ?? null,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (asVoice) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"DingTalk send with asVoice requires media/path/filePath/mediaUrl pointing to an audio file.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!message.trim()) {
|
|
152
|
+
throw new Error("send requires message when media is not provided");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const result = await sendMessage(config, target, message, {
|
|
156
|
+
log,
|
|
157
|
+
accountId: accountId ?? undefined,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!result.ok) {
|
|
161
|
+
throw new Error(result.error || "send message failed");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const data = result.data as any;
|
|
165
|
+
return jsonResult({
|
|
166
|
+
ok: true,
|
|
167
|
+
to: target,
|
|
168
|
+
messageId: data?.processQueryKey || data?.messageId || null,
|
|
169
|
+
result: data ?? null,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getConfig } from "../config";
|
|
3
|
+
import { getLogger } from "../logger-context";
|
|
4
|
+
import { getDingTalkRuntime } from "../runtime";
|
|
5
|
+
import { sendMedia, sendMessage } from "../send-service";
|
|
6
|
+
import { normalizeResolvedDingTalkTarget } from "../targeting/target-directory-adapter";
|
|
7
|
+
import type { DingTalkChannelPlugin } from "../types";
|
|
8
|
+
import { formatDingTalkErrorPayloadLog, parseBooleanLike } from "../utils";
|
|
9
|
+
|
|
10
|
+
function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
|
11
|
+
return parseBooleanLike(params[key]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readSharedAudioAsVoiceParam(params: Record<string, unknown>): boolean {
|
|
15
|
+
const sharedValue = readBooleanLikeParam(params, "audioAsVoice");
|
|
16
|
+
if (sharedValue !== undefined) {
|
|
17
|
+
return sharedValue;
|
|
18
|
+
}
|
|
19
|
+
return readBooleanLikeParam(params, "asVoice") === true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createDingTalkOutbound(): NonNullable<DingTalkChannelPlugin["outbound"]> {
|
|
23
|
+
return {
|
|
24
|
+
deliveryMode: "direct",
|
|
25
|
+
resolveTarget: ({ to }: any) => {
|
|
26
|
+
const trimmed = to?.trim();
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false as const,
|
|
30
|
+
error: new Error("DingTalk message requires --to <conversationId>"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return { ok: true as const, to: normalizeResolvedDingTalkTarget(trimmed) };
|
|
34
|
+
},
|
|
35
|
+
sendText: async ({ cfg, to, text, accountId, log }: any) => {
|
|
36
|
+
const config = getConfig(cfg, accountId);
|
|
37
|
+
const runtime = getDingTalkRuntime();
|
|
38
|
+
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
39
|
+
agentId: accountId,
|
|
40
|
+
});
|
|
41
|
+
const effectiveLog = getLogger(accountId) || log;
|
|
42
|
+
try {
|
|
43
|
+
const result = await sendMessage(config, to, text, {
|
|
44
|
+
log: effectiveLog,
|
|
45
|
+
accountId,
|
|
46
|
+
storePath,
|
|
47
|
+
conversationId: to,
|
|
48
|
+
});
|
|
49
|
+
effectiveLog?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
throw new Error(result.error || "sendText failed");
|
|
52
|
+
}
|
|
53
|
+
const data = result.data as any;
|
|
54
|
+
const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
|
|
55
|
+
const meta =
|
|
56
|
+
result.data || result.tracking
|
|
57
|
+
? {
|
|
58
|
+
...(result.data ? { data: result.data as unknown as Record<string, unknown> } : {}),
|
|
59
|
+
...(result.tracking ? { tracking: result.tracking } : {}),
|
|
60
|
+
}
|
|
61
|
+
: undefined;
|
|
62
|
+
return {
|
|
63
|
+
channel: "dingtalk",
|
|
64
|
+
messageId,
|
|
65
|
+
meta,
|
|
66
|
+
};
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
if (err?.response?.data !== undefined) {
|
|
69
|
+
effectiveLog?.error?.(formatDingTalkErrorPayloadLog("outbound.sendText", err.response.data));
|
|
70
|
+
}
|
|
71
|
+
throw new Error(
|
|
72
|
+
typeof err?.response?.data === "string"
|
|
73
|
+
? err.response.data
|
|
74
|
+
: err?.message || "sendText failed",
|
|
75
|
+
{ cause: err },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
sendMedia: async ({
|
|
80
|
+
cfg,
|
|
81
|
+
to,
|
|
82
|
+
mediaPath,
|
|
83
|
+
filePath,
|
|
84
|
+
mediaUrl,
|
|
85
|
+
mediaType: providedMediaType,
|
|
86
|
+
audioAsVoice,
|
|
87
|
+
asVoice,
|
|
88
|
+
accountId,
|
|
89
|
+
mediaLocalRoots,
|
|
90
|
+
log,
|
|
91
|
+
expectedCardOwnerId,
|
|
92
|
+
}: any) => {
|
|
93
|
+
const config = getConfig(cfg, accountId);
|
|
94
|
+
const runtime = getDingTalkRuntime();
|
|
95
|
+
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
96
|
+
agentId: accountId,
|
|
97
|
+
});
|
|
98
|
+
const effectiveLog = getLogger(accountId) || log;
|
|
99
|
+
if (!config.clientId) {
|
|
100
|
+
throw new Error("DingTalk not configured");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rawMediaPath = mediaPath || filePath || mediaUrl;
|
|
104
|
+
if (!rawMediaPath) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`mediaPath, filePath, or mediaUrl is required. Received: ${JSON.stringify({
|
|
107
|
+
to,
|
|
108
|
+
mediaPath,
|
|
109
|
+
filePath,
|
|
110
|
+
mediaUrl,
|
|
111
|
+
})}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const requestedMediaType = typeof providedMediaType === "string"
|
|
116
|
+
? (providedMediaType as "image" | "voice" | "video" | "file")
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const result = await sendMedia(config, to, rawMediaPath, {
|
|
121
|
+
log: effectiveLog,
|
|
122
|
+
accountId,
|
|
123
|
+
storePath,
|
|
124
|
+
conversationId: to,
|
|
125
|
+
mediaType: requestedMediaType,
|
|
126
|
+
audioAsVoice: readSharedAudioAsVoiceParam({ audioAsVoice, asVoice }),
|
|
127
|
+
mediaLocalRoots,
|
|
128
|
+
expectedCardOwnerId,
|
|
129
|
+
});
|
|
130
|
+
effectiveLog?.debug?.(`[DingTalk] sendMedia result: ${JSON.stringify(result)}`);
|
|
131
|
+
if (!result.ok) {
|
|
132
|
+
throw new Error(result.error || "sendMedia failed");
|
|
133
|
+
}
|
|
134
|
+
const data = result.data;
|
|
135
|
+
const messageId = String(
|
|
136
|
+
result.messageId || data?.processQueryKey || data?.messageId || randomUUID(),
|
|
137
|
+
);
|
|
138
|
+
return {
|
|
139
|
+
channel: "dingtalk",
|
|
140
|
+
messageId,
|
|
141
|
+
meta: result.data
|
|
142
|
+
? { data: result.data as unknown as Record<string, unknown> }
|
|
143
|
+
: undefined,
|
|
144
|
+
};
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
if (err?.response?.data !== undefined) {
|
|
147
|
+
effectiveLog?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia", err.response.data));
|
|
148
|
+
}
|
|
149
|
+
throw new Error(
|
|
150
|
+
typeof err?.response?.data === "string"
|
|
151
|
+
? err.response.data
|
|
152
|
+
: err?.message || "sendMedia failed",
|
|
153
|
+
{ cause: err },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -229,6 +229,7 @@ export async function downloadGroupFile(
|
|
|
229
229
|
dentryId: string,
|
|
230
230
|
unionId: string,
|
|
231
231
|
log?: Logger,
|
|
232
|
+
originalFilename?: string,
|
|
232
233
|
): Promise<MediaFile | null> {
|
|
233
234
|
const rt = getDingTalkRuntime();
|
|
234
235
|
const token = await getAccessToken(config, log);
|
|
@@ -294,9 +295,13 @@ export async function downloadGroupFile(
|
|
|
294
295
|
const maxBytes =
|
|
295
296
|
config.mediaMaxMb && config.mediaMaxMb > 0 ? config.mediaMaxMb * 1024 * 1024 : undefined;
|
|
296
297
|
try {
|
|
297
|
-
const saved =
|
|
298
|
-
|
|
299
|
-
|
|
298
|
+
const saved = await rt.channel.media.saveMediaBuffer(
|
|
299
|
+
buffer,
|
|
300
|
+
contentType,
|
|
301
|
+
"inbound",
|
|
302
|
+
maxBytes,
|
|
303
|
+
originalFilename,
|
|
304
|
+
);
|
|
300
305
|
|
|
301
306
|
return { path: saved.path, mimeType: saved.contentType ?? contentType };
|
|
302
307
|
} catch (err: unknown) {
|
|
@@ -353,7 +358,7 @@ export async function resolveQuotedFile(
|
|
|
353
358
|
}
|
|
354
359
|
|
|
355
360
|
stage = "download-file";
|
|
356
|
-
const media = await downloadGroupFile(config, spaceId, match.dentryId, unionId, log);
|
|
361
|
+
const media = await downloadGroupFile(config, spaceId, match.dentryId, unionId, log, match.name);
|
|
357
362
|
if (!media) {
|
|
358
363
|
return null;
|
|
359
364
|
}
|