@soimy/dingtalk 3.6.0 → 3.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/openclaw.plugin.json +49 -5
- package/package.json +1 -1
- package/src/auth.ts +5 -2
- package/src/card-service.ts +41 -16
- package/src/channel.ts +5 -2
- package/src/config-schema.ts +2 -1
- package/src/config.ts +24 -67
- package/src/gateway/channel-gateway.ts +9 -8
- package/src/inbound-handler.ts +1239 -1061
- package/src/message-context-store.ts +183 -85
- package/src/messaging/channel-actions.ts +2 -1
- package/src/onboarding.ts +15 -6
- package/src/path-utils.ts +49 -0
- package/src/secret-input.ts +216 -0
- package/src/targeting/agent-routing.ts +30 -5
- package/src/types.ts +25 -10
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { resolveRelativePath } from "./path-utils";
|
|
6
|
+
|
|
7
|
+
export type SecretInputRef = {
|
|
8
|
+
source: "env" | "file" | "exec";
|
|
9
|
+
provider: string;
|
|
10
|
+
id: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SecretInput = string | SecretInputRef;
|
|
14
|
+
|
|
15
|
+
export type SecretInputResolutionFailure = {
|
|
16
|
+
source: SecretInputRef["source"];
|
|
17
|
+
provider: string;
|
|
18
|
+
id: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const SECRET_INPUT_EXEC_TIMEOUT_MS = 5000;
|
|
23
|
+
|
|
24
|
+
type SecretInputLog = {
|
|
25
|
+
warn?: (message: string, data?: unknown) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
const SECRET_INPUT_PROVIDER_PATTERN = /^[^:>]+$/;
|
|
30
|
+
const SECRET_INPUT_ID_PATTERN = /^[^>]+$/;
|
|
31
|
+
|
|
32
|
+
function buildSecretInputFailure(
|
|
33
|
+
value: SecretInputRef,
|
|
34
|
+
reason: string,
|
|
35
|
+
): SecretInputResolutionFailure {
|
|
36
|
+
return {
|
|
37
|
+
source: value.source,
|
|
38
|
+
provider: value.provider,
|
|
39
|
+
id: value.id,
|
|
40
|
+
reason,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatSecretInputResolutionFailure(failure: SecretInputResolutionFailure): string {
|
|
45
|
+
return `${failure.source}:${failure.provider}:${failure.id} - ${failure.reason}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildSecretInputSchema() {
|
|
49
|
+
return z.union([
|
|
50
|
+
z.string(),
|
|
51
|
+
z.object({
|
|
52
|
+
source: z.enum(["env", "file", "exec"]),
|
|
53
|
+
provider: z.string().min(1).max(1024).regex(SECRET_INPUT_PROVIDER_PATTERN),
|
|
54
|
+
id: z.string().min(1).max(1024).regex(SECRET_INPUT_ID_PATTERN),
|
|
55
|
+
}),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isSecretInputRef(value: unknown): value is SecretInputRef {
|
|
60
|
+
if (!value || typeof value !== "object") {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const ref = value as SecretInputRef;
|
|
64
|
+
return (
|
|
65
|
+
(ref.source === "env" || ref.source === "file" || ref.source === "exec") &&
|
|
66
|
+
typeof ref.provider === "string" &&
|
|
67
|
+
ref.provider.trim().length > 0 &&
|
|
68
|
+
SECRET_INPUT_PROVIDER_PATTERN.test(ref.provider) &&
|
|
69
|
+
typeof ref.id === "string" &&
|
|
70
|
+
ref.id.trim().length > 0 &&
|
|
71
|
+
SECRET_INPUT_ID_PATTERN.test(ref.id)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function hasConfiguredSecretInput(value: unknown): boolean {
|
|
76
|
+
if (typeof value === "string") {
|
|
77
|
+
return value.trim().length > 0;
|
|
78
|
+
}
|
|
79
|
+
if (!isSecretInputRef(value)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (value.source === "env") {
|
|
83
|
+
return Boolean(process.env[value.id]?.trim());
|
|
84
|
+
}
|
|
85
|
+
// file/exec references are considered configured when the reference shape is
|
|
86
|
+
// present. The actual filesystem/process lookup happens at runtime so status
|
|
87
|
+
// checks can stay side-effect free.
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function normalizeSecretInputString(value: unknown): string | undefined {
|
|
92
|
+
if (typeof value === "string") {
|
|
93
|
+
const trimmed = value.trim();
|
|
94
|
+
return trimmed || undefined;
|
|
95
|
+
}
|
|
96
|
+
if (!isSecretInputRef(value)) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return `<${value.source}:${value.provider}:${value.id}>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseSecretInputString(value: unknown): SecretInput | undefined {
|
|
103
|
+
if (typeof value !== "string") {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const trimmed = value.trim();
|
|
107
|
+
if (!trimmed) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const match = trimmed.match(/^<(env|file|exec):([^:>]+):([^>]+)>$/);
|
|
111
|
+
if (!match) {
|
|
112
|
+
return trimmed;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
source: match[1] as SecretInputRef["source"],
|
|
116
|
+
provider: match[2],
|
|
117
|
+
id: match[3],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function resolveSecretInputString(
|
|
122
|
+
value: unknown,
|
|
123
|
+
log?: SecretInputLog,
|
|
124
|
+
): Promise<string | undefined> {
|
|
125
|
+
return (await resolveSecretInputStringWithFailure(value, log)).value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function resolveSecretInputStringWithFailure(
|
|
129
|
+
value: unknown,
|
|
130
|
+
log?: SecretInputLog,
|
|
131
|
+
): Promise<{ value?: string; failure?: SecretInputResolutionFailure }> {
|
|
132
|
+
if (typeof value === "string") {
|
|
133
|
+
const trimmed = value.trim();
|
|
134
|
+
return { value: trimmed || undefined };
|
|
135
|
+
}
|
|
136
|
+
if (!isSecretInputRef(value)) {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
if (value.source === "env") {
|
|
140
|
+
const envValue = process.env[value.id]?.trim();
|
|
141
|
+
if (envValue) {
|
|
142
|
+
return { value: envValue };
|
|
143
|
+
}
|
|
144
|
+
const failure = buildSecretInputFailure(value, "environment variable is not set or is empty");
|
|
145
|
+
log?.warn?.("[DingTalk][SecretInput] Failed to resolve env secret", {
|
|
146
|
+
provider: value.provider,
|
|
147
|
+
id: value.id,
|
|
148
|
+
error: failure.reason,
|
|
149
|
+
});
|
|
150
|
+
return { failure };
|
|
151
|
+
}
|
|
152
|
+
if (value.source === "file") {
|
|
153
|
+
try {
|
|
154
|
+
// Trust boundary: file SecretInput reads the configured local path. Use it
|
|
155
|
+
// only with trusted plugin configuration.
|
|
156
|
+
const filePath = resolveRelativePath(value.id);
|
|
157
|
+
const secret = (await readFile(filePath, "utf8")).trim();
|
|
158
|
+
if (secret) {
|
|
159
|
+
return { value: secret };
|
|
160
|
+
}
|
|
161
|
+
return { failure: buildSecretInputFailure(value, "file secret is empty") };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const failure = buildSecretInputFailure(
|
|
164
|
+
value,
|
|
165
|
+
error instanceof Error ? error.message : String(error),
|
|
166
|
+
);
|
|
167
|
+
log?.warn?.("[DingTalk][SecretInput] Failed to read file secret", {
|
|
168
|
+
provider: value.provider,
|
|
169
|
+
id: value.id,
|
|
170
|
+
error: failure.reason,
|
|
171
|
+
});
|
|
172
|
+
return { failure };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
// Trust boundary: exec SecretInput runs the configured provider binary with
|
|
177
|
+
// the secret id as its only argument. Use it only with trusted plugin
|
|
178
|
+
// configuration; execFile avoids shell interpolation but still executes the
|
|
179
|
+
// selected program.
|
|
180
|
+
const result = await execFileAsync(value.provider, [value.id], {
|
|
181
|
+
encoding: "utf8",
|
|
182
|
+
timeout: SECRET_INPUT_EXEC_TIMEOUT_MS,
|
|
183
|
+
windowsHide: true,
|
|
184
|
+
});
|
|
185
|
+
const secret = String(result.stdout).trim();
|
|
186
|
+
if (secret) {
|
|
187
|
+
return { value: secret };
|
|
188
|
+
}
|
|
189
|
+
return { failure: buildSecretInputFailure(value, "exec secret output is empty") };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const failure = buildSecretInputFailure(
|
|
192
|
+
value,
|
|
193
|
+
error instanceof Error ? error.message : String(error),
|
|
194
|
+
);
|
|
195
|
+
log?.warn?.("[DingTalk][SecretInput] Failed to resolve exec secret", {
|
|
196
|
+
provider: value.provider,
|
|
197
|
+
id: value.id,
|
|
198
|
+
error: failure.reason,
|
|
199
|
+
});
|
|
200
|
+
return { failure };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function resolveDingTalkSecretConfig<T extends { clientSecret?: unknown }>(
|
|
205
|
+
config: T,
|
|
206
|
+
log?: SecretInputLog,
|
|
207
|
+
): Promise<
|
|
208
|
+
T & { clientSecret?: string; clientSecretResolutionFailure?: SecretInputResolutionFailure }
|
|
209
|
+
> {
|
|
210
|
+
const resolvedSecret = await resolveSecretInputStringWithFailure(config.clientSecret, log);
|
|
211
|
+
return {
|
|
212
|
+
...config,
|
|
213
|
+
clientSecret: resolvedSecret.value,
|
|
214
|
+
clientSecretResolutionFailure: resolvedSecret.failure,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -158,11 +158,36 @@ export async function dispatchSubAgents(params: {
|
|
|
158
158
|
const { matchedAgents, cfg, accountId, data, dingtalkConfig, sessionWebhook, extractedContent, handleMessage, downloadMedia: download, log } = params;
|
|
159
159
|
|
|
160
160
|
// Pre-download media once to avoid duplication across sub-agents
|
|
161
|
-
let preDownloadedMedia: {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
let preDownloadedMedia: {
|
|
162
|
+
mediaPath?: string;
|
|
163
|
+
mediaType?: string;
|
|
164
|
+
mediaPaths?: string[];
|
|
165
|
+
mediaTypes?: string[];
|
|
166
|
+
} | undefined;
|
|
167
|
+
const robotCode = resolveRobotCode(dingtalkConfig);
|
|
168
|
+
if (robotCode) {
|
|
169
|
+
const downloadCodes =
|
|
170
|
+
extractedContent.mediaPaths && extractedContent.mediaPaths.length > 0
|
|
171
|
+
? extractedContent.mediaPaths
|
|
172
|
+
: extractedContent.mediaPath
|
|
173
|
+
? [extractedContent.mediaPath]
|
|
174
|
+
: [];
|
|
175
|
+
const mediaPaths: string[] = [];
|
|
176
|
+
const mediaTypes: string[] = [];
|
|
177
|
+
for (const downloadCode of downloadCodes) {
|
|
178
|
+
const media = await download(dingtalkConfig, downloadCode, log);
|
|
179
|
+
if (media) {
|
|
180
|
+
mediaPaths.push(media.path);
|
|
181
|
+
mediaTypes.push(media.mimeType);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (mediaPaths.length > 0) {
|
|
185
|
+
preDownloadedMedia = {
|
|
186
|
+
mediaPath: mediaPaths[0],
|
|
187
|
+
mediaType: mediaTypes[0],
|
|
188
|
+
mediaPaths,
|
|
189
|
+
mediaTypes,
|
|
190
|
+
};
|
|
166
191
|
}
|
|
167
192
|
}
|
|
168
193
|
let helperMissingWarningSent = false;
|
package/src/types.ts
CHANGED
|
@@ -9,16 +9,14 @@
|
|
|
9
9
|
* - Session and token management
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type {
|
|
13
|
-
ChannelPlugin as SDKChannelPlugin,
|
|
14
|
-
OpenClawConfig,
|
|
15
|
-
} from "openclaw/plugin-sdk/core";
|
|
16
12
|
import type {
|
|
17
13
|
ChannelAccountSnapshot as SDKChannelAccountSnapshot,
|
|
18
14
|
ChannelGatewayContext as SDKChannelGatewayContext,
|
|
19
15
|
ChannelLogSink as SDKChannelLogSink,
|
|
20
16
|
} from "openclaw/plugin-sdk/channel-runtime";
|
|
17
|
+
import type { ChannelPlugin as SDKChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
21
18
|
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
|
19
|
+
import type { SecretInput } from "./secret-input";
|
|
22
20
|
|
|
23
21
|
export type AckReactionMode = "off" | "emoji" | "kaomoji";
|
|
24
22
|
// Accept arbitrary strings for backward compatibility; the recommended
|
|
@@ -32,7 +30,7 @@ export type ContextVisibilityMode = "all" | "allowlist" | "allowlist_quote";
|
|
|
32
30
|
*/
|
|
33
31
|
export interface DingTalkConfig extends OpenClawConfig {
|
|
34
32
|
clientId: string;
|
|
35
|
-
clientSecret:
|
|
33
|
+
clientSecret: SecretInput;
|
|
36
34
|
name?: string;
|
|
37
35
|
enabled?: boolean;
|
|
38
36
|
dmPolicy?: "open" | "pairing" | "allowlist";
|
|
@@ -50,7 +48,10 @@ export interface DingTalkConfig extends OpenClawConfig {
|
|
|
50
48
|
cardTemplateId?: string;
|
|
51
49
|
/** @deprecated 已固定使用内置模板契约 */
|
|
52
50
|
cardTemplateKey?: string;
|
|
53
|
-
groups?: Record<
|
|
51
|
+
groups?: Record<
|
|
52
|
+
string,
|
|
53
|
+
{ systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }
|
|
54
|
+
>;
|
|
54
55
|
accounts?: Record<string, DingTalkConfig>;
|
|
55
56
|
// Connection robustness configuration
|
|
56
57
|
maxConnectionAttempts?: number;
|
|
@@ -111,7 +112,7 @@ export interface DingTalkConfig extends OpenClawConfig {
|
|
|
111
112
|
export interface DingTalkChannelConfig {
|
|
112
113
|
enabled?: boolean;
|
|
113
114
|
clientId: string;
|
|
114
|
-
clientSecret:
|
|
115
|
+
clientSecret: SecretInput;
|
|
115
116
|
name?: string;
|
|
116
117
|
dmPolicy?: "open" | "pairing" | "allowlist";
|
|
117
118
|
groupPolicy?: "open" | "allowlist" | "disabled";
|
|
@@ -128,7 +129,10 @@ export interface DingTalkChannelConfig {
|
|
|
128
129
|
cardTemplateId?: string;
|
|
129
130
|
/** @deprecated 已固定使用内置模板契约 */
|
|
130
131
|
cardTemplateKey?: string;
|
|
131
|
-
groups?: Record<
|
|
132
|
+
groups?: Record<
|
|
133
|
+
string,
|
|
134
|
+
{ systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }
|
|
135
|
+
>;
|
|
132
136
|
accounts?: Record<string, DingTalkConfig>;
|
|
133
137
|
maxConnectionAttempts?: number;
|
|
134
138
|
initialReconnectDelay?: number;
|
|
@@ -306,7 +310,12 @@ export interface DingTalkInboundMessage {
|
|
|
306
310
|
sessionWebhook: string;
|
|
307
311
|
}
|
|
308
312
|
|
|
309
|
-
export type QuotedRefKey =
|
|
313
|
+
export type QuotedRefKey =
|
|
314
|
+
| "msgId"
|
|
315
|
+
| "processQueryKey"
|
|
316
|
+
| "messageId"
|
|
317
|
+
| "outTrackId"
|
|
318
|
+
| "cardInstanceId";
|
|
310
319
|
|
|
311
320
|
export type AttachmentTextSource = "text" | "html" | "pdf" | "docx";
|
|
312
321
|
|
|
@@ -468,6 +477,8 @@ export interface HandleDingTalkMessageParams {
|
|
|
468
477
|
preDownloadedMedia?: {
|
|
469
478
|
mediaPath?: string;
|
|
470
479
|
mediaType?: string;
|
|
480
|
+
mediaPaths?: string[];
|
|
481
|
+
mediaTypes?: string[];
|
|
471
482
|
};
|
|
472
483
|
}
|
|
473
484
|
|
|
@@ -644,7 +655,9 @@ export interface DingTalkOutboundHandler {
|
|
|
644
655
|
deliveryMode: "direct" | "queued" | "batch";
|
|
645
656
|
resolveTarget: (params: ResolveTargetParams) => TargetResolutionResult;
|
|
646
657
|
sendText: (params: SendTextParams) => Promise<{ ok: boolean; data?: unknown; error?: unknown }>;
|
|
647
|
-
sendMedia?: (
|
|
658
|
+
sendMedia?: (
|
|
659
|
+
params: SendMediaParams,
|
|
660
|
+
) => Promise<{ ok: boolean; data?: unknown; error?: unknown }>;
|
|
648
661
|
}
|
|
649
662
|
|
|
650
663
|
/**
|
|
@@ -683,6 +696,8 @@ export interface AICardInstance {
|
|
|
683
696
|
outTrackId?: string;
|
|
684
697
|
/** Cumulative DingTalk API call count for this card instance. */
|
|
685
698
|
dapiUsage?: number;
|
|
699
|
+
/** True after this card has successfully opened DingTalk's streaming lifecycle. */
|
|
700
|
+
streamLifecycleOpened?: boolean;
|
|
686
701
|
}
|
|
687
702
|
|
|
688
703
|
/**
|