@soimy/dingtalk 3.5.3 → 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.
@@ -156,6 +156,25 @@ const DingTalkAccountConfigShape = {
156
156
  * Set to a non-empty string (e.g. "✅ 回复完成") to enable — the value is used as the message text.
157
157
  * Leave empty or omit to disable. */
158
158
  cardAtSender: z.string().optional(),
159
+
160
+ /** Status line visibility toggles for the AI card footer. */
161
+ cardStatusLine: z
162
+ .object({
163
+ /** Show model name. */
164
+ model: z.boolean().optional().default(true),
165
+ /** Show thinking effort level. */
166
+ effort: z.boolean().optional().default(true),
167
+ /** Show agent display name. */
168
+ agent: z.boolean().optional().default(true),
169
+ /** Show task elapsed time. */
170
+ taskTime: z.boolean().optional().default(false),
171
+ /** Show token usage summary (input/output/cache). */
172
+ tokens: z.boolean().optional().default(false),
173
+ /** Show DingTalk API call count. */
174
+ dapiUsage: z.boolean().optional().default(false),
175
+ })
176
+ .optional()
177
+ .default({ model: true, effort: true, agent: true, taskTime: false, tokens: false, dapiUsage: false }),
159
178
  } as const;
160
179
 
161
180
  const DingTalkAccountConfigSchema = z.object(DingTalkAccountConfigShape);
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
- import type { DingTalkConfig } from "./types";
4
+ import type { DingTalkChannelConfig, DingTalkConfig } from "./types";
5
5
 
6
6
  const WINDOWS_ROOT_DIRECTORIES = new Set([
7
7
  "Users",
@@ -272,3 +272,119 @@ export function stripTargetPrefix(target: string): { targetId: string; isExplici
272
272
  }
273
273
  return { targetId: target, isExplicitUser: false };
274
274
  }
275
+
276
+ // ============ Onboarding Helper Functions ============
277
+
278
+ const DEFAULT_ACCOUNT_ID = "default";
279
+
280
+
281
+ /**
282
+ * List all DingTalk account IDs from config
283
+ */
284
+ export function listDingTalkAccountIds(cfg: OpenClawConfig): string[] {
285
+ const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
286
+ if (!dingtalk) {
287
+ return [];
288
+ }
289
+
290
+ const accountIds: string[] = [];
291
+
292
+ if (dingtalk.clientId || dingtalk.clientSecret) {
293
+ accountIds.push(DEFAULT_ACCOUNT_ID);
294
+ }
295
+
296
+ if (dingtalk.accounts) {
297
+ accountIds.push(...Object.keys(dingtalk.accounts));
298
+ }
299
+
300
+ return accountIds;
301
+ }
302
+
303
+ /**
304
+ * Resolved DingTalk account with configuration status
305
+ */
306
+ export interface ResolvedDingTalkAccount extends DingTalkConfig {
307
+ accountId: string;
308
+ configured: boolean;
309
+ }
310
+
311
+ /**
312
+ * Resolve a specific DingTalk account configuration
313
+ */
314
+ export function resolveDingTalkAccount(
315
+ cfg: OpenClawConfig,
316
+ accountId?: string | null,
317
+ ): ResolvedDingTalkAccount {
318
+ const id = accountId || DEFAULT_ACCOUNT_ID;
319
+ const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
320
+
321
+ if (id === DEFAULT_ACCOUNT_ID) {
322
+ const rawConfig: DingTalkConfig = {
323
+ clientId: dingtalk?.clientId ?? "",
324
+ clientSecret: dingtalk?.clientSecret ?? "",
325
+ name: dingtalk?.name,
326
+ enabled: dingtalk?.enabled,
327
+ dmPolicy: dingtalk?.dmPolicy,
328
+ groupPolicy: dingtalk?.groupPolicy,
329
+ allowFrom: dingtalk?.allowFrom,
330
+ groupAllowFrom: dingtalk?.groupAllowFrom,
331
+ displayNameResolution: dingtalk?.displayNameResolution,
332
+ contextVisibility: dingtalk?.contextVisibility,
333
+ journalTTLDays: dingtalk?.journalTTLDays,
334
+ ackReaction: dingtalk?.ackReaction,
335
+ debug: dingtalk?.debug,
336
+ messageType: dingtalk?.messageType,
337
+ cardTemplateId: dingtalk?.cardTemplateId,
338
+ cardTemplateKey: dingtalk?.cardTemplateKey,
339
+ groups: dingtalk?.groups,
340
+ accounts: dingtalk?.accounts,
341
+ maxConnectionAttempts: dingtalk?.maxConnectionAttempts,
342
+ initialReconnectDelay: dingtalk?.initialReconnectDelay,
343
+ maxReconnectDelay: dingtalk?.maxReconnectDelay,
344
+ reconnectJitter: dingtalk?.reconnectJitter,
345
+ maxReconnectCycles: dingtalk?.maxReconnectCycles,
346
+ reconnectDeadlineMs: dingtalk?.reconnectDeadlineMs,
347
+ useConnectionManager: dingtalk?.useConnectionManager,
348
+ mediaMaxMb: dingtalk?.mediaMaxMb,
349
+ keepAlive: dingtalk?.keepAlive,
350
+ bypassProxyForSend: dingtalk?.bypassProxyForSend,
351
+ proactivePermissionHint: dingtalk?.proactivePermissionHint,
352
+ cardStreamingMode: dingtalk?.cardStreamingMode,
353
+ cardRealTimeStream: dingtalk?.cardRealTimeStream,
354
+ cardStreamInterval: dingtalk?.cardStreamInterval,
355
+ aicardDegradeMs: dingtalk?.aicardDegradeMs,
356
+ learningEnabled: dingtalk?.learningEnabled,
357
+ learningAutoApply: dingtalk?.learningAutoApply,
358
+ learningNoteTtlMs: dingtalk?.learningNoteTtlMs,
359
+ convertMarkdownTables: dingtalk?.convertMarkdownTables,
360
+ cardAtSender: dingtalk?.cardAtSender,
361
+ };
362
+ const config = stripRemovedLegacyFields(rawConfig);
363
+ return {
364
+ ...config,
365
+ accountId: id,
366
+ configured: Boolean(config.clientId && config.clientSecret),
367
+ };
368
+ }
369
+
370
+ const accountConfig = dingtalk?.accounts?.[id];
371
+ if (accountConfig) {
372
+ const merged = mergeAccountWithDefaults(
373
+ dingtalk as DingTalkConfig,
374
+ accountConfig,
375
+ );
376
+ const publicMerged = stripRemovedLegacyFields(merged);
377
+ return {
378
+ ...publicMerged,
379
+ accountId: id,
380
+ configured: Boolean(merged.clientId && merged.clientSecret),
381
+ };
382
+ }
383
+
384
+ return {
385
+ clientId: "",
386
+ clientSecret: "",
387
+ accountId: id,
388
+ configured: false,
389
+ };
390
+ }
@@ -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
+ }