@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.
@@ -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: { mediaPath?: string; mediaType?: string } | undefined;
162
- if (extractedContent.mediaPath && resolveRobotCode(dingtalkConfig)) {
163
- const media = await download(dingtalkConfig, extractedContent.mediaPath, log);
164
- if (media) {
165
- preDownloadedMedia = { mediaPath: media.path, mediaType: media.mimeType };
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: string;
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<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
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: string;
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<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
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 = "msgId" | "processQueryKey" | "messageId" | "outTrackId" | "cardInstanceId";
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?: (params: SendMediaParams) => Promise<{ ok: boolean; data?: unknown; error?: unknown }>;
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
  /**