@zhin.js/adapter-wecom 1.1.25 → 1.1.27

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/lib/webhook.js CHANGED
@@ -1,18 +1,15 @@
1
+ import { readTextBody } from '@zhin.js/host-http-contract';
1
2
  import { formatCompact, getLogger } from '@zhin.js/logger';
2
- import { decryptMessage, extractEncryptFromXml, normalizeEchostrParam, parseXmlMessage, queryParam, readTextBody, verifySignature, } from './protocol.js';
3
+ import { decryptMessage, extractEncryptFromXml, normalizeEchostrParam, parseXmlMessage, queryParam, verifySignature, } from './protocol.js';
3
4
  const logger = getLogger('wecom');
4
5
  export function registerWecomWebhookRoutes(http, handler) {
5
6
  const path = handler.config.webhookPath;
6
7
  return [
7
- http.route('GET', path, (request, response, url) => {
8
- handleWecomVerificationRequest(request, response, url, handler);
9
- }, { summary: 'WeCom URL verification', tags: ['wecom'] }),
10
- http.route('POST', path, async (request, response, url) => {
11
- await handleWecomWebhookRequest(request, response, url, handler);
12
- }, { summary: 'WeCom inbound webhook', tags: ['wecom'] }),
8
+ http.route('GET', path, (request, { url }) => handleWecomVerificationRequest(request, url, handler), { summary: 'WeCom URL verification', tags: ['wecom'] }),
9
+ http.route('POST', path, (request, { url }) => handleWecomWebhookRequest(request, url, handler), { summary: 'WeCom inbound webhook', tags: ['wecom'] }),
13
10
  ];
14
11
  }
15
- export function handleWecomVerificationRequest(_request, response, url, handler) {
12
+ export function handleWecomVerificationRequest(_request, url, handler) {
16
13
  try {
17
14
  const msgSignature = queryParam(url.searchParams.get('msg_signature'));
18
15
  const timestamp = queryParam(url.searchParams.get('timestamp'));
@@ -20,32 +17,24 @@ export function handleWecomVerificationRequest(_request, response, url, handler)
20
17
  const echostr = normalizeEchostrParam(queryParam(url.searchParams.get('echostr')));
21
18
  const { token, encodingAESKey, corpId } = handler.config;
22
19
  if (!msgSignature || !timestamp || !nonce || !echostr) {
23
- response.writeHead(400, { 'Content-Type': 'text/plain' });
24
- response.end('Missing required query parameters');
25
- return;
20
+ return new Response('Missing required query parameters', { status: 400, headers: { 'Content-Type': 'text/plain' } });
26
21
  }
27
22
  if (!verifySignature(token, timestamp, nonce, echostr, msgSignature)) {
28
23
  logger.warn(formatCompact({ op: 'verify', ok: false, error: 'invalid signature' }));
29
- response.writeHead(403, { 'Content-Type': 'text/plain' });
30
- response.end('Forbidden');
31
- return;
24
+ return new Response('Forbidden', { status: 403, headers: { 'Content-Type': 'text/plain' } });
32
25
  }
33
26
  const decrypted = decryptMessage(echostr, encodingAESKey, corpId);
34
27
  if (!decrypted) {
35
- response.writeHead(400, { 'Content-Type': 'text/plain' });
36
- response.end('Decryption failed');
37
- return;
28
+ return new Response('Decryption failed', { status: 400, headers: { 'Content-Type': 'text/plain' } });
38
29
  }
39
- response.writeHead(200, { 'Content-Type': 'text/plain' });
40
- response.end(decrypted);
30
+ return new Response(decrypted, { status: 200, headers: { 'Content-Type': 'text/plain' } });
41
31
  }
42
32
  catch (error) {
43
33
  logger.error('URL verification error:', error);
44
- response.writeHead(500, { 'Content-Type': 'text/plain' });
45
- response.end('Internal Server Error');
34
+ return new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain' } });
46
35
  }
47
36
  }
48
- export async function handleWecomWebhookRequest(request, response, url, handler) {
37
+ export async function handleWecomWebhookRequest(request, url, handler) {
49
38
  try {
50
39
  const msgSignature = queryParam(url.searchParams.get('msg_signature'));
51
40
  const timestamp = queryParam(url.searchParams.get('timestamp'));
@@ -55,32 +44,24 @@ export async function handleWecomWebhookRequest(request, response, url, handler)
55
44
  const encrypted = extractEncryptFromXml(rawBody);
56
45
  if (!encrypted) {
57
46
  logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'no Encrypt field' }));
58
- response.writeHead(200, { 'Content-Type': 'text/plain' });
59
- response.end('success');
60
- return;
47
+ return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
61
48
  }
62
49
  if (!verifySignature(token, timestamp, nonce, encrypted, msgSignature)) {
63
50
  logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
64
- response.writeHead(403, { 'Content-Type': 'text/plain' });
65
- response.end('Forbidden');
66
- return;
51
+ return new Response('Forbidden', { status: 403, headers: { 'Content-Type': 'text/plain' } });
67
52
  }
68
53
  const decryptedXml = decryptMessage(encrypted, encodingAESKey, corpId);
69
54
  if (!decryptedXml) {
70
- response.writeHead(200, { 'Content-Type': 'text/plain' });
71
- response.end('success');
72
- return;
55
+ return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
73
56
  }
74
57
  const message = parseXmlMessage(decryptedXml);
75
58
  if (message && handler.isOpen) {
76
59
  handler.admit(message);
77
60
  }
78
- response.writeHead(200, { 'Content-Type': 'text/plain' });
79
- response.end('success');
61
+ return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
80
62
  }
81
63
  catch (error) {
82
64
  logger.error('Webhook error:', error);
83
- response.writeHead(200, { 'Content-Type': 'text/plain' });
84
- response.end('success');
65
+ return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
85
66
  }
86
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-wecom",
3
- "version": "1.1.25",
3
+ "version": "1.1.27",
4
4
  "description": "Zhin.js WeCom (企业微信) adapter for Plugin Runtime (HTTP webhook)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,26 +33,27 @@
33
33
  "directory": "plugins/adapters/wecom"
34
34
  },
35
35
  "dependencies": {
36
- "@zhin.js/adapter": "1.1.31",
37
- "@zhin.js/agent-feature": "1.1.16",
38
- "@zhin.js/console-contract": "1.1.5",
39
- "@zhin.js/core": "1.1.59",
40
- "@zhin.js/feature-kit": "1.1.15",
41
- "@zhin.js/host-http": "1.1.17",
42
- "@zhin.js/im-contract": "1.1.13",
43
- "@zhin.js/logger": "1.1.2",
44
- "@zhin.js/page": "1.1.17",
45
- "@zhin.js/skill": "1.1.16",
46
- "@zhin.js/tool": "1.1.18"
36
+ "zod": "^4.6.7",
37
+ "@zhin.js/adapter": "1.1.33",
38
+ "@zhin.js/agent-feature": "1.1.17",
39
+ "@zhin.js/console-contract": "1.1.6",
40
+ "@zhin.js/core": "1.2.0",
41
+ "@zhin.js/feature-kit": "1.1.16",
42
+ "@zhin.js/host-http": "1.2.0",
43
+ "@zhin.js/host-http-contract": "1.2.0",
44
+ "@zhin.js/im-contract": "1.1.14",
45
+ "@zhin.js/logger": "1.1.3",
46
+ "@zhin.js/page": "1.1.18",
47
+ "@zhin.js/skill": "1.1.17",
48
+ "@zhin.js/tool": "1.1.19"
47
49
  },
48
50
  "peerDependencies": {
49
- "zod": "^4.0.0",
50
- "@zhin.js/adapter": "^1.1.31",
51
- "@zhin.js/command": "^1.1.17",
52
- "@zhin.js/core": "^1.1.59",
53
- "@zhin.js/host-http": "^1.1.17",
54
- "@zhin.js/permission": "^1.1.15",
55
- "zhin.js": "^1.1.24"
51
+ "@zhin.js/adapter": "^1.1.33",
52
+ "@zhin.js/command": "^1.2.0",
53
+ "@zhin.js/core": "^1.2.0",
54
+ "@zhin.js/host-http": "^1.2.0",
55
+ "@zhin.js/permission": "^1.2.0",
56
+ "zhin.js": "^1.1.26"
56
57
  },
57
58
  "peerDependenciesMeta": {
58
59
  "@zhin.js/command": {
@@ -60,17 +61,13 @@
60
61
  },
61
62
  "zhin.js": {
62
63
  "optional": true
63
- },
64
- "zod": {
65
- "optional": true
66
64
  }
67
65
  },
68
66
  "devDependencies": {
69
67
  "@types/node": "^26.1.2",
70
68
  "typescript": "^6.0.3",
71
69
  "vitest": "^4.1.10",
72
- "zod": "^4.4.3",
73
- "zhin.js": "1.1.24"
70
+ "zhin.js": "1.1.26"
74
71
  },
75
72
  "files": [
76
73
  "adapters",
@@ -80,7 +77,6 @@
80
77
  "schema.json",
81
78
  "src",
82
79
  "lib",
83
- "tools",
84
80
  "agents",
85
81
  "README.md",
86
82
  "CHANGELOG.md",
@@ -130,6 +126,6 @@
130
126
  "scripts": {
131
127
  "build": "tsc",
132
128
  "clean": "rimraf lib",
133
- "test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/wecom/tests"
129
+ "test": "vitest run --root ../../.. plugins/adapters/wecom/tests"
134
130
  }
135
131
  }
package/plugin.js CHANGED
@@ -1,7 +1,5 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { definePlugin } from 'zhin.js';
3
- import { permissionHostToken } from '@zhin.js/permission';
4
- import { checkWecomPlatformPermit } from "./lib/platform-permit.js";
5
3
  import { createWecomConsoleRpc } from "./lib/console.js";
6
4
  export default definePlugin({
7
5
  name: 'wecom',
@@ -10,12 +8,8 @@ export default definePlugin({
10
8
  displayName: 'WeCom (企业微信) Adapter',
11
9
  },
12
10
  setup(context) {
13
- const dispose = context.resources.has(permissionHostToken)
14
- ? context.resources.use(permissionHostToken).registerPlatform('wecom', checkWecomPlatformPermit)
15
- : undefined;
16
11
  return {
17
12
  consoleRpc: createWecomConsoleRpc(context.resources, () => context.config.get()),
18
- ...(dispose ? { dispose } : {}),
19
13
  };
20
14
  },
21
15
  });
package/schema.json CHANGED
@@ -11,34 +11,6 @@
11
11
  "type": "string",
12
12
  "default": "https://qyapi.weixin.qq.com"
13
13
  },
14
- "master": {
15
- "type": [
16
- "string",
17
- "number"
18
- ],
19
- "description": "框架 master(WeCom userid;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖",
20
- "x-i18n": {
21
- "en": {
22
- "description": "Framework masters (WeCom userid; AI/tool permissions and endpoint management). endpoints[i].master can override this value."
23
- }
24
- }
25
- },
26
- "trusted": {
27
- "type": "array",
28
- "items": {
29
- "type": [
30
- "string",
31
- "number"
32
- ],
33
- "description": "Trusted WeCom userid"
34
- },
35
- "description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加",
36
- "x-i18n": {
37
- "en": {
38
- "description": "Framework trusted users, below master authority. endpoints[i].trusted can add users."
39
- }
40
- }
41
- },
42
14
  "endpoints": {
43
15
  "type": "array",
44
16
  "minItems": 1,
@@ -47,34 +19,6 @@
47
19
  "type": "object",
48
20
  "additionalProperties": false,
49
21
  "properties": {
50
- "master": {
51
- "type": [
52
- "string",
53
- "number"
54
- ],
55
- "description": "本 endpoint 的框架 master(WeCom userid);覆盖顶层 master",
56
- "x-i18n": {
57
- "en": {
58
- "description": "Framework masters for this endpoint (WeCom userid), overriding the top-level master list."
59
- }
60
- }
61
- },
62
- "trusted": {
63
- "type": "array",
64
- "items": {
65
- "type": [
66
- "string",
67
- "number"
68
- ],
69
- "description": "Trusted WeCom userid"
70
- },
71
- "description": "本 endpoint 的 trusted 列表",
72
- "x-i18n": {
73
- "en": {
74
- "description": "Trusted users for this endpoint."
75
- }
76
- }
77
- },
78
22
  "corpId": {
79
23
  "type": "string",
80
24
  "description": "WeCom corp ID"
package/src/adapter.ts CHANGED
@@ -12,7 +12,7 @@ export class WecomAdapter extends Adapter<WecomEndpoint, WecomEndpointConfig, We
12
12
  override get configurationIdentityProbeFields(): readonly string[] { return ['agentSecret', 'apiBaseUrl']; }
13
13
  protected override async identifyConfiguration(input: WecomEndpointConfig, signal: AbortSignal, _changed: readonly string[]): Promise<string | undefined> {
14
14
  const config = resolveWecomConfig(input);
15
- const fetch = async (url: string, init?: Parameters<typeof globalThis.fetch>[1]) => { signal.throwIfAborted(); return globalThis.fetch(url, { ...init, signal }); };
15
+ const fetch = async (url: string, init?: Parameters<typeof globalThis.fetch>[1]) => { signal.throwIfAborted(); return globalThis.fetch(url, { ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal }); };
16
16
  const client = new WecomClient({ id: this.descriptor.id, config, fetch });
17
17
  const response = await client.getApplicationInfo();
18
18
  if (typeof response.agentid !== 'string' && typeof response.agentid !== 'number') throw new Error('WeCom identity verification returned no agentid');
package/src/client.ts CHANGED
@@ -226,8 +226,10 @@ export class WecomClient extends EventEmitter {
226
226
  await createEndpointOperation({ signal }).prepare(() => this.#ensureAccessToken());
227
227
  const binary = await createEndpointOperation({ signal }).prepare(() => resolveMediaBinary(media, { signal }));
228
228
  const form = buildMediaUploadForm(binary);
229
- const url = `${this.#options.config.apiBaseUrl}/cgi-bin/media/upload?access_token=${this.#accessToken.access_token}&type=image`;
230
- const response = await this.#fetch(url, { method: 'POST', body: form, signal });
229
+ const query = new URLSearchParams({ access_token: this.#accessToken.access_token, type: 'image' });
230
+ const url = `${this.#options.config.apiBaseUrl}/cgi-bin/media/upload?${query.toString()}`;
231
+ const response = await this.#fetch(url, { method: 'POST', body: form, signal: requestSignal(signal) });
232
+ if (!response.ok) throw new Error(`WeCom media upload failed: HTTP ${response.status}`);
231
233
  const data = await response.json() as WecomMediaUploadResult;
232
234
  if (data.errcode === 0 && data.media_id) return data.media_id;
233
235
  throw new Error(`WeCom media upload failed: ${data.errmsg ?? 'unknown'} (${data.errcode ?? '?'})`);
@@ -368,7 +370,7 @@ export class WecomClient extends EventEmitter {
368
370
  method,
369
371
  headers: { 'Content-Type': 'application/json; charset=utf-8' },
370
372
  body: body && method === 'POST' ? JSON.stringify(body) : undefined,
371
- signal: options.signal,
373
+ signal: requestSignal(options.signal),
372
374
  });
373
375
  if (!response.ok) {
374
376
  const text = await response.text().catch(() => '');
@@ -397,8 +399,11 @@ export class WecomClient extends EventEmitter {
397
399
 
398
400
  async #refreshAccessToken(): Promise<void> {
399
401
  const { corpId, agentSecret, apiBaseUrl } = this.#options.config;
400
- const url = `${apiBaseUrl}/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${agentSecret}`;
401
- const response = await this.#fetch(url);
402
+ const query = new URLSearchParams({ corpid: corpId, corpsecret: agentSecret });
403
+ const url = `${apiBaseUrl}/cgi-bin/gettoken?${query.toString()}`;
404
+ // The refresh promise is shared by every sender, so it must never hang.
405
+ const response = await this.#fetch(url, { signal: requestSignal() });
406
+ if (!response.ok) throw new Error(`Failed to get access token: HTTP ${response.status}`);
402
407
  const data = await response.json() as WecomApiResponse;
403
408
  if (data.errcode === 0 && data.access_token) {
404
409
  this.#accessToken = {
@@ -413,6 +418,13 @@ export class WecomClient extends EventEmitter {
413
418
  }
414
419
  }
415
420
 
421
+ const REQUEST_TIMEOUT_MS = 30_000;
422
+
423
+ function requestSignal(signal?: AbortSignal): AbortSignal {
424
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
425
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
426
+ }
427
+
416
428
  export type WecomClientEventMap = Record<string, WecomMessage>;
417
429
 
418
430
  interface WecomMediaUploadRequest extends EndpointOperationOptions {
@@ -7,11 +7,68 @@ export const wecomConsoleSpec = {
7
7
  adapterKey: 'wecom',
8
8
  adapterDisplayName: '企业微信',
9
9
  fields: [
10
- { key: 'corpId', required: true, env: true, description: '企业 corpId', descriptionI18n: { en: 'WeCom corporation ID' } },
11
- { key: 'agentId', required: true, description: '应用 AgentId', descriptionI18n: { en: 'WeCom application AgentId' } },
12
- { key: 'agentSecret', required: true, env: true, description: '应用 secret', descriptionI18n: { en: 'Application secret' } },
13
- { key: 'token', required: true, env: true, description: '回调 token', descriptionI18n: { en: 'Callback token' } },
14
- { key: 'encodingAESKey', required: true, env: true, description: '回调加解密 key', descriptionI18n: { en: 'Callback encryption key' } },
10
+ {
11
+ key: 'corpId',
12
+ required: true,
13
+ env: true,
14
+ label: '企业 ID (CorpID)',
15
+ labelI18n: { en: 'CorpID' },
16
+ group: '企业应用凭证',
17
+ groupI18n: { en: 'Enterprise App Credentials' },
18
+ placeholder: 'ww1234567890abcdef',
19
+ description: '企业微信管理后台「我的企业」中的企业 ID',
20
+ descriptionI18n: { en: 'WeCom corporation ID' },
21
+ },
22
+ {
23
+ key: 'agentId',
24
+ required: true,
25
+ label: '应用 AgentId',
26
+ labelI18n: { en: 'Application AgentId' },
27
+ group: '企业应用凭证',
28
+ groupI18n: { en: 'Enterprise App Credentials' },
29
+ placeholder: '1000002',
30
+ description: '自建应用的 AgentId(数字编号)',
31
+ descriptionI18n: { en: 'WeCom application AgentId' },
32
+ },
33
+ {
34
+ key: 'agentSecret',
35
+ required: true,
36
+ env: true,
37
+ label: '应用 Secret',
38
+ labelI18n: { en: 'Application Secret' },
39
+ group: '企业应用凭证',
40
+ groupI18n: { en: 'Enterprise App Credentials' },
41
+ placeholder: '自建应用的 Secret 密钥',
42
+ placeholderI18n: { en: 'WeCom application secret' },
43
+ description: '自建应用的 Secret 密钥',
44
+ descriptionI18n: { en: 'Application secret' },
45
+ },
46
+ {
47
+ key: 'token',
48
+ required: true,
49
+ env: true,
50
+ label: '接收消息 Token',
51
+ labelI18n: { en: 'Callback Token' },
52
+ group: '事件回调与加解密',
53
+ groupI18n: { en: 'Event Callback & Encryption' },
54
+ placeholder: 'API 接收消息配置的 Token',
55
+ placeholderI18n: { en: 'Callback verification token' },
56
+ description: '「接收消息 → 设置 API 接收」中的 Token',
57
+ descriptionI18n: { en: 'Callback token' },
58
+ },
59
+ {
60
+ key: 'encodingAESKey',
61
+ required: true,
62
+ env: true,
63
+ label: 'EncodingAESKey (消息加解密密钥)',
64
+ labelI18n: { en: 'EncodingAESKey' },
65
+ group: '事件回调与加解密',
66
+ groupI18n: { en: 'Event Callback & Encryption' },
67
+ placeholder: '43 位字符的 EncodingAESKey',
68
+ placeholderI18n: { en: '43-char EncodingAESKey' },
69
+ description: '「接收消息 → 设置 API 接收」中的 EncodingAESKey',
70
+ descriptionI18n: { en: 'Callback encryption key' },
71
+ },
15
72
  ] as readonly AdapterConsoleField[],
16
73
  docsUrl: 'https://zhin.js.org/adapters/wecom',
17
74
  readPermission: 'wecom:read',
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@ export {
9
9
  normalizeWebhookPath,
10
10
  parseXmlMessage,
11
11
  queryParam,
12
- readTextBody,
13
12
  resolveChatType,
14
13
  resolveWecomConfig,
15
14
  verifySignature,
@@ -24,12 +23,7 @@ export {
24
23
 
25
24
  export { type WecomClientEventMap } from './client.js';
26
25
 
27
- export {
28
- checkWecomPlatformPermit,
29
- normalizeWecomSenderForPermit,
30
- platformPermit,
31
- wecomGroupPermitResolver,
32
- } from './platform-permit.js';
26
+ export { platformPermit } from './platform-permit.js';
33
27
 
34
28
  export {
35
29
  WecomEndpoint,
@@ -1,48 +1,10 @@
1
1
  /**
2
- * 企业微信 WeCom platform permit
2
+ * 企业微信 WeCom platform permit helper(ADR 0056 §5)。
3
+ *
4
+ * 平台身份由适配器上报到 `sender.platform`,permit 由内置评估器(`@zhin.js/permission`)判断;
5
+ * 适配器不再导出自有的 permit 解析器或检查函数。
3
6
  */
4
- import type { PermissionSubject } from '@zhin.js/permission';
5
-
6
- const ADAPTER = 'wecom';
7
7
 
8
8
  export function platformPermit(perm: string): string {
9
- return `platform(${ADAPTER},${perm})`;
10
- }
11
-
12
- const FACTORY_PERM_MAP: Record<string, string> = {
13
- scene_admin: 'chat_admin',
14
- scene_owner: 'chat_owner',
15
- };
16
-
17
- export function wecomGroupPermitResolver(logicalPerm: string): string {
18
- return platformPermit(FACTORY_PERM_MAP[logicalPerm] ?? logicalPerm);
19
- }
20
-
21
- export function normalizeWecomSenderForPermit(input: {
22
- isOwner?: boolean;
23
- isAdmin?: boolean;
24
- }): { role?: string; permissions?: string[] } {
25
- if (input.isOwner) {
26
- return { role: 'owner', permissions: ['chat_owner', 'chat_admin'] };
27
- }
28
- if (input.isAdmin) {
29
- return { role: 'admin', permissions: ['chat_admin'] };
30
- }
31
- return { role: 'member', permissions: [] };
9
+ return `platform-permission:${perm}`;
32
10
  }
33
-
34
- export function checkWecomPlatformPermit(perm: string, subject: PermissionSubject): boolean {
35
- const role = subject.sender?.role?.[0];
36
- const permissions = subject.sender?.permissions ?? [];
37
- const has = (t: string) => permissions.includes(t);
38
-
39
- switch (perm) {
40
- case 'chat_owner':
41
- return role === 'owner' || has('chat_owner');
42
- case 'chat_admin':
43
- return role === 'owner' || role === 'admin' || has('chat_admin') || has('chat_owner');
44
- default:
45
- return false;
46
- }
47
- }
48
-
package/src/protocol.ts CHANGED
@@ -6,7 +6,6 @@ import { createMessages, type Locale } from 'zhin.js/i18n';
6
6
  */
7
7
 
8
8
  import { createHash, createDecipheriv, timingSafeEqual } from 'node:crypto';
9
- import type { IncomingMessage } from 'node:http';
10
9
  import type { ConversationRef } from '@zhin.js/im-contract';
11
10
 
12
11
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
@@ -368,21 +367,3 @@ export function buildSendRequestBody(
368
367
  return body;
369
368
  }
370
369
 
371
- export async function readTextBody(
372
- request: IncomingMessage,
373
- options: { readonly limit?: number } = {},
374
- ): Promise<string> {
375
- const limit = options.limit ?? 1_048_576;
376
- const chunks: Buffer[] = [];
377
- let size = 0;
378
- for await (const chunk of request) {
379
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
380
- size += buffer.length;
381
- if (size > limit) {
382
- request.destroy();
383
- throw new Error(`Request body exceeds ${limit} bytes`);
384
- }
385
- chunks.push(buffer);
386
- }
387
- return Buffer.concat(chunks).toString('utf8');
388
- }
package/src/webhook.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * WeCom webhook HTTP: URL verification (GET) + encrypted inbound (POST).
3
3
  */
4
- import type { IncomingMessage, ServerResponse } from 'node:http';
5
4
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
5
+ import { readTextBody } from '@zhin.js/host-http-contract';
6
6
  import { formatCompact, getLogger } from '@zhin.js/logger';
7
7
  import {
8
8
  decryptMessage,
@@ -10,7 +10,6 @@ import {
10
10
  normalizeEchostrParam,
11
11
  parseXmlMessage,
12
12
  queryParam,
13
- readTextBody,
14
13
  verifySignature,
15
14
  type ResolvedWecomConfig,
16
15
  type WecomMessage,
@@ -30,21 +29,18 @@ export function registerWecomWebhookRoutes(
30
29
  ): HttpRouteRegistration[] {
31
30
  const path = handler.config.webhookPath;
32
31
  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'] }),
32
+ http.route('GET', path, (request, { url }) => handleWecomVerificationRequest(request, url, handler),
33
+ { summary: 'WeCom URL verification', tags: ['wecom'] }),
34
+ http.route('POST', path, (request, { url }) => handleWecomWebhookRequest(request, url, handler),
35
+ { summary: 'WeCom inbound webhook', tags: ['wecom'] }),
39
36
  ];
40
37
  }
41
38
 
42
39
  export function handleWecomVerificationRequest(
43
- _request: IncomingMessage,
44
- response: ServerResponse,
40
+ _request: Request,
45
41
  url: URL,
46
42
  handler: WecomWebhookHandler,
47
- ): void {
43
+ ): Response {
48
44
  try {
49
45
  const msgSignature = queryParam(url.searchParams.get('msg_signature'));
50
46
  const timestamp = queryParam(url.searchParams.get('timestamp'));
@@ -53,39 +49,30 @@ export function handleWecomVerificationRequest(
53
49
  const { token, encodingAESKey, corpId } = handler.config;
54
50
 
55
51
  if (!msgSignature || !timestamp || !nonce || !echostr) {
56
- response.writeHead(400, { 'Content-Type': 'text/plain' });
57
- response.end('Missing required query parameters');
58
- return;
52
+ return new Response('Missing required query parameters', { status: 400, headers: { 'Content-Type': 'text/plain' } });
59
53
  }
60
54
 
61
55
  if (!verifySignature(token, timestamp, nonce, echostr, msgSignature)) {
62
56
  logger.warn(formatCompact({ op: 'verify', ok: false, error: 'invalid signature' }));
63
- response.writeHead(403, { 'Content-Type': 'text/plain' });
64
- response.end('Forbidden');
65
- return;
57
+ return new Response('Forbidden', { status: 403, headers: { 'Content-Type': 'text/plain' } });
66
58
  }
67
59
 
68
60
  const decrypted = decryptMessage(echostr, encodingAESKey, corpId);
69
61
  if (!decrypted) {
70
- response.writeHead(400, { 'Content-Type': 'text/plain' });
71
- response.end('Decryption failed');
72
- return;
62
+ return new Response('Decryption failed', { status: 400, headers: { 'Content-Type': 'text/plain' } });
73
63
  }
74
- response.writeHead(200, { 'Content-Type': 'text/plain' });
75
- response.end(decrypted);
64
+ return new Response(decrypted, { status: 200, headers: { 'Content-Type': 'text/plain' } });
76
65
  } catch (error) {
77
66
  logger.error('URL verification error:', error);
78
- response.writeHead(500, { 'Content-Type': 'text/plain' });
79
- response.end('Internal Server Error');
67
+ return new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain' } });
80
68
  }
81
69
  }
82
70
 
83
71
  export async function handleWecomWebhookRequest(
84
- request: IncomingMessage,
85
- response: ServerResponse,
72
+ request: Request,
86
73
  url: URL,
87
74
  handler: WecomWebhookHandler,
88
- ): Promise<void> {
75
+ ): Promise<Response> {
89
76
  try {
90
77
  const msgSignature = queryParam(url.searchParams.get('msg_signature'));
91
78
  const timestamp = queryParam(url.searchParams.get('timestamp'));
@@ -96,23 +83,17 @@ export async function handleWecomWebhookRequest(
96
83
  const encrypted = extractEncryptFromXml(rawBody);
97
84
  if (!encrypted) {
98
85
  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;
86
+ return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
102
87
  }
103
88
 
104
89
  if (!verifySignature(token, timestamp, nonce, encrypted, msgSignature)) {
105
90
  logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
106
- response.writeHead(403, { 'Content-Type': 'text/plain' });
107
- response.end('Forbidden');
108
- return;
91
+ return new Response('Forbidden', { status: 403, headers: { 'Content-Type': 'text/plain' } });
109
92
  }
110
93
 
111
94
  const decryptedXml = decryptMessage(encrypted, encodingAESKey, corpId);
112
95
  if (!decryptedXml) {
113
- response.writeHead(200, { 'Content-Type': 'text/plain' });
114
- response.end('success');
115
- return;
96
+ return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
116
97
  }
117
98
 
118
99
  const message = parseXmlMessage(decryptedXml);
@@ -120,11 +101,9 @@ export async function handleWecomWebhookRequest(
120
101
  handler.admit(message);
121
102
  }
122
103
 
123
- response.writeHead(200, { 'Content-Type': 'text/plain' });
124
- response.end('success');
104
+ return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
125
105
  } catch (error) {
126
106
  logger.error('Webhook error:', error);
127
- response.writeHead(200, { 'Content-Type': 'text/plain' });
128
- response.end('success');
107
+ return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
129
108
  }
130
109
  }