@largezhou/ddingtalk 1.3.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/client.ts ADDED
@@ -0,0 +1,620 @@
1
+ import * as $OpenApi from "@alicloud/openapi-client";
2
+ import * as $Util from "@alicloud/tea-util";
3
+ import dingtalk from "@alicloud/dingtalk";
4
+ import type { ResolvedDingTalkAccount, WebhookResponse, MarkdownReplyBody } from "./types.js";
5
+ import { logger } from "./logger.js";
6
+
7
+ const { oauth2_1_0, robot_1_0 } = dingtalk;
8
+
9
+ // SDK 客户端类型
10
+ type OAuth2Client = InstanceType<typeof oauth2_1_0.default>;
11
+ type RobotClient = InstanceType<typeof robot_1_0.default>;
12
+
13
+ // ======================= Access Token 缓存 =======================
14
+
15
+ interface TokenCache {
16
+ token: string;
17
+ expireTime: number;
18
+ }
19
+
20
+ const tokenCacheMap = new Map<string, TokenCache>();
21
+
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
+ /**
43
+ * 获取钉钉 access_token
44
+ */
45
+ export async function getAccessToken(account: ResolvedDingTalkAccount): Promise<string> {
46
+ const cacheKey = `${account.clientId}`;
47
+ const cached = tokenCacheMap.get(cacheKey);
48
+
49
+ // 检查缓存的 token 是否有效(提前5分钟过期)
50
+ if (cached && Date.now() < cached.expireTime - 5 * 60 * 1000) {
51
+ return cached.token;
52
+ }
53
+
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
+
62
+ if (response.body?.accessToken) {
63
+ const token = response.body.accessToken;
64
+ const expireTime = Date.now() + (response.body.expireIn ?? 7200) * 1000;
65
+ tokenCacheMap.set(cacheKey, { token, expireTime });
66
+ return token;
67
+ }
68
+
69
+ throw new Error("获取 access_token 失败: 返回结果为空");
70
+ }
71
+
72
+ // ======================= 发送消息 =======================
73
+
74
+ export interface SendMessageOptions {
75
+ account: ResolvedDingTalkAccount;
76
+ verbose?: boolean;
77
+ }
78
+
79
+ export interface SendMessageResult {
80
+ messageId: string;
81
+ chatId: string;
82
+ }
83
+
84
+ /**
85
+ * 通过 sessionWebhook 回复消息(markdown 格式)
86
+ */
87
+ export async function replyViaWebhook(
88
+ webhook: string,
89
+ content: string,
90
+ options?: {
91
+ atUserIds?: string[];
92
+ isAtAll?: boolean;
93
+ }
94
+ ): Promise<WebhookResponse> {
95
+ const contentPreview = content.slice(0, 50).replace(/\n/g, " ");
96
+ logger.log(`[回复消息] via Webhook | ${contentPreview}${content.length > 50 ? "..." : ""}`);
97
+
98
+ const title = content.slice(0, 10).replace(/\n/g, " ");
99
+ const body: MarkdownReplyBody = {
100
+ msgtype: "markdown",
101
+ markdown: {
102
+ title,
103
+ text: content,
104
+ },
105
+ at: {
106
+ atUserIds: options?.atUserIds ?? [],
107
+ isAtAll: options?.isAtAll ?? false,
108
+ },
109
+ };
110
+
111
+ const response = await fetch(webhook, {
112
+ method: "POST",
113
+ headers: {
114
+ "Content-Type": "application/json",
115
+ },
116
+ body: JSON.stringify(body),
117
+ });
118
+
119
+ const result = (await response.json()) as WebhookResponse;
120
+
121
+ if (result.errcode === 0) {
122
+ logger.log(`[回复消息] 发送成功`);
123
+ } else {
124
+ logger.error(`[回复消息] 发送失败: ${result.errmsg ?? JSON.stringify(result)}`);
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ // ======================= 主动发送消息(BatchSendOTO / OrgGroupSend) =======================
131
+
132
+ /**
133
+ * 钉钉机器人消息类型(msgKey)
134
+ * @see https://open.dingtalk.com/document/orgapp/types-of-messages-sent-by-enterprise-robots
135
+ */
136
+ export type DingTalkMsgKey =
137
+ | "sampleText" // 文本
138
+ | "sampleMarkdown" // Markdown
139
+ | "sampleImageMsg" // 图片
140
+ | "sampleLink" // 链接
141
+ | "sampleAudio" // 语音
142
+ | "sampleVideo" // 视频
143
+ | "sampleFile" // 文件
144
+ | "sampleActionCard" // 卡片
145
+ | "sampleActionCard2" // 卡片(独立跳转)
146
+ | "sampleActionCard3" // 卡片(竖向按钮)
147
+ | "sampleActionCard4" // 卡片(横向按钮)
148
+ | "sampleActionCard5" // 卡片(横向2按钮)
149
+ | "sampleActionCard6"; // 卡片(横向3按钮)
150
+
151
+ /**
152
+ * 底层通用方法:主动发送单聊消息(BatchSendOTO)
153
+ * 所有 sendXxxMessage 方法都基于此方法实现
154
+ */
155
+ async function sendOTOMessage(
156
+ userId: string,
157
+ msgKey: DingTalkMsgKey,
158
+ msgParam: Record<string, unknown>,
159
+ options: SendMessageOptions
160
+ ): Promise<SendMessageResult> {
161
+ 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
+
175
+ const response = await robotClient.batchSendOTOWithOptions(
176
+ request,
177
+ headers,
178
+ new $Util.RuntimeOptions({})
179
+ );
180
+
181
+ const processQueryKey = response.body?.processQueryKey ?? `dingtalk-${Date.now()}`;
182
+
183
+ return {
184
+ messageId: processQueryKey,
185
+ chatId: userId,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * 底层通用方法:主动发送群聊消息(OrgGroupSend)
191
+ */
192
+ async function sendGroupMessage(
193
+ openConversationId: string,
194
+ msgKey: DingTalkMsgKey,
195
+ msgParam: Record<string, unknown>,
196
+ options: SendMessageOptions
197
+ ): Promise<SendMessageResult> {
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
+
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({})
216
+ );
217
+
218
+ const processQueryKey = response.body?.processQueryKey ?? `dingtalk-group-${Date.now()}`;
219
+
220
+ return {
221
+ messageId: processQueryKey,
222
+ chatId: openConversationId,
223
+ };
224
+ }
225
+
226
+ // ======================= 统一目标路由 =======================
227
+
228
+ /**
229
+ * 判断目标是否为群聊
230
+ * 群聊目标格式:chat:<openConversationId>
231
+ * 单聊目标格式:user:<userId> 或直接 <userId>
232
+ */
233
+ export function isGroupTarget(to: string): boolean {
234
+ return to.startsWith("chat:");
235
+ }
236
+
237
+ /** 从 to 中提取实际 ID(去除 chat: / user: 前缀) */
238
+ export function extractTargetId(to: string): string {
239
+ if (to.startsWith("chat:")) return to.slice(5);
240
+ if (to.startsWith("user:")) return to.slice(5);
241
+ return to;
242
+ }
243
+
244
+ /**
245
+ * 统一发送消息(自动根据 to 格式路由到单聊或群聊)
246
+ */
247
+ async function sendMessage(
248
+ to: string,
249
+ msgKey: DingTalkMsgKey,
250
+ msgParam: Record<string, unknown>,
251
+ options: SendMessageOptions
252
+ ): Promise<SendMessageResult> {
253
+ const targetId = extractTargetId(to);
254
+ if (isGroupTarget(to)) {
255
+ return sendGroupMessage(targetId, msgKey, msgParam, options);
256
+ }
257
+ return sendOTOMessage(targetId, msgKey, msgParam, options);
258
+ }
259
+
260
+ /**
261
+ * 发送文本消息(markdown 格式,自动路由群聊/单聊)
262
+ */
263
+ export async function sendTextMessage(
264
+ to: string,
265
+ content: string,
266
+ options: SendMessageOptions
267
+ ): Promise<SendMessageResult> {
268
+ const contentPreview = content.slice(0, 50).replace(/\n/g, " ");
269
+ const isGroup = isGroupTarget(to);
270
+ logger.log(`[主动发送] 文本消息 | ${isGroup ? "群聊" : "单聊"} | to: ${to} | ${contentPreview}${content.length > 50 ? "..." : ""}`);
271
+
272
+ const title = content.slice(0, 10).replace(/\n/g, " ");
273
+ const result = await sendMessage(to, "sampleMarkdown", { title, text: content }, options);
274
+
275
+ logger.log(`[主动发送] 文本消息发送成功 | messageId: ${result.messageId}`);
276
+ return result;
277
+ }
278
+
279
+ /**
280
+ * 发送图片消息(自动路由群聊/单聊)
281
+ * @param photoURL - 图片的公网可访问 URL
282
+ */
283
+ export async function sendImageMessage(
284
+ to: string,
285
+ photoURL: string,
286
+ options: SendMessageOptions
287
+ ): Promise<SendMessageResult> {
288
+ const isGroup = isGroupTarget(to);
289
+ logger.log(`[主动发送] 图片消息 | ${isGroup ? "群聊" : "单聊"} | to: ${to} | photoURL: ${photoURL.slice(0, 80)}...`);
290
+
291
+ const result = await sendMessage(to, "sampleImageMsg", { photoURL }, options);
292
+
293
+ logger.log(`[主动发送] 图片消息发送成功 | messageId: ${result.messageId}`);
294
+ return result;
295
+ }
296
+
297
+ /**
298
+ * 发送语音消息(自动路由群聊/单聊)
299
+ * @param mediaId - 语音文件的 mediaId(通过 uploadMedia 获取)
300
+ * @param duration - 语音时长(秒),可选
301
+ */
302
+ export async function sendAudioMessage(
303
+ to: string,
304
+ mediaId: string,
305
+ options: SendMessageOptions & {
306
+ duration?: string;
307
+ }
308
+ ): Promise<SendMessageResult> {
309
+ logger.log(`[主动发送] 语音消息 | to: ${to} | mediaId: ${mediaId} | duration: ${options.duration ?? "未知"}`);
310
+
311
+ const msgParam: Record<string, string> = { mediaId };
312
+ if (options.duration) {
313
+ msgParam.duration = options.duration;
314
+ }
315
+
316
+ const result = await sendMessage(to, "sampleAudio", msgParam, options);
317
+
318
+ logger.log(`[主动发送] 语音消息发送成功 | messageId: ${result.messageId}`);
319
+ return result;
320
+ }
321
+
322
+ /**
323
+ * 发送视频消息(自动路由群聊/单聊)
324
+ */
325
+ export async function sendVideoMessage(
326
+ to: string,
327
+ videoMediaId: string,
328
+ options: SendMessageOptions & {
329
+ duration?: string;
330
+ picMediaId?: string;
331
+ width?: string;
332
+ height?: string;
333
+ }
334
+ ): Promise<SendMessageResult> {
335
+ logger.log(`[主动发送] 视频消息 | to: ${to} | videoMediaId: ${videoMediaId}`);
336
+
337
+ const msgParam: Record<string, string> = {
338
+ videoMediaId,
339
+ videoType: "mp4",
340
+ };
341
+ if (options.duration) {
342
+ msgParam.duration = options.duration;
343
+ }
344
+ if (options.picMediaId) {
345
+ msgParam.picMediaId = options.picMediaId;
346
+ }
347
+ if (options.width) {
348
+ msgParam.width = options.width;
349
+ }
350
+ if (options.height) {
351
+ msgParam.height = options.height;
352
+ }
353
+
354
+ const result = await sendMessage(to, "sampleVideo", msgParam, options);
355
+
356
+ logger.log(`[主动发送] 视频消息发送成功 | messageId: ${result.messageId}`);
357
+ return result;
358
+ }
359
+
360
+ /**
361
+ * 发送文件消息(自动路由群聊/单聊)
362
+ * @param mediaId - 文件的 mediaId(通过 uploadMedia 获取)
363
+ * @param fileName - 文件名
364
+ * @param fileType - 文件扩展名(如 pdf、doc 等)
365
+ */
366
+ export async function sendFileMessage(
367
+ to: string,
368
+ mediaId: string,
369
+ fileName: string,
370
+ fileType: string,
371
+ options: SendMessageOptions
372
+ ): Promise<SendMessageResult> {
373
+ logger.log(`[主动发送] 文件消息 | to: ${to} | fileName: ${fileName} | fileType: ${fileType}`);
374
+
375
+ const result = await sendMessage(to, "sampleFile", { mediaId, fileName, fileType }, options);
376
+
377
+ logger.log(`[主动发送] 文件消息发送成功 | messageId: ${result.messageId}`);
378
+ return result;
379
+ }
380
+
381
+ /**
382
+ * 发送链接消息(自动路由群聊/单聊)
383
+ */
384
+ export async function sendLinkMessage(
385
+ to: string,
386
+ options: SendMessageOptions & {
387
+ title: string;
388
+ text: string;
389
+ messageUrl: string;
390
+ picUrl?: string;
391
+ }
392
+ ): Promise<SendMessageResult> {
393
+ logger.log(`[主动发送] 链接消息 | to: ${to} | title: ${options.title}`);
394
+
395
+ const result = await sendMessage(
396
+ to,
397
+ "sampleLink",
398
+ {
399
+ title: options.title,
400
+ text: options.text,
401
+ messageUrl: options.messageUrl,
402
+ picUrl: options.picUrl ?? "",
403
+ },
404
+ options
405
+ );
406
+
407
+ logger.log(`[主动发送] 链接消息发送成功 | messageId: ${result.messageId}`);
408
+ return result;
409
+ }
410
+
411
+ // ======================= 探测 Bot =======================
412
+
413
+ export interface DingTalkProbeResult {
414
+ ok: boolean;
415
+ bot?: {
416
+ name?: string;
417
+ robotCode?: string;
418
+ };
419
+ error?: string;
420
+ }
421
+
422
+ /**
423
+ * 探测钉钉机器人状态
424
+ */
425
+ export async function probeDingTalkBot(
426
+ account: ResolvedDingTalkAccount,
427
+ _timeoutMs?: number
428
+ ): Promise<DingTalkProbeResult> {
429
+ try {
430
+ // 尝试获取 access_token 来验证凭据是否有效
431
+ await getAccessToken(account);
432
+ return {
433
+ ok: true,
434
+ bot: {
435
+ robotCode: account.clientId,
436
+ name: account.name,
437
+ },
438
+ };
439
+ } catch (err) {
440
+ return {
441
+ ok: false,
442
+ error: err instanceof Error ? err.message : String(err),
443
+ };
444
+ }
445
+ }
446
+
447
+ // ======================= 图片处理 =======================
448
+
449
+ /**
450
+ * 获取钉钉文件下载链接
451
+ * @param downloadCode - 文件下载码
452
+ * @param account - 钉钉账户配置
453
+ * @returns 下载链接
454
+ */
455
+ export async function getFileDownloadUrl(
456
+ downloadCode: string,
457
+ account: ResolvedDingTalkAccount
458
+ ): Promise<string> {
459
+ 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
+
471
+ const response = await robotClient.robotMessageFileDownloadWithOptions(
472
+ request,
473
+ headers,
474
+ new $Util.RuntimeOptions({})
475
+ );
476
+
477
+ if (response.body?.downloadUrl) {
478
+ return response.body.downloadUrl;
479
+ }
480
+
481
+ throw new Error("获取下载链接失败: 返回结果为空");
482
+ }
483
+
484
+ /**
485
+ * 从 URL 下载文件
486
+ * @param url - 下载链接
487
+ * @returns 文件内容 Buffer
488
+ */
489
+ export async function downloadFromUrl(url: string): Promise<Buffer> {
490
+ const response = await fetch(url);
491
+
492
+ if (!response.ok) {
493
+ throw new Error(`下载文件失败: ${response.status} ${response.statusText}`);
494
+ }
495
+
496
+ const arrayBuffer = await response.arrayBuffer();
497
+ return Buffer.from(arrayBuffer);
498
+ }
499
+
500
+ // ======================= 媒体文件上传 =======================
501
+
502
+ /**
503
+ * 钉钉支持的媒体类型(media/upload 接口)
504
+ * - image: 图片,最大 20MB,支持 jpg/gif/png/bmp
505
+ * - voice: 语音,最大 2MB,支持 amr/mp3/wav
506
+ * - video: 视频,最大 20MB,支持 mp4
507
+ * - file: 普通文件,最大 20MB,支持 doc/docx/xls/xlsx/ppt/pptx/zip/pdf/rar
508
+ */
509
+ export type DingTalkMediaType = "image" | "voice" | "video" | "file";
510
+
511
+ export interface UploadMediaResult {
512
+ mediaId: string;
513
+ /** 图片类型返回公网可访问 URL,其他类型返回空字符串 */
514
+ url: string;
515
+ /** 媒体类型 */
516
+ type: DingTalkMediaType;
517
+ }
518
+
519
+ /**
520
+ * 根据 MIME 类型推断钉钉媒体类型
521
+ */
522
+ export function inferMediaType(mimeType: string): DingTalkMediaType {
523
+ if (mimeType.startsWith("image/")) {
524
+ return "image";
525
+ }
526
+ if (mimeType.startsWith("audio/")) {
527
+ return "voice";
528
+ }
529
+ if (mimeType.startsWith("video/")) {
530
+ return "video";
531
+ }
532
+ return "file";
533
+ }
534
+
535
+ /**
536
+ * 根据媒体类型获取对应的 Content-Type
537
+ */
538
+ function getContentType(type: DingTalkMediaType, mimeType?: string): string {
539
+ if (mimeType) {
540
+ return mimeType;
541
+ }
542
+ switch (type) {
543
+ case "image":
544
+ return "image/png";
545
+ case "voice":
546
+ return "audio/amr";
547
+ case "video":
548
+ return "video/mp4";
549
+ case "file":
550
+ default:
551
+ return "application/octet-stream";
552
+ }
553
+ }
554
+
555
+ /**
556
+ * 上传媒体文件到钉钉(使用旧版 oapi 接口)
557
+ * @param fileBuffer - 文件 Buffer
558
+ * @param fileName - 文件名
559
+ * @param account - 钉钉账户配置
560
+ * @param options - 上传选项
561
+ * @returns 包含 media_id 和公网可访问 URL 的对象
562
+ */
563
+ export async function uploadMedia(
564
+ fileBuffer: Buffer,
565
+ fileName: string,
566
+ account: ResolvedDingTalkAccount,
567
+ options?: {
568
+ /** 媒体类型,不传则根据 mimeType 自动推断 */
569
+ type?: DingTalkMediaType;
570
+ /** MIME 类型,用于推断媒体类型和设置 Content-Type */
571
+ mimeType?: string;
572
+ }
573
+ ): Promise<UploadMediaResult> {
574
+ const mimeType = options?.mimeType;
575
+ const type = options?.type ?? (mimeType ? inferMediaType(mimeType) : "image");
576
+ const contentType = getContentType(type, mimeType);
577
+
578
+ logger.log(`[上传媒体] type: ${type} | fileName: ${fileName} | size: ${fileBuffer.length} bytes`);
579
+
580
+ const accessToken = await getAccessToken(account);
581
+
582
+ // 使用 FormData 上传
583
+ const formData = new FormData();
584
+ const uint8Array = new Uint8Array(fileBuffer);
585
+ const blob = new Blob([uint8Array], { type: contentType });
586
+ formData.append("media", blob, fileName);
587
+ formData.append("type", type);
588
+
589
+ const response = await fetch(
590
+ `https://oapi.dingtalk.com/media/upload?access_token=${accessToken}`,
591
+ {
592
+ method: "POST",
593
+ body: formData,
594
+ }
595
+ );
596
+
597
+ const result = (await response.json()) as {
598
+ errcode?: number;
599
+ errmsg?: string;
600
+ media_id?: string;
601
+ };
602
+
603
+ if (result.errcode === 0 && result.media_id) {
604
+ logger.log(`[上传媒体] 上传成功 | mediaId: ${result.media_id}`);
605
+
606
+ // 只有图片类型才构造公网可访问的 URL
607
+ const url = type === "image"
608
+ ? `https://oapi.dingtalk.com/media/downloadFile?access_token=${accessToken}&media_id=${result.media_id}`
609
+ : "";
610
+
611
+ return {
612
+ mediaId: result.media_id,
613
+ url,
614
+ type,
615
+ };
616
+ }
617
+
618
+ logger.error(`[上传媒体] 上传失败: ${result.errmsg ?? JSON.stringify(result)}`);
619
+ throw new Error(`上传媒体文件失败: ${result.errmsg ?? JSON.stringify(result)}`);
620
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 钉钉渠道插件 ID / 通道名
3
+ * 用于标识该渠道插件,在配置中作为 channels.{PLUGIN_ID} 使用
4
+ * 同时也作为通道名,用于地址前缀(如 ddingtalk:user:xxx)
5
+ */
6
+ export const PLUGIN_ID = "ddingtalk" as const;
package/src/logger.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 钉钉插件日志工具
3
+ * 统一日志前缀,保持输出格式一致
4
+ */
5
+
6
+ const PREFIX = "[DingTalk]";
7
+
8
+ export const logger = {
9
+ log: (message: string) => console.log(`${PREFIX} ${message}`),
10
+ info: (message: string) => console.info(`${PREFIX} ${message}`),
11
+ warn: (message: string) => console.warn(`${PREFIX} ${message}`),
12
+ error: (message: string, err?: unknown) => {
13
+ if (err) {
14
+ console.error(`${PREFIX} ${message}`, err);
15
+ } else {
16
+ console.error(`${PREFIX} ${message}`);
17
+ }
18
+ },
19
+ };