@soimy/dingtalk 3.5.3 → 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.
Files changed (39) hide show
  1. package/README.md +4 -1
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +153 -5
  4. package/package.json +1 -1
  5. package/src/auth.ts +5 -2
  6. package/src/card/card-markdown-image-reroute.ts +106 -0
  7. package/src/card/card-run-registry.ts +54 -1
  8. package/src/card/card-stop-handler.ts +10 -20
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/statusline-renderer.ts +94 -0
  11. package/src/card-draft-controller.ts +245 -52
  12. package/src/card-service.ts +408 -23
  13. package/src/channel.ts +24 -1083
  14. package/src/config-schema.ts +21 -1
  15. package/src/config.ts +139 -66
  16. package/src/device-registration.ts +245 -0
  17. package/src/gateway/channel-gateway.ts +637 -0
  18. package/src/inbound-handler.ts +1276 -975
  19. package/src/media-utils.ts +6 -0
  20. package/src/message-context-store.ts +183 -85
  21. package/src/message-utils.ts +124 -16
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +174 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/onboarding.ts +333 -235
  26. package/src/path-utils.ts +49 -0
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/reply-strategy-card.ts +373 -64
  29. package/src/reply-strategy-markdown.ts +1 -1
  30. package/src/reply-strategy-types.ts +93 -0
  31. package/src/reply-strategy-with-reaction.ts +1 -1
  32. package/src/reply-strategy.ts +14 -72
  33. package/src/run-usage-store.ts +59 -0
  34. package/src/secret-input.ts +216 -0
  35. package/src/send-service.ts +115 -3
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +30 -5
  39. package/src/types.ts +48 -157
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { DEFAULT_MESSAGE_CONTEXT_TTL_DAYS } from "./message-context-store";
3
+ import { buildSecretInputSchema } from "./secret-input";
3
4
 
4
5
  const AckReactionSchema = z.union([
5
6
  z.literal(""),
@@ -29,7 +30,7 @@ const DingTalkAccountConfigShape = {
29
30
  clientId: z.string().optional(),
30
31
 
31
32
  /** DingTalk App Secret (Client Secret) used to obtain DingTalk access tokens. */
32
- clientSecret: z.string().optional(),
33
+ clientSecret: buildSecretInputSchema().optional(),
33
34
 
34
35
  /** Direct-message access policy: open, pairing, or allowlist. */
35
36
  dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
@@ -156,6 +157,25 @@ const DingTalkAccountConfigShape = {
156
157
  * Set to a non-empty string (e.g. "✅ 回复完成") to enable — the value is used as the message text.
157
158
  * Leave empty or omit to disable. */
158
159
  cardAtSender: z.string().optional(),
160
+
161
+ /** Status line visibility toggles for the AI card footer. */
162
+ cardStatusLine: z
163
+ .object({
164
+ /** Show model name. */
165
+ model: z.boolean().optional().default(true),
166
+ /** Show thinking effort level. */
167
+ effort: z.boolean().optional().default(true),
168
+ /** Show agent display name. */
169
+ agent: z.boolean().optional().default(true),
170
+ /** Show task elapsed time. */
171
+ taskTime: z.boolean().optional().default(false),
172
+ /** Show token usage summary (input/output/cache). */
173
+ tokens: z.boolean().optional().default(false),
174
+ /** Show DingTalk API call count. */
175
+ dapiUsage: z.boolean().optional().default(false),
176
+ })
177
+ .optional()
178
+ .default({ model: true, effort: true, agent: true, taskTime: false, tokens: false, dapiUsage: false }),
159
179
  } as const;
160
180
 
161
181
  const DingTalkAccountConfigSchema = z.object(DingTalkAccountConfigShape);
package/src/config.ts CHANGED
@@ -1,17 +1,13 @@
1
- import * as os from "node:os";
2
- import * as path from "node:path";
3
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
- import type { DingTalkConfig } from "./types";
5
-
6
- const WINDOWS_ROOT_DIRECTORIES = new Set([
7
- "Users",
8
- "Program Files",
9
- "Program Files (x86)",
10
- "ProgramData",
11
- "Windows",
12
- "Documents and Settings",
13
- ]);
2
+ import {
3
+ formatSecretInputResolutionFailure,
4
+ hasConfiguredSecretInput,
5
+ resolveDingTalkSecretConfig,
6
+ } from "./secret-input";
7
+ import type { DingTalkChannelConfig, DingTalkConfig } from "./types";
8
+ export { resolveRelativePath, resolveUserPath } from "./path-utils";
14
9
  const DEFAULT_LEARNING_NOTE_TTL_MS = 6 * 60 * 60 * 1000;
10
+ export type RuntimeDingTalkConfig = Omit<DingTalkConfig, "clientSecret"> & { clientSecret: string };
15
11
 
16
12
  function normalizeLearningConfig(
17
13
  config: DingTalkConfig,
@@ -114,65 +110,26 @@ export function getConfig(cfg: OpenClawConfig, accountId?: string): DingTalkConf
114
110
 
115
111
  export function isConfigured(cfg: OpenClawConfig, accountId?: string): boolean {
116
112
  const config = getConfig(cfg, accountId);
117
- return Boolean(config.clientId && config.clientSecret);
113
+ return Boolean(config.clientId && hasConfiguredSecretInput(config.clientSecret));
118
114
  }
119
115
 
120
- /**
121
- * Resolve relative paths against a base directory, with intelligent platform-specific handling.
122
- *
123
- * Supports:
124
- * - ~ and ~/ expansion to home directory
125
- * - Absolute paths (Unix: /path, Windows: \path or C:\path)
126
- * - Relative paths resolved against cwd
127
- * - Windows absolute paths without drive letters (e.g., Users\name\.openclaw\file.txt)
128
- * - Mixed path separators (/ and \)
129
- *
130
- * @param input - The path string to resolve
131
- * @returns The resolved absolute path
132
- */
133
- export function resolveRelativePath(input: string): string {
134
- const trimmed = input.trim();
135
- if (!trimmed) {
136
- return trimmed;
137
- }
138
-
139
- const segments = (value: string): string[] => value.split(/[\\/]+/).filter(Boolean);
140
- const pathSegments = segments(trimmed);
141
- const firstSegment = pathSegments[0];
142
-
143
- // Expand bare "~" and "~/" or "~\\" prefixes into the user home directory.
144
- if (trimmed === "~") {
145
- return path.resolve(os.homedir());
146
- }
147
- if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
148
- return path.resolve(os.homedir(), ...segments(trimmed.slice(2)));
149
- }
150
-
151
- if (process.platform === "win32") {
152
- // On Windows, OpenClaw may drop the leading "\" from root-based paths like
153
- // "Users\name\.openclaw\workspace\file.xlsx". Only recover paths that start
154
- // with well-known root directories to avoid misclassifying ordinary relative paths.
155
- if (/^[a-zA-Z]:[\\/]/.test(trimmed)) {
156
- return path.win32.normalize(trimmed);
157
- }
158
- if (firstSegment && /^[a-zA-Z]:$/.test(firstSegment)) {
159
- return path.win32.resolve(`${firstSegment}\\`, ...pathSegments.slice(1));
160
- }
161
- if (firstSegment && WINDOWS_ROOT_DIRECTORIES.has(firstSegment)) {
162
- return path.win32.resolve("\\", ...pathSegments);
163
- }
164
- }
165
- // Treat both "/" and "\\" as absolute root prefixes for cross-platform input.
166
- if (/^[\\/]/.test(trimmed)) {
167
- return path.resolve(path.sep, ...pathSegments);
116
+ export async function resolveRuntimeConfig(
117
+ config: DingTalkConfig,
118
+ log?: { warn?: (message: string, data?: unknown) => void },
119
+ ): Promise<RuntimeDingTalkConfig> {
120
+ const resolved = await resolveDingTalkSecretConfig(config, log);
121
+ if (!resolved.clientId || !resolved.clientSecret) {
122
+ const secretFailure = resolved.clientSecretResolutionFailure
123
+ ? `: clientSecret resolution failed for ${formatSecretInputResolutionFailure(resolved.clientSecretResolutionFailure)}`
124
+ : "";
125
+ throw new Error(`DingTalk clientId and resolved clientSecret are required${secretFailure}`);
168
126
  }
169
-
170
- // Resolve relative path against cwd; supports mixed separators and "..\\..".
171
- return path.resolve(process.cwd(), ...pathSegments);
127
+ return {
128
+ ...resolved,
129
+ clientSecret: resolved.clientSecret,
130
+ };
172
131
  }
173
132
 
174
- export const resolveUserPath = resolveRelativePath;
175
-
176
133
  /**
177
134
  * Resolve the robot code used by DingTalk APIs.
178
135
  * DingTalk robotCode is always equal to clientId; this helper trims whitespace.
@@ -272,3 +229,119 @@ export function stripTargetPrefix(target: string): { targetId: string; isExplici
272
229
  }
273
230
  return { targetId: target, isExplicitUser: false };
274
231
  }
232
+
233
+ // ============ Onboarding Helper Functions ============
234
+
235
+ const DEFAULT_ACCOUNT_ID = "default";
236
+
237
+
238
+ /**
239
+ * List all DingTalk account IDs from config
240
+ */
241
+ export function listDingTalkAccountIds(cfg: OpenClawConfig): string[] {
242
+ const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
243
+ if (!dingtalk) {
244
+ return [];
245
+ }
246
+
247
+ const accountIds: string[] = [];
248
+
249
+ if (dingtalk.clientId || dingtalk.clientSecret) {
250
+ accountIds.push(DEFAULT_ACCOUNT_ID);
251
+ }
252
+
253
+ if (dingtalk.accounts) {
254
+ accountIds.push(...Object.keys(dingtalk.accounts));
255
+ }
256
+
257
+ return accountIds;
258
+ }
259
+
260
+ /**
261
+ * Resolved DingTalk account with configuration status
262
+ */
263
+ export interface ResolvedDingTalkAccount extends DingTalkConfig {
264
+ accountId: string;
265
+ configured: boolean;
266
+ }
267
+
268
+ /**
269
+ * Resolve a specific DingTalk account configuration
270
+ */
271
+ export function resolveDingTalkAccount(
272
+ cfg: OpenClawConfig,
273
+ accountId?: string | null,
274
+ ): ResolvedDingTalkAccount {
275
+ const id = accountId || DEFAULT_ACCOUNT_ID;
276
+ const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
277
+
278
+ if (id === DEFAULT_ACCOUNT_ID) {
279
+ const rawConfig: DingTalkConfig = {
280
+ clientId: dingtalk?.clientId ?? "",
281
+ clientSecret: dingtalk?.clientSecret ?? "",
282
+ name: dingtalk?.name,
283
+ enabled: dingtalk?.enabled,
284
+ dmPolicy: dingtalk?.dmPolicy,
285
+ groupPolicy: dingtalk?.groupPolicy,
286
+ allowFrom: dingtalk?.allowFrom,
287
+ groupAllowFrom: dingtalk?.groupAllowFrom,
288
+ displayNameResolution: dingtalk?.displayNameResolution,
289
+ contextVisibility: dingtalk?.contextVisibility,
290
+ journalTTLDays: dingtalk?.journalTTLDays,
291
+ ackReaction: dingtalk?.ackReaction,
292
+ debug: dingtalk?.debug,
293
+ messageType: dingtalk?.messageType,
294
+ cardTemplateId: dingtalk?.cardTemplateId,
295
+ cardTemplateKey: dingtalk?.cardTemplateKey,
296
+ groups: dingtalk?.groups,
297
+ accounts: dingtalk?.accounts,
298
+ maxConnectionAttempts: dingtalk?.maxConnectionAttempts,
299
+ initialReconnectDelay: dingtalk?.initialReconnectDelay,
300
+ maxReconnectDelay: dingtalk?.maxReconnectDelay,
301
+ reconnectJitter: dingtalk?.reconnectJitter,
302
+ maxReconnectCycles: dingtalk?.maxReconnectCycles,
303
+ reconnectDeadlineMs: dingtalk?.reconnectDeadlineMs,
304
+ useConnectionManager: dingtalk?.useConnectionManager,
305
+ mediaMaxMb: dingtalk?.mediaMaxMb,
306
+ keepAlive: dingtalk?.keepAlive,
307
+ bypassProxyForSend: dingtalk?.bypassProxyForSend,
308
+ proactivePermissionHint: dingtalk?.proactivePermissionHint,
309
+ cardStreamingMode: dingtalk?.cardStreamingMode,
310
+ cardRealTimeStream: dingtalk?.cardRealTimeStream,
311
+ cardStreamInterval: dingtalk?.cardStreamInterval,
312
+ aicardDegradeMs: dingtalk?.aicardDegradeMs,
313
+ learningEnabled: dingtalk?.learningEnabled,
314
+ learningAutoApply: dingtalk?.learningAutoApply,
315
+ learningNoteTtlMs: dingtalk?.learningNoteTtlMs,
316
+ convertMarkdownTables: dingtalk?.convertMarkdownTables,
317
+ cardAtSender: dingtalk?.cardAtSender,
318
+ };
319
+ const config = stripRemovedLegacyFields(rawConfig);
320
+ return {
321
+ ...config,
322
+ accountId: id,
323
+ configured: Boolean(config.clientId && hasConfiguredSecretInput(config.clientSecret)),
324
+ };
325
+ }
326
+
327
+ const accountConfig = dingtalk?.accounts?.[id];
328
+ if (accountConfig) {
329
+ const merged = mergeAccountWithDefaults(
330
+ dingtalk as DingTalkConfig,
331
+ accountConfig,
332
+ );
333
+ const publicMerged = stripRemovedLegacyFields(merged);
334
+ return {
335
+ ...publicMerged,
336
+ accountId: id,
337
+ configured: Boolean(merged.clientId && hasConfiguredSecretInput(merged.clientSecret)),
338
+ };
339
+ }
340
+
341
+ return {
342
+ clientId: "",
343
+ clientSecret: "",
344
+ accountId: id,
345
+ configured: false,
346
+ };
347
+ }
@@ -0,0 +1,245 @@
1
+ import { execFile } from "node:child_process";
2
+ import httpClient from "./http-client.js";
3
+
4
+ // ── Constants ──────────────────────────────────────────────────────────────
5
+
6
+ const REGISTRATION_BASE_URL = "https://oapi.dingtalk.com";
7
+ const REGISTRATION_SOURCE = "openClaw";
8
+ const RETRY_WINDOW_MS = 120_000; // 2 minutes for transient errors
9
+
10
+ // ── Types ──────────────────────────────────────────────────────────────────
11
+
12
+ export class RegistrationError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "RegistrationError";
16
+ }
17
+ }
18
+
19
+ export interface RegistrationResult {
20
+ clientId: string;
21
+ clientSecret: string;
22
+ }
23
+
24
+ interface BeginResult {
25
+ deviceCode: string;
26
+ verificationUrl: string;
27
+ expiresIn: number;
28
+ interval: number;
29
+ }
30
+
31
+ type PollStatus = "WAITING" | "SUCCESS" | "FAIL" | "EXPIRED";
32
+
33
+ interface PollResult {
34
+ status: PollStatus;
35
+ clientId?: string;
36
+ clientSecret?: string;
37
+ failReason?: string;
38
+ }
39
+
40
+ // ── Internal helpers ───────────────────────────────────────────────────────
41
+
42
+ function asString(value: unknown): string {
43
+ return typeof value === "string" ? value : "";
44
+ }
45
+
46
+ async function apiPost(
47
+ path: string,
48
+ payload: Record<string, unknown>,
49
+ ): Promise<Record<string, unknown>> {
50
+ const url = `${REGISTRATION_BASE_URL}${path}`;
51
+ const resp = await httpClient.post(url, payload, { timeout: 15_000 });
52
+ const data = resp.data as Record<string, unknown>;
53
+ const errcode = data.errcode;
54
+ if (errcode !== undefined && errcode !== 0) {
55
+ const errmsg = asString(data.errmsg) || "unknown error";
56
+ throw new RegistrationError(`API error [${path}]: ${errmsg} (errcode=${typeof errcode === "number" ? errcode : asString(errcode)})`);
57
+ }
58
+ return data;
59
+ }
60
+
61
+ // ── Step 1: init → nonce ───────────────────────────────────────────────────
62
+
63
+ async function initRegistration(): Promise<string> {
64
+ const data = await apiPost("/app/registration/init", { source: REGISTRATION_SOURCE });
65
+ const nonce = asString(data.nonce).trim();
66
+ if (!nonce) {
67
+ throw new RegistrationError("init response missing nonce");
68
+ }
69
+ return nonce;
70
+ }
71
+
72
+ // ── Step 2: begin → deviceCode + verificationUrl ───────────────────────────
73
+
74
+ async function beginRegistration(nonce: string): Promise<BeginResult> {
75
+ const data = await apiPost("/app/registration/begin", { nonce });
76
+ const deviceCode = asString(data.device_code).trim();
77
+ const verificationUrl = asString(data.verification_uri_complete).trim();
78
+ if (!deviceCode) {
79
+ throw new RegistrationError("begin response missing device_code");
80
+ }
81
+ if (!verificationUrl) {
82
+ throw new RegistrationError("begin response missing verification_uri_complete");
83
+ }
84
+ return {
85
+ deviceCode,
86
+ verificationUrl,
87
+ expiresIn: Number(data.expires_in ?? 7200) || 7200,
88
+ interval: Math.max(Number(data.interval ?? 3) || 3, 2),
89
+ };
90
+ }
91
+
92
+ // ── Step 3: poll ───────────────────────────────────────────────────────────
93
+
94
+ async function pollRegistration(deviceCode: string): Promise<PollResult> {
95
+ const data = await apiPost("/app/registration/poll", { device_code: deviceCode });
96
+ const raw = asString(data.status).trim().toUpperCase();
97
+ const status: PollStatus = ["WAITING", "SUCCESS", "FAIL", "EXPIRED"].includes(raw)
98
+ ? (raw as PollStatus)
99
+ : "FAIL";
100
+ return {
101
+ status,
102
+ clientId: asString(data.client_id).trim() || undefined,
103
+ clientSecret: asString(data.client_secret).trim() || undefined,
104
+ failReason: asString(data.fail_reason).trim() || undefined,
105
+ };
106
+ }
107
+
108
+ // ── Public API ─────────────────────────────────────────────────────────────
109
+
110
+ export interface DeviceRegistrationSession {
111
+ verificationUrl: string;
112
+ waitForResult: (options?: {
113
+ onWaiting?: () => void;
114
+ signal?: AbortSignal;
115
+ }) => Promise<RegistrationResult>;
116
+ }
117
+
118
+ export async function beginDeviceRegistration(): Promise<DeviceRegistrationSession> {
119
+ const nonce = await initRegistration();
120
+ const { deviceCode, verificationUrl, expiresIn, interval } = await beginRegistration(nonce);
121
+
122
+ const waitForResult = async (options?: {
123
+ onWaiting?: () => void;
124
+ signal?: AbortSignal;
125
+ }): Promise<RegistrationResult> => {
126
+ const deadline = Date.now() + expiresIn * 1000;
127
+ let networkRetryStart = 0;
128
+ let statusRetryStart = 0;
129
+
130
+ const signal = options?.signal;
131
+ let abortHandler: (() => void) | null = null;
132
+ const abortPromise = signal
133
+ ? new Promise<never>((_resolve, reject) => {
134
+ abortHandler = () => reject(new RegistrationError("registration cancelled"));
135
+ signal.addEventListener("abort", abortHandler, { once: true });
136
+ })
137
+ : null;
138
+ // Suppress unhandled rejection when abort fires outside Promise.race
139
+ abortPromise?.catch(() => {});
140
+
141
+ const sleep = () =>
142
+ new Promise((resolve) => setTimeout(resolve, interval * 1000));
143
+
144
+ try {
145
+ while (Date.now() < deadline) {
146
+ if (signal?.aborted) {
147
+ throw new RegistrationError("registration cancelled");
148
+ }
149
+
150
+ // AbortSignal-aware sleep
151
+ await (abortPromise ? Promise.race([sleep(), abortPromise]) : sleep());
152
+
153
+ // Check again after sleep — abort may have fired during sleep
154
+ if (signal?.aborted) {
155
+ throw new RegistrationError("registration cancelled");
156
+ }
157
+
158
+ let result: PollResult;
159
+ try {
160
+ result = await pollRegistration(deviceCode);
161
+ } catch {
162
+ if (!networkRetryStart) {
163
+ networkRetryStart = Date.now();
164
+ }
165
+ if (Date.now() - networkRetryStart < RETRY_WINDOW_MS) {
166
+ continue;
167
+ }
168
+ throw new RegistrationError("registration polling failed after retry window");
169
+ }
170
+
171
+ // Successful poll resets network retry window
172
+ networkRetryStart = 0;
173
+
174
+ const { status } = result;
175
+
176
+ if (status === "WAITING") {
177
+ statusRetryStart = 0;
178
+ options?.onWaiting?.();
179
+ continue;
180
+ }
181
+
182
+ if (status === "SUCCESS") {
183
+ const clientId = result.clientId;
184
+ const clientSecret = result.clientSecret;
185
+ if (!clientId || !clientSecret) {
186
+ throw new RegistrationError("authorization succeeded but credentials are missing");
187
+ }
188
+ return { clientId, clientSecret };
189
+ }
190
+
191
+ if (status === "EXPIRED") {
192
+ throw new RegistrationError("authorization expired, please restart registration");
193
+ }
194
+
195
+ // FAIL — retry within window
196
+ if (!statusRetryStart) {
197
+ statusRetryStart = Date.now();
198
+ }
199
+ if (Date.now() - statusRetryStart < RETRY_WINDOW_MS) {
200
+ continue;
201
+ }
202
+ throw new RegistrationError(`authorization failed: ${result.failReason ?? status}`);
203
+ }
204
+
205
+ throw new RegistrationError("authorization timed out, please retry");
206
+ } finally {
207
+ if (abortHandler && signal) {
208
+ signal.removeEventListener("abort", abortHandler);
209
+ }
210
+ }
211
+ };
212
+
213
+ return { verificationUrl, waitForResult };
214
+ }
215
+
216
+ // ── Browser helper ─────────────────────────────────────────────────────────
217
+
218
+ export function openUrlInBrowser(url: string): void {
219
+ // Validate URL before handing to OS launcher
220
+ try {
221
+ const parsed = new URL(url);
222
+ if (parsed.protocol !== "https:" || !parsed.hostname.endsWith(".dingtalk.com")) {
223
+ return;
224
+ }
225
+ } catch {
226
+ return;
227
+ }
228
+
229
+ const platform = process.platform;
230
+ let bin: string;
231
+ let args: string[];
232
+ if (platform === "darwin") {
233
+ bin = "open";
234
+ args = [url];
235
+ } else if (platform === "win32") {
236
+ bin = "cmd";
237
+ args = ["/c", "start", "", url];
238
+ } else {
239
+ bin = "xdg-open";
240
+ args = [url];
241
+ }
242
+ execFile(bin, args, (err) => {
243
+ void err;
244
+ });
245
+ }