@zhin.js/adapter-wechat-mp 1.0.1 → 1.1.2
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 +551 -0
- package/README.md +41 -88
- package/adapters/wechat-mp/index.js +32 -0
- package/adapters/wechat-mp/index.ts +38 -0
- package/commands/wechat-mp/endpoint/add/[id]/index.js +3 -0
- package/commands/wechat-mp/endpoint/add/[id]/index.ts +3 -0
- package/commands/wechat-mp/endpoint/definition.js +20 -0
- package/commands/wechat-mp/endpoint/definition.ts +20 -0
- package/commands/wechat-mp/endpoint/list/index.js +3 -0
- package/commands/wechat-mp/endpoint/list/index.ts +3 -0
- package/commands/wechat-mp/endpoint/remove/[id]/index.js +3 -0
- package/commands/wechat-mp/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +32 -0
- package/lib/client.js +70 -0
- package/lib/endpoint.d.ts +31 -72
- package/lib/endpoint.js +226 -747
- package/lib/index.d.ts +6 -15
- package/lib/index.js +6 -25
- package/lib/media-upload.d.ts +22 -0
- package/lib/media-upload.js +64 -0
- package/lib/passive-reply.d.ts +0 -1
- package/lib/passive-reply.js +0 -1
- package/lib/protocol.d.ts +126 -0
- package/lib/protocol.js +350 -0
- package/lib/side-event-dispatch.d.ts +4 -0
- package/lib/side-event-dispatch.js +38 -0
- package/lib/webhook.d.ts +20 -0
- package/lib/webhook.js +152 -0
- package/lib/wechat-mp-runtime-state.d.ts +1 -0
- package/lib/wechat-mp-runtime-state.js +6 -0
- package/package.json +57 -12
- package/plugin.js +14 -0
- package/schema.json +144 -0
- package/src/client.ts +121 -0
- package/src/endpoint.ts +276 -902
- package/src/index.ts +56 -35
- package/src/media-upload.ts +82 -0
- package/src/protocol.ts +508 -0
- package/src/side-event-dispatch.ts +45 -0
- package/src/webhook.ts +237 -0
- package/src/wechat-mp-runtime-state.ts +7 -0
- package/lib/adapter.d.ts +0 -14
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -17
- package/lib/adapter.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/passive-reply.d.ts.map +0 -1
- package/lib/passive-reply.js.map +0 -1
- package/lib/types.d.ts +0 -58
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/skills/wechat-mp/SKILL.md +0 -33
- package/src/adapter.ts +0 -22
- package/src/types.ts +0 -60
package/lib/webhook.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat MP webhook HTTP: URL verification + inbound message handling.
|
|
3
|
+
*/
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
|
+
import type { CapabilityId } from 'zhin.js';
|
|
7
|
+
import { type ResolvedWeChatMpConfig, type WeChatMessage } from './protocol.js';
|
|
8
|
+
export interface WeChatMpWebhookHandler {
|
|
9
|
+
readonly config: ResolvedWeChatMpConfig;
|
|
10
|
+
readonly isOpen: boolean;
|
|
11
|
+
readonly id: CapabilityId;
|
|
12
|
+
admit(msg: WeChatMessage): void | Promise<unknown>;
|
|
13
|
+
/** MsgId 去重缓存(可选;实现见 WeChatMpEndpoint)。 */
|
|
14
|
+
getCachedReply?(msgId: string): string | undefined;
|
|
15
|
+
cacheReply?(msgId: string, replyXML: string): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function registerWeChatMpWebhookRoutes(http: HttpHost, handler: WeChatMpWebhookHandler): HttpRouteRegistration[];
|
|
18
|
+
export declare function handleWeChatMpVerification(_request: IncomingMessage, response: ServerResponse, url: URL, config: ResolvedWeChatMpConfig): void;
|
|
19
|
+
export declare function handleWeChatMpMessage(request: IncomingMessage, response: ServerResponse, url: URL, handler: WeChatMpWebhookHandler): Promise<void>;
|
|
20
|
+
export declare function collectPassiveReply(handler: WeChatMpWebhookHandler, wechatMsg: WeChatMessage): Promise<string>;
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
+
import { getPassiveReplyCapture, runWithPassiveReplyCapture, } from './passive-reply.js';
|
|
3
|
+
import { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, verifySignature, wechatMpInboundConversation, } from './protocol.js';
|
|
4
|
+
const logger = getLogger('wechat-mp');
|
|
5
|
+
export function registerWeChatMpWebhookRoutes(http, handler) {
|
|
6
|
+
const path = handler.config.path;
|
|
7
|
+
return [
|
|
8
|
+
http.route('GET', path, (request, response, url) => {
|
|
9
|
+
handleWeChatMpVerification(request, response, url, handler.config);
|
|
10
|
+
}, { summary: 'WeChat MP URL verification', tags: ['wechat-mp'] }),
|
|
11
|
+
http.route('POST', path, async (request, response, url) => {
|
|
12
|
+
await handleWeChatMpMessage(request, response, url, handler);
|
|
13
|
+
}, { summary: 'WeChat MP inbound webhook', tags: ['wechat-mp'] }),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
export function handleWeChatMpVerification(_request, response, url, config) {
|
|
17
|
+
const signature = queryParam(url.searchParams.get('signature'));
|
|
18
|
+
const msgSignature = queryParam(url.searchParams.get('msg_signature'));
|
|
19
|
+
const timestamp = queryParam(url.searchParams.get('timestamp'));
|
|
20
|
+
const nonce = queryParam(url.searchParams.get('nonce'));
|
|
21
|
+
const echostr = normalizeEchostrParam(queryParam(url.searchParams.get('echostr')));
|
|
22
|
+
const secureMode = !!(config.encrypt && config.encodingAESKey);
|
|
23
|
+
const signToCheck = msgSignature || signature;
|
|
24
|
+
const signPayload = msgSignature
|
|
25
|
+
? { signature: msgSignature, timestamp, nonce, echostr }
|
|
26
|
+
: { signature, timestamp, nonce };
|
|
27
|
+
if (!signToCheck || !timestamp || !nonce) {
|
|
28
|
+
response.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
29
|
+
response.end('Forbidden');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!verifySignature(config.token, signPayload)) {
|
|
33
|
+
const expected = computeSignatureHash(config.token, {
|
|
34
|
+
timestamp,
|
|
35
|
+
nonce,
|
|
36
|
+
...(msgSignature ? { echostr } : {}),
|
|
37
|
+
});
|
|
38
|
+
logger.error(formatCompact({
|
|
39
|
+
op: 'verify',
|
|
40
|
+
stage: 'sign',
|
|
41
|
+
ok: false,
|
|
42
|
+
expectedPrefix: expected.slice(0, 8),
|
|
43
|
+
gotPrefix: signToCheck.slice(0, 8),
|
|
44
|
+
}));
|
|
45
|
+
response.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
46
|
+
response.end('Forbidden');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let body = echostr;
|
|
50
|
+
if (secureMode && echostr && isEncryptedEchostr(echostr) && config.encodingAESKey) {
|
|
51
|
+
try {
|
|
52
|
+
body = decryptEchostr(echostr, config.encodingAESKey, config.appId);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
logger.error(formatCompact({
|
|
56
|
+
op: 'verify',
|
|
57
|
+
stage: 'decrypt',
|
|
58
|
+
ok: false,
|
|
59
|
+
error: error instanceof Error ? error.message : String(error),
|
|
60
|
+
}));
|
|
61
|
+
response.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
62
|
+
response.end('Forbidden');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
response.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
67
|
+
response.end(body);
|
|
68
|
+
}
|
|
69
|
+
export async function handleWeChatMpMessage(request, response, url, handler) {
|
|
70
|
+
try {
|
|
71
|
+
const config = handler.config;
|
|
72
|
+
const signature = queryParam(url.searchParams.get('signature'));
|
|
73
|
+
const timestamp = queryParam(url.searchParams.get('timestamp'));
|
|
74
|
+
const nonce = queryParam(url.searchParams.get('nonce'));
|
|
75
|
+
const msgSignature = queryParam(url.searchParams.get('msg_signature'));
|
|
76
|
+
const encryptType = queryParam(url.searchParams.get('encrypt_type'));
|
|
77
|
+
if (!verifySignature(config.token, { signature, timestamp, nonce })) {
|
|
78
|
+
logger.error('Invalid signature');
|
|
79
|
+
response.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
80
|
+
response.end('Forbidden');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let xmlString = await readTextBody(request);
|
|
84
|
+
if (config.encrypt && encryptType === 'aes' && config.encodingAESKey) {
|
|
85
|
+
xmlString = await decryptMessage(xmlString, msgSignature, timestamp, nonce, config.token, config.encodingAESKey, config.appId);
|
|
86
|
+
}
|
|
87
|
+
const wechatMessage = await parseXMLMessage(xmlString);
|
|
88
|
+
if (!wechatMessage) {
|
|
89
|
+
response.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
90
|
+
response.end('success');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// 微信 5s 重推去重:同一 MsgId 直接回放首次回复,避免二次 admit。
|
|
94
|
+
const msgId = wechatMessage.MsgId;
|
|
95
|
+
if (msgId && handler.getCachedReply) {
|
|
96
|
+
const cached = handler.getCachedReply(msgId);
|
|
97
|
+
if (cached !== undefined) {
|
|
98
|
+
logger.debug(formatCompact({ op: 'wechat_mp_dedup_replay', msgId }));
|
|
99
|
+
response.writeHead(200, { 'Content-Type': cached ? 'text/xml' : 'text/plain' });
|
|
100
|
+
response.end(cached || 'success');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let replyXML = resolveEventPassiveReply(wechatMessage);
|
|
105
|
+
if (!replyXML && handler.isOpen) {
|
|
106
|
+
if (config.replyMode === 'passive') {
|
|
107
|
+
replyXML = await collectPassiveReply(handler, wechatMessage);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
handler.admit(wechatMessage);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const encryptReply = !!(replyXML
|
|
114
|
+
&& config.encodingAESKey
|
|
115
|
+
&& encryptType === 'aes'
|
|
116
|
+
&& config.encryptMode === 'secure');
|
|
117
|
+
if (encryptReply && config.encodingAESKey) {
|
|
118
|
+
replyXML = encryptMessage(replyXML, config.token, config.encodingAESKey, config.appId, timestamp);
|
|
119
|
+
}
|
|
120
|
+
if (msgId && handler.cacheReply) {
|
|
121
|
+
handler.cacheReply(msgId, replyXML || '');
|
|
122
|
+
}
|
|
123
|
+
response.writeHead(200, { 'Content-Type': 'text/xml' });
|
|
124
|
+
response.end(replyXML || 'success');
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
logger.error('Error handling WeChat message:', error);
|
|
128
|
+
response.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
129
|
+
response.end('success');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function collectPassiveReply(handler, wechatMsg) {
|
|
133
|
+
const timeoutMs = handler.config.passiveReplyTimeoutMs;
|
|
134
|
+
const text = await runWithPassiveReplyCapture(async () => {
|
|
135
|
+
const conversation = wechatMpInboundConversation(String(handler.id), wechatMsg);
|
|
136
|
+
await Promise.race([
|
|
137
|
+
Promise.resolve(handler.admit(wechatMsg)),
|
|
138
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
139
|
+
]);
|
|
140
|
+
return getPassiveReplyCapture()?.text ?? null;
|
|
141
|
+
});
|
|
142
|
+
if (!text) {
|
|
143
|
+
logger.warn(formatCompact({
|
|
144
|
+
op: 'passive_reply',
|
|
145
|
+
ok: false,
|
|
146
|
+
reason: 'timeout_or_empty',
|
|
147
|
+
timeoutMs,
|
|
148
|
+
}));
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
return buildTextReply(wechatMsg, text);
|
|
152
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const wechatMpRuntimeStateToken: import("zhin.js").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat MP 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `wechat-mp endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from 'zhin.js/adapter';
|
|
6
|
+
export const wechatMpRuntimeStateToken = defineEndpointRuntimeStateToken('wechat-mp');
|
package/package.json
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-wechat-mp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Zhin.js adapter for
|
|
5
|
+
"description": "Zhin.js WeChat Official Account adapter for Plugin Runtime (HTTP webhook)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
8
8
|
"dependencies": {
|
|
9
|
+
"axios": "^1.19.0",
|
|
9
10
|
"xml2js": "^0.6.2",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
11
|
+
"@zhin.js/adapter": "1.1.14",
|
|
12
|
+
"@zhin.js/core": "1.1.37",
|
|
13
|
+
"@zhin.js/feature-kit": "1.1.1",
|
|
14
|
+
"@zhin.js/host-http": "1.1.1",
|
|
15
|
+
"@zhin.js/im-contract": "1.1.1",
|
|
16
|
+
"@zhin.js/logger": "1.1.1",
|
|
17
|
+
"@zhin.js/skill": "1.1.1"
|
|
12
18
|
},
|
|
13
19
|
"peerDependencies": {
|
|
14
|
-
"@zhin.js/
|
|
15
|
-
"zhin.js": "
|
|
20
|
+
"@zhin.js/adapter": "^1.1.14",
|
|
21
|
+
"@zhin.js/command": "^1.1.1",
|
|
22
|
+
"@zhin.js/core": "^1.1.37",
|
|
23
|
+
"@zhin.js/host-http": "^1.1.1",
|
|
24
|
+
"zhin.js": "^1.1.2"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"@zhin.js/command": {
|
|
28
|
+
"optional": true
|
|
29
|
+
},
|
|
30
|
+
"zhin.js": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
16
33
|
},
|
|
17
34
|
"devDependencies": {
|
|
18
|
-
"@types/
|
|
19
|
-
"@types/node": "^25.9.2",
|
|
35
|
+
"@types/node": "^26.1.2",
|
|
20
36
|
"@types/xml2js": "^0.4.14",
|
|
21
37
|
"typescript": "^6.0.3",
|
|
22
|
-
"
|
|
23
|
-
"zhin.js": "
|
|
38
|
+
"vitest": "^4.1.10",
|
|
39
|
+
"zhin.js": "1.1.2"
|
|
24
40
|
},
|
|
25
41
|
"keywords": [
|
|
26
42
|
"zhin",
|
|
@@ -46,6 +62,10 @@
|
|
|
46
62
|
"directory": "plugins/adapters/wechat-mp"
|
|
47
63
|
},
|
|
48
64
|
"files": [
|
|
65
|
+
"adapters",
|
|
66
|
+
"commands",
|
|
67
|
+
"plugin.js",
|
|
68
|
+
"schema.json",
|
|
49
69
|
"src",
|
|
50
70
|
"lib",
|
|
51
71
|
"skills",
|
|
@@ -63,9 +83,34 @@
|
|
|
63
83
|
"import": "./lib/index.js"
|
|
64
84
|
}
|
|
65
85
|
},
|
|
86
|
+
"engines": {
|
|
87
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
88
|
+
},
|
|
89
|
+
"zhin": {
|
|
90
|
+
"protocol": 1,
|
|
91
|
+
"type": "plugin",
|
|
92
|
+
"entry": "./plugin.js",
|
|
93
|
+
"engine": "^1.0.0",
|
|
94
|
+
"runtime": "trusted",
|
|
95
|
+
"features": [
|
|
96
|
+
{
|
|
97
|
+
"package": "@zhin.js/adapter",
|
|
98
|
+
"api": "^1.0.0"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"package": "@zhin.js/command",
|
|
102
|
+
"api": "^1.0.0"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"package": "@zhin.js/skill",
|
|
106
|
+
"api": "^1.0.0"
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
"plugins": []
|
|
110
|
+
},
|
|
66
111
|
"scripts": {
|
|
67
|
-
"build": "
|
|
112
|
+
"build": "tsc",
|
|
68
113
|
"clean": "rimraf lib",
|
|
69
|
-
"
|
|
114
|
+
"test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/wechat-mp/tests"
|
|
70
115
|
}
|
|
71
116
|
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { createEndpointRuntimeState } from 'zhin.js/adapter';
|
|
3
|
+
import { definePlugin } from 'zhin.js';
|
|
4
|
+
import { wechatMpRuntimeStateToken } from "./lib/wechat-mp-runtime-state.js";
|
|
5
|
+
export default definePlugin({
|
|
6
|
+
name: 'wechat-mp',
|
|
7
|
+
metadata: {
|
|
8
|
+
displayName: 'WeChat Official Account Adapter',
|
|
9
|
+
},
|
|
10
|
+
setup(context) {
|
|
11
|
+
// 运行中 endpoint 注册表(wechat-mp endpoint list 的"运行中"数据源)
|
|
12
|
+
context.resources.provide(wechatMpRuntimeStateToken, createEndpointRuntimeState());
|
|
13
|
+
},
|
|
14
|
+
});
|
package/schema.json
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"properties": {
|
|
6
|
+
"path": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"default": "/wechat/webhook"
|
|
9
|
+
},
|
|
10
|
+
"encrypt": {
|
|
11
|
+
"type": "boolean",
|
|
12
|
+
"default": false
|
|
13
|
+
},
|
|
14
|
+
"encryptMode": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"enum": [
|
|
17
|
+
"plain",
|
|
18
|
+
"compatible",
|
|
19
|
+
"secure"
|
|
20
|
+
],
|
|
21
|
+
"description": "Default follows encrypt: 'plain' when encrypt=false, 'compatible' when encrypt=true"
|
|
22
|
+
},
|
|
23
|
+
"replyMode": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"enum": [
|
|
26
|
+
"passive",
|
|
27
|
+
"customer_service"
|
|
28
|
+
],
|
|
29
|
+
"default": "passive"
|
|
30
|
+
},
|
|
31
|
+
"passiveReplyTimeoutMs": {
|
|
32
|
+
"type": "number",
|
|
33
|
+
"default": 4500
|
|
34
|
+
},
|
|
35
|
+
"master": {
|
|
36
|
+
"type": [
|
|
37
|
+
"string",
|
|
38
|
+
"number"
|
|
39
|
+
],
|
|
40
|
+
"description": "框架 master(WeChat OpenID;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖"
|
|
41
|
+
},
|
|
42
|
+
"trusted": {
|
|
43
|
+
"type": "array",
|
|
44
|
+
"items": {
|
|
45
|
+
"type": [
|
|
46
|
+
"string",
|
|
47
|
+
"number"
|
|
48
|
+
],
|
|
49
|
+
"description": "Trusted WeChat OpenID"
|
|
50
|
+
},
|
|
51
|
+
"description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加"
|
|
52
|
+
},
|
|
53
|
+
"endpoints": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"minItems": 1,
|
|
56
|
+
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
|
|
57
|
+
"items": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"additionalProperties": false,
|
|
60
|
+
"properties": {
|
|
61
|
+
"master": {
|
|
62
|
+
"type": [
|
|
63
|
+
"string",
|
|
64
|
+
"number"
|
|
65
|
+
],
|
|
66
|
+
"description": "本 endpoint 的框架 master(WeChat OpenID);覆盖顶层 master"
|
|
67
|
+
},
|
|
68
|
+
"trusted": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"items": {
|
|
71
|
+
"type": [
|
|
72
|
+
"string",
|
|
73
|
+
"number"
|
|
74
|
+
],
|
|
75
|
+
"description": "Trusted WeChat OpenID"
|
|
76
|
+
},
|
|
77
|
+
"description": "本 endpoint 的 trusted 列表"
|
|
78
|
+
},
|
|
79
|
+
"appId": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "WeChat MP app ID"
|
|
82
|
+
},
|
|
83
|
+
"appSecret": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"description": "WeChat MP app secret"
|
|
86
|
+
},
|
|
87
|
+
"token": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "WeChat MP callback token"
|
|
90
|
+
},
|
|
91
|
+
"encodingAESKey": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"description": "WeChat MP encoding AES key"
|
|
94
|
+
},
|
|
95
|
+
"path": {
|
|
96
|
+
"type": "string"
|
|
97
|
+
},
|
|
98
|
+
"encrypt": {
|
|
99
|
+
"type": "boolean"
|
|
100
|
+
},
|
|
101
|
+
"encryptMode": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"enum": [
|
|
104
|
+
"plain",
|
|
105
|
+
"compatible",
|
|
106
|
+
"secure"
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
"replyMode": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"enum": [
|
|
112
|
+
"passive",
|
|
113
|
+
"customer_service"
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
"passiveReplyTimeoutMs": {
|
|
117
|
+
"type": "number"
|
|
118
|
+
},
|
|
119
|
+
"commandPrefix": {
|
|
120
|
+
"type": "string"
|
|
121
|
+
},
|
|
122
|
+
"id": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"description": "WeChat MP bot name"
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"required": [
|
|
128
|
+
"id",
|
|
129
|
+
"appId",
|
|
130
|
+
"appSecret",
|
|
131
|
+
"token"
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"commandPrefix": {
|
|
136
|
+
"type": "string",
|
|
137
|
+
"default": "",
|
|
138
|
+
"description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"required": [
|
|
142
|
+
"endpoints"
|
|
143
|
+
]
|
|
144
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ResolvedWeChatMpConfig,
|
|
3
|
+
TokenResponse,
|
|
4
|
+
WeChatAPIResponse,
|
|
5
|
+
} from './protocol.js';
|
|
6
|
+
import type { WeChatMediaUploadResult } from './media-upload.js';
|
|
7
|
+
import { defineEndpointClient } from 'zhin.js/adapter';
|
|
8
|
+
|
|
9
|
+
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
10
|
+
|
|
11
|
+
export type WeChatMpFetch = (
|
|
12
|
+
url: string,
|
|
13
|
+
init?: {
|
|
14
|
+
readonly method?: string;
|
|
15
|
+
readonly body?: unknown;
|
|
16
|
+
readonly headers?: Record<string, string>;
|
|
17
|
+
},
|
|
18
|
+
) => Promise<{ readonly data: unknown }>;
|
|
19
|
+
|
|
20
|
+
/** Direct WeChat Official Account API client exposed to plugins. */
|
|
21
|
+
export class WeChatMpClient {
|
|
22
|
+
#accessToken: string | null = null;
|
|
23
|
+
#tokenExpireTime = 0;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
readonly config: ResolvedWeChatMpConfig,
|
|
27
|
+
readonly fetch: WeChatMpFetch,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
get accessToken(): string | null {
|
|
31
|
+
return this.#accessToken;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get tokenExpired(): boolean {
|
|
35
|
+
return !this.#accessToken || Date.now() >= this.#tokenExpireTime;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async refreshAccessToken(): Promise<string> {
|
|
39
|
+
const { appId, appSecret } = this.config;
|
|
40
|
+
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
41
|
+
const response = await this.fetch(url);
|
|
42
|
+
const data = response.data as TokenResponse & WeChatAPIResponse;
|
|
43
|
+
if (!data.access_token) {
|
|
44
|
+
throw new Error(data.errmsg
|
|
45
|
+
? `Failed to get access token: ${data.errcode} ${data.errmsg}`
|
|
46
|
+
: 'Failed to get access token');
|
|
47
|
+
}
|
|
48
|
+
this.#accessToken = data.access_token;
|
|
49
|
+
this.#tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000;
|
|
50
|
+
return data.access_token;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async request<T = unknown>(
|
|
54
|
+
path: string,
|
|
55
|
+
init?: Parameters<WeChatMpFetch>[1],
|
|
56
|
+
): Promise<T> {
|
|
57
|
+
if (this.tokenExpired) await this.refreshAccessToken();
|
|
58
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
59
|
+
const response = await this.fetch(
|
|
60
|
+
`https://api.weixin.qq.com${path}${separator}access_token=${this.#accessToken}`,
|
|
61
|
+
init,
|
|
62
|
+
);
|
|
63
|
+
return response.data as T;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async sendCustomerService(messageData: unknown): Promise<WeChatAPIResponse> {
|
|
67
|
+
let result = await this.request<WeChatAPIResponse>(
|
|
68
|
+
'/cgi-bin/message/custom/send',
|
|
69
|
+
{ method: 'POST', body: messageData },
|
|
70
|
+
);
|
|
71
|
+
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
72
|
+
await this.refreshAccessToken();
|
|
73
|
+
result = await this.request<WeChatAPIResponse>(
|
|
74
|
+
'/cgi-bin/message/custom/send',
|
|
75
|
+
{ method: 'POST', body: messageData },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async uploadMedia(
|
|
82
|
+
type: 'image' | 'voice' | 'video',
|
|
83
|
+
body: unknown,
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
const data = await this.request<WeChatMediaUploadResult>(
|
|
86
|
+
`/cgi-bin/media/upload?type=${type}`,
|
|
87
|
+
{ method: 'POST', body },
|
|
88
|
+
);
|
|
89
|
+
if (data.media_id) return data.media_id;
|
|
90
|
+
throw new Error(`WeChat media upload failed: ${data.errcode ?? 'unknown'} ${data.errmsg ?? ''}`.trim());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getFollowerIds(): Promise<readonly string[]> {
|
|
94
|
+
const followers: string[] = [];
|
|
95
|
+
let nextOpenid = '';
|
|
96
|
+
do {
|
|
97
|
+
const data = await this.request<WeChatAPIResponse & {
|
|
98
|
+
count?: number;
|
|
99
|
+
data?: { openid?: string[] };
|
|
100
|
+
next_openid?: string;
|
|
101
|
+
}>(`/cgi-bin/user/get?next_openid=${encodeURIComponent(nextOpenid)}`);
|
|
102
|
+
if (data.errcode && data.errcode !== 0) {
|
|
103
|
+
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
104
|
+
}
|
|
105
|
+
followers.push(...(data.data?.openid ?? []).filter(Boolean));
|
|
106
|
+
if (Number(data.count ?? 0) < 10_000) break;
|
|
107
|
+
nextOpenid = data.next_openid ?? '';
|
|
108
|
+
} while (nextOpenid);
|
|
109
|
+
return Object.freeze(followers);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type WeChatMpClientEventMap = Record<string, unknown>;
|
|
114
|
+
|
|
115
|
+
declare module '@zhin.js/feature-kit' {
|
|
116
|
+
interface AdapterClientRegistry {
|
|
117
|
+
readonly 'wechat-mp': { readonly client: WeChatMpClient; readonly events: WeChatMpClientEventMap };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const wechatMpClient = defineEndpointClient<WeChatMpClient, WeChatMpClientEventMap>('wechat-mp');
|