@zhin.js/adapter-dingtalk 4.0.1 → 4.0.3

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