@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 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` — `messageGatewayToken` 入站/出站
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>`
@@ -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
  });
@@ -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, WeChatMpFetch } from '../src/endpoint.js';
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
  });
@@ -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 type { EndpointFriend, EndpointInstance, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
2
- import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
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
- export type WeChatMpFetch = (url: string, init?: {
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 implements EndpointInstance {
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
- /** token 失效类错误码:40001/40014 invalid access_token、42001 access_token expired。 */
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.#refreshAccessToken();
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
- if (receiveWeChatMpSideEvent(this.#options.sideEvents, this.#options.config.id, msg, this.#logger)) {
137
- return;
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
- void this.#options.gateway.receive({
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
- let result = await this.#postCustomerService(messageData);
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
- const url = `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${this.#accessToken}&type=${type}`;
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 (Date.now() >= this.#tokenExpireTime) {
312
- void this.#refreshAccessToken().catch((error) => {
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(endpoint) {
255
+ function createWeChatMpEndpointManagement(requireClient) {
320
256
  return Object.freeze({
321
257
  // 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
322
- listFriends: () => endpoint.getFollowers(),
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 { WeChatMpEndpoint, type WeChatMpEndpointOptions, type WeChatMpFetch, } from './endpoint.js';
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 MessageGateway.receive. */
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 MessageGateway.receive. */
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 { SideEventGateway } from '@zhin.js/core/runtime';
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(sideEvents: SideEventGateway | undefined, configId: string, msg: WeChatMessage, logger: ReturnType<typeof getAdapterLogger>): boolean;
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(sideEvents, configId, msg, logger) {
15
- if (!sideEvents || msg.MsgType !== 'event')
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 sideEvents.receiveNotice(buildNotice(msg, {
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
- readonly gateway: MessageGateway;
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, formatInboundContent, formatInboundId, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, verifySignature, wechatMpInboundConversation, } from './protocol.js';
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.gateway.receive({
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.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.0",
12
- "@zhin.js/core": "1.5.13",
13
- "@zhin.js/host-http": "1.0.12",
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.76"
16
+ "@zhin.js/logger": "1.0.77"
16
17
  },
17
18
  "peerDependencies": {
18
- "@zhin.js/adapter": "1.2.0",
19
- "@zhin.js/command": "1.0.15",
20
- "@zhin.js/core": "1.5.13",
21
- "@zhin.js/host-http": "1.0.12",
22
- "zhin.js": "6.0.13"
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.13"
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 { EndpointFriend, EndpointInstance, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
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 implements EndpointInstance {
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.#refreshAccessToken();
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.#options.sideEvents,
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
- void this.#options.gateway.receive({
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
- let result = await this.#postCustomerService(messageData);
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
- const url = `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${this.#accessToken}&type=${type}`;
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 (Date.now() >= this.#tokenExpireTime) {
377
- void this.#refreshAccessToken().catch((error) => {
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(endpoint: WeChatMpEndpoint): EndpointManagement {
302
+ function createWeChatMpEndpointManagement(
303
+ requireClient: () => WeChatMpClient,
304
+ ): EndpointManagement {
386
305
  return Object.freeze<EndpointManagement>({
387
306
  // 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
388
- listFriends: () => endpoint.getFollowers(),
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 MessageGateway.receive. */
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 { SideEventGateway } from '@zhin.js/core/runtime';
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
- sideEvents: SideEventGateway | undefined,
18
+ emit: EndpointEventEmitter,
19
19
  configId: string,
20
20
  msg: WeChatMessage,
21
21
  logger: ReturnType<typeof getAdapterLogger>,
22
22
  ): boolean {
23
- if (!sideEvents || msg.MsgType !== 'event') return false;
23
+ if (msg.MsgType !== 'event') return false;
24
24
  const eventName = msg.Event ?? 'unknown';
25
25
  const parts = mapWeChatMpEventParts(eventName);
26
- void sideEvents.receiveNotice(buildNotice(msg, {
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
- readonly gateway: MessageGateway;
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.gateway.receive({
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;