@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/channel.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildChannelConfigSchema,
|
|
3
3
|
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
setAccountEnabledInConfigSection,
|
|
5
|
+
deleteAccountFromConfigSection,
|
|
6
|
+
applyAccountNameToChannelSection,
|
|
4
7
|
formatPairingApproveHint,
|
|
5
8
|
loadWebMedia,
|
|
6
9
|
missingTargetError,
|
|
10
|
+
normalizeAccountId,
|
|
7
11
|
type ChannelPlugin,
|
|
8
12
|
type ChannelStatusIssue,
|
|
9
13
|
type ChannelAccountSnapshot,
|
|
@@ -13,16 +17,16 @@ import path from "path";
|
|
|
13
17
|
import { getDingTalkRuntime } from "./runtime.js";
|
|
14
18
|
import {
|
|
15
19
|
listDingTalkAccountIds,
|
|
16
|
-
normalizeAccountId,
|
|
17
20
|
resolveDefaultDingTalkAccountId,
|
|
18
21
|
resolveDingTalkAccount,
|
|
19
22
|
} from "./accounts.js";
|
|
20
23
|
import { DingTalkConfigSchema, type DingTalkConfig, type ResolvedDingTalkAccount, type DingTalkGroupConfig } from "./types.js";
|
|
21
|
-
import { sendTextMessage, sendImageMessage, sendFileMessage, uploadMedia, probeDingTalkBot, inferMediaType, isGroupTarget } from "./client.js";
|
|
24
|
+
import { sendTextMessage, sendImageMessage, sendFileMessage, sendAudioMessage, sendVideoMessage, uploadMedia, probeDingTalkBot, inferMediaType, isGroupTarget } from "./client.js";
|
|
22
25
|
import { logger } from "./logger.js";
|
|
23
26
|
import { monitorDingTalkProvider } from "./monitor.js";
|
|
24
27
|
import { dingtalkOnboardingAdapter } from "./onboarding.js";
|
|
25
28
|
import { PLUGIN_ID } from "./constants.js";
|
|
29
|
+
import { hasFFmpeg, probeMediaBuffer } from "./ffmpeg.js";
|
|
26
30
|
|
|
27
31
|
// ======================= Target Normalization =======================
|
|
28
32
|
|
|
@@ -102,10 +106,10 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
102
106
|
enforceOwnerForCommands: true,
|
|
103
107
|
},
|
|
104
108
|
groups: {
|
|
105
|
-
resolveToolPolicy: ({ cfg, groupId }) => {
|
|
109
|
+
resolveToolPolicy: ({ cfg, accountId, groupId }) => {
|
|
106
110
|
if (!groupId) return undefined;
|
|
107
|
-
const
|
|
108
|
-
const groups =
|
|
111
|
+
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
112
|
+
const groups = account.groups;
|
|
109
113
|
if (!groups) return undefined;
|
|
110
114
|
const key = Object.keys(groups).find(
|
|
111
115
|
(k) => k === groupId || k.toLowerCase() === groupId.toLowerCase()
|
|
@@ -117,32 +121,23 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
117
121
|
configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
|
|
118
122
|
config: {
|
|
119
123
|
listAccountIds: (cfg) => listDingTalkAccountIds(cfg),
|
|
120
|
-
resolveAccount: (cfg,
|
|
121
|
-
defaultAccountId: (
|
|
122
|
-
setAccountEnabled: ({ cfg, enabled }) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const { clientId, clientSecret, ...rest } = dingtalkConfig;
|
|
138
|
-
return {
|
|
139
|
-
...cfg,
|
|
140
|
-
channels: {
|
|
141
|
-
...cfg.channels,
|
|
142
|
-
[PLUGIN_ID]: rest,
|
|
143
|
-
},
|
|
144
|
-
};
|
|
145
|
-
},
|
|
124
|
+
resolveAccount: (cfg, accountId) => resolveDingTalkAccount({ cfg, accountId }),
|
|
125
|
+
defaultAccountId: (cfg) => resolveDefaultDingTalkAccountId(cfg),
|
|
126
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
127
|
+
setAccountEnabledInConfigSection({
|
|
128
|
+
cfg,
|
|
129
|
+
sectionKey: PLUGIN_ID,
|
|
130
|
+
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
131
|
+
enabled,
|
|
132
|
+
allowTopLevel: true,
|
|
133
|
+
}),
|
|
134
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
135
|
+
deleteAccountFromConfigSection({
|
|
136
|
+
cfg,
|
|
137
|
+
sectionKey: PLUGIN_ID,
|
|
138
|
+
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
139
|
+
clearBaseFields: ["clientId", "clientSecret", "name"],
|
|
140
|
+
}),
|
|
146
141
|
isConfigured: (account) => Boolean(account.clientId?.trim() && account.clientSecret?.trim()),
|
|
147
142
|
describeAccount: (account) => ({
|
|
148
143
|
accountId: account.accountId,
|
|
@@ -151,8 +146,8 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
151
146
|
configured: Boolean(account.clientId?.trim() && account.clientSecret?.trim()),
|
|
152
147
|
tokenSource: account.tokenSource,
|
|
153
148
|
}),
|
|
154
|
-
resolveAllowFrom: ({ cfg }) =>
|
|
155
|
-
resolveDingTalkAccount({ cfg }).allowFrom.map((entry) => String(entry)),
|
|
149
|
+
resolveAllowFrom: ({ cfg, accountId }) =>
|
|
150
|
+
resolveDingTalkAccount({ cfg, accountId }).allowFrom.map((entry) => String(entry)),
|
|
156
151
|
formatAllowFrom: ({ allowFrom }) =>
|
|
157
152
|
allowFrom
|
|
158
153
|
.map((entry) => String(entry).trim())
|
|
@@ -160,13 +155,16 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
160
155
|
.map((entry) => entry.replace(new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i"), "")),
|
|
161
156
|
},
|
|
162
157
|
security: {
|
|
163
|
-
resolveDmPolicy: ({ cfg }) => {
|
|
164
|
-
const account = resolveDingTalkAccount({ cfg });
|
|
158
|
+
resolveDmPolicy: ({ cfg, accountId }) => {
|
|
159
|
+
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
160
|
+
const basePath = account.accountId === DEFAULT_ACCOUNT_ID
|
|
161
|
+
? `channels.${PLUGIN_ID}`
|
|
162
|
+
: `channels.${PLUGIN_ID}.accounts.${account.accountId}`;
|
|
165
163
|
return {
|
|
166
164
|
policy: "allowlist",
|
|
167
165
|
allowFrom: account.allowFrom,
|
|
168
|
-
policyPath:
|
|
169
|
-
allowFromPath:
|
|
166
|
+
policyPath: `${basePath}.allowFrom`,
|
|
167
|
+
allowFromPath: `${basePath}.`,
|
|
170
168
|
approveHint: formatPairingApproveHint(PLUGIN_ID),
|
|
171
169
|
normalizeEntry: (raw) => raw.replace(new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i"), ""),
|
|
172
170
|
};
|
|
@@ -198,20 +196,14 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
198
196
|
},
|
|
199
197
|
|
|
200
198
|
setup: {
|
|
201
|
-
resolveAccountId: () => normalizeAccountId(),
|
|
202
|
-
applyAccountName: ({ cfg, name }) =>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
...dingtalkConfig,
|
|
210
|
-
name,
|
|
211
|
-
},
|
|
212
|
-
},
|
|
213
|
-
};
|
|
214
|
-
},
|
|
199
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
200
|
+
applyAccountName: ({ cfg, accountId, name }) =>
|
|
201
|
+
applyAccountNameToChannelSection({
|
|
202
|
+
cfg,
|
|
203
|
+
channelKey: PLUGIN_ID,
|
|
204
|
+
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
205
|
+
name,
|
|
206
|
+
}),
|
|
215
207
|
validateInput: ({ input }) => {
|
|
216
208
|
const typedInput = input as {
|
|
217
209
|
clientId?: string;
|
|
@@ -225,24 +217,57 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
225
217
|
}
|
|
226
218
|
return null;
|
|
227
219
|
},
|
|
228
|
-
applyAccountConfig: ({ cfg, input }) => {
|
|
220
|
+
applyAccountConfig: ({ cfg, accountId, input }) => {
|
|
229
221
|
const typedInput = input as {
|
|
230
222
|
name?: string;
|
|
231
223
|
clientId?: string;
|
|
232
224
|
clientSecret?: string;
|
|
233
225
|
};
|
|
234
|
-
const
|
|
226
|
+
const aid = normalizeAccountId(accountId);
|
|
227
|
+
|
|
228
|
+
// 应用账号名称
|
|
229
|
+
let next = applyAccountNameToChannelSection({
|
|
230
|
+
cfg,
|
|
231
|
+
channelKey: PLUGIN_ID,
|
|
232
|
+
accountId: aid,
|
|
233
|
+
name: typedInput.name,
|
|
234
|
+
});
|
|
235
235
|
|
|
236
|
+
const dingtalkConfig = (next.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
|
|
237
|
+
|
|
238
|
+
// default 账号 → 写顶层(兼容旧版 + 前端面板)
|
|
239
|
+
if (aid === DEFAULT_ACCOUNT_ID) {
|
|
240
|
+
return {
|
|
241
|
+
...next,
|
|
242
|
+
channels: {
|
|
243
|
+
...next.channels,
|
|
244
|
+
[PLUGIN_ID]: {
|
|
245
|
+
...dingtalkConfig,
|
|
246
|
+
enabled: true,
|
|
247
|
+
...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
|
|
248
|
+
...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 非 default 账号 → 写 accounts[accountId]
|
|
236
255
|
return {
|
|
237
|
-
...
|
|
256
|
+
...next,
|
|
238
257
|
channels: {
|
|
239
|
-
...
|
|
258
|
+
...next.channels,
|
|
240
259
|
[PLUGIN_ID]: {
|
|
241
260
|
...dingtalkConfig,
|
|
242
261
|
enabled: true,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
262
|
+
accounts: {
|
|
263
|
+
...dingtalkConfig.accounts,
|
|
264
|
+
[aid]: {
|
|
265
|
+
...dingtalkConfig.accounts?.[aid],
|
|
266
|
+
enabled: true,
|
|
267
|
+
...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
|
|
268
|
+
...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
|
|
269
|
+
},
|
|
270
|
+
},
|
|
246
271
|
},
|
|
247
272
|
},
|
|
248
273
|
};
|
|
@@ -324,19 +349,19 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
324
349
|
),
|
|
325
350
|
};
|
|
326
351
|
},
|
|
327
|
-
sendText: async ({ to, text, cfg }) => {
|
|
328
|
-
const account = resolveDingTalkAccount({ cfg });
|
|
352
|
+
sendText: async ({ to, text, cfg, accountId }) => {
|
|
353
|
+
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
329
354
|
const result = await sendTextMessage(to, text, { account });
|
|
330
355
|
return { channel: PLUGIN_ID, ...result };
|
|
331
356
|
},
|
|
332
|
-
sendMedia: async ({ to, text, mediaUrl, cfg }) => {
|
|
357
|
+
sendMedia: async ({ to, text, mediaUrl, cfg, accountId }) => {
|
|
333
358
|
// 没有媒体 URL,提前返回
|
|
334
359
|
if (!mediaUrl) {
|
|
335
360
|
logger.warn("[sendMedia] 没有 mediaUrl,跳过");
|
|
336
361
|
return { channel: PLUGIN_ID, messageId: "", chatId: to };
|
|
337
362
|
}
|
|
338
363
|
|
|
339
|
-
const account = resolveDingTalkAccount({ cfg });
|
|
364
|
+
const account = resolveDingTalkAccount({ cfg, accountId });
|
|
340
365
|
|
|
341
366
|
try {
|
|
342
367
|
logger.log(`准备发送媒体: ${mediaUrl}`);
|
|
@@ -348,26 +373,91 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
348
373
|
|
|
349
374
|
logger.log(`加载媒体成功 | type: ${mediaType} | mimeType: ${mimeType} | size: ${(media.buffer.length / 1024).toFixed(2)} KB`);
|
|
350
375
|
|
|
351
|
-
// 上传到钉钉
|
|
352
376
|
const fileName = media.fileName || path.basename(mediaUrl) || `file_${Date.now()}`;
|
|
377
|
+
const ext = path.extname(fileName).slice(1) || "file";
|
|
378
|
+
|
|
379
|
+
// 上传到钉钉
|
|
353
380
|
const uploadResult = await uploadMedia(media.buffer, fileName, account, {
|
|
354
381
|
mimeType,
|
|
355
382
|
type: mediaType,
|
|
356
383
|
});
|
|
357
384
|
|
|
358
|
-
// 统一使用文件发送(语音/视频因格式限制和参数要求,也降级为文件)
|
|
359
|
-
const ext = path.extname(fileName).slice(1) || "file";
|
|
360
385
|
let sendResult: { messageId: string; chatId: string };
|
|
361
386
|
|
|
362
387
|
if (mediaType === "image") {
|
|
363
388
|
// 图片使用 photoURL
|
|
364
389
|
sendResult = await sendImageMessage(to, uploadResult.url, { account });
|
|
390
|
+
logger.log("发送图片消息成功");
|
|
391
|
+
} else if (mediaType === "voice" && hasFFmpeg()) {
|
|
392
|
+
// 语音:使用 ffprobe 获取时长,发送原生语音消息
|
|
393
|
+
try {
|
|
394
|
+
const probe = await probeMediaBuffer(media.buffer, fileName, "voice");
|
|
395
|
+
sendResult = await sendAudioMessage(to, uploadResult.mediaId, {
|
|
396
|
+
account,
|
|
397
|
+
duration: String(probe.duration),
|
|
398
|
+
});
|
|
399
|
+
logger.log(`发送语音消息成功 | duration: ${(probe.duration / 1000).toFixed(1)}s`);
|
|
400
|
+
} catch (probeErr) {
|
|
401
|
+
logger.warn(`[sendMedia] 语音探测失败,降级为文件发送: ${probeErr}`);
|
|
402
|
+
sendResult = await sendFileMessage(to, uploadResult.mediaId, fileName, ext, { account });
|
|
403
|
+
logger.log("发送语音消息成功(降级为文件形式)");
|
|
404
|
+
}
|
|
405
|
+
} else if (mediaType === "video" && hasFFmpeg()) {
|
|
406
|
+
// 视频:使用 ffprobe 获取时长和分辨率,提取封面,发送原生视频消息
|
|
407
|
+
try {
|
|
408
|
+
const probe = await probeMediaBuffer(media.buffer, fileName, "video");
|
|
409
|
+
const videoOpts: {
|
|
410
|
+
account: typeof account;
|
|
411
|
+
duration?: string;
|
|
412
|
+
picMediaId?: string;
|
|
413
|
+
width?: string;
|
|
414
|
+
height?: string;
|
|
415
|
+
} = { account };
|
|
416
|
+
|
|
417
|
+
if (probe.duration) {
|
|
418
|
+
videoOpts.duration = String(Math.floor(probe.duration / 1000));
|
|
419
|
+
}
|
|
420
|
+
if (probe.width) {
|
|
421
|
+
videoOpts.width = String(probe.width);
|
|
422
|
+
}
|
|
423
|
+
if (probe.height) {
|
|
424
|
+
videoOpts.height = String(probe.height);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 上传封面图
|
|
428
|
+
if (probe.coverBuffer) {
|
|
429
|
+
try {
|
|
430
|
+
const coverUpload = await uploadMedia(probe.coverBuffer, "cover.jpg", account, {
|
|
431
|
+
mimeType: "image/jpeg",
|
|
432
|
+
type: "image",
|
|
433
|
+
});
|
|
434
|
+
videoOpts.picMediaId = coverUpload.mediaId;
|
|
435
|
+
logger.log(`视频封面上传成功 | picMediaId: ${coverUpload.mediaId}`);
|
|
436
|
+
} catch (coverErr) {
|
|
437
|
+
logger.warn(`[sendMedia] 视频封面上传失败,将不带封面发送: ${coverErr}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
sendResult = await sendVideoMessage(to, uploadResult.mediaId, videoOpts);
|
|
442
|
+
logger.log(`发送视频消息成功 | duration: ${(probe.duration / 1000).toFixed(1)}s | ${probe.width}x${probe.height}`);
|
|
443
|
+
} catch (probeErr) {
|
|
444
|
+
logger.warn(`[sendMedia] 视频探测失败,降级为文件发送: ${probeErr}`);
|
|
445
|
+
sendResult = await sendFileMessage(to, uploadResult.mediaId, fileName, ext, { account });
|
|
446
|
+
logger.log("发送视频消息成功(降级为文件形式)");
|
|
447
|
+
}
|
|
365
448
|
} else {
|
|
366
|
-
//
|
|
449
|
+
// 文件 或 无 ffmpeg 的语音/视频:降级为文件发送
|
|
367
450
|
sendResult = await sendFileMessage(to, uploadResult.mediaId, fileName, ext, { account });
|
|
368
|
-
}
|
|
369
451
|
|
|
370
|
-
|
|
452
|
+
if ((mediaType === "voice" || mediaType === "video") && !hasFFmpeg()) {
|
|
453
|
+
logger.log(`发送${mediaType}消息成功(文件形式,系统未安装 ffmpeg)`);
|
|
454
|
+
// 附带降级提示文本
|
|
455
|
+
const hint = `⚠️ 系统未安装 ffmpeg,${mediaType === "voice" ? "语音" : "视频"}已降级为文件发送。如需原生${mediaType === "voice" ? "语音" : "视频"}体验,请安装 ffmpeg。`;
|
|
456
|
+
await sendTextMessage(to, hint, { account });
|
|
457
|
+
} else {
|
|
458
|
+
logger.log("发送文件消息成功");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
371
461
|
|
|
372
462
|
// 如果有文本,再发送文本消息
|
|
373
463
|
if (text?.trim()) {
|
|
@@ -469,40 +559,50 @@ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
|
469
559
|
abortSignal: ctx.abortSignal,
|
|
470
560
|
});
|
|
471
561
|
},
|
|
472
|
-
logoutAccount: async ({ cfg }) => {
|
|
562
|
+
logoutAccount: async ({ cfg, accountId: rawAccountId }) => {
|
|
563
|
+
const accountId = normalizeAccountId(rawAccountId);
|
|
473
564
|
const nextCfg = { ...cfg } as OpenClawConfig;
|
|
474
565
|
const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
|
|
475
|
-
const nextDingTalk = { ...dingtalkConfig };
|
|
476
566
|
let cleared = false;
|
|
477
567
|
let changed = false;
|
|
478
568
|
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
nextDingTalk
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
569
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
570
|
+
// default 账号:清顶层凭据
|
|
571
|
+
const nextDingTalk = { ...dingtalkConfig };
|
|
572
|
+
if (nextDingTalk.clientId || nextDingTalk.clientSecret) {
|
|
573
|
+
delete nextDingTalk.clientId;
|
|
574
|
+
delete nextDingTalk.clientSecret;
|
|
575
|
+
cleared = true;
|
|
576
|
+
changed = true;
|
|
577
|
+
}
|
|
578
|
+
if (changed) {
|
|
579
|
+
nextCfg.channels = { ...nextCfg.channels, [PLUGIN_ID]: nextDingTalk };
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
// 非 default 账号:清 accounts[accountId] 凭据
|
|
583
|
+
const accounts = { ...(dingtalkConfig.accounts ?? {}) };
|
|
584
|
+
const target = accounts[accountId];
|
|
585
|
+
if (target && (target.clientId || target.clientSecret)) {
|
|
586
|
+
const { clientId: _cid, clientSecret: _cs, ...rest } = target;
|
|
587
|
+
accounts[accountId] = rest;
|
|
588
|
+
cleared = true;
|
|
589
|
+
changed = true;
|
|
590
|
+
}
|
|
591
|
+
if (changed) {
|
|
592
|
+
nextCfg.channels = {
|
|
593
|
+
...nextCfg.channels,
|
|
594
|
+
[PLUGIN_ID]: { ...dingtalkConfig, accounts },
|
|
595
|
+
};
|
|
596
|
+
}
|
|
487
597
|
}
|
|
488
598
|
|
|
489
599
|
if (changed) {
|
|
490
|
-
if (Object.keys(nextDingTalk).length > 0) {
|
|
491
|
-
nextCfg.channels = { ...nextCfg.channels, [PLUGIN_ID]: nextDingTalk };
|
|
492
|
-
} else {
|
|
493
|
-
const nextChannels = { ...nextCfg.channels };
|
|
494
|
-
delete (nextChannels as Record<string, unknown>)[PLUGIN_ID];
|
|
495
|
-
if (Object.keys(nextChannels).length > 0) {
|
|
496
|
-
nextCfg.channels = nextChannels;
|
|
497
|
-
} else {
|
|
498
|
-
delete nextCfg.channels;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
600
|
await getDingTalkRuntime().config.writeConfigFile(nextCfg);
|
|
502
601
|
}
|
|
503
602
|
|
|
504
603
|
const resolved = resolveDingTalkAccount({
|
|
505
604
|
cfg: changed ? nextCfg : cfg,
|
|
605
|
+
accountId,
|
|
506
606
|
});
|
|
507
607
|
const loggedOut = resolved.tokenSource === "none";
|
|
508
608
|
|
package/src/client.ts
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
|
-
import * as $OpenApi from "@alicloud/openapi-client";
|
|
2
|
-
import * as $Util from "@alicloud/tea-util";
|
|
3
|
-
import dingtalk from "@alicloud/dingtalk";
|
|
4
1
|
import type { ResolvedDingTalkAccount, WebhookResponse, MarkdownReplyBody } from "./types.js";
|
|
5
2
|
import { logger } from "./logger.js";
|
|
6
3
|
|
|
7
|
-
|
|
4
|
+
// ======================= 钉钉 API 基础封装 =======================
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
const DINGTALK_API_BASE = "https://api.dingtalk.com";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 钉钉新版 API 统一调用(v1.0 接口)
|
|
10
|
+
* @param path - API 路径,如 `/v1.0/oauth2/accessToken`
|
|
11
|
+
* @param body - 请求体
|
|
12
|
+
* @param accessToken - 可选,需要鉴权的接口传入
|
|
13
|
+
*/
|
|
14
|
+
async function dingtalkApi<T = Record<string, unknown>>(
|
|
15
|
+
path: string,
|
|
16
|
+
body: Record<string, unknown>,
|
|
17
|
+
accessToken?: string
|
|
18
|
+
): Promise<T> {
|
|
19
|
+
const headers: Record<string, string> = {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
};
|
|
22
|
+
if (accessToken) {
|
|
23
|
+
headers["x-acs-dingtalk-access-token"] = accessToken;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const response = await fetch(`${DINGTALK_API_BASE}${path}`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers,
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const text = await response.text();
|
|
34
|
+
throw new Error(`钉钉 API 请求失败 [${response.status}]: ${text}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (await response.json()) as T;
|
|
38
|
+
}
|
|
12
39
|
|
|
13
40
|
// ======================= Access Token 缓存 =======================
|
|
14
41
|
|
|
@@ -19,26 +46,6 @@ interface TokenCache {
|
|
|
19
46
|
|
|
20
47
|
const tokenCacheMap = new Map<string, TokenCache>();
|
|
21
48
|
|
|
22
|
-
/**
|
|
23
|
-
* 创建 OAuth2 客户端
|
|
24
|
-
*/
|
|
25
|
-
function createOAuth2Client(): OAuth2Client {
|
|
26
|
-
const config = new $OpenApi.Config({});
|
|
27
|
-
config.protocol = "https";
|
|
28
|
-
config.regionId = "central";
|
|
29
|
-
return new oauth2_1_0.default(config);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 创建 Robot 客户端
|
|
34
|
-
*/
|
|
35
|
-
function createRobotClient(): RobotClient {
|
|
36
|
-
const config = new $OpenApi.Config({});
|
|
37
|
-
config.protocol = "https";
|
|
38
|
-
config.regionId = "central";
|
|
39
|
-
return new robot_1_0.default(config);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
49
|
/**
|
|
43
50
|
* 获取钉钉 access_token
|
|
44
51
|
*/
|
|
@@ -51,17 +58,17 @@ export async function getAccessToken(account: ResolvedDingTalkAccount): Promise<
|
|
|
51
58
|
return cached.token;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
const result = await dingtalkApi<{ accessToken?: string; expireIn?: number }>(
|
|
62
|
+
"/v1.0/oauth2/accessToken",
|
|
63
|
+
{
|
|
64
|
+
appKey: account.clientId,
|
|
65
|
+
appSecret: account.clientSecret,
|
|
66
|
+
}
|
|
67
|
+
);
|
|
61
68
|
|
|
62
|
-
if (
|
|
63
|
-
const token =
|
|
64
|
-
const expireTime = Date.now() + (
|
|
69
|
+
if (result.accessToken) {
|
|
70
|
+
const token = result.accessToken;
|
|
71
|
+
const expireTime = Date.now() + (result.expireIn ?? 7200) * 1000;
|
|
65
72
|
tokenCacheMap.set(cacheKey, { token, expireTime });
|
|
66
73
|
return token;
|
|
67
74
|
}
|
|
@@ -159,26 +166,19 @@ async function sendOTOMessage(
|
|
|
159
166
|
options: SendMessageOptions
|
|
160
167
|
): Promise<SendMessageResult> {
|
|
161
168
|
const accessToken = await getAccessToken(options.account);
|
|
162
|
-
const robotClient = createRobotClient();
|
|
163
|
-
|
|
164
|
-
const headers = new robot_1_0.BatchSendOTOHeaders({
|
|
165
|
-
xAcsDingtalkAccessToken: accessToken,
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const request = new robot_1_0.BatchSendOTORequest({
|
|
169
|
-
robotCode: options.account.clientId,
|
|
170
|
-
userIds: [userId],
|
|
171
|
-
msgKey,
|
|
172
|
-
msgParam: JSON.stringify(msgParam),
|
|
173
|
-
});
|
|
174
169
|
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
170
|
+
const result = await dingtalkApi<{ processQueryKey?: string }>(
|
|
171
|
+
"/v1.0/robot/oToMessages/batchSend",
|
|
172
|
+
{
|
|
173
|
+
robotCode: options.account.clientId,
|
|
174
|
+
userIds: [userId],
|
|
175
|
+
msgKey,
|
|
176
|
+
msgParam: JSON.stringify(msgParam),
|
|
177
|
+
},
|
|
178
|
+
accessToken
|
|
179
179
|
);
|
|
180
180
|
|
|
181
|
-
const processQueryKey =
|
|
181
|
+
const processQueryKey = result.processQueryKey ?? `dingtalk-${Date.now()}`;
|
|
182
182
|
|
|
183
183
|
return {
|
|
184
184
|
messageId: processQueryKey,
|
|
@@ -196,26 +196,19 @@ async function sendGroupMessage(
|
|
|
196
196
|
options: SendMessageOptions
|
|
197
197
|
): Promise<SendMessageResult> {
|
|
198
198
|
const accessToken = await getAccessToken(options.account);
|
|
199
|
-
const robotClient = createRobotClient();
|
|
200
|
-
|
|
201
|
-
const headers = new robot_1_0.OrgGroupSendHeaders({
|
|
202
|
-
xAcsDingtalkAccessToken: accessToken,
|
|
203
|
-
});
|
|
204
199
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
headers,
|
|
215
|
-
new $Util.RuntimeOptions({})
|
|
200
|
+
const result = await dingtalkApi<{ processQueryKey?: string }>(
|
|
201
|
+
"/v1.0/robot/groupMessages/send",
|
|
202
|
+
{
|
|
203
|
+
robotCode: options.account.clientId,
|
|
204
|
+
openConversationId,
|
|
205
|
+
msgKey,
|
|
206
|
+
msgParam: JSON.stringify(msgParam),
|
|
207
|
+
},
|
|
208
|
+
accessToken
|
|
216
209
|
);
|
|
217
210
|
|
|
218
|
-
const processQueryKey =
|
|
211
|
+
const processQueryKey = result.processQueryKey ?? `dingtalk-group-${Date.now()}`;
|
|
219
212
|
|
|
220
213
|
return {
|
|
221
214
|
messageId: processQueryKey,
|
|
@@ -297,7 +290,7 @@ export async function sendImageMessage(
|
|
|
297
290
|
/**
|
|
298
291
|
* 发送语音消息(自动路由群聊/单聊)
|
|
299
292
|
* @param mediaId - 语音文件的 mediaId(通过 uploadMedia 获取)
|
|
300
|
-
* @param duration -
|
|
293
|
+
* @param duration - 语音时长(毫秒),可选
|
|
301
294
|
*/
|
|
302
295
|
export async function sendAudioMessage(
|
|
303
296
|
to: string,
|
|
@@ -321,6 +314,7 @@ export async function sendAudioMessage(
|
|
|
321
314
|
|
|
322
315
|
/**
|
|
323
316
|
* 发送视频消息(自动路由群聊/单聊)
|
|
317
|
+
* @param duration - 视频时长(秒),可选
|
|
324
318
|
*/
|
|
325
319
|
export async function sendVideoMessage(
|
|
326
320
|
to: string,
|
|
@@ -457,25 +451,18 @@ export async function getFileDownloadUrl(
|
|
|
457
451
|
account: ResolvedDingTalkAccount
|
|
458
452
|
): Promise<string> {
|
|
459
453
|
const accessToken = await getAccessToken(account);
|
|
460
|
-
const robotClient = createRobotClient();
|
|
461
|
-
|
|
462
|
-
const headers = new robot_1_0.RobotMessageFileDownloadHeaders({
|
|
463
|
-
xAcsDingtalkAccessToken: accessToken,
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
const request = new robot_1_0.RobotMessageFileDownloadRequest({
|
|
467
|
-
downloadCode,
|
|
468
|
-
robotCode: account.clientId,
|
|
469
|
-
});
|
|
470
454
|
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
455
|
+
const result = await dingtalkApi<{ downloadUrl?: string }>(
|
|
456
|
+
"/v1.0/robot/messageFiles/download",
|
|
457
|
+
{
|
|
458
|
+
downloadCode,
|
|
459
|
+
robotCode: account.clientId,
|
|
460
|
+
},
|
|
461
|
+
accessToken
|
|
475
462
|
);
|
|
476
463
|
|
|
477
|
-
if (
|
|
478
|
-
return
|
|
464
|
+
if (result.downloadUrl) {
|
|
465
|
+
return result.downloadUrl;
|
|
479
466
|
}
|
|
480
467
|
|
|
481
468
|
throw new Error("获取下载链接失败: 返回结果为空");
|