@huo15/dingtalk-connector-pro 1.0.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 (62) hide show
  1. package/CHANGELOG.md +485 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +479 -0
  4. package/README.md +209 -0
  5. package/docs/AGENT_ROUTING.md +335 -0
  6. package/docs/DEAP_AGENT_GUIDE.en.md +115 -0
  7. package/docs/DEAP_AGENT_GUIDE.md +115 -0
  8. package/docs/images/dingtalk.svg +1 -0
  9. package/docs/images/image-1.png +0 -0
  10. package/docs/images/image-2.png +0 -0
  11. package/docs/images/image-3.png +0 -0
  12. package/docs/images/image-4.png +0 -0
  13. package/docs/images/image-5.png +0 -0
  14. package/docs/images/image-6.png +0 -0
  15. package/docs/images/image-7.png +0 -0
  16. package/index.ts +28 -0
  17. package/install-beta.sh +438 -0
  18. package/install-npm.sh +167 -0
  19. package/openclaw.plugin.json +498 -0
  20. package/package.json +80 -0
  21. package/src/channel.ts +463 -0
  22. package/src/config/accounts.ts +242 -0
  23. package/src/config/schema.ts +148 -0
  24. package/src/core/connection.ts +722 -0
  25. package/src/core/message-handler.ts +1700 -0
  26. package/src/core/provider.ts +111 -0
  27. package/src/core/state.ts +54 -0
  28. package/src/directory.ts +95 -0
  29. package/src/docs.ts +293 -0
  30. package/src/gateway-methods.ts +404 -0
  31. package/src/onboarding.ts +413 -0
  32. package/src/policy.ts +32 -0
  33. package/src/probe.ts +212 -0
  34. package/src/reply-dispatcher.ts +630 -0
  35. package/src/runtime.ts +32 -0
  36. package/src/sdk/helpers.ts +322 -0
  37. package/src/sdk/types.ts +513 -0
  38. package/src/secret-input.ts +19 -0
  39. package/src/services/media/audio.ts +54 -0
  40. package/src/services/media/chunk-upload.ts +296 -0
  41. package/src/services/media/common.ts +155 -0
  42. package/src/services/media/file.ts +70 -0
  43. package/src/services/media/image.ts +81 -0
  44. package/src/services/media/index.ts +10 -0
  45. package/src/services/media/video.ts +162 -0
  46. package/src/services/media.ts +1136 -0
  47. package/src/services/messaging/card.ts +342 -0
  48. package/src/services/messaging/index.ts +17 -0
  49. package/src/services/messaging/send.ts +141 -0
  50. package/src/services/messaging.ts +1013 -0
  51. package/src/targets.ts +45 -0
  52. package/src/types/index.ts +59 -0
  53. package/src/utils/agent.ts +63 -0
  54. package/src/utils/async.ts +51 -0
  55. package/src/utils/constants.ts +27 -0
  56. package/src/utils/http-client.ts +37 -0
  57. package/src/utils/index.ts +8 -0
  58. package/src/utils/logger.ts +78 -0
  59. package/src/utils/session.ts +147 -0
  60. package/src/utils/token.ts +93 -0
  61. package/src/utils/utils-legacy.ts +454 -0
  62. package/tsconfig.json +20 -0
package/src/channel.ts ADDED
@@ -0,0 +1,463 @@
1
+ import type {
2
+ ChannelPlugin,
3
+ ClawdbotConfig,
4
+ } from "openclaw/plugin-sdk";
5
+ import {
6
+ buildChannelConfigSchema,
7
+ } from "openclaw/plugin-sdk/core";
8
+ import {
9
+ createDefaultChannelRuntimeState,
10
+ DEFAULT_ACCOUNT_ID,
11
+ resolveAllowlistProviderRuntimeGroupPolicy,
12
+ resolveDefaultGroupPolicy,
13
+ } from "./sdk/helpers.ts";
14
+ import { DingtalkConfigBaseSchema } from "./config/schema.ts";
15
+ import { createLogger } from "./utils/logger.ts";
16
+ import {
17
+ resolveDingtalkAccount,
18
+ resolveDingtalkCredentials,
19
+ listDingtalkAccountIds,
20
+ resolveDefaultDingtalkAccountId,
21
+ } from "./config/accounts.ts";
22
+ import {
23
+ listDingtalkDirectoryPeers,
24
+ listDingtalkDirectoryGroups,
25
+ listDingtalkDirectoryPeersLive,
26
+ listDingtalkDirectoryGroupsLive,
27
+ } from "./directory.ts";
28
+ import { resolveDingtalkGroupToolPolicy } from "./policy.ts";
29
+ import { probeDingtalk } from "./probe.ts";
30
+ import { normalizeDingtalkTarget, looksLikeDingtalkId } from "./targets.ts";
31
+ import { dingtalkOnboardingAdapter } from "./onboarding.ts";
32
+ import { monitorDingtalkProvider } from "./core/provider.ts";
33
+ import { sendTextToDingTalk, sendMediaToDingTalk } from "./services/messaging/index.ts";
34
+ import type { ResolvedDingtalkAccount, DingtalkConfig } from "./types/index.ts";
35
+
36
+ const meta = {
37
+ id: "dingtalk-connector",
38
+ label: "DingTalk",
39
+ selectionLabel: "DingTalk (钉钉)",
40
+ docsPath: "/channels/dingtalk-connector",
41
+ docsLabel: "dingtalk-connector",
42
+ blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP,支持 AI Card 流式响应。",
43
+ aliases: ["dd", "ding"] as string[],
44
+ order: 70,
45
+ };
46
+
47
+ export const dingtalkPlugin: ChannelPlugin<ResolvedDingtalkAccount> = {
48
+ id: "dingtalk-connector",
49
+ meta: {
50
+ ...meta,
51
+ },
52
+ pairing: {
53
+ idLabel: "dingtalkUserId",
54
+ normalizeAllowEntry: (entry) => entry.replace(/^(dingtalk|user|dd):/i, ""),
55
+ notifyApproval: async ({ cfg, id }) => {
56
+ // TODO: Implement notification when pairing is approved
57
+ const logger = createLogger(false, 'DingTalk:Pairing');
58
+ logger.info(`Pairing approved for user: ${id}`);
59
+ },
60
+ },
61
+ capabilities: {
62
+ chatTypes: ["direct", "group"],
63
+ polls: false,
64
+ threads: false,
65
+ media: true, // ✅ 启用媒体支持
66
+ reactions: false,
67
+ edit: false,
68
+ reply: false,
69
+ },
70
+ agentPrompt: {
71
+ messageToolHints: () => [
72
+ "- DingTalk targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:userId` or `group:conversationId`.",
73
+ "- DingTalk supports interactive cards for rich messages.",
74
+ ],
75
+ },
76
+ groups: {
77
+ resolveToolPolicy: resolveDingtalkGroupToolPolicy,
78
+ },
79
+ mentions: {
80
+ stripPatterns: () => ['@[^\\s]+'], // Strip @mentions
81
+ },
82
+ reload: { configPrefixes: ["channels.dingtalk-connector"] },
83
+ configSchema: buildChannelConfigSchema(DingtalkConfigBaseSchema),
84
+ config: {
85
+ listAccountIds: (cfg) => listDingtalkAccountIds(cfg),
86
+ resolveAccount: (cfg, accountId) => resolveDingtalkAccount({ cfg, accountId }),
87
+ defaultAccountId: (cfg) => resolveDefaultDingtalkAccountId(cfg),
88
+ setAccountEnabled: ({ cfg, accountId, enabled }) => {
89
+ const account = resolveDingtalkAccount({ cfg, accountId });
90
+ const isDefault = accountId === DEFAULT_ACCOUNT_ID;
91
+
92
+ if (isDefault) {
93
+ // For default account, set top-level enabled
94
+ return {
95
+ ...cfg,
96
+ channels: {
97
+ ...cfg.channels,
98
+ "dingtalk-connector": {
99
+ ...cfg.channels?.["dingtalk-connector"],
100
+ enabled,
101
+ },
102
+ },
103
+ };
104
+ }
105
+
106
+ // For named accounts, set enabled in accounts[accountId]
107
+ const dingtalkCfg = cfg.channels?.["dingtalk-connector"] as DingtalkConfig | undefined;
108
+ return {
109
+ ...cfg,
110
+ channels: {
111
+ ...cfg.channels,
112
+ "dingtalk-connector": {
113
+ ...dingtalkCfg,
114
+ accounts: {
115
+ ...dingtalkCfg?.accounts,
116
+ [accountId]: {
117
+ ...dingtalkCfg?.accounts?.[accountId],
118
+ enabled,
119
+ },
120
+ },
121
+ },
122
+ },
123
+ };
124
+ },
125
+ deleteAccount: ({ cfg, accountId }) => {
126
+ const isDefault = accountId === DEFAULT_ACCOUNT_ID;
127
+
128
+ if (isDefault) {
129
+ // Delete entire dingtalk-connector config
130
+ const next = { ...cfg } as ClawdbotConfig;
131
+ const nextChannels = { ...cfg.channels };
132
+ delete (nextChannels as Record<string, unknown>)["dingtalk-connector"];
133
+ if (Object.keys(nextChannels).length > 0) {
134
+ next.channels = nextChannels;
135
+ } else {
136
+ delete next.channels;
137
+ }
138
+ return next;
139
+ }
140
+
141
+ // Delete specific account from accounts
142
+ const dingtalkCfg = cfg.channels?.["dingtalk-connector"] as DingtalkConfig | undefined;
143
+ const accounts = { ...dingtalkCfg?.accounts };
144
+ delete accounts[accountId];
145
+
146
+ return {
147
+ ...cfg,
148
+ channels: {
149
+ ...cfg.channels,
150
+ "dingtalk-connector": {
151
+ ...dingtalkCfg,
152
+ accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
153
+ },
154
+ },
155
+ };
156
+ },
157
+ isConfigured: (account) => account.configured,
158
+ describeAccount: (account) => ({
159
+ accountId: account.accountId,
160
+ enabled: account.enabled,
161
+ configured: account.configured,
162
+ name: account.name,
163
+ clientId: account.clientId,
164
+ }),
165
+ resolveAllowFrom: ({ cfg, accountId }) => {
166
+ const account = resolveDingtalkAccount({ cfg, accountId });
167
+ return (account.config?.allowFrom ?? []).map((entry) => String(entry));
168
+ },
169
+ formatAllowFrom: ({ allowFrom }) =>
170
+ allowFrom
171
+ .map((entry) => String(entry).trim())
172
+ .filter(Boolean)
173
+ .map((entry) => entry.toLowerCase()),
174
+ },
175
+ security: {
176
+ collectWarnings: ({ cfg, accountId }) => {
177
+ const account = resolveDingtalkAccount({ cfg, accountId });
178
+ const dingtalkCfg = account.config;
179
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
180
+ const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
181
+ providerConfigPresent: cfg.channels?.["dingtalk-connector"] !== undefined,
182
+ groupPolicy: dingtalkCfg?.groupPolicy,
183
+ defaultGroupPolicy,
184
+ });
185
+ if (groupPolicy !== "open") return [];
186
+ return [
187
+ `- DingTalk[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.dingtalk-connector.groupPolicy="allowlist" + channels.dingtalk-connector.groupAllowFrom to restrict senders.`,
188
+ ];
189
+ },
190
+ },
191
+ setup: {
192
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
193
+ applyAccountConfig: ({ cfg, accountId }) => {
194
+ const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
195
+
196
+ if (isDefault) {
197
+ return {
198
+ ...cfg,
199
+ channels: {
200
+ ...cfg.channels,
201
+ "dingtalk-connector": {
202
+ ...cfg.channels?.["dingtalk-connector"],
203
+ enabled: true,
204
+ },
205
+ },
206
+ };
207
+ }
208
+
209
+ const dingtalkCfg = cfg.channels?.["dingtalk-connector"] as DingtalkConfig | undefined;
210
+ return {
211
+ ...cfg,
212
+ channels: {
213
+ ...cfg.channels,
214
+ "dingtalk-connector": {
215
+ ...dingtalkCfg,
216
+ accounts: {
217
+ ...dingtalkCfg?.accounts,
218
+ [accountId]: {
219
+ ...dingtalkCfg?.accounts?.[accountId],
220
+ enabled: true,
221
+ },
222
+ },
223
+ },
224
+ },
225
+ };
226
+ },
227
+ },
228
+ setupWizard: dingtalkOnboardingAdapter as any,
229
+ messaging: {
230
+ normalizeTarget: (raw) => normalizeDingtalkTarget(raw) ?? undefined,
231
+ targetResolver: {
232
+ looksLikeId: looksLikeDingtalkId,
233
+ hint: "<userId|user:userId|group:conversationId>",
234
+ },
235
+ },
236
+ directory: {
237
+ self: async () => null,
238
+ listPeers: async ({ cfg, query, limit, accountId }) =>
239
+ listDingtalkDirectoryPeers({
240
+ cfg,
241
+ query: query ?? undefined,
242
+ limit: limit ?? undefined,
243
+ accountId: accountId ?? undefined,
244
+ }),
245
+ listGroups: async ({ cfg, query, limit, accountId }) =>
246
+ listDingtalkDirectoryGroups({
247
+ cfg,
248
+ query: query ?? undefined,
249
+ limit: limit ?? undefined,
250
+ accountId: accountId ?? undefined,
251
+ }),
252
+ listPeersLive: async ({ cfg, query, limit, accountId }) =>
253
+ listDingtalkDirectoryPeersLive({
254
+ cfg,
255
+ query: query ?? undefined,
256
+ limit: limit ?? undefined,
257
+ accountId: accountId ?? undefined,
258
+ }),
259
+ listGroupsLive: async ({ cfg, query, limit, accountId }) =>
260
+ listDingtalkDirectoryGroupsLive({
261
+ cfg,
262
+ query: query ?? undefined,
263
+ limit: limit ?? undefined,
264
+ accountId: accountId ?? undefined,
265
+ }),
266
+ },
267
+ outbound: {
268
+ deliveryMode: "direct",
269
+ chunker: (text, limit) => {
270
+ // Simple markdown chunking - split by newlines
271
+ const chunks: string[] = [];
272
+ const lines = text.split("\n");
273
+ let currentChunk = "";
274
+
275
+ for (const line of lines) {
276
+ const testChunk = currentChunk + (currentChunk ? "\n" : "") + line;
277
+ if (testChunk.length <= limit) {
278
+ currentChunk = testChunk;
279
+ } else {
280
+ if (currentChunk) chunks.push(currentChunk);
281
+ currentChunk = line;
282
+ }
283
+ }
284
+ if (currentChunk) chunks.push(currentChunk);
285
+
286
+ return chunks;
287
+ },
288
+ chunkerMode: "markdown",
289
+ textChunkLimit: 2000,
290
+ sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
291
+ const account = resolveDingtalkAccount({ cfg, accountId });
292
+ const result = await sendTextToDingTalk({
293
+ config: account.config,
294
+ target: to,
295
+ text,
296
+ replyToId,
297
+ });
298
+ return {
299
+ channel: "dingtalk-connector",
300
+ messageId: result.processQueryKey ?? result.cardInstanceId ?? "unknown",
301
+ conversationId: to,
302
+ };
303
+ },
304
+ sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots, replyToId, threadId }) => {
305
+ const account = resolveDingtalkAccount({ cfg, accountId });
306
+ const logger = createLogger(account.config?.debug ?? false, 'DingTalk:SendMedia');
307
+
308
+ logger.info('开始处理,参数:', JSON.stringify({
309
+ to,
310
+ text,
311
+ mediaUrl,
312
+ accountId,
313
+ replyToId,
314
+ threadId,
315
+ toType: typeof to,
316
+ mediaUrlType: typeof mediaUrl,
317
+ }));
318
+
319
+ // 参数校验
320
+ if (!to || typeof to !== 'string') {
321
+ throw new Error(`Invalid 'to' parameter: ${to}`);
322
+ }
323
+
324
+ if (!mediaUrl || typeof mediaUrl !== 'string') {
325
+ throw new Error(`Invalid 'mediaUrl' parameter: ${mediaUrl}`);
326
+ }
327
+
328
+ const result = await sendMediaToDingTalk({
329
+ config: account.config,
330
+ target: to,
331
+ text,
332
+ mediaUrl,
333
+ replyToId,
334
+ });
335
+
336
+ logger.info('sendMediaToDingTalk 返回结果:', JSON.stringify({
337
+ ok: result.ok,
338
+ error: result.error,
339
+ hasProcessQueryKey: !!result.processQueryKey,
340
+ hasCardInstanceId: !!result.cardInstanceId,
341
+ }));
342
+
343
+ return {
344
+ channel: "dingtalk-connector",
345
+ messageId: result.processQueryKey ?? result.cardInstanceId ?? "unknown",
346
+ conversationId: to,
347
+ };
348
+ },
349
+ },
350
+ status: {
351
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }) as any,
352
+ buildChannelSummary: ({ snapshot }) => ({
353
+ // 只返回 probe 相关字段,不透传运行时字段(running/lastStartAt 等)。
354
+ // 运行时状态由框架从 store.runtimes 自动维护,buildChannelSummary 在 probe
355
+ // 流程中被调用时 runtime 为 undefined,透传会导致 lastStartAt 永远是 null。
356
+ configured: snapshot.configured ?? false,
357
+ port: snapshot.port ?? null,
358
+ probe: snapshot.probe,
359
+ lastProbeAt: snapshot.lastProbeAt ?? null,
360
+ }),
361
+ probeAccount: async ({ account }) => await probeDingtalk({
362
+ clientId: account.clientId!,
363
+ clientSecret: account.clientSecret!,
364
+ accountId: account.accountId,
365
+ }),
366
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
367
+ accountId: account.accountId,
368
+ enabled: account.enabled,
369
+ configured: account.configured,
370
+ name: account.name,
371
+ clientId: account.clientId,
372
+ running: runtime?.running ?? false,
373
+ lastStartAt: runtime?.lastStartAt ?? null,
374
+ lastStopAt: runtime?.lastStopAt ?? null,
375
+ lastError: runtime?.lastError ?? null,
376
+ port: runtime?.port ?? null,
377
+ // 连接状态和消息时间戳:由 startAccount 里的 onStatusChange 回调写入 runtime,
378
+ // 必须在此处透传,否则 UI 的 Connected 和 Last inbound 字段永远显示 n/a。
379
+ connected: runtime?.connected ?? null,
380
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
381
+ lastInboundAt: runtime?.lastInboundAt ?? null,
382
+ probe,
383
+ }),
384
+ },
385
+ gateway: {
386
+ startAccount: async (ctx) => {
387
+ const account = resolveDingtalkAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
388
+
389
+ // 检查账号是否启用和配置
390
+ if (!account.enabled) {
391
+ ctx.log?.info?.(`dingtalk-connector[${ctx.accountId}] is disabled, skipping startup`);
392
+ // 返回一个永不 resolve 的 Promise,保持 pending 状态直到 abort
393
+ return new Promise<void>((resolve) => {
394
+ if (ctx.abortSignal?.aborted) {
395
+ resolve();
396
+ return;
397
+ }
398
+ ctx.abortSignal?.addEventListener('abort', () => resolve(), { once: true });
399
+ });
400
+ }
401
+
402
+ if (!account.configured) {
403
+ throw new Error(`DingTalk account "${ctx.accountId}" is not properly configured`);
404
+ }
405
+
406
+ // 去重检查:如果列表中排在当前账号之前的账号已使用相同 clientId,则跳过当前账号
407
+ // 使用静态配置分析(而非运行时状态),避免并发竞态条件
408
+ // 规则:同一 clientId 只有列表中第一个启用且已配置的账号才会建立连接
409
+ if (account.clientId) {
410
+ const clientId = String(account.clientId);
411
+ const allAccountIds = listDingtalkAccountIds(ctx.cfg);
412
+ const currentIndex = allAccountIds.indexOf(ctx.accountId);
413
+ const priorAccountWithSameClientId = allAccountIds.slice(0, currentIndex).find((otherId) => {
414
+ const other = resolveDingtalkAccount({ cfg: ctx.cfg, accountId: otherId });
415
+ return other.enabled && other.configured && other.clientId && String(other.clientId) === clientId;
416
+ });
417
+ if (priorAccountWithSameClientId) {
418
+ ctx.log?.info?.(
419
+ `dingtalk-connector[${ctx.accountId}] skipped: clientId "${clientId.substring(0, 8)}..." is already used by account "${priorAccountWithSameClientId}"`
420
+ );
421
+ return new Promise<void>((resolve) => {
422
+ if (ctx.abortSignal?.aborted) {
423
+ resolve();
424
+ return;
425
+ }
426
+ ctx.abortSignal?.addEventListener('abort', () => resolve(), { once: true });
427
+ });
428
+ }
429
+ }
430
+
431
+ ctx.setStatus({ accountId: ctx.accountId, port: null });
432
+ ctx.log?.info(
433
+ `starting dingtalk-connector[${ctx.accountId}] (mode: stream)`,
434
+ );
435
+
436
+ // 把 ctx.setStatus 包装成 onStatusChange 回调,传入连接层,
437
+ // 使连接层能在 WebSocket 连接/断开/收到消息时更新 UI 显示的
438
+ // Connected 和 Last inbound 字段。
439
+ // 注意:ctx.setStatus 是完全替换而非 merge patch,必须先 getStatus()
440
+ // 获取当前快照再合并,否则会清空 configured/running 等已有字段。
441
+ const onStatusChange = (patch: Record<string, unknown>) => {
442
+ const currentSnapshot = ctx.getStatus?.() ?? { accountId: ctx.accountId };
443
+ const nextSnapshot = { ...currentSnapshot, ...patch, accountId: ctx.accountId };
444
+ process.stderr.write(`[dingtalk-connector][${ctx.accountId}] onStatusChange patch=${JSON.stringify(patch)} current=${JSON.stringify(currentSnapshot)} next=${JSON.stringify(nextSnapshot)}\n`);
445
+ ctx.setStatus(nextSnapshot as any);
446
+ };
447
+
448
+ try {
449
+ return await monitorDingtalkProvider({
450
+ config: ctx.cfg,
451
+ runtime: ctx.runtime,
452
+ abortSignal: ctx.abortSignal,
453
+ accountId: ctx.accountId,
454
+ onStatusChange,
455
+ });
456
+ } catch (err: any) {
457
+ // 打印真实错误到 stderr,绕过框架 log 系统(框架的 runtime.log 可能未初始化)
458
+ ctx.log?.error(`[dingtalk-connector][${ctx.accountId}] startAccount error: ${err?.message ?? err}\n${err?.stack ?? ''}`);
459
+ throw err;
460
+ }
461
+ },
462
+ },
463
+ };
@@ -0,0 +1,242 @@
1
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId , normalizeResolvedSecretInputString, normalizeSecretInputString } from "../sdk/helpers.ts";
2
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
3
+ import type {
4
+ DingtalkConfig,
5
+ DingtalkAccountConfig,
6
+ DingtalkDefaultAccountSelectionSource,
7
+ ResolvedDingtalkAccount,
8
+ } from "../types/index.ts";
9
+
10
+ /**
11
+ * List all configured account IDs from the accounts field.
12
+ */
13
+ function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
14
+ const accounts = (cfg.channels?.["dingtalk-connector"] as DingtalkConfig)?.accounts;
15
+ if (!accounts || typeof accounts !== "object") {
16
+ return [];
17
+ }
18
+ return Object.keys(accounts).filter(Boolean);
19
+ }
20
+
21
+ /**
22
+ * List all DingTalk account IDs.
23
+ * If no accounts are configured, returns [DEFAULT_ACCOUNT_ID] for backward compatibility.
24
+ */
25
+ export function listDingtalkAccountIds(cfg: ClawdbotConfig): string[] {
26
+ const ids = listConfiguredAccountIds(cfg);
27
+ if (ids.length === 0) {
28
+ // Backward compatibility: no accounts configured, use default
29
+ return [DEFAULT_ACCOUNT_ID];
30
+ }
31
+ return [...ids].toSorted((a, b) => a.localeCompare(b));
32
+ }
33
+
34
+ /**
35
+ * Resolve the default account selection and its source.
36
+ */
37
+ export function resolveDefaultDingtalkAccountSelection(cfg: ClawdbotConfig): {
38
+ accountId: string;
39
+ source: DingtalkDefaultAccountSelectionSource;
40
+ } {
41
+ const preferredRaw = (cfg.channels?.["dingtalk-connector"] as DingtalkConfig | undefined)?.defaultAccount?.trim();
42
+ const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
43
+ if (preferred) {
44
+ return {
45
+ accountId: preferred,
46
+ source: "explicit-default",
47
+ };
48
+ }
49
+ const ids = listDingtalkAccountIds(cfg);
50
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) {
51
+ return {
52
+ accountId: DEFAULT_ACCOUNT_ID,
53
+ source: "mapped-default",
54
+ };
55
+ }
56
+ return {
57
+ accountId: ids[0] ?? DEFAULT_ACCOUNT_ID,
58
+ source: "fallback",
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Resolve the default account ID.
64
+ */
65
+ export function resolveDefaultDingtalkAccountId(cfg: ClawdbotConfig): string {
66
+ return resolveDefaultDingtalkAccountSelection(cfg).accountId;
67
+ }
68
+
69
+ /**
70
+ * Get the raw account-specific config.
71
+ */
72
+ function resolveAccountConfig(
73
+ cfg: ClawdbotConfig,
74
+ accountId: string,
75
+ ): DingtalkAccountConfig | undefined {
76
+ const accounts = (cfg.channels?.["dingtalk-connector"] as DingtalkConfig)?.accounts;
77
+ if (!accounts || typeof accounts !== "object") {
78
+ return undefined;
79
+ }
80
+ return accounts[accountId];
81
+ }
82
+
83
+ /**
84
+ * Merge top-level config with account-specific config.
85
+ * Account-specific fields override top-level fields.
86
+ */
87
+ function mergeDingtalkAccountConfig(cfg: ClawdbotConfig, accountId: string): DingtalkConfig {
88
+ const dingtalkCfg = cfg.channels?.["dingtalk-connector"] as DingtalkConfig | undefined;
89
+
90
+ // Extract base config (exclude accounts field to avoid recursion)
91
+ const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, ...base } = dingtalkCfg ?? {};
92
+
93
+ // Get account-specific overrides
94
+ const account = resolveAccountConfig(cfg, accountId) ?? {};
95
+
96
+ // Merge: account config overrides base config
97
+ return { ...base, ...account } as DingtalkConfig;
98
+ }
99
+
100
+ /**
101
+ * Resolve DingTalk credentials from a config.
102
+ */
103
+ export function resolveDingtalkCredentials(cfg?: DingtalkConfig): {
104
+ clientId: string;
105
+ clientSecret: string;
106
+ } | null;
107
+ export function resolveDingtalkCredentials(
108
+ cfg: DingtalkConfig | undefined,
109
+ options: { allowUnresolvedSecretRef?: boolean },
110
+ ): {
111
+ clientId: string;
112
+ clientSecret: string;
113
+ } | null;
114
+ export function resolveDingtalkCredentials(
115
+ cfg?: DingtalkConfig,
116
+ options?: { allowUnresolvedSecretRef?: boolean },
117
+ ): {
118
+ clientId: string;
119
+ clientSecret: string;
120
+ } | null {
121
+ const normalizeString = (value: unknown): string | undefined => {
122
+ if (typeof value === "number") {
123
+ return String(value);
124
+ }
125
+ if (typeof value !== "string") {
126
+ return undefined;
127
+ }
128
+ const trimmed = value.trim();
129
+ return trimmed ? trimmed : undefined;
130
+ };
131
+
132
+ const resolveSecretLike = (value: unknown, path: string): string | undefined => {
133
+ // Missing credential: treat as not configured (no exception).
134
+ // This path is used in non-onboarding contexts (e.g. channel listing/status),
135
+ // so we must not throw when credentials are absent.
136
+ if (value === undefined || value === null) {
137
+ return undefined;
138
+ }
139
+
140
+ const asString = normalizeString(value);
141
+ if (asString) {
142
+ return asString;
143
+ }
144
+
145
+ // In relaxed/onboarding paths only: allow direct env SecretRef reads for UX.
146
+ // Default resolution path must preserve unresolved-ref diagnostics/policy semantics.
147
+ if (options?.allowUnresolvedSecretRef && typeof value === "object" && value !== null) {
148
+ const rec = value as Record<string, unknown>;
149
+ const source = normalizeString(rec.source)?.toLowerCase();
150
+ const id = normalizeString(rec.id);
151
+ if (source === "env" && id) {
152
+ const envValue = normalizeString(process.env[id]);
153
+ if (envValue) {
154
+ return envValue;
155
+ }
156
+ }
157
+ }
158
+
159
+ if (options?.allowUnresolvedSecretRef) {
160
+ return normalizeSecretInputString(value);
161
+ }
162
+ return normalizeResolvedSecretInputString({ value, path });
163
+ };
164
+
165
+ const clientId = resolveSecretLike(cfg?.clientId, "channels.dingtalk-connector.clientId");
166
+ const clientSecret = resolveSecretLike(cfg?.clientSecret, "channels.dingtalk-connector.clientSecret");
167
+
168
+ if (!clientId || !clientSecret) {
169
+ return null;
170
+ }
171
+ return {
172
+ clientId,
173
+ clientSecret,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Resolve a complete DingTalk account with merged config.
179
+ */
180
+ export function resolveDingtalkAccount(params: {
181
+ cfg: ClawdbotConfig;
182
+ accountId?: string | null;
183
+ }): ResolvedDingtalkAccount {
184
+ const hasExplicitAccountId =
185
+ typeof params.accountId === "string" && params.accountId.trim() !== "";
186
+ const defaultSelection = hasExplicitAccountId
187
+ ? null
188
+ : resolveDefaultDingtalkAccountSelection(params.cfg);
189
+ const accountId = hasExplicitAccountId
190
+ ? normalizeAccountId(params.accountId ?? "")
191
+ : (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID);
192
+ const selectionSource = hasExplicitAccountId
193
+ ? "explicit"
194
+ : (defaultSelection?.source ?? "fallback");
195
+ const dingtalkCfg = params.cfg.channels?.["dingtalk-connector"] as DingtalkConfig | undefined;
196
+
197
+ // Base enabled state (top-level)
198
+ const baseEnabled = dingtalkCfg?.enabled !== false;
199
+
200
+ // Merge configs
201
+ const merged = mergeDingtalkAccountConfig(params.cfg, accountId);
202
+
203
+ // Account-level enabled state
204
+ const accountEnabled = merged.enabled !== false;
205
+ const enabled = baseEnabled && accountEnabled;
206
+
207
+ // Resolve credentials from merged config
208
+ const creds = resolveDingtalkCredentials(merged);
209
+ const accountName = (merged as DingtalkAccountConfig).name;
210
+
211
+ return {
212
+ accountId,
213
+ selectionSource,
214
+ enabled,
215
+ configured: Boolean(creds),
216
+ name: typeof accountName === "string" ? accountName.trim() || undefined : undefined,
217
+ clientId: creds?.clientId,
218
+ clientSecret: creds?.clientSecret,
219
+ config: merged,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * List all enabled and configured accounts.
225
+ * Deduplicates by clientId to avoid creating multiple connections with the same credentials.
226
+ */
227
+ export function listEnabledDingtalkAccounts(cfg: ClawdbotConfig): ResolvedDingtalkAccount[] {
228
+ const accounts = listDingtalkAccountIds(cfg)
229
+ .map((accountId) => resolveDingtalkAccount({ cfg, accountId }))
230
+ .filter((account) => account.enabled && account.configured);
231
+
232
+ // Deduplicate by clientId to avoid multiple connections with same credentials
233
+ const seen = new Set<string>();
234
+ return accounts.filter((account) => {
235
+ if (!account.clientId) return true;
236
+ if (seen.has(account.clientId)) {
237
+ return false;
238
+ }
239
+ seen.add(account.clientId);
240
+ return true;
241
+ });
242
+ }