@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/CHANGELOG.md +165 -0
- package/LICENSE +21 -0
- package/PERMITS.md +3 -15
- package/README.md +6 -2
- package/agents/wecom/skills/wecom/tools/send_text/index.js +19 -3
- package/agents/wecom/skills/wecom/tools/send_text/index.ts +20 -5
- package/lib/adapter.js +1 -1
- package/lib/client.js +17 -5
- package/lib/console-spec.js +62 -5
- package/lib/index.d.ts +2 -2
- package/lib/index.js +2 -2
- package/lib/platform-permit.d.ts +4 -11
- package/lib/platform-permit.js +7 -31
- package/lib/protocol.d.ts +0 -4
- package/lib/protocol.js +0 -15
- package/lib/webhook.d.ts +2 -3
- package/lib/webhook.js +16 -35
- package/package.json +22 -26
- package/plugin.js +0 -6
- package/schema.json +0 -56
- package/src/adapter.ts +1 -1
- package/src/client.ts +17 -5
- package/src/console-spec.ts +62 -5
- package/src/index.ts +1 -7
- package/src/platform-permit.ts +5 -43
- package/src/protocol.ts +0 -19
- package/src/webhook.ts +19 -40
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,
|
|
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,
|
|
8
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
response.end('Decryption failed');
|
|
37
|
-
return;
|
|
28
|
+
return new Response('Decryption failed', { status: 400, headers: { 'Content-Type': 'text/plain' } });
|
|
38
29
|
}
|
|
39
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
37
|
-
"@zhin.js/
|
|
38
|
-
"@zhin.js/
|
|
39
|
-
"@zhin.js/
|
|
40
|
-
"@zhin.js/
|
|
41
|
-
"@zhin.js/
|
|
42
|
-
"@zhin.js/
|
|
43
|
-
"@zhin.js/
|
|
44
|
-
"@zhin.js/
|
|
45
|
-
"@zhin.js/
|
|
46
|
-
"@zhin.js/
|
|
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
|
-
"
|
|
50
|
-
"@zhin.js/
|
|
51
|
-
"@zhin.js/
|
|
52
|
-
"@zhin.js/
|
|
53
|
-
"@zhin.js/
|
|
54
|
-
"
|
|
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
|
-
"
|
|
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": "
|
|
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
|
|
230
|
-
const
|
|
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
|
|
401
|
-
const
|
|
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 {
|
package/src/console-spec.ts
CHANGED
|
@@ -7,11 +7,68 @@ export const wecomConsoleSpec = {
|
|
|
7
7
|
adapterKey: 'wecom',
|
|
8
8
|
adapterDisplayName: '企业微信',
|
|
9
9
|
fields: [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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,
|
package/src/platform-permit.ts
CHANGED
|
@@ -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
|
|
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,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
44
|
-
response: ServerResponse,
|
|
40
|
+
_request: Request,
|
|
45
41
|
url: URL,
|
|
46
42
|
handler: WecomWebhookHandler,
|
|
47
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
response.end('Decryption failed');
|
|
72
|
-
return;
|
|
62
|
+
return new Response('Decryption failed', { status: 400, headers: { 'Content-Type': 'text/plain' } });
|
|
73
63
|
}
|
|
74
|
-
|
|
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
|
-
|
|
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:
|
|
85
|
-
response: ServerResponse,
|
|
72
|
+
request: Request,
|
|
86
73
|
url: URL,
|
|
87
74
|
handler: WecomWebhookHandler,
|
|
88
|
-
): Promise<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
response.end('success');
|
|
107
|
+
return new Response('success', { status: 200, headers: { 'Content-Type': 'text/plain' } });
|
|
129
108
|
}
|
|
130
109
|
}
|