@zhin.js/adapter-wechat-mp 1.0.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +551 -0
  2. package/README.md +41 -88
  3. package/adapters/wechat-mp/index.js +32 -0
  4. package/adapters/wechat-mp/index.ts +38 -0
  5. package/commands/wechat-mp/endpoint/add/[id]/index.js +3 -0
  6. package/commands/wechat-mp/endpoint/add/[id]/index.ts +3 -0
  7. package/commands/wechat-mp/endpoint/definition.js +20 -0
  8. package/commands/wechat-mp/endpoint/definition.ts +20 -0
  9. package/commands/wechat-mp/endpoint/list/index.js +3 -0
  10. package/commands/wechat-mp/endpoint/list/index.ts +3 -0
  11. package/commands/wechat-mp/endpoint/remove/[id]/index.js +3 -0
  12. package/commands/wechat-mp/endpoint/remove/[id]/index.ts +3 -0
  13. package/lib/client.d.ts +32 -0
  14. package/lib/client.js +70 -0
  15. package/lib/endpoint.d.ts +31 -72
  16. package/lib/endpoint.js +226 -747
  17. package/lib/index.d.ts +6 -15
  18. package/lib/index.js +6 -25
  19. package/lib/media-upload.d.ts +22 -0
  20. package/lib/media-upload.js +64 -0
  21. package/lib/passive-reply.d.ts +0 -1
  22. package/lib/passive-reply.js +0 -1
  23. package/lib/protocol.d.ts +126 -0
  24. package/lib/protocol.js +350 -0
  25. package/lib/side-event-dispatch.d.ts +4 -0
  26. package/lib/side-event-dispatch.js +38 -0
  27. package/lib/webhook.d.ts +20 -0
  28. package/lib/webhook.js +152 -0
  29. package/lib/wechat-mp-runtime-state.d.ts +1 -0
  30. package/lib/wechat-mp-runtime-state.js +6 -0
  31. package/package.json +57 -12
  32. package/plugin.js +14 -0
  33. package/schema.json +144 -0
  34. package/src/client.ts +121 -0
  35. package/src/endpoint.ts +276 -902
  36. package/src/index.ts +56 -35
  37. package/src/media-upload.ts +82 -0
  38. package/src/protocol.ts +508 -0
  39. package/src/side-event-dispatch.ts +45 -0
  40. package/src/webhook.ts +237 -0
  41. package/src/wechat-mp-runtime-state.ts +7 -0
  42. package/lib/adapter.d.ts +0 -14
  43. package/lib/adapter.d.ts.map +0 -1
  44. package/lib/adapter.js +0 -17
  45. package/lib/adapter.js.map +0 -1
  46. package/lib/endpoint.d.ts.map +0 -1
  47. package/lib/endpoint.js.map +0 -1
  48. package/lib/index.d.ts.map +0 -1
  49. package/lib/index.js.map +0 -1
  50. package/lib/passive-reply.d.ts.map +0 -1
  51. package/lib/passive-reply.js.map +0 -1
  52. package/lib/types.d.ts +0 -58
  53. package/lib/types.d.ts.map +0 -1
  54. package/lib/types.js +0 -2
  55. package/lib/types.js.map +0 -1
  56. package/skills/wechat-mp/SKILL.md +0 -33
  57. package/src/adapter.ts +0 -22
  58. package/src/types.ts +0 -60
package/src/endpoint.ts CHANGED
@@ -1,939 +1,313 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
1
2
  /**
2
- * 微信公众号 Endpoint 实现
3
+ * WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
3
4
  */
4
- import axios from "axios";
5
- import * as xml2js from "xml2js";
6
- import { createHash, createDecipheriv, createCipheriv, randomBytes } from "crypto";
7
- import { EventEmitter } from "events";
8
- import FormData from "form-data";
5
+ import axios from 'axios';
6
+ import type { EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
7
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
+ import type { CapabilityId } from 'zhin.js';
9
10
  import {
10
- formatCompact,
11
- Endpoint,
12
- Message,
13
- MessageSegment,
14
- segment,
15
- SendContent,
16
- SendOptions,
17
- hasOutbound,
18
- runInboundMessage,
19
- truncatePreview,
20
- type MessageBase,
21
- } from 'zhin.js';
22
- import { registerFetchRoute, type Router, type RouterContext } from "@zhin.js/host-router/router";
23
- import type { WeChatMPConfig, WeChatMessage, WeChatAPIResponse, TokenResponse } from "./types.js";
24
- import type { WeChatMPAdapter } from "./adapter.js";
11
+ extractOutboundText,
12
+ formatCustomerServiceBody,
13
+ formatInboundContent,
14
+ formatInboundId,
15
+ wechatMpInboundConversation,
16
+ type ResolvedWeChatMpConfig,
17
+ type WeChatMessage,
18
+ } from './protocol.js';
25
19
  import {
26
- getPassiveReplyCapture,
27
- recordPassiveReplyText,
28
- runWithPassiveReplyCapture,
29
- } from "./passive-reply.js";
20
+ buildMediaUploadForm,
21
+ readOutboundMedia,
22
+ resolveMediaBinary,
23
+ type WeChatMediaUploadResult,
24
+ } from './media-upload.js';
25
+ import {
26
+ getPassiveReplyCapture,
27
+ recordPassiveReplyText,
28
+ } from './passive-reply.js';
29
+ import { registerWeChatMpWebhookRoutes } from './webhook.js';
30
+ import { receiveWeChatMpSideEvent } from './side-event-dispatch.js';
31
+ import { WeChatMpClient, type WeChatMpFetch } from './client.js';
30
32
 
31
- function queryParam(value: unknown): string {
32
- if (typeof value === "string") return value;
33
- if (Array.isArray(value) && typeof value[0] === "string") return value[0];
34
- return "";
33
+ /**
34
+ * canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
35
+ * 客服消息无 file 投递面,file 段不可投递。
36
+ */
37
+ const WECHAT_UPLOAD_TYPE: Readonly<Record<string, 'image' | 'voice' | 'video'>> = {
38
+ image: 'image',
39
+ audio: 'voice',
40
+ voice: 'voice',
41
+ video: 'video',
42
+ };
43
+
44
+ export interface WeChatMpEndpointOptions {
45
+ readonly id: CapabilityId;
46
+ readonly http: HttpHost;
47
+ readonly config: ResolvedWeChatMpConfig;
48
+ readonly fetch?: WeChatMpFetch;
35
49
  }
36
50
 
37
- /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
38
- function normalizeEchostrParam(echostr: string): string {
39
- return echostr.replace(/ /g, "+");
51
+ function defaultFetch(
52
+ url: string,
53
+ init?: { readonly method?: string; readonly body?: unknown; readonly headers?: Record<string, string> },
54
+ ): Promise<{ data: unknown }> {
55
+ return axios({
56
+ url,
57
+ method: (init?.method ?? 'GET') as 'GET' | 'POST',
58
+ data: init?.body,
59
+ headers: init?.headers,
60
+ }).then((response) => ({ data: response.data }));
40
61
  }
41
62
 
42
- export class WeChatMPEndpoint extends EventEmitter implements Endpoint<WeChatMPConfig, WeChatMessage> {
43
- $config: WeChatMPConfig;
44
- $connected: boolean = false;
45
- router: Router;
46
-
47
- private accessToken: string | null = null;
48
- private tokenExpireTime: number = 0;
49
-
50
- get logger() {
51
- return this.adapter.plugin.logger;
63
+ export class WeChatMpEndpoint extends Endpoint<WeChatMpClient> {
64
+ readonly client: WeChatMpClient;
65
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
66
+
67
+ readonly #options: WeChatMpEndpointOptions;
68
+ readonly #fetch: WeChatMpFetch;
69
+ #routeReleases: HttpRouteRegistration[] = [];
70
+ #tokenRefreshTimer?: ReturnType<typeof setInterval>;
71
+ /** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
72
+ readonly #replyCache = new Map<string, string>();
73
+ static readonly #REPLY_CACHE_LIMIT = 1000;
74
+ #open = false;
75
+ #started = false;
76
+ readonly management: EndpointManagement = createWeChatMpEndpointManagement(() => this.client);
77
+
78
+ constructor(options: WeChatMpEndpointOptions) {
79
+ super();
80
+ this.#logger = getAdapterLogger('wechat-mp', options.config.id);
81
+ this.#options = options;
82
+ this.#fetch = options.fetch ?? defaultFetch;
83
+ this.client = new WeChatMpClient(options.config, this.#fetch);
52
84
  }
53
85
 
54
- get $id() {
55
- return this.$config.name;
56
- }
86
+ /** Used by webhook handler. */
87
+ get isOpen(): boolean {
88
+ return this.#open;
89
+ }
57
90
 
58
- constructor(public adapter: WeChatMPAdapter, router: Router, config: WeChatMPConfig) {
59
- super();
60
- this.$config = config;
61
- this.router = router;
62
-
63
- // 设置默认值
64
- this.$config.encrypt = this.$config.encrypt || false;
65
- }
91
+ get config(): ResolvedWeChatMpConfig {
92
+ return this.#options.config;
93
+ }
66
94
 
67
- private setupRoutes(): void {
68
- const path = this.$config.path;
69
-
70
- // 微信服务器验证 (GET)
71
- registerFetchRoute(this.router, "GET", path, (ctx: RouterContext) => {
72
- this.handleVerification(ctx);
73
- });
74
-
75
- // 接收微信消息 (POST);必须 await,否则 Koa 会在被动回复写入 ctx.body 前就结束响应
76
- registerFetchRoute(this.router, "POST", path, async (ctx: RouterContext) => {
77
- await this.handleMessage(ctx);
78
- });
79
- }
95
+ get id(): CapabilityId {
96
+ return this.#options.id;
97
+ }
80
98
 
81
- async $connect(): Promise<void> {
82
- try {
83
- // 获取access_token
84
- await this.refreshAccessToken();
85
-
86
- // 设置路由
87
- this.setupRoutes();
88
-
89
- // 定期刷新access_token
90
- this.startTokenRefreshTimer();
91
-
92
- this.logger.info(formatCompact({ endpoint: this.$config.name }));
93
- this.logger.info(formatCompact( { op: "webhook", path: this.$config.path }));
94
- this.$connected= true;
95
- } catch (error) {
96
- this.logger.error('Failed to connect WeChat MP bot:', error);
97
- throw error;
98
- }
99
- }
99
+ /** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
100
+ getCachedReply(msgId: string): string | undefined {
101
+ return this.#replyCache.get(msgId);
102
+ }
100
103
 
101
- async $disconnect(): Promise<void> {
102
- if (this.tokenRefreshTimer) {
103
- clearInterval(this.tokenRefreshTimer);
104
- this.tokenRefreshTimer = undefined;
105
- }
106
- this.$connected = false;
107
- this.logger.info(formatCompact( { op: "disconnect", endpoint: this.$config.name }));
104
+ cacheReply(msgId: string, replyXML: string): void {
105
+ if (this.#replyCache.has(msgId)) this.#replyCache.delete(msgId);
106
+ this.#replyCache.set(msgId, replyXML);
107
+ while (this.#replyCache.size > WeChatMpEndpoint.#REPLY_CACHE_LIMIT) {
108
+ const oldest = this.#replyCache.keys().next().value;
109
+ if (oldest === undefined) break;
110
+ this.#replyCache.delete(oldest);
108
111
  }
112
+ }
109
113
 
110
- private handleVerification(ctx: RouterContext): void {
111
- const signature = queryParam(ctx.query.signature);
112
- const msgSignature = queryParam(ctx.query.msg_signature);
113
- const timestamp = queryParam(ctx.query.timestamp);
114
- const nonce = queryParam(ctx.query.nonce);
115
- const echostr = normalizeEchostrParam(queryParam(ctx.query.echostr));
116
-
117
- const secureMode = !!(this.$config.encrypt && this.$config.encodingAESKey);
118
- // GET 验证:signature 始终为 3 参数;msg_signature(若存在)为 4 参数含 echostr
119
- const signMode = msgSignature ? "msg_signature" : "signature";
120
- const signToCheck = msgSignature || signature;
121
- const signPayload = msgSignature
122
- ? { signature: msgSignature, timestamp, nonce, echostr }
123
- : { signature, timestamp, nonce };
124
- const signFields = msgSignature ? 4 : 3;
125
-
126
- this.logger.info(formatCompact({
127
- op: "verify",
128
- stage: "recv",
129
- path: ctx.path,
130
- secureMode,
131
- signMode,
132
- hasSignature: !!signature,
133
- hasMsgSignature: !!msgSignature,
134
- hasEchostr: !!echostr,
135
- timestamp,
136
- nonce,
137
- echostrLen: echostr.length,
138
- tokenLen: this.$config.token.length,
139
- }));
140
-
141
- if (!signToCheck || !timestamp || !nonce) {
142
- this.logger.error(formatCompact({
143
- op: "verify",
144
- stage: "sign",
145
- ok: false,
146
- error: "missing_query_params",
147
- }));
148
- ctx.status = 403;
149
- ctx.body = "Forbidden";
150
- return;
151
- }
152
-
153
- if (!this.verifySignature(signPayload)) {
154
- const expected = this.computeSignatureHash(signPayload);
155
- this.logger.error(formatCompact({
156
- op: "verify",
157
- stage: "sign",
158
- ok: false,
159
- secureMode,
160
- signMode,
161
- signFields,
162
- expectedPrefix: expected.slice(0, 8),
163
- gotPrefix: signToCheck.slice(0, 8),
164
- }));
165
- ctx.status = 403;
166
- ctx.body = "Forbidden";
167
- return;
168
- }
169
-
170
- this.logger.info(formatCompact({
171
- op: "verify",
172
- stage: "sign",
173
- ok: true,
174
- signMode,
175
- signFields,
176
- }));
177
-
178
- let body = echostr;
179
- if (secureMode && echostr && this.isEncryptedEchostr(echostr)) {
180
- try {
181
- body = this.decryptEchostr(echostr);
182
- this.logger.info(formatCompact({
183
- op: "verify",
184
- stage: "decrypt",
185
- ok: true,
186
- mode: "aes",
187
- plainLen: body.length,
188
- }));
189
- } catch (error) {
190
- this.logger.error(formatCompact({
191
- op: "verify",
192
- stage: "decrypt",
193
- ok: false,
194
- mode: "aes",
195
- error: error instanceof Error ? error.message : String(error),
196
- }));
197
- ctx.status = 403;
198
- ctx.body = "Forbidden";
199
- return;
200
- }
201
- } else if (secureMode && echostr) {
202
- this.logger.info(formatCompact({
203
- op: "verify",
204
- stage: "decrypt",
205
- ok: true,
206
- mode: "plain_echostr",
207
- plainLen: body.length,
208
- }));
209
- }
210
-
211
- this.logger.info(formatCompact({
212
- op: "verify",
213
- stage: "done",
214
- ok: true,
215
- replyLen: body.length,
216
- }));
217
- ctx.body = body;
114
+ async start(): Promise<void> {
115
+ if (this.#started) return;
116
+ this.#started = true;
117
+ try {
118
+ await this.client.refreshAccessToken();
119
+ this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, this));
120
+ this.#startTokenRefreshTimer();
121
+ this.#logger.debug(formatCompact({
122
+ endpoint: this.#options.config.id,
123
+ op: 'webhook',
124
+ path: this.#options.config.path,
125
+ }));
126
+ } catch (error) {
127
+ await this.stop();
128
+ this.#logger.error('Failed to connect WeChat MP bot:', error);
129
+ throw error;
218
130
  }
131
+ }
219
132
 
220
- private async handleMessage(ctx: RouterContext): Promise<void> {
221
- try {
222
- const signature = queryParam(ctx.query.signature);
223
- const timestamp = queryParam(ctx.query.timestamp);
224
- const nonce = queryParam(ctx.query.nonce);
225
- const msg_signature = queryParam(ctx.query.msg_signature);
226
- const encrypt_type = queryParam(ctx.query.encrypt_type);
227
-
228
- // 验证签名
229
- if (!this.verifySignature({
230
- signature,
231
- timestamp,
232
- nonce,
233
- })) {
234
- this.logger.error('Invalid signature');
235
- ctx.status = 403;
236
- ctx.body = 'Forbidden';
237
- return;
238
- }
239
-
240
- // 获取原始XML数据
241
- let xmlString = typeof ctx.request.body === 'string' ? ctx.request.body : '';
242
-
243
- // AES 加密模式:先解密
244
- if (this.$config.encrypt && encrypt_type === 'aes' && this.$config.encodingAESKey) {
245
- xmlString = await this.decryptMessage(
246
- xmlString,
247
- msg_signature as string,
248
- timestamp as string,
249
- nonce as string
250
- );
251
- }
252
-
253
- const wechatMessage = await this.parseXMLMessage(xmlString);
254
-
255
- if (wechatMessage) {
256
- const message = this.$formatMessage(wechatMessage);
257
- this.logger.info(formatCompact({
258
- recv: `private(${message.$channel.id})`,
259
- endpoint: message.$endpoint,
260
- preview: truncatePreview(segment.raw(message.$content)),
261
- replyMode: this.getReplyMode(),
262
- encryptMode: this.getEncryptMode(),
263
- encryptType: encrypt_type || "plain",
264
- }));
265
-
266
- let replyXML = await this.handlePassiveReply(wechatMessage, message);
267
-
268
- if (!replyXML && this.usesPassiveReply()) {
269
- replyXML = await this.collectPassiveReplyXml(wechatMessage, message);
270
- } else if (!replyXML) {
271
- this.adapter.emit("message.receive", message);
272
- }
273
-
274
- // 仅安全模式加密被动回复;兼容模式可明文回包(微信官方允许)
275
- const encryptReply = !!(
276
- replyXML &&
277
- this.$config.encodingAESKey &&
278
- encrypt_type === "aes" &&
279
- this.getEncryptMode() === "secure"
280
- );
281
- if (encryptReply) {
282
- replyXML = this.encryptMessage(replyXML, timestamp);
283
- }
133
+ open(): void {
134
+ this.#open = true;
135
+ }
284
136
 
285
- ctx.set("Content-Type", "text/xml");
286
- ctx.body = replyXML || "success";
287
- if (replyXML) {
288
- this.logger.info(formatCompact({
289
- op: "passive_reply",
290
- stage: "sent",
291
- encrypted: encryptReply,
292
- encryptMode: this.getEncryptMode(),
293
- bodyLen: replyXML.length,
294
- }));
295
- }
296
- } else {
297
- ctx.body = 'success';
298
- }
299
- } catch (error) {
300
- this.logger.error('Error handling WeChat message:', error);
301
- ctx.body = 'success';
302
- }
303
- }
137
+ close(): void {
138
+ this.#open = false;
139
+ }
304
140
 
305
- private computeSignatureHash(params: {
306
- timestamp: string;
307
- nonce: string;
308
- echostr?: string;
309
- }): string {
310
- const { timestamp, nonce, echostr } = params;
311
- const token = this.$config.token;
312
- const arr = echostr
313
- ? [token, timestamp, nonce, echostr]
314
- : [token, timestamp, nonce];
315
- arr.sort();
316
- return createHash("sha1").update(arr.join("")).digest("hex");
141
+ async stop(): Promise<void> {
142
+ this.#open = false;
143
+ if (this.#tokenRefreshTimer) {
144
+ clearInterval(this.#tokenRefreshTimer);
145
+ this.#tokenRefreshTimer = undefined;
317
146
  }
147
+ for (const release of this.#routeReleases.splice(0)) release();
148
+ this.#started = false;
149
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
150
+ }
318
151
 
319
- private verifySignature(params: {
320
- signature: string;
321
- timestamp: string;
322
- nonce: string;
323
- echostr?: string;
324
- }): boolean {
325
- const { signature, timestamp, nonce, echostr } = params;
326
- if (!signature || !timestamp || !nonce) return false;
327
- return this.computeSignatureHash({ timestamp, nonce, echostr }) === signature;
152
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
153
+ if (getPassiveReplyCapture()) {
154
+ const text = extractOutboundText(payload);
155
+ recordPassiveReplyText(text);
156
+ return `passive_${Date.now()}`;
328
157
  }
329
158
 
330
- private async parseXMLMessage(xmlString: string): Promise<WeChatMessage | null> {
331
- try {
332
- const parser = new xml2js.Parser({ explicitArray: false, ignoreAttrs: true });
333
- const result = await parser.parseStringPromise(xmlString);
334
- return result.xml as WeChatMessage;
335
- } catch (error) {
336
- this.logger.error('Error parsing XML:', error);
337
- return null;
338
- }
159
+ if (this.#options.config.replyMode === 'customer_service') {
160
+ return this.#sendCustomerService(conversation.id, payload);
339
161
  }
340
162
 
341
- $formatMessage(wechatMsg: WeChatMessage): Message<WeChatMessage> {
342
- const channelType = 'private'; // 公众号消息都是私聊
343
- const channelId = wechatMsg.FromUserName;
344
-
345
- // 解析消息内容
346
- const content = WeChatMPEndpoint.parseMessageContent(wechatMsg);
347
-
348
- const base: MessageBase = {
349
- $id: wechatMsg.MsgId || `${wechatMsg.CreateTime}`,
350
- $adapter: 'wechat-mp',
351
- $endpoint: this.$config.name,
352
- $sender: {
353
- id: wechatMsg.FromUserName,
354
- name: wechatMsg.FromUserName
355
- },
356
- $channel: {
357
- id: channelId,
358
- type: channelType as any
359
- },
360
- $raw: JSON.stringify(wechatMsg),
361
- $timestamp: wechatMsg.CreateTime * 1000,
362
- $content: content,
363
- };
364
-
365
- if (hasOutbound(this)) {
366
- base.$recall = async () => {
367
- await this.$recallMessage(wechatMsg.MsgId || `${wechatMsg.CreateTime}`);
368
- };
369
- base.$reply = async (replyContent: SendContent): Promise<string> => {
370
- return await this.adapter.sendMessage({
371
- context: this.$config.context,
372
- endpoint: this.$config.name,
373
- id: wechatMsg.FromUserName,
374
- type: 'private',
375
- content: replyContent
376
- });
377
- };
378
- }
163
+ this.#logger.warn(formatCompact({
164
+ op: 'send',
165
+ skip: 'passive_outside_webhook',
166
+ endpoint: this.#options.config.id,
167
+ target: `${conversation.kind}:${conversation.id}`,
168
+ }));
169
+ return `passive_skipped_${Date.now()}`;
170
+ }
379
171
 
380
- return Message.from(wechatMsg, base);
381
- }
172
+ /** Test / internal: admit a parsed message when open (non-webhook path). */
173
+ admit(msg: WeChatMessage): void | Promise<unknown> {
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
+ });
181
+ if (receiveWeChatMpSideEvent(
182
+ (name, payload) => this.emit(name, payload),
183
+ this.#options.config.id,
184
+ msg,
185
+ this.#logger,
186
+ )) {
187
+ return undefined;
188
+ }
189
+ const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
190
+ return this.emit('message.receive', {
191
+ conversation,
192
+ message: { conversation, id: formatInboundId(msg) },
193
+ content: formatInboundContent(msg),
194
+ sender: { id: msg.FromUserName },
195
+ endpointId: this.#options.config.id,
196
+ metadata: Object.freeze({
197
+ msgType: msg.MsgType,
198
+ event: msg.Event,
199
+ toUserName: msg.ToUserName,
200
+ }),
201
+ }).catch((err) => {
202
+ this.#logger.warn(formatCompact({
203
+ op: 'wechat_mp_gateway_receive_failed',
204
+ target: `${conversation.kind}:${conversation.id}`,
205
+ error: err instanceof Error ? err.message : String(err),
206
+ }));
207
+ });
208
+ }
382
209
 
383
- static parseMessageContent(wechatMsg: WeChatMessage): MessageSegment[] {
384
- const segments: MessageSegment[] = [];
385
-
386
- switch (wechatMsg.MsgType) {
387
- case 'text':
388
- if (wechatMsg.Content) {
389
- segments.push(segment.text(wechatMsg.Content));
390
- }
391
- break;
392
-
393
- case 'image':
394
- segments.push(segment('image', {
395
- url: wechatMsg.PicUrl,
396
- mediaId: wechatMsg.MediaId
397
- }));
398
- break;
399
-
400
- case 'voice':
401
- segments.push(segment('voice', {
402
- mediaId: wechatMsg.MediaId,
403
- format: wechatMsg.Format,
404
- recognition: wechatMsg.Recognition
405
- }));
406
- break;
407
-
408
- case 'video':
409
- case 'shortvideo':
410
- segments.push(segment('video', {
411
- mediaId: wechatMsg.MediaId,
412
- thumbMediaId: wechatMsg.ThumbMediaId
413
- }));
414
- break;
415
-
416
- case 'location':
417
- segments.push(segment('location', {
418
- latitude: wechatMsg.Location_X,
419
- longitude: wechatMsg.Location_Y,
420
- scale: wechatMsg.Scale,
421
- label: wechatMsg.Label
422
- }));
423
- break;
424
-
425
- case 'link':
426
- segments.push(segment('link', {
427
- title: wechatMsg.Title,
428
- description: wechatMsg.Description,
429
- url: wechatMsg.Url
430
- }));
431
- break;
432
-
433
- case 'event':
434
- segments.push(segment('event', {
435
- event: wechatMsg.Event,
436
- eventKey: wechatMsg.EventKey
437
- }));
438
- break;
439
-
440
- default:
441
- segments.push(segment.text(`[不支持的消息类型: ${wechatMsg.MsgType}]`));
442
- }
443
-
444
- return segments.length > 0 ? segments : [segment.text('(空消息)')];
210
+ async #sendCustomerService(target: string, payload: unknown): Promise<string> {
211
+ // 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
212
+ const materialized = await this.#materializeOutboundMedia(payload);
213
+ const messageData = formatCustomerServiceBody(target, materialized);
214
+ const result = await this.client.sendCustomerService(messageData);
215
+ if (result.errcode && result.errcode !== 0) {
216
+ throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
445
217
  }
218
+ this.#logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
219
+ return result.msgid?.toString() || `cs_${Date.now()}`;
220
+ }
446
221
 
447
- async $sendMessage(options: SendOptions): Promise<string> {
448
- if (getPassiveReplyCapture()) {
449
- const text = this.extractSendText(options);
450
- recordPassiveReplyText(text);
451
- return `passive_${Date.now()}`;
452
- }
453
-
454
- if (!this.usesPassiveReply()) {
455
- try {
456
- return await this.sendCustomerServiceMessage(options);
457
- } catch (error) {
458
- this.logger.error("Failed to send WeChat message:", error);
459
- throw error;
460
- }
461
- }
462
-
463
- this.logger.warn(formatCompact({
464
- op: "send",
465
- skip: "passive_outside_webhook",
466
- endpoint: this.$config.name,
222
+ /**
223
+ * 客服消息媒体段只接受 media_id:canonical MediaRef 是唯一来源。
224
+ * - kind=file(平台不透明引用,即既有 media_id)→ 直接透传;
225
+ * - kind=base64 / path / url → 经 /cgi-bin/media/upload 物化;
226
+ * - 无 MediaRef / 类型不可投递(file 段)→ warn + 丢弃;
227
+ * - 上传失败降级为文本(alt 优先),不阻断发送。
228
+ */
229
+ async #materializeOutboundMedia(payload: unknown): Promise<unknown> {
230
+ if (!Array.isArray(payload)) return payload;
231
+ const materialized = await Promise.all(payload.map(async (item) => {
232
+ if (typeof item === 'string' || !item || typeof item !== 'object') return item;
233
+ const seg = item as { type?: unknown; data?: Record<string, unknown> };
234
+ if (typeof seg.type !== 'string') return item;
235
+ const data = seg.data ?? {};
236
+ const uploadType = WECHAT_UPLOAD_TYPE[seg.type];
237
+ const isMediaSegment = uploadType != null || seg.type === 'file';
238
+ if (!isMediaSegment) return item;
239
+ const media = readOutboundMedia(data);
240
+ if (!media) {
241
+ // 已物化(mediaId/media_id)的段透传;其余无 canonical 媒体引用,丢弃留痕
242
+ if (typeof data.mediaId === 'string' && data.mediaId) return item;
243
+ if (typeof data.media_id === 'string' && data.media_id) return item;
244
+ this.#logger.warn(formatCompact({
245
+ op: 'wechat_mp_outbound_media_dropped',
246
+ endpoint: this.#options.config.id,
247
+ type: seg.type,
248
+ reason: 'missing_media_ref',
467
249
  }));
468
- return `passive_skipped_${Date.now()}`;
469
- }
470
- async $recallMessage(id: string): Promise<void> {
471
- // 公众号不支持撤回消息
472
- }
473
-
474
- private async sendCustomerServiceMessage(options: SendOptions): Promise<string> {
475
- if (!this.accessToken) {
476
- await this.refreshAccessToken();
477
- }
478
-
479
- const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${this.accessToken}`;
480
-
481
- const messageData = this.formatSendContent(options);
482
-
483
- const response = await axios.post(url, messageData);
484
- const result = response.data as WeChatAPIResponse;
485
-
486
- if (result.errcode && result.errcode !== 0) {
487
- throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
488
- }
489
-
490
- return result.msgid?.toString() || `cs_${Date.now()}`;
491
- }
492
-
493
- private formatSendContent(options: SendOptions): any {
494
- const messageData: any = {
495
- touser: options.id,
496
- msgtype: 'text',
497
- text: {
498
- content: ''
499
- }
500
- };
501
-
502
- if (typeof options.content === 'string') {
503
- messageData.text.content = options.content;
504
- } else if (Array.isArray(options.content)) {
505
- const textParts: string[] = [];
506
- let hasMedia = false;
507
-
508
- for (const item of options.content) {
509
- if (typeof item === 'string') {
510
- textParts.push(item);
511
- } else {
512
- const segment = item as MessageSegment;
513
- switch (segment.type) {
514
- case 'text':
515
- const textContent = segment.data.text || segment.data.content || '';
516
- textParts.push(textContent);
517
- break;
518
-
519
- case 'image':
520
- if (!hasMedia && segment.data.mediaId) {
521
- messageData.msgtype = 'image';
522
- messageData.image = { media_id: segment.data.mediaId };
523
- delete messageData.text;
524
- hasMedia = true;
525
- }
526
- break;
527
-
528
- case 'voice':
529
- if (!hasMedia && segment.data.mediaId) {
530
- messageData.msgtype = 'voice';
531
- messageData.voice = { media_id: segment.data.mediaId };
532
- delete messageData.text;
533
- hasMedia = true;
534
- }
535
- break;
536
-
537
- case 'video':
538
- if (!hasMedia && segment.data.mediaId) {
539
- messageData.msgtype = 'video';
540
- messageData.video = {
541
- media_id: segment.data.mediaId,
542
- title: segment.data.title || '',
543
- description: segment.data.description || ''
544
- };
545
- delete messageData.text;
546
- hasMedia = true;
547
- }
548
- break;
549
- }
550
- }
551
- }
552
-
553
- if (!hasMedia && textParts.length > 0) {
554
- messageData.text.content = textParts.join('\n');
555
- }
556
- }
557
-
558
- return messageData;
559
- }
560
-
561
- private getReplyMode(): "passive" | "customer_service" {
562
- return this.$config.replyMode ?? "passive";
563
- }
564
-
565
- private getEncryptMode(): "plain" | "compatible" | "secure" {
566
- if (!this.$config.encrypt || !this.$config.encodingAESKey) {
567
- return "plain";
568
- }
569
- return this.$config.encryptMode ?? "compatible";
570
- }
571
-
572
- private usesPassiveReply(): boolean {
573
- return this.getReplyMode() === "passive";
574
- }
575
-
576
- private extractSendText(options: SendOptions): string {
577
- if (typeof options.content === "string") {
578
- return options.content;
579
- }
580
- return segment.raw(options.content as MessageSegment[]);
581
- }
582
-
583
- private async collectPassiveReplyXml(
584
- wechatMsg: WeChatMessage,
585
- message: Message<WeChatMessage>,
586
- ): Promise<string> {
587
- const timeoutMs = this.$config.passiveReplyTimeoutMs ?? 4500;
588
-
589
- const text = await runWithPassiveReplyCapture(async () => {
590
- await Promise.race([
591
- runInboundMessage({
592
- plugin: this.adapter.plugin,
593
- message,
594
- emitAdapterObservers: () => {
595
- EventEmitter.prototype.emit.call(
596
- this.adapter,
597
- "message.receive",
598
- message,
599
- );
600
- },
601
- }),
602
- new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
603
- ]);
604
- return getPassiveReplyCapture()?.text ?? null;
605
- });
606
- if (!text) {
607
- this.logger.warn(formatCompact({
608
- op: "passive_reply",
609
- ok: false,
610
- reason: "timeout_or_empty",
611
- timeoutMs,
612
- }));
613
- return "";
614
- }
615
-
616
- this.logger.info(formatCompact({
617
- op: "passive_reply",
618
- ok: true,
619
- plainLen: text.length,
250
+ return null;
251
+ }
252
+ if (media.kind === 'file') {
253
+ // 平台不透明引用:value 即 media_id,直接透传不上传
254
+ return { type: seg.type, data: { mediaId: media.value } };
255
+ }
256
+ if (!uploadType) {
257
+ this.#logger.warn(formatCompact({
258
+ op: 'wechat_mp_outbound_media_dropped',
259
+ endpoint: this.#options.config.id,
260
+ type: seg.type,
261
+ reason: 'unsupported_segment_type',
620
262
  }));
621
- return this.buildTextReply(wechatMsg, text);
622
- }
623
-
624
- private async handlePassiveReply(wechatMsg: WeChatMessage, message: Message<WeChatMessage>): Promise<string> {
625
- // 事件类型消息的自动回复
626
- if (wechatMsg.MsgType === 'event') {
627
- switch (wechatMsg.Event) {
628
- case 'subscribe':
629
- this.logger.info(formatCompact( { op: "subscribe", user: wechatMsg.FromUserName, scene: wechatMsg.EventKey }));
630
- return this.buildTextReply(wechatMsg, '感谢关注!');
631
- case 'unsubscribe':
632
- this.logger.info(formatCompact( { op: "unsubscribe", user: wechatMsg.FromUserName }));
633
- return '';
634
- case 'SCAN':
635
- this.logger.info(formatCompact( { op: "scan", user: wechatMsg.FromUserName, scene: wechatMsg.EventKey }));
636
- return '';
637
- case 'LOCATION':
638
- this.logger.debug(`User location: ${wechatMsg.FromUserName}, lat=${wechatMsg.Location_X}, lng=${wechatMsg.Location_Y}`);
639
- return '';
640
- case 'CLICK':
641
- this.logger.debug(`Menu click: ${wechatMsg.EventKey}`);
642
- return '';
643
- case 'VIEW':
644
- this.logger.debug(`Menu view: ${wechatMsg.EventKey}`);
645
- return '';
646
- }
647
- }
648
-
649
- return '';
650
- }
651
-
652
- private buildTextReply(wechatMsg: WeChatMessage, content: string): string {
653
- const cdata = (value: string) =>
654
- value.replace(/]]>/g, "]]]]><![CDATA[>");
655
- const createTime = Math.floor(Date.now() / 1000);
656
- return [
657
- "<xml>",
658
- `<ToUserName><![CDATA[${cdata(wechatMsg.FromUserName)}]]></ToUserName>`,
659
- `<FromUserName><![CDATA[${cdata(wechatMsg.ToUserName)}]]></FromUserName>`,
660
- `<CreateTime>${createTime}</CreateTime>`,
661
- `<MsgType><![CDATA[text]]></MsgType>`,
662
- `<Content><![CDATA[${cdata(content)}]]></Content>`,
663
- "</xml>",
664
- ].join("");
665
- }
666
-
667
- private async refreshAccessToken(): Promise<void> {
668
- const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${this.$config.appId}&secret=${this.$config.appSecret}`;
669
-
670
- try {
671
- const response = await axios.get<TokenResponse>(url);
672
- const data = response.data;
673
-
674
- if (data.access_token) {
675
- this.accessToken = data.access_token;
676
- this.tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000; // 提前5分钟刷新
677
- this.logger.debug(formatCompact( { op: "token_refresh" }));
678
- } else {
679
- throw new Error('Failed to get access token');
680
- }
681
- } catch (error) {
682
- this.logger.error('Failed to refresh access token:', error);
683
- throw error;
684
- }
685
- }
686
-
687
- private tokenRefreshTimer?: ReturnType<typeof setInterval>;
688
-
689
- private startTokenRefreshTimer(): void {
690
- // 每小时检查一次token是否需要刷新
691
- this.tokenRefreshTimer = setInterval(async () => {
692
- if (Date.now() >= this.tokenExpireTime) {
693
- try {
694
- await this.refreshAccessToken();
695
- } catch (error) {
696
- this.logger.error('Failed to refresh access token in timer:', error);
697
- }
698
- }
699
- }, 3600000); // 1小时
700
- }
701
-
702
- // 获取用户信息
703
- async getUserInfo(openid: string): Promise<any> {
704
- if (!this.accessToken) {
705
- await this.refreshAccessToken();
706
- }
707
-
708
- const url = `https://api.weixin.qq.com/cgi-bin/user/info?access_token=${this.accessToken}&openid=${openid}&lang=zh_CN`;
709
-
710
- const response = await axios.get(url);
711
- return response.data;
712
- }
713
-
714
- /**
715
- * 上传多媒体文件到微信服务器
716
- * @param type 媒体类型:image(图片)、voice(语音)、video(视频)、thumb(缩略图)
717
- * @param buffer 文件 Buffer
718
- * @param filename 文件名(可选,用于确定文件类型)
719
- * @returns 微信服务器返回的 media_id
720
- */
721
- async uploadMedia(
722
- type: 'image' | 'voice' | 'video' | 'thumb',
723
- buffer: Buffer,
724
- filename?: string
725
- ): Promise<string> {
726
- try {
727
- // 确保有有效的 access_token
728
- if (!this.accessToken) {
729
- await this.refreshAccessToken();
730
- }
731
- const token = this.accessToken;
732
- const url = `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${token}&type=${type}`;
733
-
734
- // 创建 FormData
735
- const form = new FormData();
736
-
737
- // 根据类型确定文件扩展名
738
- const ext = this.getFileExtension(type, filename);
739
- const mediaFilename = filename || `media.${ext}`;
740
-
741
- // 添加文件到 FormData
742
- form.append('media', buffer, {
743
- filename: mediaFilename,
744
- contentType: this.getContentType(type),
745
- });
746
-
747
- // 发送上传请求
748
- const response = await axios.post(url, form, {
749
- headers: {
750
- ...form.getHeaders(),
751
- },
752
- maxBodyLength: Infinity,
753
- maxContentLength: Infinity,
754
- });
755
-
756
- if (response.data.errcode) {
757
- throw new Error(
758
- `微信媒体上传失败: ${response.data.errmsg} (错误码: ${response.data.errcode})`
759
- );
760
- }
761
-
762
- return response.data.media_id;
763
- } catch (error) {
764
- this.logger.error('上传媒体文件失败:', error);
765
- throw error;
766
- }
767
- }
768
-
769
- /**
770
- * 获取文件扩展名
771
- */
772
- private getFileExtension(type: string, filename?: string): string {
773
- if (filename) {
774
- const match = filename.match(/\.([^.]+)$/);
775
- if (match) return match[1];
776
- }
777
-
778
- // 默认扩展名
779
- const defaultExt: Record<string, string> = {
780
- image: 'jpg',
781
- voice: 'mp3',
782
- video: 'mp4',
783
- thumb: 'jpg',
784
- };
785
-
786
- return defaultExt[type] || 'bin';
787
- }
788
-
789
- /**
790
- * 获取 Content-Type
791
- */
792
- private getContentType(type: string): string {
793
- const contentTypes: Record<string, string> = {
794
- image: 'image/jpeg',
795
- voice: 'audio/mpeg',
796
- video: 'video/mp4',
797
- thumb: 'image/jpeg',
798
- };
799
-
800
- return contentTypes[type] || 'application/octet-stream';
801
- }
802
-
803
- // ── AES 加解密(安全模式) ──────────────────────────────
804
-
805
- private getAESKey(): Buffer {
806
- const key = this.$config.encodingAESKey!;
807
- return Buffer.from(key + '=', 'base64');
808
- }
809
-
810
- /** 微信安全模式加密 echostr 为较长 Base64;明文/兼容模式多为短字符串 */
811
- private isEncryptedEchostr(echostr: string): boolean {
812
- if (echostr.length < 32) return false;
813
- return /^[A-Za-z0-9+/]+={0,2}$/.test(echostr);
814
- }
815
-
816
- /**
817
- * 解密安全模式 URL 验证中的 echostr
818
- */
819
- private decryptEchostr(encrypted: string): string {
820
- const aesKey = this.getAESKey();
821
- const iv = aesKey.subarray(0, 16);
822
- const decipher = createDecipheriv("aes-256-cbc", aesKey, iv);
823
- decipher.setAutoPadding(false);
824
-
825
- const decrypted = Buffer.concat([
826
- decipher.update(Buffer.from(encrypted, "base64")),
827
- decipher.final(),
828
- ]);
829
-
830
- const pad = decrypted[decrypted.length - 1];
831
- const content = decrypted.subarray(0, decrypted.length - pad);
832
-
833
- const msgLen = content.readUInt32BE(16);
834
- const plain = content.subarray(20, 20 + msgLen).toString("utf8");
835
- const appId = content.subarray(20 + msgLen).toString("utf8");
836
-
837
- if (appId !== this.$config.appId) {
838
- throw new Error(`AppID mismatch: expected ${this.$config.appId}, got ${appId}`);
839
- }
840
-
841
- return plain;
842
- }
843
-
844
- /**
845
- * 解密微信推送的加密消息
846
- */
847
- private async decryptMessage(
848
- encryptedXml: string,
849
- msgSignature: string,
850
- timestamp: string,
851
- nonce: string
852
- ): Promise<string> {
853
- // 从外层 XML 提取 Encrypt 字段
854
- const parsed = await this.parseXMLMessage(encryptedXml);
855
- const encrypt = (parsed as any)?.Encrypt;
856
- if (!encrypt) throw new Error('Missing Encrypt field in encrypted message');
857
-
858
- // 校验 msg_signature
859
- const expected = createHash('sha1')
860
- .update([this.$config.token, timestamp, nonce, encrypt].sort().join(''))
861
- .digest('hex');
862
- if (expected !== msgSignature) {
863
- throw new Error('msg_signature verification failed');
864
- }
865
-
866
- // AES-256-CBC 解密
867
- const aesKey = this.getAESKey();
868
- const iv = aesKey.subarray(0, 16);
869
- const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
870
- decipher.setAutoPadding(false);
871
-
872
- const decrypted = Buffer.concat([
873
- decipher.update(Buffer.from(encrypt, 'base64')),
874
- decipher.final()
875
- ]);
876
-
877
- // 去除 PKCS#7 填充
878
- const pad = decrypted[decrypted.length - 1];
879
- const content = decrypted.subarray(0, decrypted.length - pad);
880
-
881
- // 格式: 16 bytes random + 4 bytes msgLen (network order) + msg + appId
882
- const msgLen = content.readUInt32BE(16);
883
- const xmlContent = content.subarray(20, 20 + msgLen).toString('utf8');
884
- const appId = content.subarray(20 + msgLen).toString('utf8');
885
-
886
- if (appId !== this.$config.appId) {
887
- throw new Error(`AppID mismatch: expected ${this.$config.appId}, got ${appId}`);
888
- }
889
-
890
- return xmlContent;
891
- }
892
-
893
- /**
894
- * 加密被动回复消息
895
- */
896
- private encryptMessage(replyXml: string, requestTimestamp?: string): string {
897
- const aesKey = this.getAESKey();
898
- const iv = aesKey.subarray(0, 16);
899
-
900
- // 组装明文: 16 bytes random + 4 bytes msgLen + msg + appId
901
- const random = randomBytes(16);
902
- const msgBuf = Buffer.from(replyXml, 'utf8');
903
- const appIdBuf = Buffer.from(this.$config.appId, 'utf8');
904
- const lenBuf = Buffer.alloc(4);
905
- lenBuf.writeUInt32BE(msgBuf.length, 0);
906
-
907
- const plaintext = Buffer.concat([random, lenBuf, msgBuf, appIdBuf]);
908
-
909
- // PKCS#7 填充
910
- const blockSize = 32;
911
- const padLen = blockSize - (plaintext.length % blockSize);
912
- const padBuf = Buffer.alloc(padLen, padLen);
913
- const padded = Buffer.concat([plaintext, padBuf]);
914
-
915
- // AES-256-CBC 加密
916
- const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
917
- cipher.setAutoPadding(false);
918
- const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
919
- const encryptStr = encrypted.toString('base64');
263
+ return null;
264
+ }
265
+ try {
266
+ const mediaId = await this.#uploadMedia(uploadType, media);
267
+ return { type: seg.type, data: { mediaId } };
268
+ } catch (error) {
269
+ this.#logger.warn(formatCompact({
270
+ op: 'wechat_mp_media_upload_failed',
271
+ endpoint: this.#options.config.id,
272
+ error: error instanceof Error ? error.message : String(error),
273
+ }));
274
+ const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
275
+ return { type: 'text', data: { text: alt } };
276
+ }
277
+ }));
278
+ return materialized.filter((item) => item != null);
279
+ }
920
280
 
921
- // 签名(TimeStamp 优先复用入站请求值,与微信官方示例一致)
922
- const timestamp = requestTimestamp || Math.floor(Date.now() / 1000).toString();
923
- const nonce = randomBytes(8).toString('hex');
924
- const signature = createHash('sha1')
925
- .update([this.$config.token, timestamp, nonce, encryptStr].sort().join(''))
926
- .digest('hex');
281
+ /** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
282
+ async #uploadMedia(
283
+ type: 'image' | 'voice' | 'video',
284
+ media: Parameters<typeof resolveMediaBinary>[0],
285
+ ): Promise<string> {
286
+ const binary = await resolveMediaBinary(media);
287
+ const form = buildMediaUploadForm(binary);
288
+ return this.client.uploadMedia(type, form);
289
+ }
927
290
 
928
- return [
929
- '<xml>',
930
- `<Encrypt><![CDATA[${encryptStr}]]></Encrypt>`,
931
- `<MsgSignature><![CDATA[${signature}]]></MsgSignature>`,
932
- `<TimeStamp>${timestamp}</TimeStamp>`,
933
- `<Nonce><![CDATA[${nonce}]]></Nonce>`,
934
- '</xml>'
935
- ].join('\n');
936
- }
291
+ #startTokenRefreshTimer(): void {
292
+ this.#tokenRefreshTimer = setInterval(() => {
293
+ if (this.client.tokenExpired) {
294
+ void this.client.refreshAccessToken().catch((error) => {
295
+ this.#logger.error('Failed to refresh access token in timer:', error);
296
+ });
297
+ }
298
+ }, 3_600_000);
299
+ }
937
300
  }
938
301
 
939
- // 定义 Adapter 类
302
+ function createWeChatMpEndpointManagement(
303
+ requireClient: () => WeChatMpClient,
304
+ ): EndpointManagement {
305
+ return Object.freeze<EndpointManagement>({
306
+ // 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
307
+ listFriends: async () => (await requireClient().getFollowerIds()).map((openid) => ({
308
+ user_id: openid,
309
+ nickname: openid,
310
+ remark: '',
311
+ })),
312
+ });
313
+ }