@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.
- package/README.md +6 -23
- package/index.ts +7 -0
- package/openclaw.plugin.json +799 -0
- package/package.json +5 -5
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/card-template.ts +14 -3
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +326 -54
- package/src/card-service.ts +479 -8
- package/src/channel.ts +19 -1062
- package/src/config-schema.ts +81 -38
- package/src/config.ts +142 -4
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +489 -49
- package/src/media-utils.ts +169 -7
- package/src/message-utils.ts +153 -17
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/messaging/quoted-file-service.ts +9 -4
- package/src/onboarding.ts +323 -205
- package/src/platform/channel-status.ts +81 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +568 -44
- package/src/reply-strategy-markdown.ts +2 -2
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -56
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +225 -7
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +44 -28
- package/src/types.ts +49 -117
- package/src/utils.ts +25 -0
package/src/config-schema.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
|
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)
|
|
31
|
+
/** DingTalk App Secret (Client Secret) used to obtain DingTalk access tokens. */
|
|
21
32
|
clientSecret: z.string().optional(),
|
|
22
33
|
|
|
23
|
-
/** Direct
|
|
34
|
+
/** Direct-message access policy: open, pairing, or allowlist. */
|
|
24
35
|
dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
|
|
25
36
|
|
|
26
|
-
/** Group
|
|
37
|
+
/** Group-message access policy: open, allowlist, or disabled. */
|
|
27
38
|
groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional().default("open"),
|
|
28
39
|
|
|
29
|
-
/**
|
|
40
|
+
/** User IDs allowed when `dmPolicy` is `allowlist`. */
|
|
30
41
|
allowFrom: z.array(z.string()).optional(),
|
|
31
42
|
|
|
32
|
-
/**
|
|
43
|
+
/** Sender IDs allowed when `groupPolicy` is `allowlist`. */
|
|
33
44
|
groupAllowFrom: z.array(z.string()).optional(),
|
|
34
45
|
|
|
35
|
-
/** Default disabled. Enabling
|
|
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
|
|
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
|
-
/**
|
|
63
|
+
/** Default reply delivery mode: markdown or card. */
|
|
48
64
|
messageType: z.enum(["markdown", "card"]).optional().default("markdown"),
|
|
49
65
|
|
|
50
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
92
|
+
/** Initial reconnect backoff delay in milliseconds. */
|
|
80
93
|
initialReconnectDelay: z.number().int().min(100).optional().default(1000),
|
|
81
94
|
|
|
82
|
-
/**
|
|
95
|
+
/** Upper bound for reconnect backoff delay in milliseconds. */
|
|
83
96
|
maxReconnectDelay: z.number().int().min(1000).optional().default(60000),
|
|
84
97
|
|
|
85
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
113
|
+
/** Enable the underlying Stream client heartbeat. When omitted, runtime derives a default from `useConnectionManager`. */
|
|
101
114
|
keepAlive: z.boolean().optional(),
|
|
102
|
-
/** Bypass
|
|
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
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
/**
|
|
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
|
|
143
|
+
/** Enable the local feedback-learning loop for notes, reflections, and command-assisted learning. */
|
|
120
144
|
learningEnabled: z.boolean().optional(),
|
|
121
145
|
|
|
122
|
-
/**
|
|
146
|
+
/** Automatically apply generated learning output into session notes or global rules when available. */
|
|
123
147
|
learningAutoApply: z.boolean().optional(),
|
|
124
148
|
|
|
125
|
-
/**
|
|
149
|
+
/** Retention window in milliseconds for temporary learning notes. */
|
|
126
150
|
learningNoteTtlMs: z.number().int().min(60_000).optional(),
|
|
127
151
|
|
|
128
|
-
/**
|
|
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 {
|
|
34
|
-
|
|
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
|
+
}
|