@largezhou/ddingtalk 1.3.2 → 1.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ffmpeg.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import crypto from "node:crypto";
7
+ import { logger } from "./logger.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ const FFPROBE_TIMEOUT_MS = 10_000;
12
+ const FFMPEG_TIMEOUT_MS = 30_000;
13
+ const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
14
+
15
+ // ======================= ffmpeg 检测 =======================
16
+
17
+ let ffmpegAvailable: boolean | null = null;
18
+
19
+ /**
20
+ * 检测系统是否安装了 ffmpeg 和 ffprobe
21
+ * 结果会被缓存,只检测一次
22
+ */
23
+ export function hasFFmpeg(): boolean {
24
+ if (ffmpegAvailable !== null) {
25
+ return ffmpegAvailable;
26
+ }
27
+
28
+ const pathEnv = process.env.PATH ?? "";
29
+ const parts = pathEnv.split(path.delimiter).filter(Boolean);
30
+ const extensions = process.platform === "win32"
31
+ ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean)
32
+ : [""];
33
+
34
+ let foundFfmpeg = false;
35
+ let foundFfprobe = false;
36
+
37
+ for (const dir of parts) {
38
+ for (const ext of extensions) {
39
+ if (!foundFfmpeg) {
40
+ try {
41
+ fs.accessSync(path.join(dir, `ffmpeg${ext}`), fs.constants.X_OK);
42
+ foundFfmpeg = true;
43
+ } catch { /* keep scanning */ }
44
+ }
45
+ if (!foundFfprobe) {
46
+ try {
47
+ fs.accessSync(path.join(dir, `ffprobe${ext}`), fs.constants.X_OK);
48
+ foundFfprobe = true;
49
+ } catch { /* keep scanning */ }
50
+ }
51
+ if (foundFfmpeg && foundFfprobe) break;
52
+ }
53
+ if (foundFfmpeg && foundFfprobe) break;
54
+ }
55
+
56
+ ffmpegAvailable = foundFfmpeg && foundFfprobe;
57
+
58
+ if (ffmpegAvailable) {
59
+ logger.log("[ffmpeg] 检测到 ffmpeg 和 ffprobe 已安装");
60
+ } else {
61
+ logger.log(`[ffmpeg] 未检测到 ffmpeg/ffprobe(ffmpeg: ${foundFfmpeg}, ffprobe: ${foundFfprobe})`);
62
+ }
63
+
64
+ return ffmpegAvailable;
65
+ }
66
+
67
+ // ======================= ffprobe 探测 =======================
68
+
69
+ async function runFfprobe(args: string[]): Promise<string> {
70
+ const { stdout } = await execFileAsync("ffprobe", args, {
71
+ timeout: FFPROBE_TIMEOUT_MS,
72
+ maxBuffer: MAX_BUFFER_BYTES,
73
+ });
74
+ return stdout.toString();
75
+ }
76
+
77
+ async function runFfmpeg(args: string[]): Promise<string> {
78
+ const { stdout } = await execFileAsync("ffmpeg", args, {
79
+ timeout: FFMPEG_TIMEOUT_MS,
80
+ maxBuffer: MAX_BUFFER_BYTES,
81
+ });
82
+ return stdout.toString();
83
+ }
84
+
85
+ /**
86
+ * 获取音频/视频的时长(毫秒)
87
+ * @param filePath - 本地文件路径
88
+ * @returns 时长(毫秒),整数
89
+ */
90
+ export async function getMediaDuration(filePath: string): Promise<number> {
91
+ const stdout = await runFfprobe([
92
+ "-v", "error",
93
+ "-show_entries", "format=duration",
94
+ "-of", "csv=p=0",
95
+ filePath,
96
+ ]);
97
+ const durationSec = parseFloat(stdout.trim());
98
+ if (isNaN(durationSec)) {
99
+ throw new Error(`无法解析时长: ${stdout.trim()}`);
100
+ }
101
+ return Math.round(durationSec * 1000);
102
+ }
103
+
104
+ /**
105
+ * 获取视频的宽高
106
+ * @param filePath - 本地文件路径
107
+ * @returns { width, height }
108
+ */
109
+ export async function getVideoResolution(filePath: string): Promise<{ width: number; height: number }> {
110
+ const stdout = await runFfprobe([
111
+ "-v", "error",
112
+ "-select_streams", "v:0",
113
+ "-show_entries", "stream=width,height",
114
+ "-of", "csv=p=0:s=x",
115
+ filePath,
116
+ ]);
117
+ const match = stdout.trim().match(/^(\d+)x(\d+)/);
118
+ if (!match) {
119
+ throw new Error(`无法解析视频分辨率: ${stdout.trim()}`);
120
+ }
121
+ return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
122
+ }
123
+
124
+ /**
125
+ * 提取视频的第一帧作为封面图
126
+ * @param videoPath - 本地视频文件路径
127
+ * @returns 封面图片的 Buffer(JPEG 格式)
128
+ */
129
+ export async function extractVideoCover(videoPath: string): Promise<Buffer> {
130
+ const tmpDir = os.tmpdir();
131
+ const coverPath = path.join(tmpDir, `dingtalk-cover-${crypto.randomUUID()}.jpg`);
132
+
133
+ try {
134
+ await runFfmpeg([
135
+ "-y",
136
+ "-i", videoPath,
137
+ "-vframes", "1",
138
+ "-q:v", "2",
139
+ "-f", "image2",
140
+ coverPath,
141
+ ]);
142
+
143
+ const buffer = fs.readFileSync(coverPath);
144
+ return buffer;
145
+ } finally {
146
+ // 清理临时文件
147
+ try {
148
+ fs.unlinkSync(coverPath);
149
+ } catch { /* ignore */ }
150
+ }
151
+ }
152
+
153
+ export interface MediaProbeResult {
154
+ /** 时长(毫秒),整数 */
155
+ duration: number;
156
+ /** 视频分辨率(仅视频有) */
157
+ width?: number;
158
+ height?: number;
159
+ /** 视频封面图 Buffer(仅视频有) */
160
+ coverBuffer?: Buffer;
161
+ }
162
+
163
+ /**
164
+ * 将 Buffer 写入临时文件,执行探测,然后清理
165
+ * @param buffer - 媒体文件内容
166
+ * @param fileName - 文件名(用于扩展名推断)
167
+ * @param type - 媒体类型 "voice" | "video"
168
+ */
169
+ export async function probeMediaBuffer(
170
+ buffer: Buffer,
171
+ fileName: string,
172
+ type: "voice" | "video"
173
+ ): Promise<MediaProbeResult> {
174
+ const ext = path.extname(fileName) || (type === "video" ? ".mp4" : ".mp3");
175
+ const tmpDir = os.tmpdir();
176
+ const tmpPath = path.join(tmpDir, `dingtalk-probe-${crypto.randomUUID()}${ext}`);
177
+
178
+ fs.writeFileSync(tmpPath, buffer);
179
+
180
+ try {
181
+ const duration = await getMediaDuration(tmpPath);
182
+ const result: MediaProbeResult = { duration };
183
+
184
+ if (type === "video") {
185
+ try {
186
+ const { width, height } = await getVideoResolution(tmpPath);
187
+ result.width = width;
188
+ result.height = height;
189
+ } catch (err) {
190
+ logger.warn(`[ffmpeg] 获取视频分辨率失败: ${err}`);
191
+ }
192
+
193
+ try {
194
+ result.coverBuffer = await extractVideoCover(tmpPath);
195
+ } catch (err) {
196
+ logger.warn(`[ffmpeg] 提取视频封面失败: ${err}`);
197
+ }
198
+ }
199
+
200
+ return result;
201
+ } finally {
202
+ try {
203
+ fs.unlinkSync(tmpPath);
204
+ } catch { /* ignore */ }
205
+ }
206
+ }
package/src/monitor.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DWClient, TOPIC_ROBOT, type DWClientDownStream } from "dingtalk-stream";
2
- import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { recordInboundSession, type OpenClawConfig, type RuntimeEnv } from "openclaw/plugin-sdk";
3
3
  import type { DingTalkMessageData, ResolvedDingTalkAccount, DingTalkGroupConfig, AudioContent, VideoContent, FileContent, PictureContent, RichTextContent, RichTextElement, RichTextPictureElement } from "./types.js";
4
4
  import { replyViaWebhook, getFileDownloadUrl, downloadFromUrl, sendTextMessage } from "./client.js";
5
5
  import { resolveDingTalkAccount } from "./accounts.js";
@@ -778,7 +778,7 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
778
778
  return { textContent, rawBody };
779
779
  };
780
780
 
781
- /** 构建入站消息上下文 */
781
+ /** 构建入站消息上下文(含路由信息) */
782
782
  const buildInboundContext = (
783
783
  data: DingTalkMessageData,
784
784
  sender: ReturnType<typeof buildSenderInfo>,
@@ -794,7 +794,7 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
794
794
  channel: PLUGIN_ID,
795
795
  accountId,
796
796
  peer: {
797
- kind: isGroup ? "group" : "dm",
797
+ kind: isGroup ? "group" : "direct",
798
798
  id: sender.chatId,
799
799
  },
800
800
  });
@@ -845,10 +845,12 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
845
845
  // 合并媒体字段
846
846
  const mediaFields = buildMediaContextFields(media);
847
847
 
848
- return pluginRuntime.channel.reply.finalizeInboundContext({
848
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
849
849
  ...baseContext,
850
850
  ...mediaFields,
851
851
  });
852
+
853
+ return { ctxPayload, route };
852
854
  };
853
855
 
854
856
  /** 创建回复分发器 */
@@ -902,10 +904,33 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
902
904
  // 2. 构建消息体
903
905
  const { rawBody } = buildMessageBody(data, media);
904
906
 
905
- // 3. 构建入站上下文
906
- const ctxPayload = buildInboundContext(data, sender, rawBody, media);
907
+ // 3. 构建入站上下文(含路由信息)
908
+ const { ctxPayload, route } = buildInboundContext(data, sender, rawBody, media);
909
+
910
+ // 4. 持久化 session 元数据 + 更新回复路由(参照 Discord/Telegram)
911
+ const storePath = pluginRuntime.channel.session.resolveStorePath(undefined, {
912
+ agentId: route.agentId,
913
+ });
914
+ const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
915
+
916
+ await recordInboundSession({
917
+ storePath,
918
+ sessionKey: persistedSessionKey,
919
+ ctx: ctxPayload,
920
+ updateLastRoute: !sender.isGroup
921
+ ? {
922
+ sessionKey: route.mainSessionKey,
923
+ channel: PLUGIN_ID,
924
+ to: sender.toAddress,
925
+ accountId: route.accountId,
926
+ }
927
+ : undefined,
928
+ onRecordError: (err) => {
929
+ logger.error("recordInboundSession failed:", err);
930
+ },
931
+ });
907
932
 
908
- // 4. 分发消息给 OpenClaw
933
+ // 5. 分发消息给 OpenClaw
909
934
  const { queuedFinal } = await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
910
935
  ctx: ctxPayload,
911
936
  cfg: config,
@@ -958,7 +983,7 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
958
983
  // 打印收到的消息信息
959
984
  const preview = handler.getPreview(data);
960
985
  const chatLabel = isGroup
961
- ? `群聊(${data.conversationTitle ?? groupId})`
986
+ ? `群聊(${data.conversationTitle ? `${data.conversationTitle} | ${groupId}` : groupId})`
962
987
  : "单聊";
963
988
  logger.log(`收到消息 | ${chatLabel} | ${data.senderNick}(${data.senderStaffId}) | ${preview}`);
964
989
 
package/src/onboarding.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
1
  import type { ChannelOnboardingAdapter } from "openclaw/plugin-sdk";
3
- import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, promptAccountId } from "openclaw/plugin-sdk";
4
3
  import type { DingTalkConfig } from "./types.js";
5
4
  import {
6
5
  listDingTalkAccountIds,
6
+ resolveDefaultDingTalkAccountId,
7
7
  resolveDingTalkAccount,
8
8
  } from "./accounts.js";
9
9
  import { PLUGIN_ID } from "./constants.js";
@@ -29,7 +29,132 @@ async function noteDingTalkCredentialsHelp(prompter: {
29
29
  }
30
30
 
31
31
  /**
32
- * DingTalk Onboarding Adapter(单账户模式)
32
+ * Prompt for DingTalk credentials (clientId + clientSecret)
33
+ */
34
+ async function promptDingTalkCredentials(prompter: {
35
+ text: (opts: { message: string; validate?: (value: string) => string | undefined }) => Promise<string | symbol>;
36
+ }): Promise<{ clientId: string; clientSecret: string }> {
37
+ const clientId = String(
38
+ await prompter.text({
39
+ message: "Enter DingTalk AppKey (Client ID)",
40
+ validate: (value) => (value?.trim() ? undefined : "Required"),
41
+ })
42
+ ).trim();
43
+ const clientSecret = String(
44
+ await prompter.text({
45
+ message: "Enter DingTalk AppSecret (Client Secret)",
46
+ validate: (value) => (value?.trim() ? undefined : "Required"),
47
+ })
48
+ ).trim();
49
+ return { clientId, clientSecret };
50
+ }
51
+
52
+ /** 需要从顶层迁移到 accounts.default 的字段 */
53
+ const ACCOUNT_LEVEL_KEYS = new Set([
54
+ "name",
55
+ "clientId",
56
+ "clientSecret",
57
+ "allowFrom",
58
+ "groupPolicy",
59
+ "groupAllowFrom",
60
+ "groups",
61
+ ]);
62
+
63
+ /**
64
+ * 当添加非 default 账号时,把顶层的账号级字段迁移到 accounts.default 下。
65
+ * 如果 accounts 字典已存在(已经是多账号模式),则不做迁移。
66
+ */
67
+ function moveTopLevelToDefaultAccount(
68
+ section: DingTalkConfig,
69
+ ): DingTalkConfig {
70
+ // 已有 accounts 字典,不需要迁移
71
+ if (section.accounts && Object.keys(section.accounts).length > 0) {
72
+ return section;
73
+ }
74
+
75
+ const defaultAccount: Record<string, unknown> = {};
76
+ const cleaned: Record<string, unknown> = {};
77
+
78
+ for (const [key, value] of Object.entries(section)) {
79
+ if (key === "accounts" || key === "defaultAccount") {
80
+ cleaned[key] = value;
81
+ } else if (ACCOUNT_LEVEL_KEYS.has(key) && value !== undefined) {
82
+ defaultAccount[key] = value;
83
+ // 不复制到 cleaned,从顶层移除
84
+ } else {
85
+ cleaned[key] = value;
86
+ }
87
+ }
88
+
89
+ // 没有可迁移的字段
90
+ if (Object.keys(defaultAccount).length === 0) {
91
+ return section;
92
+ }
93
+
94
+ return {
95
+ ...cleaned,
96
+ accounts: {
97
+ [DEFAULT_ACCOUNT_ID]: defaultAccount,
98
+ },
99
+ } as DingTalkConfig;
100
+ }
101
+
102
+ /**
103
+ * Apply credentials to the config for a given accountId.
104
+ *
105
+ * 策略(与框架层 Discord/Telegram 一致):
106
+ * - default 账号:写顶层(兼容单账号模式)
107
+ * - 非 default 账号:先把顶层账号级字段迁移到 accounts.default,再写 accounts[accountId]
108
+ */
109
+ function applyCredentials(
110
+ cfg: Record<string, unknown>,
111
+ accountId: string,
112
+ credentials: { clientId: string; clientSecret: string }
113
+ ): Record<string, unknown> {
114
+ const dingtalkConfig = ((cfg.channels as Record<string, unknown>)?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
115
+
116
+ if (accountId === DEFAULT_ACCOUNT_ID) {
117
+ // default 账号:写顶层
118
+ return {
119
+ ...cfg,
120
+ channels: {
121
+ ...(cfg.channels as Record<string, unknown>),
122
+ [PLUGIN_ID]: {
123
+ ...dingtalkConfig,
124
+ enabled: true,
125
+ clientId: credentials.clientId,
126
+ clientSecret: credentials.clientSecret,
127
+ },
128
+ },
129
+ };
130
+ }
131
+
132
+ // 非 default 账号:先迁移顶层到 accounts.default,再写新账号
133
+ const migrated = moveTopLevelToDefaultAccount(dingtalkConfig);
134
+
135
+ return {
136
+ ...cfg,
137
+ channels: {
138
+ ...(cfg.channels as Record<string, unknown>),
139
+ [PLUGIN_ID]: {
140
+ ...migrated,
141
+ enabled: true,
142
+ accounts: {
143
+ ...migrated.accounts,
144
+ [accountId]: {
145
+ ...migrated.accounts?.[accountId],
146
+ enabled: true,
147
+ clientId: credentials.clientId,
148
+ clientSecret: credentials.clientSecret,
149
+ },
150
+ },
151
+ },
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * DingTalk Onboarding Adapter(支持多账号)
33
158
  */
34
159
  export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
35
160
  channel,
@@ -49,73 +174,58 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
49
174
  configure: async ({
50
175
  cfg,
51
176
  prompter,
177
+ shouldPromptAccountIds,
178
+ accountOverrides,
52
179
  }) => {
53
180
  let next = cfg;
54
- const resolvedAccount = resolveDingTalkAccount({ cfg: next });
55
- const accountConfigured = Boolean(
56
- resolvedAccount.clientId?.trim() && resolvedAccount.clientSecret?.trim()
57
- );
58
- const dingtalkConfig = (next.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
59
- const hasConfigCredentials = Boolean(dingtalkConfig.clientId);
60
181
 
61
- let clientId: string | null = null;
62
- let clientSecret: string | null = null;
182
+ // 1. 解析 accountId:支持多账号选择 / 添加新账号
183
+ const defaultAccountId = resolveDefaultDingTalkAccountId(cfg);
184
+ const override = accountOverrides?.[PLUGIN_ID]?.trim();
185
+ let accountId = override ?? defaultAccountId;
63
186
 
64
- if (!accountConfigured) {
65
- await noteDingTalkCredentialsHelp(prompter);
187
+ if (shouldPromptAccountIds && !override) {
188
+ accountId = await promptAccountId({
189
+ cfg,
190
+ prompter,
191
+ label: "DingTalk",
192
+ currentId: accountId,
193
+ listAccountIds: listDingTalkAccountIds,
194
+ defaultAccountId,
195
+ });
66
196
  }
67
197
 
68
- if (hasConfigCredentials) {
198
+ // 2. 检查该账号自身是否已配置凭据(不继承顶层,避免新账号误判为已配置)
199
+ const accountConfigured = (() => {
200
+ const dingtalkSection = (next.channels as Record<string, unknown>)?.[PLUGIN_ID] as DingTalkConfig | undefined;
201
+ if (!dingtalkSection) return false;
202
+ // 检查 accounts[accountId] 自身
203
+ const acct = dingtalkSection.accounts?.[accountId];
204
+ if (acct?.clientId?.trim() && acct?.clientSecret?.trim()) return true;
205
+ // default 账号额外兼容顶层旧配置(手动编辑或旧版迁移)
206
+ if (accountId === DEFAULT_ACCOUNT_ID) {
207
+ return Boolean(dingtalkSection.clientId?.trim() && dingtalkSection.clientSecret?.trim());
208
+ }
209
+ return false;
210
+ })();
211
+
212
+ // 3. 凭据输入
213
+ if (!accountConfigured) {
214
+ await noteDingTalkCredentialsHelp(prompter);
215
+ const credentials = await promptDingTalkCredentials(prompter);
216
+ next = applyCredentials(next, accountId, credentials) as typeof next;
217
+ } else {
69
218
  const keep = await prompter.confirm({
70
219
  message: "DingTalk credentials already configured. Keep them?",
71
220
  initialValue: true,
72
221
  });
73
222
  if (!keep) {
74
- clientId = String(
75
- await prompter.text({
76
- message: "Enter DingTalk AppKey (Client ID)",
77
- validate: (value) => (value?.trim() ? undefined : "Required"),
78
- })
79
- ).trim();
80
- clientSecret = String(
81
- await prompter.text({
82
- message: "Enter DingTalk AppSecret (Client Secret)",
83
- validate: (value) => (value?.trim() ? undefined : "Required"),
84
- })
85
- ).trim();
223
+ const credentials = await promptDingTalkCredentials(prompter);
224
+ next = applyCredentials(next, accountId, credentials) as typeof next;
86
225
  }
87
- } else {
88
- clientId = String(
89
- await prompter.text({
90
- message: "Enter DingTalk AppKey (Client ID)",
91
- validate: (value) => (value?.trim() ? undefined : "Required"),
92
- })
93
- ).trim();
94
- clientSecret = String(
95
- await prompter.text({
96
- message: "Enter DingTalk AppSecret (Client Secret)",
97
- validate: (value) => (value?.trim() ? undefined : "Required"),
98
- })
99
- ).trim();
100
- }
101
-
102
- if (clientId && clientSecret) {
103
- const updatedDingtalkConfig = (next.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
104
- next = {
105
- ...next,
106
- channels: {
107
- ...next.channels,
108
- [PLUGIN_ID]: {
109
- ...updatedDingtalkConfig,
110
- enabled: true,
111
- clientId,
112
- clientSecret,
113
- },
114
- },
115
- };
116
226
  }
117
227
 
118
- return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
228
+ return { cfg: next, accountId };
119
229
  },
120
230
  disable: (cfg) => {
121
231
  const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
package/src/types.ts CHANGED
@@ -23,13 +23,14 @@ export const DingTalkGroupConfigSchema = z.object({
23
23
  export type DingTalkGroupConfig = z.infer<typeof DingTalkGroupConfigSchema>;
24
24
 
25
25
  /**
26
- * 钉钉渠道配置 Schema(单账户)
26
+ * 单个钉钉账户配置 Schema
27
+ * 每个账户对应一个钉钉企业机器人应用
27
28
  */
28
- export const DingTalkConfigSchema = z.object({
29
- /** 是否启用钉钉渠道 */
30
- enabled: z.boolean().optional(),
29
+ export const DingTalkAccountConfigSchema = z.object({
31
30
  /** 账户名称 */
32
31
  name: z.string().optional(),
32
+ /** 是否启用 */
33
+ enabled: z.boolean().optional(),
33
34
  /** 钉钉应用 AppKey */
34
35
  clientId: z.string().optional(),
35
36
  /** 钉钉应用 AppSecret */
@@ -44,6 +45,38 @@ export const DingTalkConfigSchema = z.object({
44
45
  groups: z.record(z.string(), DingTalkGroupConfigSchema).optional(),
45
46
  });
46
47
 
48
+ export type DingTalkAccountConfig = z.infer<typeof DingTalkAccountConfigSchema>;
49
+
50
+ /**
51
+ * 钉钉渠道配置 Schema
52
+ *
53
+ * 支持两种配置格式(兼容旧版单账户 + 新版多账户):
54
+ *
55
+ * 单账户(旧版兼容):
56
+ * ```json
57
+ * { "channels": { "ddingtalk": { "clientId": "xxx", "clientSecret": "yyy" } } }
58
+ * ```
59
+ *
60
+ * 多账户(新版):
61
+ * ```json
62
+ * { "channels": { "ddingtalk": {
63
+ * "accounts": {
64
+ * "bot1": { "clientId": "xxx", "clientSecret": "yyy" },
65
+ * "bot2": { "clientId": "aaa", "clientSecret": "bbb" }
66
+ * },
67
+ * "defaultAccount": "bot1"
68
+ * } } }
69
+ * ```
70
+ *
71
+ * 顶层字段作为所有账户的默认值,账户级字段可覆盖。
72
+ */
73
+ export const DingTalkConfigSchema = DingTalkAccountConfigSchema.extend({
74
+ /** 多账户配置字典,key 为 accountId */
75
+ accounts: z.record(z.string(), DingTalkAccountConfigSchema).optional(),
76
+ /** 默认账户 ID(多账户时指定) */
77
+ defaultAccount: z.string().optional(),
78
+ });
79
+
47
80
  export type DingTalkConfig = z.infer<typeof DingTalkConfigSchema>;
48
81
 
49
82
  // ======================= Resolved Account Type =======================
@@ -52,7 +85,7 @@ export type DingTalkConfig = z.infer<typeof DingTalkConfigSchema>;
52
85
  * 解析后的钉钉账户配置
53
86
  */
54
87
  export interface ResolvedDingTalkAccount {
55
- /** 账户 ID(固定为 default) */
88
+ /** 账户 ID */
56
89
  accountId: string;
57
90
  /** 账户名称 */
58
91
  name?: string;