@zhin.js/adapter-wechat-mp 6.0.0 → 6.0.1
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 +18 -0
- package/README.md +1 -1
- package/adapters/wechat-mp.js +0 -3
- package/adapters/wechat-mp.ts +2 -4
- package/lib/client.d.ts +32 -0
- package/lib/client.js +70 -0
- package/lib/endpoint.d.ts +6 -20
- package/lib/endpoint.js +27 -87
- package/lib/index.d.ts +2 -1
- package/lib/index.js +1 -0
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +1 -1
- package/lib/side-event-dispatch.d.ts +2 -2
- package/lib/side-event-dispatch.js +3 -3
- package/lib/webhook.d.ts +1 -3
- package/lib/webhook.js +2 -13
- package/lib/wechat-mp-endpoint-commands.d.ts +1 -1
- package/package.json +12 -11
- package/src/client.ts +121 -0
- package/src/endpoint.ts +31 -108
- package/src/index.ts +7 -1
- package/src/protocol.ts +1 -1
- package/src/side-event-dispatch.ts +4 -4
- package/src/webhook.ts +2 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @zhin.js/adapter-wechat-mp
|
|
2
2
|
|
|
3
|
+
## 6.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1fc78bc: Unify native platform Client access behind the literal `adapter` discriminant. Handlers infer both native events and Clients, while command, inbound/outbound middleware, and both Agent tool authoring surfaces expose the exact operation-scoped Client through a lazy `$client` getter. Definitions without `adapter` keep `$client` typed as `unknown`, and runtime dispatch rejects adapter mismatches before resolving the Client. Bundled platform tools now use this single path instead of model-provided endpoint ids and adapter-specific dependency wrappers. Every adapter registers one Client/EventMap contract, and protocol adapters including NapCat, Milky, OneBot and Satori now produce transport-independent Client objects rather than letting Endpoint instances impersonate Clients.
|
|
8
|
+
- Updated dependencies [4e8117c]
|
|
9
|
+
- Updated dependencies [54bfd6b]
|
|
10
|
+
- Updated dependencies [12025ee]
|
|
11
|
+
- Updated dependencies [09b14d6]
|
|
12
|
+
- Updated dependencies [1fc78bc]
|
|
13
|
+
- @zhin.js/adapter@1.2.1
|
|
14
|
+
- @zhin.js/core@1.5.14
|
|
15
|
+
- @zhin.js/host-http@1.0.13
|
|
16
|
+
- @zhin.js/command@1.0.16
|
|
17
|
+
- @zhin.js/logger@1.0.77
|
|
18
|
+
- zhin.js@6.0.14
|
|
19
|
+
- @zhin.js/feature-kit@1.0.13
|
|
20
|
+
|
|
3
21
|
## 6.0.0
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ pnpm add @zhin.js/adapter-wechat-mp
|
|
|
19
19
|
## Plugin Runtime
|
|
20
20
|
|
|
21
21
|
- `@zhin.js/adapter` — 约定式 `adapters/wechat-mp.ts`(`defineAdapter`)
|
|
22
|
-
- `@zhin.js/core` — `
|
|
22
|
+
- `@zhin.js/core` — `Endpoint.emit(...)` 入站、`outboundMessageToken` 出站
|
|
23
23
|
- `@zhin.js/host-http` — `httpHostToken` 注册 Webhook 路由(**非** legacy host-router/Koa)
|
|
24
24
|
- `zhin.js` — `plugin.ts`(`definePlugin`)
|
|
25
25
|
- 配置经插件 `schema.json` 落到 `plugins.<instanceKey>`
|
package/adapters/wechat-mp.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Convention entry: discover `adapters/wechat-mp.ts` → defineAdapter.
|
|
4
4
|
*/
|
|
5
5
|
import { defineAdapter } from 'zhin.js/adapter';
|
|
6
|
-
import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
|
|
7
6
|
import { httpHostToken } from '@zhin.js/host-http';
|
|
8
7
|
import { WeChatMpEndpoint } from "../lib/endpoint.js";
|
|
9
8
|
import { resolveWeChatMpConfig, } from "../lib/protocol.js";
|
|
@@ -26,8 +25,6 @@ export default defineAdapter({
|
|
|
26
25
|
});
|
|
27
26
|
return new WeChatMpEndpoint({
|
|
28
27
|
id: context.id,
|
|
29
|
-
gateway: context.use(messageGatewayToken),
|
|
30
|
-
sideEvents: context.use(sideEventGatewayToken),
|
|
31
28
|
http: context.use(httpHostToken),
|
|
32
29
|
config,
|
|
33
30
|
});
|
package/adapters/wechat-mp.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Convention entry: discover `adapters/wechat-mp.ts` → defineAdapter.
|
|
3
3
|
*/
|
|
4
4
|
import { defineAdapter } from 'zhin.js/adapter';
|
|
5
|
-
import { messageGatewayToken, sideEventGatewayToken } from '@zhin.js/core/runtime';
|
|
6
5
|
import { httpHostToken } from '@zhin.js/host-http';
|
|
7
6
|
import { WeChatMpEndpoint } from '../src/endpoint.js';
|
|
8
7
|
import {
|
|
@@ -12,7 +11,8 @@ import {
|
|
|
12
11
|
import { wechatMpRuntimeStateToken } from '../src/wechat-mp-runtime-state.js';
|
|
13
12
|
|
|
14
13
|
export { WeChatMpEndpoint } from '../src/endpoint.js';
|
|
15
|
-
export type { WeChatMpEndpointOptions
|
|
14
|
+
export type { WeChatMpEndpointOptions } from '../src/endpoint.js';
|
|
15
|
+
export type { WeChatMpFetch } from '../src/client.js';
|
|
16
16
|
|
|
17
17
|
export default defineAdapter<WeChatMpAdapterConfig>({
|
|
18
18
|
capabilities: ['inbound', 'outbound'],
|
|
@@ -31,8 +31,6 @@ export default defineAdapter<WeChatMpAdapterConfig>({
|
|
|
31
31
|
});
|
|
32
32
|
return new WeChatMpEndpoint({
|
|
33
33
|
id: context.id,
|
|
34
|
-
gateway: context.use(messageGatewayToken),
|
|
35
|
-
sideEvents: context.use(sideEventGatewayToken),
|
|
36
34
|
http: context.use(httpHostToken),
|
|
37
35
|
config,
|
|
38
36
|
});
|
package/lib/client.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ResolvedWeChatMpConfig, WeChatAPIResponse } from './protocol.js';
|
|
2
|
+
export type WeChatMpFetch = (url: string, init?: {
|
|
3
|
+
readonly method?: string;
|
|
4
|
+
readonly body?: unknown;
|
|
5
|
+
readonly headers?: Record<string, string>;
|
|
6
|
+
}) => Promise<{
|
|
7
|
+
readonly data: unknown;
|
|
8
|
+
}>;
|
|
9
|
+
/** Direct WeChat Official Account API client exposed to plugins. */
|
|
10
|
+
export declare class WeChatMpClient {
|
|
11
|
+
#private;
|
|
12
|
+
readonly config: ResolvedWeChatMpConfig;
|
|
13
|
+
readonly fetch: WeChatMpFetch;
|
|
14
|
+
constructor(config: ResolvedWeChatMpConfig, fetch: WeChatMpFetch);
|
|
15
|
+
get accessToken(): string | null;
|
|
16
|
+
get tokenExpired(): boolean;
|
|
17
|
+
refreshAccessToken(): Promise<string>;
|
|
18
|
+
request<T = unknown>(path: string, init?: Parameters<WeChatMpFetch>[1]): Promise<T>;
|
|
19
|
+
sendCustomerService(messageData: unknown): Promise<WeChatAPIResponse>;
|
|
20
|
+
uploadMedia(type: 'image' | 'voice' | 'video', body: unknown): Promise<string>;
|
|
21
|
+
getFollowerIds(): Promise<readonly string[]>;
|
|
22
|
+
}
|
|
23
|
+
export type WeChatMpClientEventMap = Record<string, unknown>;
|
|
24
|
+
declare module '@zhin.js/feature-kit' {
|
|
25
|
+
interface AdapterClientRegistry {
|
|
26
|
+
readonly 'wechat-mp': {
|
|
27
|
+
readonly client: WeChatMpClient;
|
|
28
|
+
readonly events: WeChatMpClientEventMap;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export declare const wechatMpClient: import("@zhin.js/adapter").EndpointClientToken<WeChatMpClient, WeChatMpClientEventMap>;
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineEndpointClient } from 'zhin.js/adapter';
|
|
2
|
+
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
3
|
+
/** Direct WeChat Official Account API client exposed to plugins. */
|
|
4
|
+
export class WeChatMpClient {
|
|
5
|
+
config;
|
|
6
|
+
fetch;
|
|
7
|
+
#accessToken = null;
|
|
8
|
+
#tokenExpireTime = 0;
|
|
9
|
+
constructor(config, fetch) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.fetch = fetch;
|
|
12
|
+
}
|
|
13
|
+
get accessToken() {
|
|
14
|
+
return this.#accessToken;
|
|
15
|
+
}
|
|
16
|
+
get tokenExpired() {
|
|
17
|
+
return !this.#accessToken || Date.now() >= this.#tokenExpireTime;
|
|
18
|
+
}
|
|
19
|
+
async refreshAccessToken() {
|
|
20
|
+
const { appId, appSecret } = this.config;
|
|
21
|
+
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
22
|
+
const response = await this.fetch(url);
|
|
23
|
+
const data = response.data;
|
|
24
|
+
if (!data.access_token) {
|
|
25
|
+
throw new Error(data.errmsg
|
|
26
|
+
? `Failed to get access token: ${data.errcode} ${data.errmsg}`
|
|
27
|
+
: 'Failed to get access token');
|
|
28
|
+
}
|
|
29
|
+
this.#accessToken = data.access_token;
|
|
30
|
+
this.#tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000;
|
|
31
|
+
return data.access_token;
|
|
32
|
+
}
|
|
33
|
+
async request(path, init) {
|
|
34
|
+
if (this.tokenExpired)
|
|
35
|
+
await this.refreshAccessToken();
|
|
36
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
37
|
+
const response = await this.fetch(`https://api.weixin.qq.com${path}${separator}access_token=${this.#accessToken}`, init);
|
|
38
|
+
return response.data;
|
|
39
|
+
}
|
|
40
|
+
async sendCustomerService(messageData) {
|
|
41
|
+
let result = await this.request('/cgi-bin/message/custom/send', { method: 'POST', body: messageData });
|
|
42
|
+
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
43
|
+
await this.refreshAccessToken();
|
|
44
|
+
result = await this.request('/cgi-bin/message/custom/send', { method: 'POST', body: messageData });
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
async uploadMedia(type, body) {
|
|
49
|
+
const data = await this.request(`/cgi-bin/media/upload?type=${type}`, { method: 'POST', body });
|
|
50
|
+
if (data.media_id)
|
|
51
|
+
return data.media_id;
|
|
52
|
+
throw new Error(`WeChat media upload failed: ${data.errcode ?? 'unknown'} ${data.errmsg ?? ''}`.trim());
|
|
53
|
+
}
|
|
54
|
+
async getFollowerIds() {
|
|
55
|
+
const followers = [];
|
|
56
|
+
let nextOpenid = '';
|
|
57
|
+
do {
|
|
58
|
+
const data = await this.request(`/cgi-bin/user/get?next_openid=${encodeURIComponent(nextOpenid)}`);
|
|
59
|
+
if (data.errcode && data.errcode !== 0) {
|
|
60
|
+
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
61
|
+
}
|
|
62
|
+
followers.push(...(data.data?.openid ?? []).filter(Boolean));
|
|
63
|
+
if (Number(data.count ?? 0) < 10_000)
|
|
64
|
+
break;
|
|
65
|
+
nextOpenid = data.next_openid ?? '';
|
|
66
|
+
} while (nextOpenid);
|
|
67
|
+
return Object.freeze(followers);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export const wechatMpClient = defineEndpointClient('wechat-mp');
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,32 +1,24 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
import type { EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
|
|
3
3
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
4
4
|
import type { CapabilityId } from 'zhin.js';
|
|
5
5
|
import { type ResolvedWeChatMpConfig, type WeChatMessage } from './protocol.js';
|
|
6
|
-
|
|
7
|
-
readonly method?: string;
|
|
8
|
-
readonly body?: unknown;
|
|
9
|
-
readonly headers?: Record<string, string>;
|
|
10
|
-
}) => Promise<{
|
|
11
|
-
readonly data: unknown;
|
|
12
|
-
}>;
|
|
6
|
+
import { WeChatMpClient, type WeChatMpFetch } from './client.js';
|
|
13
7
|
export interface WeChatMpEndpointOptions {
|
|
14
8
|
readonly id: CapabilityId;
|
|
15
|
-
readonly gateway: MessageGateway;
|
|
16
|
-
readonly sideEvents?: SideEventGateway;
|
|
17
9
|
readonly http: HttpHost;
|
|
18
10
|
readonly config: ResolvedWeChatMpConfig;
|
|
19
11
|
readonly fetch?: WeChatMpFetch;
|
|
20
12
|
}
|
|
21
|
-
export declare class WeChatMpEndpoint
|
|
13
|
+
export declare class WeChatMpEndpoint extends Endpoint<WeChatMpClient> {
|
|
22
14
|
#private;
|
|
15
|
+
readonly client: WeChatMpClient;
|
|
23
16
|
readonly management: EndpointManagement;
|
|
24
17
|
constructor(options: WeChatMpEndpointOptions);
|
|
25
18
|
/** Used by webhook handler. */
|
|
26
19
|
get isOpen(): boolean;
|
|
27
20
|
get config(): ResolvedWeChatMpConfig;
|
|
28
21
|
get id(): CapabilityId;
|
|
29
|
-
get gateway(): MessageGateway;
|
|
30
22
|
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
31
23
|
getCachedReply(msgId: string): string | undefined;
|
|
32
24
|
cacheReply(msgId: string, replyXML: string): void;
|
|
@@ -36,11 +28,5 @@ export declare class WeChatMpEndpoint implements EndpointInstance {
|
|
|
36
28
|
stop(): Promise<void>;
|
|
37
29
|
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
38
30
|
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
39
|
-
admit(msg: WeChatMessage): void
|
|
40
|
-
/**
|
|
41
|
-
* 关注者列表(GET /cgi-bin/user/get,按 next_openid 分页,每页最多 10000)。
|
|
42
|
-
* 该接口只回 openid 不回昵称;昵称需逐个调 user/info(成本高且依赖用户授权),
|
|
43
|
-
* 这里 nickname 用 openid 占位,由 Console 侧自行理解。
|
|
44
|
-
*/
|
|
45
|
-
getFollowers(): Promise<readonly EndpointFriend[]>;
|
|
31
|
+
admit(msg: WeChatMessage): void | Promise<unknown>;
|
|
46
32
|
}
|
package/lib/endpoint.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
1
2
|
/**
|
|
2
3
|
* WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
|
|
3
4
|
*/
|
|
@@ -8,8 +9,7 @@ import { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './
|
|
|
8
9
|
import { getPassiveReplyCapture, recordPassiveReplyText, } from './passive-reply.js';
|
|
9
10
|
import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
10
11
|
import { receiveWeChatMpSideEvent } from './side-event-dispatch.js';
|
|
11
|
-
|
|
12
|
-
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
12
|
+
import { WeChatMpClient } from './client.js';
|
|
13
13
|
/**
|
|
14
14
|
* canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
|
|
15
15
|
* 客服消息无 file 投递面,file 段不可投递。
|
|
@@ -28,24 +28,25 @@ function defaultFetch(url, init) {
|
|
|
28
28
|
headers: init?.headers,
|
|
29
29
|
}).then((response) => ({ data: response.data }));
|
|
30
30
|
}
|
|
31
|
-
export class WeChatMpEndpoint {
|
|
31
|
+
export class WeChatMpEndpoint extends Endpoint {
|
|
32
|
+
client;
|
|
32
33
|
#logger;
|
|
33
34
|
#options;
|
|
34
35
|
#fetch;
|
|
35
36
|
#routeReleases = [];
|
|
36
|
-
#accessToken = null;
|
|
37
|
-
#tokenExpireTime = 0;
|
|
38
37
|
#tokenRefreshTimer;
|
|
39
38
|
/** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
|
|
40
39
|
#replyCache = new Map();
|
|
41
40
|
static #REPLY_CACHE_LIMIT = 1000;
|
|
42
41
|
#open = false;
|
|
43
42
|
#started = false;
|
|
44
|
-
management = createWeChatMpEndpointManagement(this);
|
|
43
|
+
management = createWeChatMpEndpointManagement(() => this.client);
|
|
45
44
|
constructor(options) {
|
|
45
|
+
super();
|
|
46
46
|
this.#logger = getAdapterLogger('wechat-mp', options.config.id);
|
|
47
47
|
this.#options = options;
|
|
48
48
|
this.#fetch = options.fetch ?? defaultFetch;
|
|
49
|
+
this.client = new WeChatMpClient(options.config, this.#fetch);
|
|
49
50
|
}
|
|
50
51
|
/** Used by webhook handler. */
|
|
51
52
|
get isOpen() {
|
|
@@ -57,9 +58,6 @@ export class WeChatMpEndpoint {
|
|
|
57
58
|
get id() {
|
|
58
59
|
return this.#options.id;
|
|
59
60
|
}
|
|
60
|
-
get gateway() {
|
|
61
|
-
return this.#options.gateway;
|
|
62
|
-
}
|
|
63
61
|
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
64
62
|
getCachedReply(msgId) {
|
|
65
63
|
return this.#replyCache.get(msgId);
|
|
@@ -80,7 +78,7 @@ export class WeChatMpEndpoint {
|
|
|
80
78
|
return;
|
|
81
79
|
this.#started = true;
|
|
82
80
|
try {
|
|
83
|
-
await this
|
|
81
|
+
await this.client.refreshAccessToken();
|
|
84
82
|
this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, this));
|
|
85
83
|
this.#startTokenRefreshTimer();
|
|
86
84
|
this.#logger.debug(formatCompact({
|
|
@@ -133,11 +131,17 @@ export class WeChatMpEndpoint {
|
|
|
133
131
|
admit(msg) {
|
|
134
132
|
if (!this.#open)
|
|
135
133
|
return;
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
void this.emitPlatform(msg.Event ? `${msg.MsgType}.${msg.Event}` : msg.MsgType, msg).catch((error) => {
|
|
135
|
+
this.#logger.warn(formatCompact({
|
|
136
|
+
op: 'wechat_mp_platform_event_failed',
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
}));
|
|
139
|
+
});
|
|
140
|
+
if (receiveWeChatMpSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, msg, this.#logger)) {
|
|
141
|
+
return undefined;
|
|
138
142
|
}
|
|
139
143
|
const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
|
|
140
|
-
|
|
144
|
+
return this.emit('message.receive', {
|
|
141
145
|
conversation,
|
|
142
146
|
message: { conversation, id: formatInboundId(msg) },
|
|
143
147
|
content: formatInboundContent(msg),
|
|
@@ -158,21 +162,9 @@ export class WeChatMpEndpoint {
|
|
|
158
162
|
}
|
|
159
163
|
async #sendCustomerService(target, payload) {
|
|
160
164
|
// 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
|
|
161
|
-
if (!this.#accessToken || Date.now() >= this.#tokenExpireTime) {
|
|
162
|
-
await this.#refreshAccessToken();
|
|
163
|
-
}
|
|
164
165
|
const materialized = await this.#materializeOutboundMedia(payload);
|
|
165
166
|
const messageData = formatCustomerServiceBody(target, materialized);
|
|
166
|
-
|
|
167
|
-
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
168
|
-
// 对端提前作废 token(多端共用等):刷新后重试一次。
|
|
169
|
-
this.#logger.warn(formatCompact({
|
|
170
|
-
op: 'wechat_mp_token_invalid_retry',
|
|
171
|
-
errcode: result.errcode,
|
|
172
|
-
}));
|
|
173
|
-
await this.#refreshAccessToken();
|
|
174
|
-
result = await this.#postCustomerService(messageData);
|
|
175
|
-
}
|
|
167
|
+
const result = await this.client.sendCustomerService(messageData);
|
|
176
168
|
if (result.errcode && result.errcode !== 0) {
|
|
177
169
|
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
|
|
178
170
|
}
|
|
@@ -248,77 +240,25 @@ export class WeChatMpEndpoint {
|
|
|
248
240
|
async #uploadMedia(type, media) {
|
|
249
241
|
const binary = await resolveMediaBinary(media);
|
|
250
242
|
const form = buildMediaUploadForm(binary);
|
|
251
|
-
|
|
252
|
-
const response = await this.#fetch(url, { method: 'POST', body: form });
|
|
253
|
-
const data = response.data;
|
|
254
|
-
if (data.media_id)
|
|
255
|
-
return data.media_id;
|
|
256
|
-
throw new Error(`WeChat media upload failed: ${data.errcode ?? 'unknown'} ${data.errmsg ?? ''}`.trim());
|
|
257
|
-
}
|
|
258
|
-
async #postCustomerService(messageData) {
|
|
259
|
-
const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${this.#accessToken}`;
|
|
260
|
-
const response = await this.#fetch(url, { method: 'POST', body: messageData });
|
|
261
|
-
return response.data;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* 关注者列表(GET /cgi-bin/user/get,按 next_openid 分页,每页最多 10000)。
|
|
265
|
-
* 该接口只回 openid 不回昵称;昵称需逐个调 user/info(成本高且依赖用户授权),
|
|
266
|
-
* 这里 nickname 用 openid 占位,由 Console 侧自行理解。
|
|
267
|
-
*/
|
|
268
|
-
async getFollowers() {
|
|
269
|
-
if (!this.#accessToken || Date.now() >= this.#tokenExpireTime) {
|
|
270
|
-
await this.#refreshAccessToken();
|
|
271
|
-
}
|
|
272
|
-
const friends = [];
|
|
273
|
-
let nextOpenid = '';
|
|
274
|
-
do {
|
|
275
|
-
const url = `https://api.weixin.qq.com/cgi-bin/user/get?access_token=${this.#accessToken}&next_openid=${encodeURIComponent(nextOpenid)}`;
|
|
276
|
-
const response = await this.#fetch(url);
|
|
277
|
-
const data = response.data;
|
|
278
|
-
if (data.errcode && data.errcode !== 0) {
|
|
279
|
-
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
280
|
-
}
|
|
281
|
-
for (const openid of data.data?.openid ?? []) {
|
|
282
|
-
if (typeof openid === 'string' && openid) {
|
|
283
|
-
friends.push({ user_id: openid, nickname: openid, remark: '' });
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
const fetched = Number(data.count ?? 0);
|
|
287
|
-
nextOpenid = typeof data.next_openid === 'string' ? data.next_openid : '';
|
|
288
|
-
// 满页(10000)才可能有下一页;不足一页即到底,避免依赖 next_openid 回显语义。
|
|
289
|
-
if (fetched < 10_000)
|
|
290
|
-
break;
|
|
291
|
-
} while (nextOpenid);
|
|
292
|
-
return friends;
|
|
293
|
-
}
|
|
294
|
-
async #refreshAccessToken() {
|
|
295
|
-
const { appId, appSecret } = this.#options.config;
|
|
296
|
-
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
297
|
-
const response = await this.#fetch(url);
|
|
298
|
-
const data = response.data;
|
|
299
|
-
if (data.access_token) {
|
|
300
|
-
this.#accessToken = data.access_token;
|
|
301
|
-
this.#tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000;
|
|
302
|
-
this.#logger.debug(formatCompact({ op: 'token_refresh' }));
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
throw new Error(data.errmsg
|
|
306
|
-
? `Failed to get access token: ${data.errcode} ${data.errmsg}`
|
|
307
|
-
: 'Failed to get access token');
|
|
243
|
+
return this.client.uploadMedia(type, form);
|
|
308
244
|
}
|
|
309
245
|
#startTokenRefreshTimer() {
|
|
310
246
|
this.#tokenRefreshTimer = setInterval(() => {
|
|
311
|
-
if (
|
|
312
|
-
void this
|
|
247
|
+
if (this.client.tokenExpired) {
|
|
248
|
+
void this.client.refreshAccessToken().catch((error) => {
|
|
313
249
|
this.#logger.error('Failed to refresh access token in timer:', error);
|
|
314
250
|
});
|
|
315
251
|
}
|
|
316
252
|
}, 3_600_000);
|
|
317
253
|
}
|
|
318
254
|
}
|
|
319
|
-
function createWeChatMpEndpointManagement(
|
|
255
|
+
function createWeChatMpEndpointManagement(requireClient) {
|
|
320
256
|
return Object.freeze({
|
|
321
257
|
// 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
|
|
322
|
-
listFriends: () =>
|
|
258
|
+
listFriends: async () => (await requireClient().getFollowerIds()).map((openid) => ({
|
|
259
|
+
user_id: openid,
|
|
260
|
+
nickname: openid,
|
|
261
|
+
remark: '',
|
|
262
|
+
})),
|
|
323
263
|
});
|
|
324
264
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, type ResolvedWeChatMpConfig, type TokenResponse, type WeChatAPIResponse, type WeChatMessage, type WeChatMpAdapterConfig, type WeChatWireSegment, } from './protocol.js';
|
|
2
2
|
export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, type PassiveReplyCapture, } from './passive-reply.js';
|
|
3
|
-
export {
|
|
3
|
+
export { WeChatMpClient, wechatMpClient, type WeChatMpClientEventMap, type WeChatMpFetch, } from './client.js';
|
|
4
|
+
export { WeChatMpEndpoint, type WeChatMpEndpointOptions, } from './endpoint.js';
|
|
4
5
|
export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, type MediaBinary, type WeChatMediaUploadResult, } from './media-upload.js';
|
|
5
6
|
export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, type WeChatMpWebhookHandler, } from './webhook.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, } from './protocol.js';
|
|
2
2
|
export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, } from './passive-reply.js';
|
|
3
|
+
export { WeChatMpClient, wechatMpClient, } from './client.js';
|
|
3
4
|
export { WeChatMpEndpoint, } from './endpoint.js';
|
|
4
5
|
export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
|
|
5
6
|
export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, } from './webhook.js';
|
package/lib/protocol.d.ts
CHANGED
|
@@ -112,7 +112,7 @@ export declare function wechatMpInboundConversation(endpointKey: string, msg: We
|
|
|
112
112
|
* 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
|
|
113
113
|
*/
|
|
114
114
|
export declare function formatInboundId(msg: WeChatMessage): string;
|
|
115
|
-
/** Build inbound text for
|
|
115
|
+
/** Build inbound text for OutboundMessageService.receive. */
|
|
116
116
|
export declare function formatInboundContent(msg: WeChatMessage): string;
|
|
117
117
|
/**
|
|
118
118
|
* Built-in passive XML for subscribe etc. Empty string = fall through to gateway.
|
package/lib/protocol.js
CHANGED
|
@@ -192,7 +192,7 @@ export function formatInboundId(msg) {
|
|
|
192
192
|
.filter((part) => part != null && part !== '')
|
|
193
193
|
.join(':');
|
|
194
194
|
}
|
|
195
|
-
/** Build inbound text for
|
|
195
|
+
/** Build inbound text for OutboundMessageService.receive. */
|
|
196
196
|
export function formatInboundContent(msg) {
|
|
197
197
|
switch (msg.MsgType) {
|
|
198
198
|
case 'text':
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
2
2
|
import { type getAdapterLogger } from '@zhin.js/logger';
|
|
3
3
|
import { type WeChatMessage } from './protocol.js';
|
|
4
|
-
export declare function receiveWeChatMpSideEvent(
|
|
4
|
+
export declare function receiveWeChatMpSideEvent(emit: EndpointEventEmitter, configId: string, msg: WeChatMessage, logger: ReturnType<typeof getAdapterLogger>): boolean;
|
|
@@ -11,12 +11,12 @@ function mapWeChatMpEventParts(eventName) {
|
|
|
11
11
|
return { scene_type: 'wechat-mp', sub_type: eventName || 'unknown' };
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
export function receiveWeChatMpSideEvent(
|
|
15
|
-
if (
|
|
14
|
+
export function receiveWeChatMpSideEvent(emit, configId, msg, logger) {
|
|
15
|
+
if (msg.MsgType !== 'event')
|
|
16
16
|
return false;
|
|
17
17
|
const eventName = msg.Event ?? 'unknown';
|
|
18
18
|
const parts = mapWeChatMpEventParts(eventName);
|
|
19
|
-
void
|
|
19
|
+
void emit('notice.receive', buildNotice(msg, {
|
|
20
20
|
$id: `wechat-mp:${formatInboundId(msg)}`,
|
|
21
21
|
$adapter: 'wechat-mp',
|
|
22
22
|
$endpoint: configId,
|
package/lib/webhook.d.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* WeChat MP webhook HTTP: URL verification + inbound message handling.
|
|
3
3
|
*/
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
-
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
5
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
7
6
|
import type { CapabilityId } from 'zhin.js';
|
|
8
7
|
import { type ResolvedWeChatMpConfig, type WeChatMessage } from './protocol.js';
|
|
@@ -10,8 +9,7 @@ export interface WeChatMpWebhookHandler {
|
|
|
10
9
|
readonly config: ResolvedWeChatMpConfig;
|
|
11
10
|
readonly isOpen: boolean;
|
|
12
11
|
readonly id: CapabilityId;
|
|
13
|
-
|
|
14
|
-
admit(msg: WeChatMessage): void;
|
|
12
|
+
admit(msg: WeChatMessage): void | Promise<unknown>;
|
|
15
13
|
/** MsgId 去重缓存(可选;实现见 WeChatMpEndpoint)。 */
|
|
16
14
|
getCachedReply?(msgId: string): string | undefined;
|
|
17
15
|
cacheReply?(msgId: string, replyXML: string): void;
|
package/lib/webhook.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
2
|
import { getPassiveReplyCapture, runWithPassiveReplyCapture, } from './passive-reply.js';
|
|
3
|
-
import { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage,
|
|
3
|
+
import { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, verifySignature, wechatMpInboundConversation, } from './protocol.js';
|
|
4
4
|
const logger = getLogger('wechat-mp');
|
|
5
5
|
export function registerWeChatMpWebhookRoutes(http, handler) {
|
|
6
6
|
const path = handler.config.path;
|
|
@@ -134,18 +134,7 @@ export async function collectPassiveReply(handler, wechatMsg) {
|
|
|
134
134
|
const text = await runWithPassiveReplyCapture(async () => {
|
|
135
135
|
const conversation = wechatMpInboundConversation(String(handler.id), wechatMsg);
|
|
136
136
|
await Promise.race([
|
|
137
|
-
handler.
|
|
138
|
-
conversation,
|
|
139
|
-
message: { conversation, id: formatInboundId(wechatMsg) },
|
|
140
|
-
content: formatInboundContent(wechatMsg),
|
|
141
|
-
sender: { id: wechatMsg.FromUserName },
|
|
142
|
-
endpointId: handler.config.id,
|
|
143
|
-
metadata: Object.freeze({
|
|
144
|
-
msgType: wechatMsg.MsgType,
|
|
145
|
-
event: wechatMsg.Event,
|
|
146
|
-
toUserName: wechatMsg.ToUserName,
|
|
147
|
-
}),
|
|
148
|
-
}),
|
|
137
|
+
Promise.resolve(handler.admit(wechatMsg)),
|
|
149
138
|
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
150
139
|
]);
|
|
151
140
|
return getPassiveReplyCapture()?.text ?? null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const wechatMpEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
|
|
1
|
+
export declare const wechatMpEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage, string | undefined>>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-wechat-mp",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zhin.js WeChat Official Account adapter for Plugin Runtime (HTTP webhook)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"axios": "^1.19.0",
|
|
10
10
|
"xml2js": "^0.6.2",
|
|
11
|
-
"@zhin.js/adapter": "1.2.
|
|
12
|
-
"@zhin.js/core": "1.5.
|
|
13
|
-
"@zhin.js/
|
|
11
|
+
"@zhin.js/adapter": "1.2.1",
|
|
12
|
+
"@zhin.js/core": "1.5.14",
|
|
13
|
+
"@zhin.js/feature-kit": "1.0.13",
|
|
14
|
+
"@zhin.js/host-http": "1.0.13",
|
|
14
15
|
"@zhin.js/im-contract": "1.0.4",
|
|
15
|
-
"@zhin.js/logger": "1.0.
|
|
16
|
+
"@zhin.js/logger": "1.0.77"
|
|
16
17
|
},
|
|
17
18
|
"peerDependencies": {
|
|
18
|
-
"@zhin.js/adapter": "1.2.
|
|
19
|
-
"@zhin.js/command": "1.0.
|
|
20
|
-
"@zhin.js/core": "1.5.
|
|
21
|
-
"@zhin.js/host-http": "1.0.
|
|
22
|
-
"zhin.js": "6.0.
|
|
19
|
+
"@zhin.js/adapter": "1.2.1",
|
|
20
|
+
"@zhin.js/command": "1.0.16",
|
|
21
|
+
"@zhin.js/core": "1.5.14",
|
|
22
|
+
"@zhin.js/host-http": "1.0.13",
|
|
23
|
+
"zhin.js": "6.0.14"
|
|
23
24
|
},
|
|
24
25
|
"peerDependenciesMeta": {
|
|
25
26
|
"@zhin.js/command": {
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"@types/xml2js": "^0.4.14",
|
|
35
36
|
"typescript": "^6.0.3",
|
|
36
37
|
"vitest": "^4.1.10",
|
|
37
|
-
"zhin.js": "6.0.
|
|
38
|
+
"zhin.js": "6.0.14"
|
|
38
39
|
},
|
|
39
40
|
"keywords": [
|
|
40
41
|
"zhin",
|
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');
|
package/src/endpoint.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
1
2
|
/**
|
|
2
3
|
* WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
|
|
3
4
|
*/
|
|
4
5
|
import axios from 'axios';
|
|
5
|
-
import type {
|
|
6
|
-
import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
|
|
6
|
+
import type { EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
|
|
7
7
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
8
8
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
9
9
|
import type { CapabilityId } from 'zhin.js';
|
|
@@ -14,8 +14,6 @@ import {
|
|
|
14
14
|
formatInboundId,
|
|
15
15
|
wechatMpInboundConversation,
|
|
16
16
|
type ResolvedWeChatMpConfig,
|
|
17
|
-
type TokenResponse,
|
|
18
|
-
type WeChatAPIResponse,
|
|
19
17
|
type WeChatMessage,
|
|
20
18
|
} from './protocol.js';
|
|
21
19
|
import {
|
|
@@ -30,9 +28,7 @@ import {
|
|
|
30
28
|
} from './passive-reply.js';
|
|
31
29
|
import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
32
30
|
import { receiveWeChatMpSideEvent } from './side-event-dispatch.js';
|
|
33
|
-
|
|
34
|
-
/** token 失效类错误码:40001/40014 invalid access_token、42001 access_token expired。 */
|
|
35
|
-
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
31
|
+
import { WeChatMpClient, type WeChatMpFetch } from './client.js';
|
|
36
32
|
|
|
37
33
|
/**
|
|
38
34
|
* canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
|
|
@@ -45,15 +41,8 @@ const WECHAT_UPLOAD_TYPE: Readonly<Record<string, 'image' | 'voice' | 'video'>>
|
|
|
45
41
|
video: 'video',
|
|
46
42
|
};
|
|
47
43
|
|
|
48
|
-
export type WeChatMpFetch = (
|
|
49
|
-
url: string,
|
|
50
|
-
init?: { readonly method?: string; readonly body?: unknown; readonly headers?: Record<string, string> },
|
|
51
|
-
) => Promise<{ readonly data: unknown }>;
|
|
52
|
-
|
|
53
44
|
export interface WeChatMpEndpointOptions {
|
|
54
45
|
readonly id: CapabilityId;
|
|
55
|
-
readonly gateway: MessageGateway;
|
|
56
|
-
readonly sideEvents?: SideEventGateway;
|
|
57
46
|
readonly http: HttpHost;
|
|
58
47
|
readonly config: ResolvedWeChatMpConfig;
|
|
59
48
|
readonly fetch?: WeChatMpFetch;
|
|
@@ -71,26 +60,27 @@ function defaultFetch(
|
|
|
71
60
|
}).then((response) => ({ data: response.data }));
|
|
72
61
|
}
|
|
73
62
|
|
|
74
|
-
export class WeChatMpEndpoint
|
|
63
|
+
export class WeChatMpEndpoint extends Endpoint<WeChatMpClient> {
|
|
64
|
+
readonly client: WeChatMpClient;
|
|
75
65
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
76
66
|
|
|
77
67
|
readonly #options: WeChatMpEndpointOptions;
|
|
78
68
|
readonly #fetch: WeChatMpFetch;
|
|
79
69
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
80
|
-
#accessToken: string | null = null;
|
|
81
|
-
#tokenExpireTime = 0;
|
|
82
70
|
#tokenRefreshTimer?: ReturnType<typeof setInterval>;
|
|
83
71
|
/** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
|
|
84
72
|
readonly #replyCache = new Map<string, string>();
|
|
85
73
|
static readonly #REPLY_CACHE_LIMIT = 1000;
|
|
86
74
|
#open = false;
|
|
87
75
|
#started = false;
|
|
88
|
-
readonly management: EndpointManagement = createWeChatMpEndpointManagement(this);
|
|
76
|
+
readonly management: EndpointManagement = createWeChatMpEndpointManagement(() => this.client);
|
|
89
77
|
|
|
90
78
|
constructor(options: WeChatMpEndpointOptions) {
|
|
79
|
+
super();
|
|
91
80
|
this.#logger = getAdapterLogger('wechat-mp', options.config.id);
|
|
92
81
|
this.#options = options;
|
|
93
82
|
this.#fetch = options.fetch ?? defaultFetch;
|
|
83
|
+
this.client = new WeChatMpClient(options.config, this.#fetch);
|
|
94
84
|
}
|
|
95
85
|
|
|
96
86
|
/** Used by webhook handler. */
|
|
@@ -106,10 +96,6 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
106
96
|
return this.#options.id;
|
|
107
97
|
}
|
|
108
98
|
|
|
109
|
-
get gateway(): MessageGateway {
|
|
110
|
-
return this.#options.gateway;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
99
|
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
114
100
|
getCachedReply(msgId: string): string | undefined {
|
|
115
101
|
return this.#replyCache.get(msgId);
|
|
@@ -129,7 +115,7 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
129
115
|
if (this.#started) return;
|
|
130
116
|
this.#started = true;
|
|
131
117
|
try {
|
|
132
|
-
await this
|
|
118
|
+
await this.client.refreshAccessToken();
|
|
133
119
|
this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, this));
|
|
134
120
|
this.#startTokenRefreshTimer();
|
|
135
121
|
this.#logger.debug(formatCompact({
|
|
@@ -184,18 +170,24 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
184
170
|
}
|
|
185
171
|
|
|
186
172
|
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
187
|
-
admit(msg: WeChatMessage): void {
|
|
173
|
+
admit(msg: WeChatMessage): void | Promise<unknown> {
|
|
188
174
|
if (!this.#open) return;
|
|
175
|
+
void this.emitPlatform(msg.Event ? `${msg.MsgType}.${msg.Event}` : msg.MsgType, msg).catch((error) => {
|
|
176
|
+
this.#logger.warn(formatCompact({
|
|
177
|
+
op: 'wechat_mp_platform_event_failed',
|
|
178
|
+
error: error instanceof Error ? error.message : String(error),
|
|
179
|
+
}));
|
|
180
|
+
});
|
|
189
181
|
if (receiveWeChatMpSideEvent(
|
|
190
|
-
this
|
|
182
|
+
(name, payload) => this.emit(name, payload),
|
|
191
183
|
this.#options.config.id,
|
|
192
184
|
msg,
|
|
193
185
|
this.#logger,
|
|
194
186
|
)) {
|
|
195
|
-
return;
|
|
187
|
+
return undefined;
|
|
196
188
|
}
|
|
197
189
|
const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
|
|
198
|
-
|
|
190
|
+
return this.emit('message.receive', {
|
|
199
191
|
conversation,
|
|
200
192
|
message: { conversation, id: formatInboundId(msg) },
|
|
201
193
|
content: formatInboundContent(msg),
|
|
@@ -217,21 +209,9 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
217
209
|
|
|
218
210
|
async #sendCustomerService(target: string, payload: unknown): Promise<string> {
|
|
219
211
|
// 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
|
|
220
|
-
if (!this.#accessToken || Date.now() >= this.#tokenExpireTime) {
|
|
221
|
-
await this.#refreshAccessToken();
|
|
222
|
-
}
|
|
223
212
|
const materialized = await this.#materializeOutboundMedia(payload);
|
|
224
213
|
const messageData = formatCustomerServiceBody(target, materialized);
|
|
225
|
-
|
|
226
|
-
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
227
|
-
// 对端提前作废 token(多端共用等):刷新后重试一次。
|
|
228
|
-
this.#logger.warn(formatCompact({
|
|
229
|
-
op: 'wechat_mp_token_invalid_retry',
|
|
230
|
-
errcode: result.errcode,
|
|
231
|
-
}));
|
|
232
|
-
await this.#refreshAccessToken();
|
|
233
|
-
result = await this.#postCustomerService(messageData);
|
|
234
|
-
}
|
|
214
|
+
const result = await this.client.sendCustomerService(messageData);
|
|
235
215
|
if (result.errcode && result.errcode !== 0) {
|
|
236
216
|
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
|
|
237
217
|
}
|
|
@@ -305,76 +285,13 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
305
285
|
): Promise<string> {
|
|
306
286
|
const binary = await resolveMediaBinary(media);
|
|
307
287
|
const form = buildMediaUploadForm(binary);
|
|
308
|
-
|
|
309
|
-
const response = await this.#fetch(url, { method: 'POST', body: form });
|
|
310
|
-
const data = response.data as WeChatMediaUploadResult;
|
|
311
|
-
if (data.media_id) return data.media_id;
|
|
312
|
-
throw new Error(`WeChat media upload failed: ${data.errcode ?? 'unknown'} ${data.errmsg ?? ''}`.trim());
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async #postCustomerService(messageData: unknown): Promise<WeChatAPIResponse> {
|
|
316
|
-
const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${this.#accessToken}`;
|
|
317
|
-
const response = await this.#fetch(url, { method: 'POST', body: messageData });
|
|
318
|
-
return response.data as WeChatAPIResponse;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* 关注者列表(GET /cgi-bin/user/get,按 next_openid 分页,每页最多 10000)。
|
|
323
|
-
* 该接口只回 openid 不回昵称;昵称需逐个调 user/info(成本高且依赖用户授权),
|
|
324
|
-
* 这里 nickname 用 openid 占位,由 Console 侧自行理解。
|
|
325
|
-
*/
|
|
326
|
-
async getFollowers(): Promise<readonly EndpointFriend[]> {
|
|
327
|
-
if (!this.#accessToken || Date.now() >= this.#tokenExpireTime) {
|
|
328
|
-
await this.#refreshAccessToken();
|
|
329
|
-
}
|
|
330
|
-
const friends: EndpointFriend[] = [];
|
|
331
|
-
let nextOpenid = '';
|
|
332
|
-
do {
|
|
333
|
-
const url = `https://api.weixin.qq.com/cgi-bin/user/get?access_token=${this.#accessToken}&next_openid=${encodeURIComponent(nextOpenid)}`;
|
|
334
|
-
const response = await this.#fetch(url);
|
|
335
|
-
const data = response.data as WeChatAPIResponse & {
|
|
336
|
-
count?: number;
|
|
337
|
-
data?: { openid?: string[] };
|
|
338
|
-
next_openid?: string;
|
|
339
|
-
};
|
|
340
|
-
if (data.errcode && data.errcode !== 0) {
|
|
341
|
-
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
342
|
-
}
|
|
343
|
-
for (const openid of data.data?.openid ?? []) {
|
|
344
|
-
if (typeof openid === 'string' && openid) {
|
|
345
|
-
friends.push({ user_id: openid, nickname: openid, remark: '' });
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
const fetched = Number(data.count ?? 0);
|
|
349
|
-
nextOpenid = typeof data.next_openid === 'string' ? data.next_openid : '';
|
|
350
|
-
// 满页(10000)才可能有下一页;不足一页即到底,避免依赖 next_openid 回显语义。
|
|
351
|
-
if (fetched < 10_000) break;
|
|
352
|
-
} while (nextOpenid);
|
|
353
|
-
return friends;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async #refreshAccessToken(): Promise<void> {
|
|
357
|
-
const { appId, appSecret } = this.#options.config;
|
|
358
|
-
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
359
|
-
const response = await this.#fetch(url);
|
|
360
|
-
const data = response.data as TokenResponse & WeChatAPIResponse;
|
|
361
|
-
if (data.access_token) {
|
|
362
|
-
this.#accessToken = data.access_token;
|
|
363
|
-
this.#tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000;
|
|
364
|
-
this.#logger.debug(formatCompact({ op: 'token_refresh' }));
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
throw new Error(
|
|
368
|
-
data.errmsg
|
|
369
|
-
? `Failed to get access token: ${data.errcode} ${data.errmsg}`
|
|
370
|
-
: 'Failed to get access token',
|
|
371
|
-
);
|
|
288
|
+
return this.client.uploadMedia(type, form);
|
|
372
289
|
}
|
|
373
290
|
|
|
374
291
|
#startTokenRefreshTimer(): void {
|
|
375
292
|
this.#tokenRefreshTimer = setInterval(() => {
|
|
376
|
-
if (
|
|
377
|
-
void this
|
|
293
|
+
if (this.client.tokenExpired) {
|
|
294
|
+
void this.client.refreshAccessToken().catch((error) => {
|
|
378
295
|
this.#logger.error('Failed to refresh access token in timer:', error);
|
|
379
296
|
});
|
|
380
297
|
}
|
|
@@ -382,9 +299,15 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
382
299
|
}
|
|
383
300
|
}
|
|
384
301
|
|
|
385
|
-
function createWeChatMpEndpointManagement(
|
|
302
|
+
function createWeChatMpEndpointManagement(
|
|
303
|
+
requireClient: () => WeChatMpClient,
|
|
304
|
+
): EndpointManagement {
|
|
386
305
|
return Object.freeze<EndpointManagement>({
|
|
387
306
|
// 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
|
|
388
|
-
listFriends: () =>
|
|
307
|
+
listFriends: async () => (await requireClient().getFollowerIds()).map((openid) => ({
|
|
308
|
+
user_id: openid,
|
|
309
|
+
nickname: openid,
|
|
310
|
+
remark: '',
|
|
311
|
+
})),
|
|
389
312
|
});
|
|
390
313
|
}
|
package/src/index.ts
CHANGED
|
@@ -31,10 +31,16 @@ export {
|
|
|
31
31
|
type PassiveReplyCapture,
|
|
32
32
|
} from './passive-reply.js';
|
|
33
33
|
|
|
34
|
+
export {
|
|
35
|
+
WeChatMpClient,
|
|
36
|
+
wechatMpClient,
|
|
37
|
+
type WeChatMpClientEventMap,
|
|
38
|
+
type WeChatMpFetch,
|
|
39
|
+
} from './client.js';
|
|
40
|
+
|
|
34
41
|
export {
|
|
35
42
|
WeChatMpEndpoint,
|
|
36
43
|
type WeChatMpEndpointOptions,
|
|
37
|
-
type WeChatMpFetch,
|
|
38
44
|
} from './endpoint.js';
|
|
39
45
|
|
|
40
46
|
export {
|
package/src/protocol.ts
CHANGED
|
@@ -337,7 +337,7 @@ export function formatInboundId(msg: WeChatMessage): string {
|
|
|
337
337
|
.join(':');
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
/** Build inbound text for
|
|
340
|
+
/** Build inbound text for OutboundMessageService.receive. */
|
|
341
341
|
export function formatInboundContent(msg: WeChatMessage): string {
|
|
342
342
|
switch (msg.MsgType) {
|
|
343
343
|
case 'text':
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildNotice, senderFromId } from '@zhin.js/core';
|
|
2
|
-
import type {
|
|
2
|
+
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
3
3
|
import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
|
|
4
4
|
import { formatInboundId, type WeChatMessage } from './protocol.js';
|
|
5
5
|
|
|
@@ -15,15 +15,15 @@ function mapWeChatMpEventParts(eventName: string): { scene_type: string; sub_typ
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function receiveWeChatMpSideEvent(
|
|
18
|
-
|
|
18
|
+
emit: EndpointEventEmitter,
|
|
19
19
|
configId: string,
|
|
20
20
|
msg: WeChatMessage,
|
|
21
21
|
logger: ReturnType<typeof getAdapterLogger>,
|
|
22
22
|
): boolean {
|
|
23
|
-
if (
|
|
23
|
+
if (msg.MsgType !== 'event') return false;
|
|
24
24
|
const eventName = msg.Event ?? 'unknown';
|
|
25
25
|
const parts = mapWeChatMpEventParts(eventName);
|
|
26
|
-
void
|
|
26
|
+
void emit('notice.receive', buildNotice(msg, {
|
|
27
27
|
$id: `wechat-mp:${formatInboundId(msg)}`,
|
|
28
28
|
$adapter: 'wechat-mp' as never,
|
|
29
29
|
$endpoint: configId,
|
package/src/webhook.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* WeChat MP webhook HTTP: URL verification + inbound message handling.
|
|
3
3
|
*/
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
-
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
5
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
7
6
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
7
|
import type { CapabilityId } from 'zhin.js';
|
|
@@ -36,8 +35,7 @@ export interface WeChatMpWebhookHandler {
|
|
|
36
35
|
readonly config: ResolvedWeChatMpConfig;
|
|
37
36
|
readonly isOpen: boolean;
|
|
38
37
|
readonly id: CapabilityId;
|
|
39
|
-
|
|
40
|
-
admit(msg: WeChatMessage): void;
|
|
38
|
+
admit(msg: WeChatMessage): void | Promise<unknown>;
|
|
41
39
|
/** MsgId 去重缓存(可选;实现见 WeChatMpEndpoint)。 */
|
|
42
40
|
getCachedReply?(msgId: string): string | undefined;
|
|
43
41
|
cacheReply?(msgId: string, replyXML: string): void;
|
|
@@ -221,18 +219,7 @@ export async function collectPassiveReply(
|
|
|
221
219
|
const text = await runWithPassiveReplyCapture(async () => {
|
|
222
220
|
const conversation = wechatMpInboundConversation(String(handler.id), wechatMsg);
|
|
223
221
|
await Promise.race([
|
|
224
|
-
handler.
|
|
225
|
-
conversation,
|
|
226
|
-
message: { conversation, id: formatInboundId(wechatMsg) },
|
|
227
|
-
content: formatInboundContent(wechatMsg),
|
|
228
|
-
sender: { id: wechatMsg.FromUserName },
|
|
229
|
-
endpointId: handler.config.id,
|
|
230
|
-
metadata: Object.freeze({
|
|
231
|
-
msgType: wechatMsg.MsgType,
|
|
232
|
-
event: wechatMsg.Event,
|
|
233
|
-
toUserName: wechatMsg.ToUserName,
|
|
234
|
-
}),
|
|
235
|
-
}),
|
|
222
|
+
Promise.resolve(handler.admit(wechatMsg)),
|
|
236
223
|
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
|
237
224
|
]);
|
|
238
225
|
return getPassiveReplyCapture()?.text ?? null;
|