@zhin.js/adapter-wecom 2.0.2 → 2.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 (53) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +79 -226
  3. package/adapters/wecom.ts +27 -0
  4. package/agent/tools/get_dept_users.ts +2 -2
  5. package/agent/tools/get_user.ts +2 -2
  6. package/agent/tools/list_departments.ts +2 -2
  7. package/agent/tools/send_text.ts +2 -2
  8. package/lib/endpoint.d.ts +46 -0
  9. package/lib/endpoint.js +205 -0
  10. package/lib/index.d.ts +5 -0
  11. package/lib/index.js +5 -0
  12. package/lib/platform-permit.d.ts +15 -0
  13. package/lib/{src/platform-permit.js → platform-permit.js} +1 -2
  14. package/lib/protocol.d.ts +96 -0
  15. package/lib/protocol.js +252 -0
  16. package/lib/webhook.d.ts +14 -0
  17. package/lib/webhook.js +86 -0
  18. package/lib/wecom-agent-deps.d.ts +17 -0
  19. package/lib/wecom-agent-deps.js +30 -0
  20. package/package.json +45 -20
  21. package/plugin.ts +12 -0
  22. package/schema.json +24 -0
  23. package/src/endpoint.ts +196 -584
  24. package/src/index.ts +48 -53
  25. package/src/platform-permit.ts +1 -1
  26. package/src/protocol.ts +362 -0
  27. package/src/webhook.ts +130 -0
  28. package/src/wecom-agent-deps.ts +37 -9
  29. package/lib/agent/tools/get_dept_users.js +0 -18
  30. package/lib/agent/tools/get_dept_users.js.map +0 -1
  31. package/lib/agent/tools/get_user.js +0 -17
  32. package/lib/agent/tools/get_user.js.map +0 -1
  33. package/lib/agent/tools/list_departments.js +0 -18
  34. package/lib/agent/tools/list_departments.js.map +0 -1
  35. package/lib/agent/tools/send_text.js +0 -19
  36. package/lib/agent/tools/send_text.js.map +0 -1
  37. package/lib/src/adapter.js +0 -22
  38. package/lib/src/adapter.js.map +0 -1
  39. package/lib/src/endpoint.js +0 -602
  40. package/lib/src/endpoint.js.map +0 -1
  41. package/lib/src/index.js +0 -41
  42. package/lib/src/index.js.map +0 -1
  43. package/lib/src/platform-permit.js.map +0 -1
  44. package/lib/src/segment-mapper.js +0 -2
  45. package/lib/src/segment-mapper.js.map +0 -1
  46. package/lib/src/types.js +0 -5
  47. package/lib/src/types.js.map +0 -1
  48. package/lib/src/wecom-agent-deps.js +0 -10
  49. package/lib/src/wecom-agent-deps.js.map +0 -1
  50. package/plugin.yml +0 -3
  51. package/src/adapter.ts +0 -28
  52. package/src/segment-mapper.ts +0 -1
  53. package/src/types.ts +0 -51
package/src/index.ts CHANGED
@@ -1,57 +1,52 @@
1
- /**
2
- * 企业微信适配器入口:类型扩展、导出、注册
3
- */
4
- import { usePlugin, type Plugin, type ToolFeature } from 'zhin.js';
5
- import type { Router } from '@zhin.js/host-router/router';
6
- import { WecomAdapter } from './adapter.js';
7
- import {
8
- registerWecomPlatformPermitChecker,
9
- } from './platform-permit.js';
10
- import { setWecomAgentDeps } from './wecom-agent-deps.js';
11
-
12
- declare module 'zhin.js' {
13
- namespace Plugin {
14
- interface Contexts {
15
- router: import('@zhin.js/host-router').Router;
16
- }
17
- }
18
- interface Adapters {
19
- wecom: WecomAdapter;
20
- }
21
- }
1
+ export {
2
+ buildSendRequestBody,
3
+ decryptMessage,
4
+ extractEncryptFromXml,
5
+ formatInboundContent,
6
+ formatOutboundBody,
7
+ getAesKey,
8
+ normalizeEchostrParam,
9
+ normalizeWebhookPath,
10
+ parseXmlMessage,
11
+ queryParam,
12
+ readTextBody,
13
+ resolveChatType,
14
+ resolveWecomConfig,
15
+ verifySignature,
16
+ type AccessToken,
17
+ type ResolvedWecomConfig,
18
+ type WecomAdapterConfig,
19
+ type WecomApiResponse,
20
+ type WecomMessage,
21
+ type WecomSendBody,
22
+ type WecomWireSegment,
23
+ } from './protocol.js';
22
24
 
23
- export * from './types.js';
24
- export { WecomEndpoint } from './endpoint.js';
25
- export { WecomAdapter } from './adapter.js';
25
+ export {
26
+ getWecomAgentDeps,
27
+ registerWecomAgentEndpoint,
28
+ setWecomAgentDeps,
29
+ type WecomAgentDeps,
30
+ type WecomAgentEndpoint,
31
+ } from './wecom-agent-deps.js';
26
32
 
27
- const plugin = usePlugin();
28
- const { provide, useContext } = plugin;
33
+ export {
34
+ checkWecomPlatformPermit,
35
+ normalizeWecomSenderForPermit,
36
+ platformPermit,
37
+ registerWecomPlatformPermitChecker,
38
+ wecomGroupPermitResolver,
39
+ } from './platform-permit.js';
29
40
 
30
- useContext('router', (router: Router) => {
31
- provide({
32
- name: 'wecom',
33
- description: 'WeCom (企业微信) Endpoint Adapter',
34
- mounted: async (p: Plugin) => {
35
- const adapter = new WecomAdapter(p, router);
36
- await adapter.start();
37
- return adapter;
38
- },
39
- dispose: async (adapter: WecomAdapter) => {
40
- await adapter.stop();
41
- },
42
- });
43
- });
41
+ export {
42
+ WecomEndpoint,
43
+ type WecomEndpointOptions,
44
+ type WecomFetch,
45
+ } from './endpoint.js';
44
46
 
45
- useContext('tool', 'wecom', (toolService: ToolFeature, wecom: WecomAdapter) => {
46
- const disposers: (() => void)[] = [];
47
- disposers.push(registerWecomPlatformPermitChecker());
48
- setWecomAgentDeps({
49
- getEndpoint: (endpointId) => {
50
- const endpoint = wecom.endpoints.get(endpointId);
51
- if (!endpoint) throw new Error(`Endpoint ${endpointId} 不存在`);
52
- return endpoint;
53
- },
54
- getAdapter: () => wecom,
55
- });
56
- return () => disposers.forEach(d => d());
57
- });
47
+ export {
48
+ registerWecomWebhookRoutes,
49
+ handleWecomVerificationRequest,
50
+ handleWecomWebhookRequest,
51
+ type WecomWebhookHandler,
52
+ } from './webhook.js';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 企业微信 WeCom platform permit
3
3
  */
4
- import { registerPlatformPermitChecker, type Message } from 'zhin.js';
4
+ import { registerPlatformPermitChecker, type Message } from '@zhin.js/core';
5
5
 
6
6
  const ADAPTER = 'wecom';
7
7
 
@@ -0,0 +1,362 @@
1
+ /**
2
+ * WeCom (企业微信) protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+
6
+ import { createHash, createDecipheriv } from 'node:crypto';
7
+ import type { IncomingMessage } from 'node:http';
8
+
9
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
10
+ export interface WecomAdapterConfig {
11
+ readonly name?: string;
12
+ readonly corpId?: string;
13
+ readonly agentSecret?: string;
14
+ readonly token?: string;
15
+ readonly encodingAESKey?: string;
16
+ readonly webhookPath?: string;
17
+ readonly apiBaseUrl?: string;
18
+ /** Transitional: legacy root `endpoints[]` with `context: wecom`. */
19
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedWecomConfig> & {
20
+ readonly context?: string;
21
+ }>;
22
+ }
23
+
24
+ export interface ResolvedWecomConfig {
25
+ readonly context: 'wecom';
26
+ readonly name: string;
27
+ readonly corpId: string;
28
+ readonly agentSecret: string;
29
+ readonly token: string;
30
+ readonly encodingAESKey: string;
31
+ readonly webhookPath: string;
32
+ readonly apiBaseUrl: string;
33
+ }
34
+
35
+ export interface WecomMessage {
36
+ readonly ToUserName: string;
37
+ readonly FromUserName: string;
38
+ readonly CreateTime: number;
39
+ readonly MsgType: 'text' | 'image' | 'voice' | 'video' | 'shortvideo' | 'location' | 'link' | 'event' | string;
40
+ readonly Content?: string;
41
+ readonly MsgId?: string;
42
+ readonly PicUrl?: string;
43
+ readonly MediaId?: string;
44
+ readonly ThumbMediaId?: string;
45
+ readonly Format?: string;
46
+ readonly Recognition?: string;
47
+ readonly Location_X?: string;
48
+ readonly Location_Y?: string;
49
+ readonly Scale?: string;
50
+ readonly Label?: string;
51
+ readonly Title?: string;
52
+ readonly Description?: string;
53
+ readonly Url?: string;
54
+ readonly Event?: string;
55
+ readonly EventKey?: string;
56
+ readonly AgentID?: string;
57
+ }
58
+
59
+ export interface AccessToken {
60
+ access_token: string;
61
+ expires_in: number;
62
+ timestamp: number;
63
+ }
64
+
65
+ export interface WecomApiResponse {
66
+ readonly errcode: number;
67
+ readonly errmsg?: string;
68
+ readonly access_token?: string;
69
+ readonly expires_in?: number;
70
+ readonly msgid?: string;
71
+ readonly userlist?: unknown[];
72
+ readonly department?: unknown[];
73
+ readonly [key: string]: unknown;
74
+ }
75
+
76
+ export interface WecomWireSegment {
77
+ readonly type: string;
78
+ readonly data?: Record<string, unknown>;
79
+ }
80
+
81
+ export interface WecomSendBody {
82
+ readonly msgtype: string;
83
+ readonly data: Record<string, unknown>;
84
+ }
85
+
86
+ export function resolveWecomConfig(config: WecomAdapterConfig = {}): ResolvedWecomConfig {
87
+ const entry = config.endpoints?.find((item) => item.context === 'wecom');
88
+ const corpId = config.corpId ?? entry?.corpId ?? process.env.WECOM_CORP_ID;
89
+ const agentSecret = config.agentSecret ?? entry?.agentSecret ?? process.env.WECOM_AGENT_SECRET;
90
+ const token = config.token ?? entry?.token ?? process.env.WECOM_TOKEN;
91
+ const encodingAESKey = config.encodingAESKey
92
+ ?? entry?.encodingAESKey
93
+ ?? process.env.WECOM_AES_KEY;
94
+ if (!corpId || !agentSecret || !token || !encodingAESKey) {
95
+ throw new TypeError(
96
+ 'WeCom adapter requires corpId + agentSecret + token + encodingAESKey (plugins.<key> or endpoints with context: wecom)',
97
+ );
98
+ }
99
+ const name = (typeof config.name === 'string' && config.name)
100
+ || (typeof entry?.name === 'string' && entry.name)
101
+ || process.env.WECOM_BOT_NAME
102
+ || 'wecom-bot';
103
+ return {
104
+ context: 'wecom',
105
+ name,
106
+ corpId,
107
+ agentSecret,
108
+ token,
109
+ encodingAESKey,
110
+ webhookPath: normalizeWebhookPath(
111
+ config.webhookPath ?? entry?.webhookPath ?? '/wecom/callback',
112
+ ),
113
+ apiBaseUrl: config.apiBaseUrl
114
+ ?? entry?.apiBaseUrl
115
+ ?? 'https://qyapi.weixin.qq.com',
116
+ };
117
+ }
118
+
119
+ export function normalizeWebhookPath(path: string): string {
120
+ const trimmed = path.trim() || '/wecom/callback';
121
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
122
+ }
123
+
124
+ export function queryParam(value: string | null | undefined): string {
125
+ return value ?? '';
126
+ }
127
+
128
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
129
+ export function normalizeEchostrParam(echostr: string): string {
130
+ return echostr.replace(/ /g, '+');
131
+ }
132
+
133
+ export function getAesKey(encodingAESKey: string): Buffer {
134
+ const aesKey = Buffer.from(`${encodingAESKey}=`, 'base64');
135
+ if (aesKey.length !== 32) {
136
+ throw new Error(`encodingAESKey must produce a 32-byte key, got ${aesKey.length} bytes`);
137
+ }
138
+ return aesKey;
139
+ }
140
+
141
+ export function verifySignature(
142
+ token: string,
143
+ timestamp: string,
144
+ nonce: string,
145
+ encrypt: string,
146
+ signature: string,
147
+ ): boolean {
148
+ try {
149
+ const hash = createHash('sha1')
150
+ .update([token, timestamp, nonce, encrypt].sort().join(''))
151
+ .digest('hex');
152
+ return hash === signature;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ export function decryptMessage(
159
+ encrypted: string,
160
+ encodingAESKey: string,
161
+ corpId: string,
162
+ ): string | null {
163
+ try {
164
+ const aesKey = getAesKey(encodingAESKey);
165
+ const buf = Buffer.from(encrypted, 'base64');
166
+ const iv = aesKey.subarray(0, 16);
167
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
168
+ decipher.setAutoPadding(false);
169
+ const decrypted = Buffer.concat([decipher.update(buf), decipher.final()]);
170
+ const pad = decrypted[decrypted.length - 1]!;
171
+ const content = decrypted.subarray(0, decrypted.length - pad);
172
+ const msgLen = content.readUInt32BE(16);
173
+ const msg = content.subarray(20, 20 + msgLen).toString('utf8');
174
+ const extractedCorpId = content.subarray(20 + msgLen).toString('utf8');
175
+ if (extractedCorpId !== corpId) return null;
176
+ return msg;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ export function extractEncryptFromXml(xml: string): string | null {
183
+ const match = xml.match(/<Encrypt><!\[CDATA\[([^[\]]+)\]\]><\/Encrypt>/);
184
+ return match?.[1] ?? null;
185
+ }
186
+
187
+ export function parseXmlMessage(xml: string): WecomMessage | null {
188
+ try {
189
+ const get = (tag: string): string | undefined => {
190
+ const m = xml.match(new RegExp(`<${tag}><!\\[CDATA\\[([^\\[\\]]*)\\]\\]><\\/${tag}>`))
191
+ || xml.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`));
192
+ return m ? m[1] : undefined;
193
+ };
194
+ const msgType = get('MsgType');
195
+ if (!msgType) return null;
196
+
197
+ const msg: WecomMessage = {
198
+ ToUserName: get('ToUserName') || '',
199
+ FromUserName: get('FromUserName') || '',
200
+ CreateTime: Number(get('CreateTime') || Date.now()),
201
+ MsgType: msgType,
202
+ MsgId: get('MsgId'),
203
+ AgentID: get('AgentID'),
204
+ Content: get('Content'),
205
+ PicUrl: get('PicUrl'),
206
+ MediaId: get('MediaId'),
207
+ ThumbMediaId: get('ThumbMediaId'),
208
+ Format: get('Format'),
209
+ Recognition: get('Recognition'),
210
+ Location_X: get('Location_X'),
211
+ Location_Y: get('Location_Y'),
212
+ Scale: get('Scale'),
213
+ Label: get('Label'),
214
+ Title: get('Title'),
215
+ Description: get('Description'),
216
+ Url: get('Url'),
217
+ Event: get('Event'),
218
+ EventKey: get('EventKey'),
219
+ };
220
+ return msg;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ /** Build inbound text for MessageGateway.receive. */
227
+ export function formatInboundContent(msg: WecomMessage): string {
228
+ switch (msg.MsgType) {
229
+ case 'text':
230
+ return msg.Content || '(空消息)';
231
+ case 'image':
232
+ return msg.PicUrl ? `[image: ${msg.PicUrl}]` : '[image]';
233
+ case 'voice':
234
+ return msg.Recognition
235
+ ? msg.Recognition
236
+ : `[voice${msg.Format ? `: ${msg.Format}` : ''}]`;
237
+ case 'video':
238
+ case 'shortvideo':
239
+ return '[video]';
240
+ case 'location':
241
+ return `[位置] ${msg.Label || ''} (${msg.Location_X}, ${msg.Location_Y})`.trim();
242
+ case 'link':
243
+ return `[link: ${msg.Title ?? ''}${msg.Url ? ` ${msg.Url}` : ''}]`;
244
+ case 'event':
245
+ return `[事件] ${msg.Event || ''} ${msg.EventKey || ''}`.trim();
246
+ default:
247
+ return `[不支持的消息类型: ${msg.MsgType}]`;
248
+ }
249
+ }
250
+
251
+ export function resolveChatType(fromUserName: string): 'group' | 'private' {
252
+ return fromUserName.endsWith('@chatroom') ? 'group' : 'private';
253
+ }
254
+
255
+ /**
256
+ * Wire-encode an already-rendered outbound payload into WeCom message/send body parts.
257
+ */
258
+ export function formatOutboundBody(payload: unknown): WecomSendBody {
259
+ if (typeof payload === 'string') {
260
+ return { msgtype: 'text', data: { content: payload } };
261
+ }
262
+ if (!Array.isArray(payload)) {
263
+ return { msgtype: 'text', data: { content: String(payload ?? '') } };
264
+ }
265
+
266
+ const textParts: string[] = [];
267
+ let hasMedia = false;
268
+ let mediaType = '';
269
+ let mediaData: Record<string, unknown> | null = null;
270
+
271
+ for (const item of payload) {
272
+ if (typeof item === 'string') {
273
+ textParts.push(item);
274
+ continue;
275
+ }
276
+ if (!item || typeof item !== 'object') continue;
277
+ const seg = item as WecomWireSegment;
278
+ const data = seg.data ?? {};
279
+ switch (seg.type) {
280
+ case 'text':
281
+ textParts.push(String(data.content ?? data.text ?? ''));
282
+ break;
283
+ case 'at': {
284
+ const userId = data.id ?? data.userId;
285
+ if (userId) textParts.push(`<@${userId}>`);
286
+ break;
287
+ }
288
+ case 'image':
289
+ if (!hasMedia) {
290
+ hasMedia = true;
291
+ mediaType = 'image';
292
+ mediaData = { media_id: data.file || data.url };
293
+ }
294
+ break;
295
+ case 'markdown':
296
+ if (!hasMedia) {
297
+ hasMedia = true;
298
+ mediaType = 'markdown';
299
+ mediaData = { content: data.content || data.text };
300
+ }
301
+ break;
302
+ case 'link':
303
+ if (!hasMedia) {
304
+ hasMedia = true;
305
+ mediaType = 'news';
306
+ mediaData = {
307
+ articles: [{
308
+ title: data.title || '链接',
309
+ description: data.text || data.content || '',
310
+ url: data.url,
311
+ picurl: data.picUrl,
312
+ }],
313
+ };
314
+ }
315
+ break;
316
+ default:
317
+ break;
318
+ }
319
+ }
320
+
321
+ if (hasMedia && mediaData) {
322
+ return { msgtype: mediaType, data: mediaData };
323
+ }
324
+ return { msgtype: 'text', data: { content: textParts.join('') } };
325
+ }
326
+
327
+ export function buildSendRequestBody(
328
+ targetId: string,
329
+ content: WecomSendBody,
330
+ agentId: string | number,
331
+ ): Record<string, unknown> {
332
+ const body: Record<string, unknown> = {
333
+ msgtype: content.msgtype,
334
+ agentid: agentId,
335
+ [content.msgtype]: content.data,
336
+ };
337
+ if (targetId.endsWith('@chatroom')) {
338
+ body.chatid = targetId;
339
+ } else {
340
+ body.touser = targetId;
341
+ }
342
+ return body;
343
+ }
344
+
345
+ export async function readTextBody(
346
+ request: IncomingMessage,
347
+ options: { readonly limit?: number } = {},
348
+ ): Promise<string> {
349
+ const limit = options.limit ?? 1_048_576;
350
+ const chunks: Buffer[] = [];
351
+ let size = 0;
352
+ for await (const chunk of request) {
353
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
354
+ size += buffer.length;
355
+ if (size > limit) {
356
+ request.destroy();
357
+ throw new Error(`Request body exceeds ${limit} bytes`);
358
+ }
359
+ chunks.push(buffer);
360
+ }
361
+ return Buffer.concat(chunks).toString('utf8');
362
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * WeCom webhook HTTP: URL verification (GET) + encrypted inbound (POST).
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ import {
8
+ decryptMessage,
9
+ extractEncryptFromXml,
10
+ normalizeEchostrParam,
11
+ parseXmlMessage,
12
+ queryParam,
13
+ readTextBody,
14
+ verifySignature,
15
+ type ResolvedWecomConfig,
16
+ type WecomMessage,
17
+ } from './protocol.js';
18
+
19
+ const logger = getLogger('wecom');
20
+
21
+ export interface WecomWebhookHandler {
22
+ readonly config: ResolvedWecomConfig;
23
+ readonly isOpen: boolean;
24
+ admit(msg: WecomMessage): void;
25
+ }
26
+
27
+ export function registerWecomWebhookRoutes(
28
+ http: HttpHost,
29
+ handler: WecomWebhookHandler,
30
+ ): HttpRouteRegistration[] {
31
+ const path = handler.config.webhookPath;
32
+ return [
33
+ http.route('GET', path, (request, response, url) => {
34
+ handleWecomVerificationRequest(request, response, url, handler);
35
+ }, { summary: 'WeCom URL verification', tags: ['wecom'] }),
36
+ http.route('POST', path, async (request, response, url) => {
37
+ await handleWecomWebhookRequest(request, response, url, handler);
38
+ }, { summary: 'WeCom inbound webhook', tags: ['wecom'] }),
39
+ ];
40
+ }
41
+
42
+ export function handleWecomVerificationRequest(
43
+ _request: IncomingMessage,
44
+ response: ServerResponse,
45
+ url: URL,
46
+ handler: WecomWebhookHandler,
47
+ ): void {
48
+ try {
49
+ const msgSignature = queryParam(url.searchParams.get('msg_signature'));
50
+ const timestamp = queryParam(url.searchParams.get('timestamp'));
51
+ const nonce = queryParam(url.searchParams.get('nonce'));
52
+ const echostr = normalizeEchostrParam(queryParam(url.searchParams.get('echostr')));
53
+ const { token, encodingAESKey, corpId } = handler.config;
54
+
55
+ if (!msgSignature || !timestamp || !nonce || !echostr) {
56
+ response.writeHead(400, { 'Content-Type': 'text/plain' });
57
+ response.end('Missing required query parameters');
58
+ return;
59
+ }
60
+
61
+ if (!verifySignature(token, timestamp, nonce, echostr, msgSignature)) {
62
+ logger.warn(formatCompact({ op: 'verify', ok: false, error: 'invalid signature' }));
63
+ response.writeHead(403, { 'Content-Type': 'text/plain' });
64
+ response.end('Forbidden');
65
+ return;
66
+ }
67
+
68
+ const decrypted = decryptMessage(echostr, encodingAESKey, corpId);
69
+ if (!decrypted) {
70
+ response.writeHead(400, { 'Content-Type': 'text/plain' });
71
+ response.end('Decryption failed');
72
+ return;
73
+ }
74
+ response.writeHead(200, { 'Content-Type': 'text/plain' });
75
+ response.end(decrypted);
76
+ } catch (error) {
77
+ logger.error('URL verification error:', error);
78
+ response.writeHead(500, { 'Content-Type': 'text/plain' });
79
+ response.end('Internal Server Error');
80
+ }
81
+ }
82
+
83
+ export async function handleWecomWebhookRequest(
84
+ request: IncomingMessage,
85
+ response: ServerResponse,
86
+ url: URL,
87
+ handler: WecomWebhookHandler,
88
+ ): Promise<void> {
89
+ try {
90
+ const msgSignature = queryParam(url.searchParams.get('msg_signature'));
91
+ const timestamp = queryParam(url.searchParams.get('timestamp'));
92
+ const nonce = queryParam(url.searchParams.get('nonce'));
93
+ const { token, encodingAESKey, corpId } = handler.config;
94
+
95
+ const rawBody = await readTextBody(request);
96
+ const encrypted = extractEncryptFromXml(rawBody);
97
+ if (!encrypted) {
98
+ logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'no Encrypt field' }));
99
+ response.writeHead(200, { 'Content-Type': 'text/plain' });
100
+ response.end('success');
101
+ return;
102
+ }
103
+
104
+ if (!verifySignature(token, timestamp, nonce, encrypted, msgSignature)) {
105
+ logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
106
+ response.writeHead(403, { 'Content-Type': 'text/plain' });
107
+ response.end('Forbidden');
108
+ return;
109
+ }
110
+
111
+ const decryptedXml = decryptMessage(encrypted, encodingAESKey, corpId);
112
+ if (!decryptedXml) {
113
+ response.writeHead(200, { 'Content-Type': 'text/plain' });
114
+ response.end('success');
115
+ return;
116
+ }
117
+
118
+ const message = parseXmlMessage(decryptedXml);
119
+ if (message && handler.isOpen) {
120
+ handler.admit(message);
121
+ }
122
+
123
+ response.writeHead(200, { 'Content-Type': 'text/plain' });
124
+ response.end('success');
125
+ } catch (error) {
126
+ logger.error('Webhook error:', error);
127
+ response.writeHead(200, { 'Content-Type': 'text/plain' });
128
+ response.end('success');
129
+ }
130
+ }
@@ -1,18 +1,46 @@
1
- import type { WecomAdapter } from './adapter.js';
2
- import type { WecomEndpoint } from './endpoint.js';
1
+ /**
2
+ * Agent tool deps for wecom (get_user / departments / send_text).
3
+ * Endpoints register themselves on start; tools look up by endpoint id.
4
+ */
5
+
6
+ export interface WecomAgentEndpoint {
7
+ getUserInfo(userId: string): Promise<unknown>;
8
+ getDepartmentUsers(deptId: number): Promise<unknown[]>;
9
+ getDepartmentList(deptId?: number): Promise<unknown[]>;
10
+ sendTextMessage(userId: string, content: string): Promise<boolean>;
11
+ }
3
12
 
4
13
  export interface WecomAgentDeps {
5
- getEndpoint: (endpointId: string) => WecomEndpoint;
6
- getAdapter: () => WecomAdapter;
14
+ getEndpoint: (endpointId: string) => WecomAgentEndpoint;
7
15
  }
8
16
 
9
- let _deps: WecomAgentDeps | null = null;
17
+ const endpoints = new Map<string, WecomAgentEndpoint>();
18
+ let override: WecomAgentDeps | null = null;
19
+
20
+ export function registerWecomAgentEndpoint(
21
+ endpointId: string,
22
+ endpoint: WecomAgentEndpoint,
23
+ ): () => void {
24
+ endpoints.set(endpointId, endpoint);
25
+ return () => {
26
+ if (endpoints.get(endpointId) === endpoint) {
27
+ endpoints.delete(endpointId);
28
+ }
29
+ };
30
+ }
10
31
 
11
- export function setWecomAgentDeps(deps: WecomAgentDeps): void {
12
- _deps = deps;
32
+ /** Optional override used by tests / transitional callers. Pass `null` to clear. */
33
+ export function setWecomAgentDeps(deps: WecomAgentDeps | null): void {
34
+ override = deps;
13
35
  }
14
36
 
15
37
  export function getWecomAgentDeps(): WecomAgentDeps {
16
- if (!_deps) throw new Error('wecom agent deps not initialized');
17
- return _deps;
38
+ if (override) return override;
39
+ return {
40
+ getEndpoint(endpointId) {
41
+ const endpoint = endpoints.get(endpointId);
42
+ if (!endpoint) throw new Error(`Endpoint ${endpointId} 不存在`);
43
+ return endpoint;
44
+ },
45
+ };
18
46
  }
@@ -1,18 +0,0 @@
1
- import { defineTool } from '@zhin.js/agent/tools';
2
- import { z } from 'zod';
3
- import { getWecomAgentDeps } from '../../src/wecom-agent-deps.js';
4
- export default defineTool({
5
- description: '获取企业微信部门用户列表',
6
- inputSchema: z.object({
7
- endpoint_id: z.string().describe('Endpoint 名称'),
8
- dept_id: z.string().describe('部门 ID'),
9
- }),
10
- platforms: ['wecom'],
11
- tags: ['wecom'],
12
- async execute({ endpoint_id, dept_id }) {
13
- const endpoint = getWecomAgentDeps().getEndpoint(endpoint_id);
14
- const users = await endpoint.getDepartmentUsers(Number(dept_id));
15
- return { users, count: users.length };
16
- },
17
- });
18
- //# sourceMappingURL=get_dept_users.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"get_dept_users.js","sourceRoot":"","sources":["../../../agent/tools/get_dept_users.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAClD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAClE,eAAe,UAAU,CAA2C;IAClE,WAAW,EAAE,cAAc;IAC3B,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC;QACpB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,aAAa,CAAC;QAC/C,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC;KACtC,CAAC;IACF,SAAS,EAAE,CAAC,OAAO,CAAC;IACpB,IAAI,EAAE,CAAC,OAAO,CAAC;IACf,KAAK,CAAC,OAAO,CAAC,EAAE,WAAW,EAAE,OAAO,EAA+C;QACjF,MAAM,QAAQ,GAAG,iBAAiB,EAAE,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC;QAC9D,MAAM,KAAK,GAAG,MAAM,QAAQ,CAAC,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;QACjE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC;IACxC,CAAC;CACF,CAAC,CAAC"}