@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/README.en.md +361 -0
- package/README.md +281 -38
- package/package.json +6 -10
- package/src/accounts.ts +130 -45
- package/src/channel.ts +189 -89
- package/src/client.ts +75 -88
- package/src/ffmpeg.ts +206 -0
- package/src/monitor.ts +33 -8
- package/src/onboarding.ts +166 -56
- package/src/types.ts +38 -5
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
|
|
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" : "
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
182
|
+
// 1. 解析 accountId:支持多账号选择 / 添加新账号
|
|
183
|
+
const defaultAccountId = resolveDefaultDingTalkAccountId(cfg);
|
|
184
|
+
const override = accountOverrides?.[PLUGIN_ID]?.trim();
|
|
185
|
+
let accountId = override ?? defaultAccountId;
|
|
63
186
|
|
|
64
|
-
if (!
|
|
65
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
*
|
|
26
|
+
* 单个钉钉账户配置 Schema
|
|
27
|
+
* 每个账户对应一个钉钉企业机器人应用
|
|
27
28
|
*/
|
|
28
|
-
export const
|
|
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
|
|
88
|
+
/** 账户 ID */
|
|
56
89
|
accountId: string;
|
|
57
90
|
/** 账户名称 */
|
|
58
91
|
name?: string;
|