@zhin.js/adapter-dingtalk 1.0.81 → 1.1.2

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 (77) hide show
  1. package/CHANGELOG.md +709 -3
  2. package/PERMITS.md +19 -0
  3. package/README.md +67 -333
  4. package/adapters/dingtalk/index.js +33 -0
  5. package/adapters/dingtalk/index.ts +38 -0
  6. package/agents/dingtalk/agent.json +20 -0
  7. package/agents/dingtalk/boundaries.md +3 -0
  8. package/agents/dingtalk/conventions.md +3 -0
  9. package/agents/dingtalk/skills/dingtalk/SKILL.md +26 -0
  10. package/agents/dingtalk/skills/dingtalk/tools/add_chat_members/index.js +19 -0
  11. package/agents/dingtalk/skills/dingtalk/tools/add_chat_members/index.ts +20 -0
  12. package/agents/dingtalk/skills/dingtalk/tools/create_chat/index.js +20 -0
  13. package/agents/dingtalk/skills/dingtalk/tools/create_chat/index.ts +21 -0
  14. package/agents/dingtalk/skills/dingtalk/tools/dept_info/index.js +15 -0
  15. package/agents/dingtalk/skills/dingtalk/tools/dept_info/index.ts +15 -0
  16. package/agents/dingtalk/skills/dingtalk/tools/get_dept_users/index.js +16 -0
  17. package/agents/dingtalk/skills/dingtalk/tools/get_dept_users/index.ts +16 -0
  18. package/agents/dingtalk/skills/dingtalk/tools/get_user/index.js +15 -0
  19. package/agents/dingtalk/skills/dingtalk/tools/get_user/index.ts +15 -0
  20. package/agents/dingtalk/skills/dingtalk/tools/list_departments/index.js +16 -0
  21. package/agents/dingtalk/skills/dingtalk/tools/list_departments/index.ts +16 -0
  22. package/agents/dingtalk/skills/dingtalk/tools/send_work_notice/index.js +18 -0
  23. package/agents/dingtalk/skills/dingtalk/tools/send_work_notice/index.ts +18 -0
  24. package/agents/dingtalk/skills/dingtalk/tools/update_chat/index.js +29 -0
  25. package/agents/dingtalk/skills/dingtalk/tools/update_chat/index.ts +25 -0
  26. package/agents/dingtalk/system.md +3 -0
  27. package/commands/dingtalk/endpoint/add/[id]/index.js +3 -0
  28. package/commands/dingtalk/endpoint/add/[id]/index.ts +3 -0
  29. package/commands/dingtalk/endpoint/definition.js +19 -0
  30. package/commands/dingtalk/endpoint/definition.ts +19 -0
  31. package/commands/dingtalk/endpoint/list/index.js +3 -0
  32. package/commands/dingtalk/endpoint/list/index.ts +3 -0
  33. package/commands/dingtalk/endpoint/remove/[id]/index.js +3 -0
  34. package/commands/dingtalk/endpoint/remove/[id]/index.ts +3 -0
  35. package/lib/client.d.ts +12 -0
  36. package/lib/client.js +2 -0
  37. package/lib/dingtalk-runtime-state.d.ts +1 -0
  38. package/lib/dingtalk-runtime-state.js +6 -0
  39. package/lib/endpoint.d.ts +72 -0
  40. package/lib/endpoint.js +353 -0
  41. package/lib/index.d.ts +5 -15
  42. package/lib/index.js +5 -214
  43. package/lib/platform-permit.d.ts +14 -0
  44. package/lib/platform-permit.js +33 -0
  45. package/lib/protocol.d.ts +130 -0
  46. package/lib/protocol.js +264 -0
  47. package/lib/webhook.d.ts +13 -0
  48. package/lib/webhook.js +47 -0
  49. package/package.json +76 -19
  50. package/plugin.js +19 -0
  51. package/schema.json +97 -0
  52. package/src/client.ts +16 -0
  53. package/src/dingtalk-runtime-state.ts +7 -0
  54. package/src/endpoint.ts +439 -0
  55. package/src/index.ts +44 -228
  56. package/src/platform-permit.ts +48 -0
  57. package/src/protocol.ts +377 -0
  58. package/src/webhook.ts +75 -0
  59. package/lib/adapter.d.ts +0 -16
  60. package/lib/adapter.d.ts.map +0 -1
  61. package/lib/adapter.js +0 -37
  62. package/lib/adapter.js.map +0 -1
  63. package/lib/bot.d.ts +0 -46
  64. package/lib/bot.d.ts.map +0 -1
  65. package/lib/bot.js +0 -539
  66. package/lib/bot.js.map +0 -1
  67. package/lib/index.d.ts.map +0 -1
  68. package/lib/index.js.map +0 -1
  69. package/lib/types.d.ts +0 -58
  70. package/lib/types.d.ts.map +0 -1
  71. package/lib/types.js +0 -5
  72. package/lib/types.js.map +0 -1
  73. package/plugin.yml +0 -3
  74. package/skills/dingtalk/SKILL.md +0 -91
  75. package/src/adapter.ts +0 -44
  76. package/src/bot.ts +0 -590
  77. package/src/types.ts +0 -56
package/schema.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "apiBaseUrl": {
7
+ "type": "string",
8
+ "default": "https://oapi.dingtalk.com"
9
+ },
10
+ "master": {
11
+ "type": [
12
+ "string",
13
+ "number"
14
+ ],
15
+ "description": "框架 master(DingTalk userid;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖"
16
+ },
17
+ "trusted": {
18
+ "type": "array",
19
+ "items": {
20
+ "type": [
21
+ "string",
22
+ "number"
23
+ ],
24
+ "description": "Trusted DingTalk userid"
25
+ },
26
+ "description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加"
27
+ },
28
+ "endpoints": {
29
+ "type": "array",
30
+ "minItems": 1,
31
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
32
+ "items": {
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "master": {
37
+ "type": [
38
+ "string",
39
+ "number"
40
+ ],
41
+ "description": "本 endpoint 的框架 master(DingTalk userid);覆盖顶层 master"
42
+ },
43
+ "trusted": {
44
+ "type": "array",
45
+ "items": {
46
+ "type": [
47
+ "string",
48
+ "number"
49
+ ],
50
+ "description": "Trusted DingTalk userid"
51
+ },
52
+ "description": "本 endpoint 的 trusted 列表"
53
+ },
54
+ "appKey": {
55
+ "type": "string",
56
+ "description": "Dingtalk app key"
57
+ },
58
+ "appSecret": {
59
+ "type": "string",
60
+ "description": "Dingtalk app secret"
61
+ },
62
+ "webhookPath": {
63
+ "type": "string",
64
+ "description": "Dingtalk webhook path"
65
+ },
66
+ "robotCode": {
67
+ "type": "string",
68
+ "description": "Dingtalk robot code"
69
+ },
70
+ "apiBaseUrl": {
71
+ "type": "string"
72
+ },
73
+ "commandPrefix": {
74
+ "type": "string"
75
+ },
76
+ "id": {
77
+ "type": "string",
78
+ "description": "Dingtalk bot name"
79
+ }
80
+ },
81
+ "required": [
82
+ "id",
83
+ "appKey",
84
+ "appSecret"
85
+ ]
86
+ }
87
+ },
88
+ "commandPrefix": {
89
+ "type": "string",
90
+ "default": "",
91
+ "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
92
+ }
93
+ },
94
+ "required": [
95
+ "endpoints"
96
+ ]
97
+ }
package/src/client.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { defineEndpointClient } from 'zhin.js/adapter';
2
+ import type { DingTalkClient } from './endpoint.js';
3
+ import type { DingTalkEvent } from './protocol.js';
4
+
5
+ export type DingtalkClientEventMap = Record<string, DingTalkEvent>;
6
+
7
+ declare module '@zhin.js/feature-kit' {
8
+ interface AdapterClientRegistry {
9
+ readonly dingtalk: {
10
+ readonly client: DingTalkClient;
11
+ readonly events: DingtalkClientEventMap;
12
+ };
13
+ }
14
+ }
15
+
16
+ export const dingtalkClient = defineEndpointClient<DingTalkClient, DingtalkClientEventMap>('dingtalk');
@@ -0,0 +1,7 @@
1
+ /**
2
+ * DingTalk 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `dingtalk endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from 'zhin.js/adapter';
6
+
7
+ export const dingtalkRuntimeStateToken = defineEndpointRuntimeStateToken('dingtalk');
@@ -0,0 +1,439 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ /**
3
+ * DingTalkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
4
+ */
5
+ import { type EndpointSendRequest } from 'zhin.js/adapter';
6
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
7
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
+ import type { CapabilityId } from 'zhin.js';
9
+ import { normalizeDingtalkSenderForPermit } from './platform-permit.js';
10
+ import {
11
+ dingtalkInboundConversation,
12
+ formatInboundContent,
13
+ formatOutboundBody,
14
+ generateMessageId,
15
+ isDingtalkBotMentioned,
16
+ resolveChatType,
17
+ resolveSender,
18
+ type AccessToken,
19
+ type DingTalkApiResponse,
20
+ type DingTalkEvent,
21
+ type DingTalkMessage,
22
+ type DingTalkSendBody,
23
+ type ResolvedDingTalkConfig,
24
+ } from './protocol.js';
25
+ import { registerDingTalkWebhookRoutes } from './webhook.js';
26
+
27
+ export type DingTalkFetch = (
28
+ url: string,
29
+ init?: {
30
+ readonly method?: string;
31
+ readonly headers?: Record<string, string>;
32
+ readonly body?: string;
33
+ },
34
+ ) => Promise<{
35
+ readonly ok: boolean;
36
+ readonly status: number;
37
+ text(): Promise<string>;
38
+ json(): Promise<unknown>;
39
+ }>;
40
+
41
+ export interface DingTalkEndpointOptions {
42
+ readonly id: CapabilityId;
43
+ readonly http: HttpHost;
44
+ readonly config: ResolvedDingTalkConfig;
45
+ readonly fetch?: DingTalkFetch;
46
+ }
47
+
48
+ export interface DingTalkClientApi {
49
+ getUserInfo(userId: string): Promise<unknown>;
50
+ getDepartmentUsers(deptId: number): Promise<unknown[]>;
51
+ sendWorkNotice(userIdList: string[], content: unknown): Promise<boolean>;
52
+ getDepartmentList(deptId?: number): Promise<unknown[]>;
53
+ getDepartmentInfo(deptId: number): Promise<unknown>;
54
+ createChat(name: string, ownerUserId: string, userIdList: string[]): Promise<string | null>;
55
+ getChatInfo(chatId: string): Promise<unknown>;
56
+ updateChat(chatId: string, options: {
57
+ name?: string;
58
+ owner?: string;
59
+ add_useridlist?: string[];
60
+ del_useridlist?: string[];
61
+ }): Promise<boolean>;
62
+ }
63
+
64
+ /** SDK-like DingTalk OpenAPI surface available on every Endpoint event. */
65
+ export class DingTalkClient implements DingTalkClientApi {
66
+ constructor(readonly api: DingTalkClientApi) {}
67
+ getUserInfo = (userId: string) => this.api.getUserInfo(userId);
68
+ getDepartmentUsers = (deptId: number) => this.api.getDepartmentUsers(deptId);
69
+ sendWorkNotice = (userIds: string[], content: unknown) =>
70
+ this.api.sendWorkNotice(userIds, content);
71
+ getDepartmentList = (deptId?: number) => this.api.getDepartmentList(deptId);
72
+ getDepartmentInfo = (deptId: number) => this.api.getDepartmentInfo(deptId);
73
+ createChat = (name: string, owner: string, users: string[]) =>
74
+ this.api.createChat(name, owner, users);
75
+ getChatInfo = (chatId: string) => this.api.getChatInfo(chatId);
76
+ updateChat = (chatId: string, options: Parameters<DingTalkClientApi['updateChat']>[1]) =>
77
+ this.api.updateChat(chatId, options);
78
+ }
79
+
80
+ /**
81
+ * 钉钉机器人(webhook/stream 模式)无常规群列表 API——机器人不持有
82
+ * 「我所在的群」枚举面,仅能收发消息;
83
+ * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
84
+ */
85
+ export class DingTalkEndpoint extends Endpoint<DingTalkClient> {
86
+ readonly client = new DingTalkClient({
87
+ getUserInfo: (userId) => this.#getUserInfo(userId),
88
+ getDepartmentUsers: (deptId) => this.#getDepartmentUsers(deptId),
89
+ sendWorkNotice: (userIds, content) => this.#sendWorkNotice(userIds, content),
90
+ getDepartmentList: (deptId) => this.#getDepartmentList(deptId),
91
+ getDepartmentInfo: (deptId) => this.#getDepartmentInfo(deptId),
92
+ createChat: (name, owner, users) => this.#createChat(name, owner, users),
93
+ getChatInfo: (chatId) => this.#getChatInfo(chatId),
94
+ updateChat: (chatId, options) => this.#updateChat(chatId, options),
95
+ });
96
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
97
+
98
+ readonly #options: DingTalkEndpointOptions;
99
+ readonly #fetch: DingTalkFetch;
100
+ #routeReleases: HttpRouteRegistration[] = [];
101
+ #accessToken: AccessToken = { token: '', expires_in: 0, timestamp: 0 };
102
+ #refreshPromise: Promise<string> | null = null;
103
+ #sessionWebhooks = new Map<string, string>();
104
+ #open = false;
105
+ #started = false;
106
+
107
+ constructor(options: DingTalkEndpointOptions) {
108
+ super();
109
+ this.#logger = getAdapterLogger('dingtalk', options.config.id);
110
+ this.#options = options;
111
+ this.#fetch = options.fetch ?? globalThis.fetch;
112
+ }
113
+
114
+ /** Used by webhook handler. */
115
+ get isOpen(): boolean {
116
+ return this.#open;
117
+ }
118
+
119
+ get config(): ResolvedDingTalkConfig {
120
+ return this.#options.config;
121
+ }
122
+
123
+ async start(): Promise<void> {
124
+ if (this.#started) return;
125
+ this.#started = true;
126
+ try {
127
+ await this.#refreshAccessToken();
128
+ this.#routeReleases.push(...registerDingTalkWebhookRoutes(this.#options.http, this));
129
+ this.#logger.debug(formatCompact({
130
+ endpoint: this.#options.config.id,
131
+ op: 'webhook',
132
+ path: this.#options.config.webhookPath,
133
+ }));
134
+ } catch (error) {
135
+ await this.stop();
136
+ this.#logger.error('Failed to connect DingTalk endpoint:', error);
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ open(): void {
142
+ this.#open = true;
143
+ }
144
+
145
+ close(): void {
146
+ this.#open = false;
147
+ }
148
+
149
+ async stop(): Promise<void> {
150
+ this.#open = false;
151
+ this.#sessionWebhooks.clear();
152
+ for (const release of this.#routeReleases.splice(0)) release();
153
+ this.#started = false;
154
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
155
+ }
156
+
157
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
158
+ const content = formatOutboundBody(payload);
159
+ const sessionWebhook = this.#sessionWebhooks.get(conversation.id);
160
+ if (sessionWebhook) {
161
+ const response = await this.#fetch(sessionWebhook, {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
164
+ body: JSON.stringify(content),
165
+ });
166
+ const data = await response.json() as DingTalkApiResponse;
167
+ if (data.errcode !== 0) {
168
+ throw new Error(`Failed to send message via session webhook: ${data.errmsg}`);
169
+ }
170
+ this.#logger.debug(formatCompact({
171
+ op: 'send',
172
+ endpoint: this.#options.config.id,
173
+ via: 'sessionWebhook',
174
+ to: conversation.id,
175
+ }));
176
+ return (data.msgId as string) || `${Date.now()}`;
177
+ }
178
+
179
+ const body: DingTalkSendBody = {
180
+ ...content,
181
+ ...(this.#options.config.robotCode
182
+ ? { robotCode: this.#options.config.robotCode }
183
+ : {}),
184
+ };
185
+ const data = await this.#request('/robot/send', {
186
+ method: 'POST',
187
+ body: body as unknown as Record<string, unknown>,
188
+ });
189
+ if (data.errcode !== 0) {
190
+ throw new Error(`Failed to send message: ${data.errmsg}`);
191
+ }
192
+ this.#logger.debug(formatCompact({ op: 'send', to: conversation.id }));
193
+ return (data.msgId as string) || `${Date.now()}`;
194
+ }
195
+
196
+ /** Test / internal: admit a parsed event when open (non-webhook path). */
197
+ admit(event: DingTalkEvent | DingTalkMessage): void {
198
+ if (!this.#open) return;
199
+ this.#emitPlatformEvent(event.msgtype || 'event', event);
200
+ if (event.sessionWebhook && event.conversationId) {
201
+ this.#sessionWebhooks.set(event.conversationId, event.sessionWebhook);
202
+ }
203
+ const conversation = dingtalkInboundConversation(String(this.#options.id), event);
204
+ const chatType = resolveChatType(event.conversationType);
205
+ const permit = normalizeDingtalkSenderForPermit({ isAdmin: event.isAdmin === true });
206
+ void this.emit('message.receive', {
207
+ conversation,
208
+ message: { conversation, id: generateMessageId(event) },
209
+ content: formatInboundContent(event),
210
+ sender: {
211
+ id: resolveSender(event),
212
+ name: event.senderNick || undefined,
213
+ ...(permit.role ? { roles: [permit.role] } : {}),
214
+ },
215
+ endpointId: this.#options.config.id,
216
+ ...(isDingtalkBotMentioned(event, this.#options.config.robotCode) ? { mentioned: true } : {}),
217
+ metadata: Object.freeze({
218
+ msgtype: event.msgtype,
219
+ chatType,
220
+ senderNick: event.senderNick,
221
+ role: permit.role,
222
+ permissions: permit.permissions,
223
+ conversationType: event.conversationType,
224
+ }),
225
+ }).catch((err) => {
226
+ this.#logger.warn(formatCompact({
227
+ op: 'dingtalk_gateway_receive_failed',
228
+ conversationId: conversation.id,
229
+ error: err instanceof Error ? err.message : String(err),
230
+ }));
231
+ });
232
+ }
233
+
234
+ #emitPlatformEvent(name: string, event: unknown): void {
235
+ void this.emitPlatform(name, event).catch((error) => {
236
+ this.#logger.warn(formatCompact({
237
+ op: 'dingtalk_platform_event_failed',
238
+ event: name,
239
+ error: error instanceof Error ? error.message : String(error),
240
+ }));
241
+ });
242
+ }
243
+
244
+ async #getUserInfo(userId: string): Promise<unknown> {
245
+ try {
246
+ const data = await this.#request('/topapi/v2/user/get', {
247
+ method: 'POST',
248
+ body: { userid: userId },
249
+ });
250
+ if (data.errcode === 0) return data.result;
251
+ throw new Error(`Failed to get user info: ${data.errmsg}`);
252
+ } catch (error) {
253
+ this.#logger.error('Failed to get user info:', error);
254
+ return null;
255
+ }
256
+ }
257
+
258
+ async #getDepartmentUsers(deptId: number): Promise<unknown[]> {
259
+ try {
260
+ const data = await this.#request('/topapi/user/listid', {
261
+ method: 'POST',
262
+ body: { dept_id: deptId },
263
+ });
264
+ if (data.errcode === 0) {
265
+ const result = data.result as { userid_list?: unknown[] } | undefined;
266
+ return result?.userid_list || [];
267
+ }
268
+ throw new Error(`Failed to get department users: ${data.errmsg}`);
269
+ } catch (error) {
270
+ this.#logger.error('Failed to get department users:', error);
271
+ return [];
272
+ }
273
+ }
274
+
275
+ async #sendWorkNotice(userIdList: string[], content: unknown): Promise<boolean> {
276
+ try {
277
+ const data = await this.#request('/topapi/message/corpconversation/asyncsend_v2', {
278
+ method: 'POST',
279
+ body: {
280
+ agent_id: this.#options.config.robotCode,
281
+ userid_list: userIdList.join(','),
282
+ msg: content,
283
+ },
284
+ });
285
+ if (data.errcode === 0) return true;
286
+ throw new Error(`Failed to send work notice: ${data.errmsg}`);
287
+ } catch (error) {
288
+ this.#logger.error('Failed to send work notice:', error);
289
+ return false;
290
+ }
291
+ }
292
+
293
+ async #getDepartmentList(deptId: number = 1): Promise<unknown[]> {
294
+ try {
295
+ const data = await this.#request('/topapi/v2/department/listsub', {
296
+ method: 'POST',
297
+ body: { dept_id: deptId },
298
+ });
299
+ if (data.errcode === 0) return (data.result as unknown[]) || [];
300
+ throw new Error(`Failed to get department list: ${data.errmsg}`);
301
+ } catch (error) {
302
+ this.#logger.error('Failed to get department list:', error);
303
+ return [];
304
+ }
305
+ }
306
+
307
+ async #getDepartmentInfo(deptId: number): Promise<unknown> {
308
+ try {
309
+ const data = await this.#request('/topapi/v2/department/get', {
310
+ method: 'POST',
311
+ body: { dept_id: deptId },
312
+ });
313
+ if (data.errcode === 0) return data.result;
314
+ throw new Error(`Failed to get department info: ${data.errmsg}`);
315
+ } catch (error) {
316
+ this.#logger.error('Failed to get department info:', error);
317
+ return null;
318
+ }
319
+ }
320
+
321
+ async #createChat(
322
+ name: string,
323
+ ownerUserId: string,
324
+ userIdList: string[],
325
+ ): Promise<string | null> {
326
+ try {
327
+ const data = await this.#request('/topapi/chat/create', {
328
+ method: 'POST',
329
+ body: { name, owner: ownerUserId, useridlist: userIdList },
330
+ });
331
+ if (data.errcode === 0) return (data.chatid as string) || null;
332
+ throw new Error(`Failed to create chat: ${data.errmsg}`);
333
+ } catch (error) {
334
+ this.#logger.error('Failed to create chat:', error);
335
+ return null;
336
+ }
337
+ }
338
+
339
+ async #getChatInfo(chatId: string): Promise<unknown> {
340
+ try {
341
+ const data = await this.#request('/topapi/chat/get', {
342
+ method: 'POST',
343
+ body: { chatid: chatId },
344
+ });
345
+ if (data.errcode === 0) return data.chat_info;
346
+ throw new Error(`Failed to get chat info: ${data.errmsg}`);
347
+ } catch (error) {
348
+ this.#logger.error('Failed to get chat info:', error);
349
+ return null;
350
+ }
351
+ }
352
+
353
+ async #updateChat(
354
+ chatId: string,
355
+ options: {
356
+ name?: string;
357
+ owner?: string;
358
+ add_useridlist?: string[];
359
+ del_useridlist?: string[];
360
+ },
361
+ ): Promise<boolean> {
362
+ try {
363
+ const data = await this.#request('/topapi/chat/update', {
364
+ method: 'POST',
365
+ body: { chatid: chatId, ...options },
366
+ });
367
+ if (data.errcode === 0) return true;
368
+ throw new Error(`Failed to update chat: ${data.errmsg}`);
369
+ } catch (error) {
370
+ this.#logger.error('Failed to update chat:', error);
371
+ return false;
372
+ }
373
+ }
374
+
375
+ async #request(
376
+ path: string,
377
+ options: {
378
+ method?: 'GET' | 'POST';
379
+ params?: Record<string, string | number>;
380
+ body?: Record<string, unknown>;
381
+ } = {},
382
+ ): Promise<DingTalkApiResponse> {
383
+ await this.#ensureAccessToken();
384
+ const { method = 'GET', params = {}, body } = options;
385
+ const urlParams = new URLSearchParams({
386
+ ...Object.fromEntries(
387
+ Object.entries(params).map(([key, value]) => [key, String(value)]),
388
+ ),
389
+ access_token: this.#accessToken.token,
390
+ });
391
+ const url = `${this.#options.config.apiBaseUrl}${path}?${urlParams.toString()}`;
392
+ const response = await this.#fetch(url, {
393
+ method,
394
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
395
+ body: body && method === 'POST' ? JSON.stringify(body) : undefined,
396
+ });
397
+ if (!response.ok) {
398
+ const text = await response.text().catch(() => '');
399
+ throw new Error(`DingTalk API error ${response.status}: ${text}`);
400
+ }
401
+ return await response.json() as DingTalkApiResponse;
402
+ }
403
+
404
+ async #ensureAccessToken(): Promise<void> {
405
+ const now = Date.now();
406
+ if (
407
+ this.#accessToken.token
408
+ && now < this.#accessToken.timestamp + (this.#accessToken.expires_in - 300) * 1000
409
+ ) {
410
+ return;
411
+ }
412
+ if (this.#refreshPromise) {
413
+ await this.#refreshPromise;
414
+ return;
415
+ }
416
+ this.#refreshPromise = this.#refreshAccessToken()
417
+ .then(() => this.#accessToken.token)
418
+ .finally(() => { this.#refreshPromise = null; });
419
+ await this.#refreshPromise;
420
+ }
421
+
422
+ async #refreshAccessToken(): Promise<void> {
423
+ const { appKey, appSecret, apiBaseUrl } = this.#options.config;
424
+ const params = new URLSearchParams({ appkey: appKey, appsecret: appSecret });
425
+ const url = `${apiBaseUrl}/gettoken?${params.toString()}`;
426
+ const response = await this.#fetch(url);
427
+ const data = await response.json() as DingTalkApiResponse;
428
+ if (data.errcode === 0 && data.access_token) {
429
+ this.#accessToken = {
430
+ token: data.access_token,
431
+ expires_in: data.expires_in ?? 7200,
432
+ timestamp: Date.now(),
433
+ };
434
+ this.#logger.debug('Access token refreshed successfully');
435
+ return;
436
+ }
437
+ throw new Error(`Failed to get access token: ${data.errmsg} (${data.errcode})`);
438
+ }
439
+ }