@largezhou/ddingtalk 1.3.1 → 1.4.0-beta.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/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";
@@ -540,10 +540,7 @@ export interface MonitorOptions {
540
540
  abortSignal?: AbortSignal;
541
541
  }
542
542
 
543
- export interface MonitorResult {
544
- account: ResolvedDingTalkAccount;
545
- stop: () => void;
546
- }
543
+ export type MonitorResult = Promise<void>;
547
544
 
548
545
  // Track runtime state in memory
549
546
  const runtimeState = new Map<
@@ -781,7 +778,7 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
781
778
  return { textContent, rawBody };
782
779
  };
783
780
 
784
- /** 构建入站消息上下文 */
781
+ /** 构建入站消息上下文(含路由信息) */
785
782
  const buildInboundContext = (
786
783
  data: DingTalkMessageData,
787
784
  sender: ReturnType<typeof buildSenderInfo>,
@@ -797,7 +794,7 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
797
794
  channel: PLUGIN_ID,
798
795
  accountId,
799
796
  peer: {
800
- kind: isGroup ? "group" : "dm",
797
+ kind: isGroup ? "group" : "direct",
801
798
  id: sender.chatId,
802
799
  },
803
800
  });
@@ -848,10 +845,12 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
848
845
  // 合并媒体字段
849
846
  const mediaFields = buildMediaContextFields(media);
850
847
 
851
- return pluginRuntime.channel.reply.finalizeInboundContext({
848
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
852
849
  ...baseContext,
853
850
  ...mediaFields,
854
851
  });
852
+
853
+ return { ctxPayload, route };
855
854
  };
856
855
 
857
856
  /** 创建回复分发器 */
@@ -905,10 +904,33 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
905
904
  // 2. 构建消息体
906
905
  const { rawBody } = buildMessageBody(data, media);
907
906
 
908
- // 3. 构建入站上下文
909
- 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
+ });
910
932
 
911
- // 4. 分发消息给 OpenClaw
933
+ // 5. 分发消息给 OpenClaw
912
934
  const { queuedFinal } = await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
913
935
  ctx: ctxPayload,
914
936
  cfg: config,
@@ -961,7 +983,7 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
961
983
  // 打印收到的消息信息
962
984
  const preview = handler.getPreview(data);
963
985
  const chatLabel = isGroup
964
- ? `群聊(${data.conversationTitle ?? groupId})`
986
+ ? `群聊(${data.conversationTitle ? `${data.conversationTitle} | ${groupId}` : groupId})`
965
987
  : "单聊";
966
988
  logger.log(`收到消息 | ${chatLabel} | ${data.senderNick}(${data.senderStaffId}) | ${preview}`);
967
989
 
@@ -1064,29 +1086,26 @@ export function monitorDingTalkProvider(options: MonitorOptions): MonitorResult
1064
1086
 
1065
1087
  client.connect();
1066
1088
 
1067
- // 处理中止信号
1068
- const stopHandler = () => {
1069
- logger.log(`[${accountId}] 停止 provider`);
1070
- client.disconnect();
1071
- recordChannelRuntimeState({
1072
- channel: PLUGIN_ID,
1073
- accountId,
1074
- state: {
1075
- running: false,
1076
- lastStopAt: Date.now(),
1077
- },
1078
- });
1079
- };
1080
-
1081
- if (abortSignal) {
1082
- abortSignal.addEventListener("abort", stopHandler);
1083
- }
1089
+ // 返回一个在 abort/disconnect 之前一直 pending 的 Promise。
1090
+ // OpenClaw 框架将 startAccount 返回的 Promise resolve 视为 "channel 已退出",
1091
+ // 会触发 auto-restart。因此需要保持 pending 直到 abort。
1092
+ return new Promise<void>((resolve) => {
1093
+ const stopHandler = () => {
1094
+ logger.log(`[${accountId}] 停止 provider`);
1095
+ client.disconnect();
1096
+ recordChannelRuntimeState({
1097
+ channel: PLUGIN_ID,
1098
+ accountId,
1099
+ state: {
1100
+ running: false,
1101
+ lastStopAt: Date.now(),
1102
+ },
1103
+ });
1104
+ resolve();
1105
+ };
1084
1106
 
1085
- return {
1086
- account,
1087
- stop: () => {
1088
- stopHandler();
1089
- abortSignal?.removeEventListener("abort", stopHandler);
1090
- },
1091
- };
1107
+ if (abortSignal) {
1108
+ abortSignal.addEventListener("abort", stopHandler);
1109
+ }
1110
+ });
1092
1111
  }
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;