@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.
Files changed (40) hide show
  1. package/README.md +6 -23
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +799 -0
  4. package/package.json +5 -5
  5. package/src/card/card-markdown-image-reroute.ts +106 -0
  6. package/src/card/card-run-registry.ts +54 -1
  7. package/src/card/card-stop-handler.ts +10 -20
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/reasoning-answer-split.ts +162 -0
  11. package/src/card/statusline-renderer.ts +94 -0
  12. package/src/card-draft-controller.ts +326 -54
  13. package/src/card-service.ts +479 -8
  14. package/src/channel.ts +19 -1062
  15. package/src/config-schema.ts +81 -38
  16. package/src/config.ts +142 -4
  17. package/src/device-registration.ts +245 -0
  18. package/src/gateway/channel-gateway.ts +636 -0
  19. package/src/inbound-handler.ts +489 -49
  20. package/src/media-utils.ts +169 -7
  21. package/src/message-utils.ts +153 -17
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +173 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/messaging/quoted-file-service.ts +9 -4
  26. package/src/onboarding.ts +323 -205
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +568 -44
  30. package/src/reply-strategy-markdown.ts +2 -2
  31. package/src/reply-strategy-types.ts +93 -0
  32. package/src/reply-strategy-with-reaction.ts +1 -1
  33. package/src/reply-strategy.ts +14 -56
  34. package/src/run-usage-store.ts +59 -0
  35. package/src/send-service.ts +225 -7
  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 +44 -28
  39. package/src/types.ts +49 -117
  40. package/src/utils.ts +25 -0
@@ -7,65 +7,78 @@ const AckReactionSchema = z.union([
7
7
  z.string().min(1),
8
8
  ]);
9
9
 
10
+ const CardStreamingModeSchema = z.enum(["off", "answer", "all"]);
11
+ const ContextVisibilitySchema = z.enum(["all", "allowlist", "allowlist_quote"]);
12
+
13
+ /**
14
+ * Runtime-parsed DingTalk account config.
15
+ *
16
+ * Compatibility note:
17
+ * - `agentId`, `corpId`, `showThinkingStream`, and `asyncMode` are intentionally
18
+ * not parsed here. They remain only in manifest metadata for legacy host/UI
19
+ * compatibility and are ignored by the current runtime.
20
+ */
10
21
  const DingTalkAccountConfigShape = {
11
22
  /** Account name (optional display name) */
12
23
  name: z.string().optional(),
13
24
 
14
- /** Whether this channel is enabled */
25
+ /** Enable or disable this DingTalk channel/account without deleting saved credentials. */
15
26
  enabled: z.boolean().optional().default(true),
16
27
 
17
- /** DingTalk App Key (Client ID) - required for authentication */
28
+ /** DingTalk App Key (Client ID) used to authenticate API and Stream connections. */
18
29
  clientId: z.string().optional(),
19
30
 
20
- /** DingTalk App Secret (Client Secret) - required for authentication */
31
+ /** DingTalk App Secret (Client Secret) used to obtain DingTalk access tokens. */
21
32
  clientSecret: z.string().optional(),
22
33
 
23
- /** Direct message policy: open, pairing, or allowlist */
34
+ /** Direct-message access policy: open, pairing, or allowlist. */
24
35
  dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
25
36
 
26
- /** Group message policy: open, allowlist, or disabled */
37
+ /** Group-message access policy: open, allowlist, or disabled. */
27
38
  groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional().default("open"),
28
39
 
29
- /** List of allowed user IDs for allowlist policy */
40
+ /** User IDs allowed when `dmPolicy` is `allowlist`. */
30
41
  allowFrom: z.array(z.string()).optional(),
31
42
 
32
- /** List of allowed user IDs for group allowlist policy */
43
+ /** Sender IDs allowed when `groupPolicy` is `allowlist`. */
33
44
  groupAllowFrom: z.array(z.string()).optional(),
34
45
 
35
- /** Default disabled. Enabling "all" allows learned displayName lookup but may misroute on stale/duplicate names and is available to all callers until upstream exposes requester authz context. */
46
+ /** Default disabled. Enabling `all` allows learned displayName lookup but may misroute on stale or duplicate names and is available to all callers until upstream exposes requester authz context. */
36
47
  displayNameResolution: z.enum(["disabled", "all"]).optional().default("disabled"),
37
48
 
49
+ /** Controls how much supplemental host context remains visible to the reply runtime. `allowlist_quote` is the safest advanced mode when only explicit quotes or replies should remain visible. */
50
+ contextVisibility: ContextVisibilitySchema.optional(),
51
+
52
+ /** Allowed remote media download hosts, IPs, or CIDRs for media fetches. */
38
53
  mediaUrlAllowlist: z.array(z.string()).optional(),
39
54
 
40
- /** Native ack reaction mode: off, emoji, or kaomoji */
55
+ /** Native acknowledgement reaction mode: off, emoji, kaomoji, or a custom compatibility string. */
41
56
  ackReaction: AckReactionSchema.optional(),
42
57
 
58
+ /** Retention window in days for short-lived message context used by quoting and media recovery. */
43
59
  journalTTLDays: z.number().int().min(1).optional().default(DEFAULT_MESSAGE_CONTEXT_TTL_DAYS),
44
- /** Enable debug logging */
60
+ /** Enable verbose DingTalk channel debug logging. */
45
61
  debug: z.boolean().optional().default(false),
46
62
 
47
- /** Message type for replies: markdown or card */
63
+ /** Default reply delivery mode: markdown or card. */
48
64
  messageType: z.enum(["markdown", "card"]).optional().default("markdown"),
49
65
 
50
- /** Card template ID for AI interactive cards
51
- * obtain the template ID from DingTalk Developer Console.
52
- * ref: https://github.com/soimy/openclaw-channel-dingtalk/blob/main/README.md#3-%E5%BB%BA%E7%AB%8B%E5%8D%A1%E7%89%87%E6%A8%A1%E6%9D%BF%E5%8F%AF%E9%80%89
53
- */
66
+ /** Deprecated and ignored. AI card replies now always use the built-in DingTalk template contract. Keep only for backward-compatible config parsing. */
54
67
  cardTemplateId: z.string().optional(),
55
68
 
56
- /** Card template key for streaming updates
57
- * Default: 'content' - maps to the content field in the card template
58
- * This key is used in the streaming API to update specific fields in the card.
59
- */
69
+ /** Deprecated and ignored. The built-in AI card contract owns the streaming field mapping. Keep only for backward-compatible config parsing. */
60
70
  cardTemplateKey: z.string().optional().default("content"),
61
71
 
62
- /** Per-group configuration, keyed by conversationId (supports "*" wildcard) */
72
+ /** Per-group overrides keyed by conversationId. Supports `*` as a wildcard fallback. */
63
73
  groups: z
64
74
  .record(
65
75
  z.string(),
66
76
  z.object({
77
+ /** Additional system prompt appended for this group. */
67
78
  systemPrompt: z.string().optional(),
79
+ /** Require an explicit @mention before the bot answers in this group. */
68
80
  requireMention: z.boolean().optional(),
81
+ /** Optional per-group sender allowlist for tighter access control than the channel default. */
69
82
  groupAllowFrom: z.array(z.string()).optional(),
70
83
  }),
71
84
  )
@@ -73,65 +86,95 @@ const DingTalkAccountConfigShape = {
73
86
 
74
87
  /** Connection robustness configuration */
75
88
 
76
- /** Maximum number of connection attempts before giving up (default: 10) */
89
+ /** Maximum connection attempts in a single reconnect cycle before backing off or giving up. */
77
90
  maxConnectionAttempts: z.number().int().min(1).optional().default(10),
78
91
 
79
- /** Initial reconnection delay in milliseconds (default: 1000ms) */
92
+ /** Initial reconnect backoff delay in milliseconds. */
80
93
  initialReconnectDelay: z.number().int().min(100).optional().default(1000),
81
94
 
82
- /** Maximum reconnection delay in milliseconds for exponential backoff (default: 60000ms = 1 minute) */
95
+ /** Upper bound for reconnect backoff delay in milliseconds. */
83
96
  maxReconnectDelay: z.number().int().min(1000).optional().default(60000),
84
97
 
85
- /** Jitter factor for reconnection delay randomization (0-1, default: 0.3) */
98
+ /** Randomization factor added to reconnect backoff to avoid synchronized reconnect storms. */
86
99
  reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
87
100
 
88
- /** Maximum number of runtime reconnect cycles before giving up (default: 10) */
101
+ /** Maximum reconnect cycles before the channel stops retrying and waits for the next lifecycle restart. */
89
102
  maxReconnectCycles: z.number().int().min(1).optional().default(10),
90
103
 
91
- /** Maximum time (ms) for a single reconnect cycle before starting a new cycle (default: 50000) */
104
+ /** Time limit in milliseconds for one reconnect cycle before starting a fresh cycle. */
92
105
  reconnectDeadlineMs: z.number().int().min(5000).optional().default(50000),
93
106
 
94
- /** Whether to use ConnectionManager (default: true). When false, rely on DWClient native keepAlive+autoReconnect. */
107
+ /** Enable the plugin connection manager. Disable only when you intentionally rely on DWClient native keepAlive plus autoReconnect behavior. */
95
108
  useConnectionManager: z.boolean().optional().default(true),
96
109
 
97
- /** Maximum inbound media file size in MB (overrides runtime default when set) */
110
+ /** Maximum inbound media size in MB accepted by the plugin. When omitted, the runtime default is used. */
98
111
  mediaMaxMb: z.number().int().min(1).optional(),
99
112
 
100
- /** Whether to enable underlying stream keepAlive heartbeat; defaults to !useConnectionManager when omitted */
113
+ /** Enable the underlying Stream client heartbeat. When omitted, runtime derives a default from `useConnectionManager`. */
101
114
  keepAlive: z.boolean().optional(),
102
- /** Bypass system/global HTTP(S) proxy for DingTalk outbound send/card/upload APIs */
115
+ /** Bypass global or system HTTP(S) proxy settings for DingTalk send, upload, and card APIs. */
103
116
  bypassProxyForSend: z.boolean().optional().default(false),
117
+ /** Controls the proactive-send permission reminder shown when a conversation has not granted send rights yet. */
104
118
  proactivePermissionHint: z
105
119
  .object({
120
+ /** Show the proactive-send permission hint when the runtime detects missing DingTalk proactive permission. */
106
121
  enabled: z.boolean().optional().default(true),
122
+ /** Minimum cooldown in hours before the same proactive permission hint can be shown again. */
107
123
  cooldownHours: z.number().int().min(1).max(24 * 30).optional().default(24),
108
124
  })
109
125
  .optional()
110
126
  .default({ enabled: true, cooldownHours: 24 }),
111
127
 
112
- /** Enable real-time card streaming (default: false).
113
- * When true, card updates are streamed per-token with 300ms throttle for a smoother experience, at the cost of more API calls. */
114
- cardRealTimeStream: z.boolean().optional().default(false),
128
+ /** Deprecated compatibility flag. When true and `cardStreamingMode` is unset, runtime resolves to `cardStreamingMode: "all"`. Do not use in new configs. */
129
+ cardRealTimeStream: z.boolean().optional(),
130
+
131
+ /** Card streaming mode:
132
+ * - off: disable incremental streaming
133
+ * - answer: stream answer text
134
+ * - all: stream answer + reasoning or thinking text */
135
+ cardStreamingMode: CardStreamingModeSchema.optional(),
115
136
 
116
- /** AICard degrade duration in milliseconds after trigger errors (default: 30 minutes) */
137
+ /** Throttle interval in milliseconds between AI card streaming updates. */
138
+ cardStreamInterval: z.number().int().min(200).optional().default(1000),
139
+
140
+ /** Cooldown window in milliseconds after AI card trigger errors. Replies fall back to non-card delivery during this period. */
117
141
  aicardDegradeMs: z.number().int().min(60_000).optional().default(30 * 60 * 1000),
118
142
 
119
- /** Enable local learning loop (default: false) */
143
+ /** Enable the local feedback-learning loop for notes, reflections, and command-assisted learning. */
120
144
  learningEnabled: z.boolean().optional(),
121
145
 
122
- /** Auto-apply generated reflections into session notes/global rules (default: false) */
146
+ /** Automatically apply generated learning output into session notes or global rules when available. */
123
147
  learningAutoApply: z.boolean().optional(),
124
148
 
125
- /** Session learning note TTL in milliseconds (default: 6 hours) */
149
+ /** Retention window in milliseconds for temporary learning notes. */
126
150
  learningNoteTtlMs: z.number().int().min(60_000).optional(),
127
151
 
128
- /** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
152
+ /** Convert markdown tables to plain text before sending when you want more consistent DingTalk rendering. */
129
153
  convertMarkdownTables: z.boolean().optional().default(true),
130
154
 
131
155
  /** @mention the sender after card finalization in group chats.
132
156
  * Set to a non-empty string (e.g. "✅ 回复完成") to enable — the value is used as the message text.
133
157
  * Leave empty or omit to disable. */
134
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 }),
135
178
  } as const;
136
179
 
137
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",
@@ -26,12 +26,34 @@ function normalizeLearningConfig(
26
26
  learningNoteTtlMs: options.applyDefaults
27
27
  ? config.learningNoteTtlMs ?? DEFAULT_LEARNING_NOTE_TTL_MS
28
28
  : config.learningNoteTtlMs,
29
+ cardStreamingMode: options.applyDefaults
30
+ ? (config.cardStreamingMode ?? (config.cardRealTimeStream === true ? "all" : "off"))
31
+ : config.cardStreamingMode,
29
32
  };
30
33
  }
31
34
 
32
35
  function stripRemovedLegacyFields(config: DingTalkConfig): DingTalkConfig {
33
- const { verboseRealtimeStream: _verboseRealtimeStream, ...rest } =
34
- config as DingTalkConfig & { verboseRealtimeStream?: unknown };
36
+ const {
37
+ verboseRealtimeStream: _verboseRealtimeStream,
38
+ cardStreamReasoning: _cardStreamReasoning,
39
+ accounts,
40
+ ...rest
41
+ } = config as DingTalkConfig & {
42
+ verboseRealtimeStream?: unknown;
43
+ cardStreamReasoning?: unknown;
44
+ accounts?: Record<string, DingTalkConfig | undefined>;
45
+ };
46
+ const sanitizedAccounts = accounts
47
+ ? Object.fromEntries(
48
+ Object.entries(accounts).map(([accountId, accountConfig]) => [
49
+ accountId,
50
+ accountConfig ? stripRemovedLegacyFields(accountConfig) : accountConfig,
51
+ ]),
52
+ )
53
+ : undefined;
54
+ if (sanitizedAccounts) {
55
+ return { ...rest, accounts: sanitizedAccounts } as DingTalkConfig;
56
+ }
35
57
  return rest as DingTalkConfig;
36
58
  }
37
59
 
@@ -84,7 +106,7 @@ export function getConfig(cfg: OpenClawConfig, accountId?: string): DingTalkConf
84
106
  }
85
107
 
86
108
  if (dingtalkCfg.accounts && Object.keys(dingtalkCfg.accounts).length > 0) {
87
- return dingtalkCfg;
109
+ return stripRemovedLegacyFields(dingtalkCfg);
88
110
  }
89
111
 
90
112
  return stripRemovedLegacyFields(normalizeLearningConfig(dingtalkCfg, { applyDefaults: true }));
@@ -250,3 +272,119 @@ export function stripTargetPrefix(target: string): { targetId: string; isExplici
250
272
  }
251
273
  return { targetId: target, isExplicitUser: false };
252
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
+ }