@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.
- package/CHANGELOG.md +56 -0
- package/README.md +58 -335
- package/adapters/dingtalk.ts +26 -0
- package/agent/tools/add_chat_members.ts +22 -0
- package/agent/tools/create_chat.ts +23 -0
- package/agent/tools/dept_info.ts +17 -0
- package/agent/tools/get_dept_users.ts +18 -0
- package/agent/tools/get_user.ts +17 -0
- package/agent/tools/list_departments.ts +18 -0
- package/agent/tools/send_work_notice.ts +20 -0
- package/agent/tools/update_chat.ts +27 -0
- package/lib/dingtalk-agent-deps.d.ts +26 -0
- package/lib/dingtalk-agent-deps.js +30 -0
- package/lib/endpoint.d.ts +45 -36
- package/lib/endpoint.js +199 -434
- package/lib/index.d.ts +5 -15
- package/lib/index.js +5 -219
- package/lib/platform-permit.d.ts +1 -2
- package/lib/platform-permit.js +4 -2
- package/lib/protocol.d.ts +121 -0
- package/lib/protocol.js +221 -0
- package/lib/webhook.d.ts +13 -0
- package/lib/webhook.js +48 -0
- package/package.json +51 -19
- package/plugin.ts +12 -0
- package/schema.json +23 -0
- package/src/dingtalk-agent-deps.ts +58 -0
- package/src/endpoint.ts +263 -479
- package/src/index.ts +46 -235
- package/src/platform-permit.ts +1 -2
- package/src/protocol.ts +338 -0
- package/src/webhook.ts +76 -0
- package/lib/adapter.d.ts +0 -19
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -40
- package/lib/adapter.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/platform-permit.d.ts.map +0 -1
- package/lib/platform-permit.js.map +0 -1
- package/lib/segment-mapper.d.ts +0 -2
- package/lib/segment-mapper.d.ts.map +0 -1
- package/lib/segment-mapper.js +0 -2
- package/lib/segment-mapper.js.map +0 -1
- package/lib/types.d.ts +0 -58
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -5
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -46
- package/src/segment-mapper.ts +0 -1
- package/src/types.ts +0 -56
- /package/{skills/dingtalk → agent}/PERMITS.md +0 -0
- /package/{skills/dingtalk/SKILL.md → agent/skills/dingtalk.md} +0 -0
package/src/endpoint.ts
CHANGED
|
@@ -1,533 +1,262 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* DingTalkEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
68
|
+
/** Used by webhook handler. */
|
|
69
|
+
get isOpen(): boolean {
|
|
70
|
+
return this.#open;
|
|
76
71
|
}
|
|
77
72
|
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
77
|
+
async start(): Promise<void> {
|
|
78
|
+
if (this.#started) return;
|
|
79
|
+
this.#started = true;
|
|
105
80
|
try {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
113
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
310
|
-
const
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
this.
|
|
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<
|
|
187
|
+
async getUserInfo(userId: string): Promise<unknown> {
|
|
456
188
|
try {
|
|
457
|
-
const data = await this
|
|
458
|
-
method:
|
|
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
|
-
|
|
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<
|
|
201
|
+
async getDepartmentUsers(deptId: number): Promise<unknown[]> {
|
|
470
202
|
try {
|
|
471
|
-
const data = await this
|
|
472
|
-
method:
|
|
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)
|
|
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
|
-
|
|
213
|
+
logger.error('Failed to get department users:', error);
|
|
479
214
|
return [];
|
|
480
215
|
}
|
|
481
216
|
}
|
|
482
217
|
|
|
483
|
-
async sendWorkNotice(userIdList: string[], content:
|
|
218
|
+
async sendWorkNotice(userIdList: string[], content: unknown): Promise<boolean> {
|
|
484
219
|
try {
|
|
485
|
-
const data = await this
|
|
486
|
-
|
|
487
|
-
{
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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<
|
|
236
|
+
async getDepartmentList(deptId: number = 1): Promise<unknown[]> {
|
|
508
237
|
try {
|
|
509
|
-
const data = await this
|
|
510
|
-
method:
|
|
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
|
-
|
|
245
|
+
logger.error('Failed to get department list:', error);
|
|
517
246
|
return [];
|
|
518
247
|
}
|
|
519
248
|
}
|
|
520
249
|
|
|
521
|
-
async getDepartmentInfo(deptId: number): Promise<
|
|
250
|
+
async getDepartmentInfo(deptId: number): Promise<unknown> {
|
|
522
251
|
try {
|
|
523
|
-
const data = await this
|
|
524
|
-
method:
|
|
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
|
-
|
|
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
|
|
542
|
-
method:
|
|
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
|
-
|
|
277
|
+
logger.error('Failed to create chat:', error);
|
|
556
278
|
return null;
|
|
557
279
|
}
|
|
558
280
|
}
|
|
559
281
|
|
|
560
|
-
async getChatInfo(chatId: string): Promise<
|
|
282
|
+
async getChatInfo(chatId: string): Promise<unknown> {
|
|
561
283
|
try {
|
|
562
|
-
const data = await this
|
|
563
|
-
method:
|
|
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
|
-
|
|
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
|
|
585
|
-
method:
|
|
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
|
-
|
|
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
|
}
|