@zhin.js/adapter-dingtalk 1.0.80 → 1.1.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +621 -0
  2. package/README.md +67 -333
  3. package/adapters/dingtalk.js +33 -0
  4. package/adapters/dingtalk.ts +38 -0
  5. package/agent/PERMITS.md +19 -0
  6. package/agent/tools/add_chat_members.ts +20 -0
  7. package/agent/tools/create_chat.ts +21 -0
  8. package/agent/tools/dept_info.ts +15 -0
  9. package/agent/tools/get_dept_users.ts +16 -0
  10. package/agent/tools/get_user.ts +15 -0
  11. package/agent/tools/list_departments.ts +16 -0
  12. package/agent/tools/send_work_notice.ts +18 -0
  13. package/agent/tools/update_chat.ts +25 -0
  14. package/commands/endpoint/add/[id].js +3 -0
  15. package/commands/endpoint/add/[id].ts +3 -0
  16. package/commands/endpoint/list.js +3 -0
  17. package/commands/endpoint/list.ts +3 -0
  18. package/commands/endpoint/remove/[id].js +3 -0
  19. package/commands/endpoint/remove/[id].ts +3 -0
  20. package/lib/client.d.ts +12 -0
  21. package/lib/client.js +2 -0
  22. package/lib/dingtalk-endpoint-commands.d.ts +1 -0
  23. package/lib/dingtalk-endpoint-commands.js +18 -0
  24. package/lib/dingtalk-runtime-state.d.ts +1 -0
  25. package/lib/dingtalk-runtime-state.js +6 -0
  26. package/lib/endpoint.d.ts +72 -0
  27. package/lib/endpoint.js +353 -0
  28. package/lib/index.d.ts +5 -15
  29. package/lib/index.js +5 -214
  30. package/lib/platform-permit.d.ts +14 -0
  31. package/lib/platform-permit.js +33 -0
  32. package/lib/protocol.d.ts +134 -0
  33. package/lib/protocol.js +265 -0
  34. package/lib/webhook.d.ts +13 -0
  35. package/lib/webhook.js +47 -0
  36. package/package.json +64 -18
  37. package/plugin.js +19 -0
  38. package/schema.json +92 -0
  39. package/src/client.ts +16 -0
  40. package/src/dingtalk-endpoint-commands.ts +19 -0
  41. package/src/dingtalk-runtime-state.ts +7 -0
  42. package/src/endpoint.ts +439 -0
  43. package/src/index.ts +44 -228
  44. package/src/platform-permit.ts +48 -0
  45. package/src/protocol.ts +385 -0
  46. package/src/webhook.ts +75 -0
  47. package/lib/adapter.d.ts +0 -16
  48. package/lib/adapter.d.ts.map +0 -1
  49. package/lib/adapter.js +0 -37
  50. package/lib/adapter.js.map +0 -1
  51. package/lib/bot.d.ts +0 -46
  52. package/lib/bot.d.ts.map +0 -1
  53. package/lib/bot.js +0 -539
  54. package/lib/bot.js.map +0 -1
  55. package/lib/index.d.ts.map +0 -1
  56. package/lib/index.js.map +0 -1
  57. package/lib/types.d.ts +0 -58
  58. package/lib/types.d.ts.map +0 -1
  59. package/lib/types.js +0 -5
  60. package/lib/types.js.map +0 -1
  61. package/plugin.yml +0 -3
  62. package/src/adapter.ts +0 -44
  63. package/src/bot.ts +0 -590
  64. package/src/types.ts +0 -56
  65. /package/{skills/dingtalk/SKILL.md → agent/skills/dingtalk.md} +0 -0
@@ -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
+ }