@zhin.js/adapter-wecom 2.0.1 → 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 (52) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +79 -216
  3. package/adapters/wecom.ts +27 -0
  4. package/agent/tools/get_dept_users.ts +18 -0
  5. package/agent/tools/get_user.ts +17 -0
  6. package/agent/tools/list_departments.ts +18 -0
  7. package/agent/tools/send_text.ts +19 -0
  8. package/lib/endpoint.d.ts +38 -35
  9. package/lib/endpoint.js +148 -545
  10. package/lib/index.d.ts +5 -15
  11. package/lib/index.js +5 -115
  12. package/lib/platform-permit.d.ts +1 -2
  13. package/lib/platform-permit.js +4 -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 +51 -16
  21. package/plugin.ts +12 -0
  22. package/schema.json +24 -0
  23. package/src/endpoint.ts +196 -584
  24. package/src/index.ts +49 -130
  25. package/src/platform-permit.ts +1 -2
  26. package/src/protocol.ts +362 -0
  27. package/src/webhook.ts +130 -0
  28. package/src/wecom-agent-deps.ts +46 -0
  29. package/lib/adapter.d.ts +0 -17
  30. package/lib/adapter.d.ts.map +0 -1
  31. package/lib/adapter.js +0 -22
  32. package/lib/adapter.js.map +0 -1
  33. package/lib/endpoint.d.ts.map +0 -1
  34. package/lib/endpoint.js.map +0 -1
  35. package/lib/index.d.ts.map +0 -1
  36. package/lib/index.js.map +0 -1
  37. package/lib/platform-permit.d.ts.map +0 -1
  38. package/lib/platform-permit.js.map +0 -1
  39. package/lib/segment-mapper.d.ts +0 -2
  40. package/lib/segment-mapper.d.ts.map +0 -1
  41. package/lib/segment-mapper.js +0 -2
  42. package/lib/segment-mapper.js.map +0 -1
  43. package/lib/types.d.ts +0 -48
  44. package/lib/types.d.ts.map +0 -1
  45. package/lib/types.js +0 -5
  46. package/lib/types.js.map +0 -1
  47. package/plugin.yml +0 -3
  48. package/src/adapter.ts +0 -28
  49. package/src/segment-mapper.ts +0 -1
  50. package/src/types.ts +0 -51
  51. /package/{skills/wecom → agent}/PERMITS.md +0 -0
  52. /package/{skills/wecom/SKILL.md → agent/skills/wecom.md} +0 -0
package/src/index.ts CHANGED
@@ -1,133 +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 {
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';
24
+
25
+ export {
26
+ getWecomAgentDeps,
27
+ registerWecomAgentEndpoint,
28
+ setWecomAgentDeps,
29
+ type WecomAgentDeps,
30
+ type WecomAgentEndpoint,
31
+ } from './wecom-agent-deps.js';
32
+
33
+ export {
34
+ checkWecomPlatformPermit,
35
+ normalizeWecomSenderForPermit,
36
+ platformPermit,
8
37
  registerWecomPlatformPermitChecker,
38
+ wecomGroupPermitResolver,
9
39
  } from './platform-permit.js';
10
40
 
11
- declare module 'zhin.js' {
12
- namespace Plugin {
13
- interface Contexts {
14
- router: import('@zhin.js/host-router').Router;
15
- }
16
- }
17
- interface Adapters {
18
- wecom: WecomAdapter;
19
- }
20
- }
21
-
22
- export * from './types.js';
23
- export { WecomEndpoint } from './endpoint.js';
24
- export { WecomAdapter } from './adapter.js';
25
-
26
- const plugin = usePlugin();
27
- const { provide, useContext } = plugin;
28
-
29
- useContext('router', (router: Router) => {
30
- provide({
31
- name: 'wecom',
32
- description: 'WeCom (企业微信) Endpoint Adapter',
33
- mounted: async (p: Plugin) => {
34
- const adapter = new WecomAdapter(p, router);
35
- await adapter.start();
36
- return adapter;
37
- },
38
- dispose: async (adapter: WecomAdapter) => {
39
- await adapter.stop();
40
- },
41
- });
42
- });
43
-
44
- useContext('tool', 'wecom', (toolService: ToolFeature, wecom: WecomAdapter) => {
45
- const disposers: (() => void)[] = [];
46
- disposers.push(registerWecomPlatformPermitChecker());
47
-
48
- disposers.push(toolService.addTool({
49
- name: 'wecom_get_user',
50
- description: '获取企业微信用户信息',
51
- parameters: {
52
- type: 'object',
53
- properties: {
54
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
55
- user_id: { type: 'string', description: '用户 ID' },
56
- },
57
- required: ['endpoint_id', 'user_id'],
58
- },
59
- platforms: ['wecom'],
60
- tags: ['wecom'],
61
- execute: async (args: Record<string, any>) => {
62
- const endpoint = wecom.endpoints.get(args.endpoint_id);
63
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
64
- return await endpoint.getUserInfo(args.user_id);
65
- },
66
- }, plugin.name));
67
-
68
- disposers.push(toolService.addTool({
69
- name: 'wecom_get_dept_users',
70
- description: '获取企业微信部门用户列表',
71
- parameters: {
72
- type: 'object',
73
- properties: {
74
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
75
- dept_id: { type: 'string', description: '部门 ID' },
76
- },
77
- required: ['endpoint_id', 'dept_id'],
78
- },
79
- platforms: ['wecom'],
80
- tags: ['wecom'],
81
- execute: async (args: Record<string, any>) => {
82
- const endpoint = wecom.endpoints.get(args.endpoint_id);
83
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
84
- const users = await endpoint.getDepartmentUsers(Number(args.dept_id));
85
- return { users, count: users.length };
86
- },
87
- }, plugin.name));
88
-
89
- disposers.push(toolService.addTool({
90
- name: 'wecom_list_departments',
91
- description: '获取企业微信部门列表',
92
- parameters: {
93
- type: 'object',
94
- properties: {
95
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
96
- dept_id: { type: 'string', description: '父部门 ID,默认 1(跟部门)' },
97
- },
98
- required: ['endpoint_id'],
99
- },
100
- platforms: ['wecom'],
101
- tags: ['wecom'],
102
- execute: async (args: Record<string, any>) => {
103
- const endpoint = wecom.endpoints.get(args.endpoint_id);
104
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
105
- const departments = await endpoint.getDepartmentList(Number(args.dept_id) || 1);
106
- return { departments, count: departments.length };
107
- },
108
- }, plugin.name));
109
-
110
- disposers.push(toolService.addTool({
111
- name: 'wecom_send_text',
112
- description: '向指定企业微信用户发送文本消息',
113
- parameters: {
114
- type: 'object',
115
- properties: {
116
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
117
- user_id: { type: 'string', description: '用户 ID' },
118
- content: { type: 'string', description: '消息内容' },
119
- },
120
- required: ['endpoint_id', 'user_id', 'content'],
121
- },
122
- platforms: ['wecom'],
123
- tags: ['wecom'],
124
- execute: async (args: Record<string, any>) => {
125
- const endpoint = wecom.endpoints.get(args.endpoint_id);
126
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
127
- const success = await endpoint.sendTextMessage(args.user_id, args.content);
128
- return { success, message: success ? '消息已发送' : '发送失败' };
129
- },
130
- }, plugin.name));
131
-
132
- return () => disposers.forEach(d => d());
133
- });
41
+ export {
42
+ WecomEndpoint,
43
+ type WecomEndpointOptions,
44
+ type WecomFetch,
45
+ } from './endpoint.js';
46
+
47
+ export {
48
+ registerWecomWebhookRoutes,
49
+ handleWecomVerificationRequest,
50
+ handleWecomWebhookRequest,
51
+ type WecomWebhookHandler,
52
+ } from './webhook.js';
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * 企业微信 WeCom platform permit
3
3
  */
4
- import type { Message } from 'zhin.js';
5
- import { registerPlatformPermitChecker } from 'zhin.js';
4
+ import { registerPlatformPermitChecker, type Message } from '@zhin.js/core';
6
5
 
7
6
  const ADAPTER = 'wecom';
8
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
+ }