@zhin.js/adapter-dingtalk 1.0.39 → 1.0.41

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/bot.ts ADDED
@@ -0,0 +1,593 @@
1
+ /**
2
+ * 钉钉 Bot 实现
3
+ */
4
+ import {
5
+ Bot,
6
+ Message,
7
+ SendOptions,
8
+ SendContent,
9
+ MessageSegment,
10
+ segment,
11
+ } from "zhin.js";
12
+ import type { Context } from "koa";
13
+ import { createHmac } from "crypto";
14
+ import type {
15
+ DingTalkBotConfig,
16
+ DingTalkMessage,
17
+ DingTalkEvent,
18
+ AccessToken,
19
+ } from "./types.js";
20
+ import type { DingTalkAdapter } from "./adapter.js";
21
+
22
+ export class DingTalkBot implements Bot<DingTalkBotConfig, DingTalkMessage> {
23
+ $connected: boolean;
24
+ private router: any;
25
+ private accessToken: AccessToken;
26
+ private baseURL: string;
27
+ private sessionWebhooks: Map<string, string> = new Map();
28
+
29
+ get $id() {
30
+ return this.$config.name;
31
+ }
32
+
33
+ get logger() {
34
+ return this.adapter.plugin.logger;
35
+ }
36
+
37
+ constructor(
38
+ public adapter: DingTalkAdapter,
39
+ router: any,
40
+ public $config: DingTalkBotConfig
41
+ ) {
42
+ this.router = router;
43
+ this.$connected = false;
44
+ this.accessToken = { token: "", expires_in: 0, timestamp: 0 };
45
+ this.baseURL = $config.apiBaseUrl || "https://oapi.dingtalk.com";
46
+ this.setupWebhookRoute();
47
+ }
48
+
49
+ private async request(
50
+ path: string,
51
+ options: {
52
+ method?: "GET" | "POST";
53
+ params?: Record<string, any>;
54
+ body?: any;
55
+ } = {}
56
+ ): Promise<any> {
57
+ await this.ensureAccessToken();
58
+ const { method = "GET", params = {}, body } = options;
59
+ const urlParams = new URLSearchParams({
60
+ ...params,
61
+ access_token: this.accessToken.token,
62
+ });
63
+ const url = `${this.baseURL}${path}?${urlParams.toString()}`;
64
+ const fetchOptions: RequestInit = {
65
+ method,
66
+ headers: {
67
+ "Content-Type": "application/json; charset=utf-8",
68
+ },
69
+ };
70
+ if (body && method === "POST") {
71
+ fetchOptions.body = JSON.stringify(body);
72
+ }
73
+ const response = await fetch(url, fetchOptions);
74
+ return await response.json();
75
+ }
76
+
77
+ private setupWebhookRoute(): void {
78
+ this.router.post(this.$config.webhookPath, (ctx: Context) => {
79
+ this.handleWebhook(ctx);
80
+ });
81
+ }
82
+
83
+ private async handleWebhook(ctx: Context): Promise<void> {
84
+ try {
85
+ const body = (ctx.request as any).body;
86
+ const headers = ctx.request.headers;
87
+ const timestamp = headers["timestamp"] as string;
88
+ const sign = headers["sign"] as string;
89
+ if (timestamp && sign) {
90
+ if (!this.verifySignature(timestamp, sign)) {
91
+ this.logger.warn("Invalid signature in webhook");
92
+ ctx.status = 403;
93
+ ctx.body = { code: -1, msg: "Forbidden" };
94
+ return;
95
+ }
96
+ }
97
+ const event: DingTalkEvent = body;
98
+ if (event.msgtype) {
99
+ await this.handleEvent(event);
100
+ }
101
+ ctx.status = 200;
102
+ ctx.body = { code: 0, msg: "success" };
103
+ } catch (error) {
104
+ this.logger.error("Webhook error:", error);
105
+ ctx.status = 500;
106
+ ctx.body = { code: -1, msg: "Internal Server Error" };
107
+ }
108
+ }
109
+
110
+ private verifySignature(timestamp: string, sign: string): boolean {
111
+ try {
112
+ const stringToSign = `${timestamp}\n${this.$config.appSecret}`;
113
+ const hmac = createHmac("sha256", this.$config.appSecret);
114
+ hmac.update(stringToSign);
115
+ const calculatedSign = hmac.digest("base64");
116
+ return calculatedSign === sign;
117
+ } catch (error) {
118
+ this.logger.error("Signature verification error:", error);
119
+ return false;
120
+ }
121
+ }
122
+
123
+ private async handleEvent(event: DingTalkEvent): Promise<void> {
124
+ if (event.sessionWebhook && event.conversationId) {
125
+ this.sessionWebhooks.set(event.conversationId, event.sessionWebhook);
126
+ }
127
+ const message = this.$formatMessage(event as any);
128
+ this.adapter.emit("message.receive", message);
129
+ this.logger.info(
130
+ `${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}): ${segment.raw(message.$content)}`
131
+ );
132
+ }
133
+
134
+ private async ensureAccessToken(): Promise<void> {
135
+ const now = Date.now();
136
+ if (
137
+ this.accessToken.token &&
138
+ now <
139
+ this.accessToken.timestamp +
140
+ (this.accessToken.expires_in - 300) * 1000
141
+ ) {
142
+ return;
143
+ }
144
+ await this.refreshAccessToken();
145
+ }
146
+
147
+ private async refreshAccessToken(): Promise<void> {
148
+ try {
149
+ const baseURL =
150
+ this.$config.apiBaseUrl || "https://oapi.dingtalk.com";
151
+ const params = new URLSearchParams({
152
+ appkey: this.$config.appKey,
153
+ appsecret: this.$config.appSecret,
154
+ });
155
+ const url = `${baseURL}/gettoken?${params.toString()}`;
156
+ const response = await fetch(url);
157
+ const data = await response.json();
158
+ if (data.errcode === 0) {
159
+ this.accessToken = {
160
+ token: data.access_token,
161
+ expires_in: data.expires_in,
162
+ timestamp: Date.now(),
163
+ };
164
+ this.logger.debug("Access token refreshed successfully");
165
+ } else {
166
+ throw new Error(`Failed to get access token: ${data.errmsg}`);
167
+ }
168
+ } catch (error) {
169
+ this.logger.error("Failed to refresh access token:", error);
170
+ throw error;
171
+ }
172
+ }
173
+
174
+ $formatMessage(msg: DingTalkMessage): Message<DingTalkMessage> {
175
+ const content = this.parseMessageContent(msg);
176
+ const chatType = msg.conversationType === "2" ? "group" : "private";
177
+ return Message.from(msg, {
178
+ $id: msg.msgId || Date.now().toString(),
179
+ $adapter: "dingtalk",
180
+ $bot: this.$config.name,
181
+ $sender: {
182
+ id: msg.senderId || msg.senderStaffId || "unknown",
183
+ name: msg.senderNick || msg.senderId || "Unknown User",
184
+ },
185
+ $channel: {
186
+ id: msg.conversationId || "unknown",
187
+ type: chatType as any,
188
+ },
189
+ $content: content,
190
+ $raw: JSON.stringify(msg),
191
+ $timestamp: msg.createAt || Date.now(),
192
+ $recall: async () => {
193
+ await this.$recallMessage(msg.msgId || "");
194
+ },
195
+ $reply: async (content: SendContent): Promise<string> => {
196
+ return await this.adapter.sendMessage({
197
+ context: "dingtalk",
198
+ bot: this.$config.name,
199
+ id: msg.conversationId || msg.senderId || "unknown",
200
+ type: chatType,
201
+ content: content,
202
+ });
203
+ },
204
+ });
205
+ }
206
+
207
+ private parseMessageContent(msg: DingTalkMessage): MessageSegment[] {
208
+ const content: MessageSegment[] = [];
209
+ if (!msg.msgtype) return content;
210
+ try {
211
+ switch (msg.msgtype) {
212
+ case "text":
213
+ if (msg.text?.content) {
214
+ content.push(segment("text", { content: msg.text.content }));
215
+ if (msg.atUsers && msg.atUsers.length > 0) {
216
+ for (const atUser of msg.atUsers) {
217
+ content.push(
218
+ segment("at", {
219
+ id: atUser.dingtalkId || atUser.staffId,
220
+ name: atUser.dingtalkId || atUser.staffId,
221
+ })
222
+ );
223
+ }
224
+ }
225
+ }
226
+ break;
227
+ case "picture":
228
+ if (msg.content) {
229
+ content.push(
230
+ segment("image", {
231
+ url:
232
+ msg.content.downloadCode ||
233
+ msg.content.pictureDownloadCode,
234
+ file:
235
+ msg.content.downloadCode ||
236
+ msg.content.pictureDownloadCode,
237
+ })
238
+ );
239
+ }
240
+ break;
241
+ case "file":
242
+ if (msg.content) {
243
+ content.push(
244
+ segment("file", {
245
+ file: msg.content.downloadCode,
246
+ name: msg.content.fileName,
247
+ size: msg.content.fileSize,
248
+ })
249
+ );
250
+ }
251
+ break;
252
+ case "audio":
253
+ if (msg.content) {
254
+ content.push(
255
+ segment("audio", {
256
+ file: msg.content.downloadCode,
257
+ duration: msg.content.duration,
258
+ })
259
+ );
260
+ }
261
+ break;
262
+ case "video":
263
+ if (msg.content) {
264
+ content.push(
265
+ segment("video", {
266
+ file: msg.content.downloadCode,
267
+ duration: msg.content.duration,
268
+ size: msg.content.videoSize,
269
+ })
270
+ );
271
+ }
272
+ break;
273
+ case "richText":
274
+ if (msg.content?.richText) {
275
+ for (const item of msg.content.richText) {
276
+ if (item.text) {
277
+ content.push(segment("text", { content: item.text }));
278
+ }
279
+ }
280
+ }
281
+ break;
282
+ case "markdown":
283
+ if (msg.content?.text) {
284
+ content.push(
285
+ segment("markdown", {
286
+ content: msg.content.text,
287
+ title: msg.content.title,
288
+ })
289
+ );
290
+ }
291
+ break;
292
+ default:
293
+ content.push(
294
+ segment("text", {
295
+ content: `[不支持的消息类型: ${msg.msgtype}]`,
296
+ })
297
+ );
298
+ break;
299
+ }
300
+ } catch (error) {
301
+ this.logger.error("Failed to parse message content:", error);
302
+ content.push(segment("text", { content: "[消息解析失败]" }));
303
+ }
304
+ return content;
305
+ }
306
+
307
+ async $sendMessage(options: SendOptions): Promise<string> {
308
+ const conversationId = options.id;
309
+ const content = this.formatSendContent(options.content);
310
+ try {
311
+ const sessionWebhook = this.sessionWebhooks.get(conversationId);
312
+ if (sessionWebhook) {
313
+ const response = await fetch(sessionWebhook, {
314
+ method: "POST",
315
+ headers: {
316
+ "Content-Type": "application/json; charset=utf-8",
317
+ },
318
+ body: JSON.stringify(content),
319
+ });
320
+ const data = await response.json();
321
+ if (data.errcode !== 0) {
322
+ throw new Error(
323
+ `Failed to send message via session webhook: ${data.errmsg}`
324
+ );
325
+ }
326
+ this.logger.debug("Message sent via session webhook");
327
+ return data.msgId || Date.now().toString();
328
+ }
329
+ const data = await this.request("/robot/send", {
330
+ method: "POST",
331
+ body: {
332
+ ...content,
333
+ robotCode: this.$config.robotCode,
334
+ },
335
+ });
336
+ if (data.errcode !== 0) {
337
+ throw new Error(`Failed to send message: ${data.errmsg}`);
338
+ }
339
+ this.logger.debug("Message sent successfully");
340
+ return data.msgId || Date.now().toString();
341
+ } catch (error) {
342
+ this.logger.error("Failed to send message:", error);
343
+ throw error;
344
+ }
345
+ }
346
+
347
+ async $recallMessage(id: string): Promise<void> {
348
+ this.logger.warn("DingTalk robot does not support message recall");
349
+ }
350
+
351
+ private formatSendContent(content: SendContent): any {
352
+ if (typeof content === "string") {
353
+ return { msgtype: "text", text: { content } };
354
+ }
355
+ if (Array.isArray(content)) {
356
+ const textParts: string[] = [];
357
+ const atUserIds: string[] = [];
358
+ let hasMedia = false;
359
+ let mediaContent: any = null;
360
+ for (const item of content) {
361
+ if (typeof item === "string") {
362
+ textParts.push(item);
363
+ } else {
364
+ const seg = item as MessageSegment;
365
+ switch (seg.type) {
366
+ case "text":
367
+ textParts.push(seg.data.content || seg.data.text || "");
368
+ break;
369
+ case "at":
370
+ const userId = seg.data.id || seg.data.userId;
371
+ if (userId) {
372
+ atUserIds.push(userId);
373
+ textParts.push(`@${seg.data.name || userId} `);
374
+ }
375
+ break;
376
+ case "image":
377
+ if (!hasMedia) {
378
+ hasMedia = true;
379
+ mediaContent = {
380
+ msgtype: "picture",
381
+ picture: {
382
+ picURL: seg.data.url || seg.data.file,
383
+ },
384
+ };
385
+ }
386
+ break;
387
+ case "markdown":
388
+ if (!hasMedia) {
389
+ hasMedia = true;
390
+ mediaContent = {
391
+ msgtype: "markdown",
392
+ markdown: {
393
+ title: seg.data.title || "消息",
394
+ text: seg.data.content || seg.data.text,
395
+ },
396
+ };
397
+ }
398
+ break;
399
+ case "link":
400
+ if (!hasMedia) {
401
+ hasMedia = true;
402
+ mediaContent = {
403
+ msgtype: "link",
404
+ link: {
405
+ title: seg.data.title || "链接",
406
+ text: seg.data.text || seg.data.content || "",
407
+ messageUrl: seg.data.url,
408
+ picUrl: seg.data.picUrl,
409
+ },
410
+ };
411
+ }
412
+ break;
413
+ }
414
+ }
415
+ }
416
+ if (hasMedia && mediaContent) return mediaContent;
417
+ const result: any = {
418
+ msgtype: "text",
419
+ text: { content: textParts.join("") },
420
+ };
421
+ if (atUserIds.length > 0) {
422
+ result.at = { atUserIds, isAtAll: false };
423
+ }
424
+ return result;
425
+ }
426
+ return { msgtype: "text", text: { content: String(content) } };
427
+ }
428
+
429
+ async $connect(): Promise<void> {
430
+ try {
431
+ await this.refreshAccessToken();
432
+ this.$connected = true;
433
+ this.logger.info(`DingTalk bot connected: ${this.$config.name}`);
434
+ this.logger.info(`Webhook URL: ${this.$config.webhookPath}`);
435
+ } catch (error) {
436
+ this.logger.error("Failed to connect DingTalk bot:", error);
437
+ throw error;
438
+ }
439
+ }
440
+
441
+ async $disconnect(): Promise<void> {
442
+ try {
443
+ this.$connected = false;
444
+ this.logger.info("DingTalk bot disconnected");
445
+ } catch (error) {
446
+ this.logger.error("Error disconnecting DingTalk bot:", error);
447
+ }
448
+ }
449
+
450
+ async getUserInfo(userId: string): Promise<any> {
451
+ try {
452
+ const data = await this.request("/topapi/v2/user/get", {
453
+ method: "POST",
454
+ body: { userid: userId },
455
+ });
456
+ if (data.errcode === 0) return data.result;
457
+ throw new Error(`Failed to get user info: ${data.errmsg}`);
458
+ } catch (error) {
459
+ this.logger.error("Failed to get user info:", error);
460
+ return null;
461
+ }
462
+ }
463
+
464
+ async getDepartmentUsers(deptId: number): Promise<any[]> {
465
+ try {
466
+ const data = await this.request("/topapi/user/listid", {
467
+ method: "POST",
468
+ body: { dept_id: deptId },
469
+ });
470
+ if (data.errcode === 0) return data.result.userid_list || [];
471
+ throw new Error(`Failed to get department users: ${data.errmsg}`);
472
+ } catch (error) {
473
+ this.logger.error("Failed to get department users:", error);
474
+ return [];
475
+ }
476
+ }
477
+
478
+ async sendWorkNotice(userIdList: string[], content: any): Promise<boolean> {
479
+ try {
480
+ const data = await this.request(
481
+ "/topapi/message/corpconversation/asyncsend_v2",
482
+ {
483
+ method: "POST",
484
+ body: {
485
+ agent_id: this.$config.robotCode,
486
+ userid_list: userIdList.join(","),
487
+ msg: content,
488
+ },
489
+ }
490
+ );
491
+ if (data.errcode === 0) {
492
+ this.logger.debug("Work notice sent successfully");
493
+ return true;
494
+ }
495
+ throw new Error(`Failed to send work notice: ${data.errmsg}`);
496
+ } catch (error) {
497
+ this.logger.error("Failed to send work notice:", error);
498
+ return false;
499
+ }
500
+ }
501
+
502
+ async getDepartmentList(deptId: number = 1): Promise<any[]> {
503
+ try {
504
+ const data = await this.request("/topapi/v2/department/listsub", {
505
+ method: "POST",
506
+ body: { dept_id: deptId },
507
+ });
508
+ if (data.errcode === 0) return data.result || [];
509
+ throw new Error(`Failed to get department list: ${data.errmsg}`);
510
+ } catch (error) {
511
+ this.logger.error("Failed to get department list:", error);
512
+ return [];
513
+ }
514
+ }
515
+
516
+ async getDepartmentInfo(deptId: number): Promise<any> {
517
+ try {
518
+ const data = await this.request("/topapi/v2/department/get", {
519
+ method: "POST",
520
+ body: { dept_id: deptId },
521
+ });
522
+ if (data.errcode === 0) return data.result;
523
+ throw new Error(`Failed to get department info: ${data.errmsg}`);
524
+ } catch (error) {
525
+ this.logger.error("Failed to get department info:", error);
526
+ return null;
527
+ }
528
+ }
529
+
530
+ async createChat(
531
+ name: string,
532
+ ownerUserId: string,
533
+ userIdList: string[]
534
+ ): Promise<string | null> {
535
+ try {
536
+ const data = await this.request("/topapi/chat/create", {
537
+ method: "POST",
538
+ body: {
539
+ name,
540
+ owner: ownerUserId,
541
+ useridlist: userIdList,
542
+ },
543
+ });
544
+ if (data.errcode === 0) {
545
+ this.logger.info(`创建群聊成功: ${data.chatid}`);
546
+ return data.chatid;
547
+ }
548
+ throw new Error(`Failed to create chat: ${data.errmsg}`);
549
+ } catch (error) {
550
+ this.logger.error("Failed to create chat:", error);
551
+ return null;
552
+ }
553
+ }
554
+
555
+ async getChatInfo(chatId: string): Promise<any> {
556
+ try {
557
+ const data = await this.request("/topapi/chat/get", {
558
+ method: "POST",
559
+ body: { chatid: chatId },
560
+ });
561
+ if (data.errcode === 0) return data.chat_info;
562
+ throw new Error(`Failed to get chat info: ${data.errmsg}`);
563
+ } catch (error) {
564
+ this.logger.error("Failed to get chat info:", error);
565
+ return null;
566
+ }
567
+ }
568
+
569
+ async updateChat(
570
+ chatId: string,
571
+ options: {
572
+ name?: string;
573
+ owner?: string;
574
+ add_useridlist?: string[];
575
+ del_useridlist?: string[];
576
+ }
577
+ ): Promise<boolean> {
578
+ try {
579
+ const data = await this.request("/topapi/chat/update", {
580
+ method: "POST",
581
+ body: { chatid: chatId, ...options },
582
+ });
583
+ if (data.errcode === 0) {
584
+ this.logger.info(`更新群聊成功: ${chatId}`);
585
+ return true;
586
+ }
587
+ throw new Error(`Failed to update chat: ${data.errmsg}`);
588
+ } catch (error) {
589
+ this.logger.error("Failed to update chat:", error);
590
+ return false;
591
+ }
592
+ }
593
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 钉钉适配器入口:类型扩展、导出、注册
3
+ */
4
+ import { usePlugin, type Plugin } from "zhin.js";
5
+ import { DingTalkAdapter } from "./adapter.js";
6
+
7
+ declare module "zhin.js" {
8
+ namespace Plugin {
9
+ interface Contexts {
10
+ router: import("@zhin.js/http").Router;
11
+ }
12
+ }
13
+ interface Adapters {
14
+ dingtalk: DingTalkAdapter;
15
+ }
16
+ }
17
+
18
+ export * from "./types.js";
19
+ export { DingTalkBot } from "./bot.js";
20
+ export { DingTalkAdapter } from "./adapter.js";
21
+
22
+ const plugin = usePlugin();
23
+ const { provide, useContext } = plugin;
24
+
25
+ useContext("router", (router: any) => {
26
+ provide({
27
+ name: "dingtalk",
28
+ description: "DingTalk Bot Adapter",
29
+ mounted: async (p: Plugin) => {
30
+ const adapter = new DingTalkAdapter(p, router);
31
+ await adapter.start();
32
+ return adapter;
33
+ },
34
+ dispose: async (adapter: DingTalkAdapter) => {
35
+ await adapter.stop();
36
+ },
37
+ });
38
+ });
package/src/types.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 钉钉适配器类型定义
3
+ */
4
+
5
+ export interface DingTalkBotConfig {
6
+ context: "dingtalk";
7
+ name: string;
8
+ appKey: string;
9
+ appSecret: string;
10
+ webhookPath: string;
11
+ robotCode?: string;
12
+ apiBaseUrl?: string;
13
+ }
14
+
15
+ export interface DingTalkMessage {
16
+ msgtype?: string;
17
+ text?: { content?: string };
18
+ msgId?: string;
19
+ createAt?: number;
20
+ conversationType?: string;
21
+ conversationId?: string;
22
+ senderId?: string;
23
+ senderNick?: string;
24
+ senderCorpId?: string;
25
+ sessionWebhook?: string;
26
+ chatbotCorpId?: string;
27
+ chatbotUserId?: string;
28
+ isAdmin?: boolean;
29
+ senderStaffId?: string;
30
+ atUsers?: Array<{ dingtalkId?: string; staffId?: string }>;
31
+ content?: any;
32
+ }
33
+
34
+ export interface DingTalkEvent {
35
+ msgtype?: string;
36
+ text?: any;
37
+ conversationId?: string;
38
+ atUsers?: any[];
39
+ chatbotUserId?: string;
40
+ msgId?: string;
41
+ senderNick?: string;
42
+ isAdmin?: boolean;
43
+ senderStaffId?: string;
44
+ sessionWebhook?: string;
45
+ createAt?: number;
46
+ senderCorpId?: string;
47
+ conversationType?: string;
48
+ senderId?: string;
49
+ [key: string]: any;
50
+ }
51
+
52
+ export interface AccessToken {
53
+ token: string;
54
+ expires_in: number;
55
+ timestamp: number;
56
+ }