@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/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 dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
108
- const groups = dingtalkConfig.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, _accountId) => resolveDingTalkAccount({ cfg }),
121
- defaultAccountId: (_cfg) => resolveDefaultDingTalkAccountId(_cfg),
122
- setAccountEnabled: ({ cfg, enabled }) => {
123
- const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
124
- return {
125
- ...cfg,
126
- channels: {
127
- ...cfg.channels,
128
- [PLUGIN_ID]: {
129
- ...dingtalkConfig,
130
- enabled,
131
- },
132
- },
133
- };
134
- },
135
- deleteAccount: ({ cfg }) => {
136
- const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
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: `channels.${PLUGIN_ID}.allowFrom`,
169
- allowFromPath: `channels.${PLUGIN_ID}.`,
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
- const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
204
- return {
205
- ...cfg,
206
- channels: {
207
- ...cfg.channels,
208
- [PLUGIN_ID]: {
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 dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
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
- ...cfg,
256
+ ...next,
238
257
  channels: {
239
- ...cfg.channels,
258
+ ...next.channels,
240
259
  [PLUGIN_ID]: {
241
260
  ...dingtalkConfig,
242
261
  enabled: true,
243
- ...(typedInput.name ? { name: typedInput.name } : {}),
244
- ...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
245
- ...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
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
- logger.log(`发送${mediaType}消息成功(${mediaType !== "image" ? "文件形式" : "图片形式"})`);
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
- nextDingTalk.clientId ||
481
- nextDingTalk.clientSecret
482
- ) {
483
- delete nextDingTalk.clientId;
484
- delete nextDingTalk.clientSecret;
485
- cleared = true;
486
- changed = true;
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
- const { oauth2_1_0, robot_1_0 } = dingtalk;
4
+ // ======================= 钉钉 API 基础封装 =======================
8
5
 
9
- // SDK 客户端类型
10
- type OAuth2Client = InstanceType<typeof oauth2_1_0.default>;
11
- type RobotClient = InstanceType<typeof robot_1_0.default>;
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 oauth2Client = createOAuth2Client();
55
- const request = new oauth2_1_0.GetAccessTokenRequest({
56
- appKey: account.clientId,
57
- appSecret: account.clientSecret,
58
- });
59
-
60
- const response = await oauth2Client.getAccessToken(request);
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 (response.body?.accessToken) {
63
- const token = response.body.accessToken;
64
- const expireTime = Date.now() + (response.body.expireIn ?? 7200) * 1000;
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 response = await robotClient.batchSendOTOWithOptions(
176
- request,
177
- headers,
178
- new $Util.RuntimeOptions({})
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 = response.body?.processQueryKey ?? `dingtalk-${Date.now()}`;
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 request = new robot_1_0.OrgGroupSendRequest({
206
- robotCode: options.account.clientId,
207
- openConversationId,
208
- msgKey,
209
- msgParam: JSON.stringify(msgParam),
210
- });
211
-
212
- const response = await robotClient.orgGroupSendWithOptions(
213
- request,
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 = response.body?.processQueryKey ?? `dingtalk-group-${Date.now()}`;
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 response = await robotClient.robotMessageFileDownloadWithOptions(
472
- request,
473
- headers,
474
- new $Util.RuntimeOptions({})
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 (response.body?.downloadUrl) {
478
- return response.body.downloadUrl;
464
+ if (result.downloadUrl) {
465
+ return result.downloadUrl;
479
466
  }
480
467
 
481
468
  throw new Error("获取下载链接失败: 返回结果为空");