@zhin.js/adapter-wechat-mp 5.0.1 → 5.0.4

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,60 @@
1
1
  # @zhin.js/adapter-wechat-mp
2
2
 
3
+ ## 5.0.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [0de46a8]
8
+ - Updated dependencies [c106ecc]
9
+ - Updated dependencies [ca92e03]
10
+ - Updated dependencies [b0f37ae]
11
+ - Updated dependencies [ba08a2f]
12
+ - Updated dependencies [daffd4c]
13
+ - Updated dependencies [36c7400]
14
+ - Updated dependencies [162fa34]
15
+ - Updated dependencies [e40b048]
16
+ - Updated dependencies [f1708c3]
17
+ - Updated dependencies [e53444f]
18
+ - Updated dependencies [92b0dd7]
19
+ - Updated dependencies [a7df753]
20
+ - @zhin.js/host-http@1.0.8
21
+ - @zhin.js/im-contract@1.0.3
22
+ - @zhin.js/adapter@1.1.7
23
+ - @zhin.js/plugin-runtime@1.1.5
24
+ - @zhin.js/command@1.0.9
25
+ - @zhin.js/core@1.5.4
26
+ - zhin.js@6.0.4
27
+
28
+ ## 5.0.3
29
+
30
+ ### Patch Changes
31
+
32
+ - f8c7a54: fix: im
33
+ - Updated dependencies [f8c7a54]
34
+ - @zhin.js/logger@1.0.76
35
+ - @zhin.js/host-http@1.0.7
36
+ - @zhin.js/adapter@1.1.6
37
+ - @zhin.js/command@1.0.8
38
+ - @zhin.js/core@1.5.3
39
+ - @zhin.js/im-contract@1.0.2
40
+ - @zhin.js/plugin-runtime@1.1.4
41
+ - zhin.js@6.0.3
42
+
43
+ ## 5.0.2
44
+
45
+ ### Patch Changes
46
+
47
+ - Updated dependencies [afc0e66]
48
+ - Updated dependencies [2e41ad5]
49
+ - Updated dependencies [9f57124]
50
+ - @zhin.js/core@1.5.2
51
+ - @zhin.js/adapter@1.1.5
52
+ - @zhin.js/im-contract@1.0.1
53
+ - @zhin.js/plugin-runtime@1.1.3
54
+ - @zhin.js/command@1.0.7
55
+ - @zhin.js/host-http@1.0.6
56
+ - zhin.js@6.0.2
57
+
3
58
  ## 5.0.1
4
59
 
5
60
  ### Patch Changes
package/README.md CHANGED
@@ -24,8 +24,8 @@ pnpm add @zhin.js/adapter-wechat-mp
24
24
  - `@zhin.js/plugin-runtime` — `plugin.ts`(`definePlugin`)
25
25
  - 配置经插件 `schema.json` 落到 `plugins.<instanceKey>`
26
26
 
27
- 入站:`gateway.receive({ adapter, target: openid, content: text, sender, metadata })`
28
- 出站:`send({ target, payload })` → 被动回复 XML(默认)或客服消息 API(`replyMode: customer_service`)
27
+ 入站:`gateway.receive({ conversation: { endpoint, kind: 'private', id: openid }, message: { conversation, id }, content: text, sender, metadata })`
28
+ 出站:`send({ conversation, payload })` → 被动回复 XML(默认)或客服消息 API(`replyMode: customer_service`)
29
29
 
30
30
  ## 前置条件
31
31
 
@@ -20,8 +20,8 @@ export default defineAdapter({
20
20
  create(context) {
21
21
  const config = resolveWeChatMpConfig(context.config);
22
22
  // 注册到插件运行时状态(wechat-mp.endpoint list 的"运行中"数据源)
23
- context.use(wechatMpRuntimeStateToken).endpoints.set(config.name, {
24
- name: config.name,
23
+ context.use(wechatMpRuntimeStateToken).endpoints.set(config.id, {
24
+ id: config.id,
25
25
  mode: 'webhook',
26
26
  });
27
27
  return new WeChatMpEndpoint({
@@ -25,8 +25,8 @@ export default defineAdapter<WeChatMpAdapterConfig>({
25
25
  create(context) {
26
26
  const config = resolveWeChatMpConfig(context.config);
27
27
  // 注册到插件运行时状态(wechat-mp.endpoint list 的"运行中"数据源)
28
- context.use(wechatMpRuntimeStateToken).endpoints.set(config.name, {
29
- name: config.name,
28
+ context.use(wechatMpRuntimeStateToken).endpoints.set(config.id, {
29
+ id: config.id,
30
30
  mode: 'webhook',
31
31
  });
32
32
  return new WeChatMpEndpoint({
package/lib/endpoint.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EndpointFriend, EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
1
+ import type { EndpointFriend, EndpointInstance, EndpointManagement, EndpointSendRequest } from '@zhin.js/adapter';
2
2
  import type { MessageGateway } from '@zhin.js/core/runtime';
3
3
  import type { HttpHost } from '@zhin.js/host-http';
4
4
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
@@ -33,10 +33,7 @@ export declare class WeChatMpEndpoint implements EndpointInstance {
33
33
  open(): void;
34
34
  close(): void;
35
35
  stop(): Promise<void>;
36
- send({ target, payload }: {
37
- readonly target: string;
38
- readonly payload: unknown;
39
- }): Promise<string>;
36
+ send({ conversation, payload }: EndpointSendRequest): Promise<string>;
40
37
  /** Test / internal: admit a parsed message when open (non-webhook path). */
41
38
  admit(msg: WeChatMessage): void;
42
39
  /**
package/lib/endpoint.js CHANGED
@@ -2,12 +2,11 @@
2
2
  * WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
3
3
  */
4
4
  import axios from 'axios';
5
- import { formatCompact, getLogger } from '@zhin.js/logger';
6
- import { extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, } from './protocol.js';
5
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
6
+ import { extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, wechatMpInboundConversation, } from './protocol.js';
7
7
  import { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
8
8
  import { getPassiveReplyCapture, recordPassiveReplyText, } from './passive-reply.js';
9
9
  import { registerWeChatMpWebhookRoutes } from './webhook.js';
10
- const logger = getLogger('wechat-mp');
11
10
  /** token 失效类错误码:40001/40014 invalid access_token、42001 access_token expired。 */
12
11
  const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
13
12
  /**
@@ -29,6 +28,7 @@ function defaultFetch(url, init) {
29
28
  }).then((response) => ({ data: response.data }));
30
29
  }
31
30
  export class WeChatMpEndpoint {
31
+ #logger;
32
32
  #options;
33
33
  #fetch;
34
34
  #routeReleases = [];
@@ -42,6 +42,7 @@ export class WeChatMpEndpoint {
42
42
  #started = false;
43
43
  management = createWeChatMpEndpointManagement(this);
44
44
  constructor(options) {
45
+ this.#logger = getAdapterLogger('wechat-mp', options.config.id);
45
46
  this.#options = options;
46
47
  this.#fetch = options.fetch ?? defaultFetch;
47
48
  }
@@ -81,15 +82,15 @@ export class WeChatMpEndpoint {
81
82
  await this.#refreshAccessToken();
82
83
  this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, this));
83
84
  this.#startTokenRefreshTimer();
84
- logger.debug(formatCompact({
85
- endpoint: this.#options.config.name,
85
+ this.#logger.debug(formatCompact({
86
+ endpoint: this.#options.config.id,
86
87
  op: 'webhook',
87
88
  path: this.#options.config.path,
88
89
  }));
89
90
  }
90
91
  catch (error) {
91
92
  await this.stop();
92
- logger.error('Failed to connect WeChat MP bot:', error);
93
+ this.#logger.error('Failed to connect WeChat MP bot:', error);
93
94
  throw error;
94
95
  }
95
96
  }
@@ -108,22 +109,22 @@ export class WeChatMpEndpoint {
108
109
  for (const release of this.#routeReleases.splice(0))
109
110
  release();
110
111
  this.#started = false;
111
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
112
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
112
113
  }
113
- async send({ target, payload }) {
114
+ async send({ conversation, payload }) {
114
115
  if (getPassiveReplyCapture()) {
115
116
  const text = extractOutboundText(payload);
116
117
  recordPassiveReplyText(text);
117
118
  return `passive_${Date.now()}`;
118
119
  }
119
120
  if (this.#options.config.replyMode === 'customer_service') {
120
- return this.#sendCustomerService(target, payload);
121
+ return this.#sendCustomerService(conversation.id, payload);
121
122
  }
122
- logger.warn(formatCompact({
123
+ this.#logger.warn(formatCompact({
123
124
  op: 'send',
124
125
  skip: 'passive_outside_webhook',
125
- endpoint: this.#options.config.name,
126
- target,
126
+ endpoint: this.#options.config.id,
127
+ target: `${conversation.kind}:${conversation.id}`,
127
128
  }));
128
129
  return `passive_skipped_${Date.now()}`;
129
130
  }
@@ -131,22 +132,22 @@ export class WeChatMpEndpoint {
131
132
  admit(msg) {
132
133
  if (!this.#open)
133
134
  return;
135
+ const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
134
136
  void this.#options.gateway.receive({
135
- adapter: this.#options.id,
136
- target: msg.FromUserName,
137
+ conversation,
138
+ message: { conversation, id: formatInboundId(msg) },
137
139
  content: formatInboundContent(msg),
138
- sender: msg.FromUserName,
139
- id: formatInboundId(msg),
140
+ sender: { id: msg.FromUserName },
141
+ endpointId: this.#options.config.id,
140
142
  metadata: Object.freeze({
141
143
  msgType: msg.MsgType,
142
144
  event: msg.Event,
143
- endpoint: this.#options.config.name,
144
145
  toUserName: msg.ToUserName,
145
146
  }),
146
147
  }).catch((err) => {
147
- logger.warn(formatCompact({
148
+ this.#logger.warn(formatCompact({
148
149
  op: 'wechat_mp_gateway_receive_failed',
149
- target: msg.FromUserName,
150
+ target: `${conversation.kind}:${conversation.id}`,
150
151
  error: err instanceof Error ? err.message : String(err),
151
152
  }));
152
153
  });
@@ -161,7 +162,7 @@ export class WeChatMpEndpoint {
161
162
  let result = await this.#postCustomerService(messageData);
162
163
  if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
163
164
  // 对端提前作废 token(多端共用等):刷新后重试一次。
164
- logger.warn(formatCompact({
165
+ this.#logger.warn(formatCompact({
165
166
  op: 'wechat_mp_token_invalid_retry',
166
167
  errcode: result.errcode,
167
168
  }));
@@ -171,7 +172,7 @@ export class WeChatMpEndpoint {
171
172
  if (result.errcode && result.errcode !== 0) {
172
173
  throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
173
174
  }
174
- logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
175
+ this.#logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
175
176
  return result.msgid?.toString() || `cs_${Date.now()}`;
176
177
  }
177
178
  /**
@@ -202,9 +203,9 @@ export class WeChatMpEndpoint {
202
203
  return item;
203
204
  if (typeof data.media_id === 'string' && data.media_id)
204
205
  return item;
205
- logger.warn(formatCompact({
206
+ this.#logger.warn(formatCompact({
206
207
  op: 'wechat_mp_outbound_media_dropped',
207
- endpoint: this.#options.config.name,
208
+ endpoint: this.#options.config.id,
208
209
  type: seg.type,
209
210
  reason: 'missing_media_ref',
210
211
  }));
@@ -215,9 +216,9 @@ export class WeChatMpEndpoint {
215
216
  return { type: seg.type, data: { mediaId: media.value } };
216
217
  }
217
218
  if (!uploadType) {
218
- logger.warn(formatCompact({
219
+ this.#logger.warn(formatCompact({
219
220
  op: 'wechat_mp_outbound_media_dropped',
220
- endpoint: this.#options.config.name,
221
+ endpoint: this.#options.config.id,
221
222
  type: seg.type,
222
223
  reason: 'unsupported_segment_type',
223
224
  }));
@@ -228,9 +229,9 @@ export class WeChatMpEndpoint {
228
229
  return { type: seg.type, data: { mediaId } };
229
230
  }
230
231
  catch (error) {
231
- logger.warn(formatCompact({
232
+ this.#logger.warn(formatCompact({
232
233
  op: 'wechat_mp_media_upload_failed',
233
- endpoint: this.#options.config.name,
234
+ endpoint: this.#options.config.id,
234
235
  error: error instanceof Error ? error.message : String(error),
235
236
  }));
236
237
  const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
@@ -294,7 +295,7 @@ export class WeChatMpEndpoint {
294
295
  if (data.access_token) {
295
296
  this.#accessToken = data.access_token;
296
297
  this.#tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000;
297
- logger.debug(formatCompact({ op: 'token_refresh' }));
298
+ this.#logger.debug(formatCompact({ op: 'token_refresh' }));
298
299
  return;
299
300
  }
300
301
  throw new Error(data.errmsg
@@ -305,7 +306,7 @@ export class WeChatMpEndpoint {
305
306
  this.#tokenRefreshTimer = setInterval(() => {
306
307
  if (Date.now() >= this.#tokenExpireTime) {
307
308
  void this.#refreshAccessToken().catch((error) => {
308
- logger.error('Failed to refresh access token in timer:', error);
309
+ this.#logger.error('Failed to refresh access token in timer:', error);
309
310
  });
310
311
  }
311
312
  }, 3_600_000);
package/lib/protocol.d.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
5
  import type { IncomingMessage } from 'node:http';
6
+ import type { ConversationRef } from '@zhin.js/im-contract';
6
7
  export interface WeChatMpAdapterConfig {
7
- readonly name?: string;
8
+ readonly id?: string;
8
9
  readonly appId?: string;
9
10
  readonly appSecret?: string;
10
11
  readonly token?: string;
@@ -31,7 +32,7 @@ export interface WeChatMpAdapterConfig {
31
32
  }
32
33
  export interface ResolvedWeChatMpConfig {
33
34
  readonly context: 'wechat-mp';
34
- readonly name: string;
35
+ readonly id: string;
35
36
  readonly appId: string;
36
37
  readonly appSecret: string;
37
38
  readonly token: string;
@@ -101,6 +102,11 @@ export declare function parseXMLMessage(xmlString: string): Promise<WeChatMessag
101
102
  export declare function decryptMessage(encryptedXml: string, msgSignature: string, timestamp: string, nonce: string, token: string, encodingAESKey: string, appId: string): Promise<string>;
102
103
  export declare function encryptMessage(replyXml: string, token: string, encodingAESKey: string, appId: string, requestTimestamp?: string): string;
103
104
  export declare function buildTextReply(wechatMsg: Pick<WeChatMessage, 'FromUserName' | 'ToUserName'>, content: string): string;
105
+ /**
106
+ * 入站归一化:公众号只有粉丝单聊场景,一律 kind='private',id=openid(FromUserName),
107
+ * 无群/频道容器(parent 恒缺省)。
108
+ */
109
+ export declare function wechatMpInboundConversation(endpointKey: string, msg: WeChatMessage): ConversationRef;
104
110
  /**
105
111
  * 入站消息 id:普通消息用 MsgId;无 MsgId 的事件消息拼 Event/EventKey,
106
112
  * 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
package/lib/protocol.js CHANGED
@@ -12,8 +12,8 @@ export function resolveWeChatMpConfig(config = {}) {
12
12
  if (!appId || !appSecret || !token) {
13
13
  throw new TypeError('WeChat MP adapter requires appId + appSecret + token (plugins.<key> or endpoints with context: wechat-mp)');
14
14
  }
15
- const name = (typeof config.name === 'string' && config.name)
16
- || (typeof entry?.name === 'string' && entry.name)
15
+ const id = (typeof config.id === 'string' && config.id)
16
+ || (typeof entry?.id === 'string' && entry.id)
17
17
  || process.env.WECHAT_BOT_NAME
18
18
  || 'wechat-mp-bot';
19
19
  const path = config.path ?? entry?.path ?? '/wechat/webhook';
@@ -26,7 +26,7 @@ export function resolveWeChatMpConfig(config = {}) {
26
26
  ?? 4500;
27
27
  return {
28
28
  context: 'wechat-mp',
29
- name,
29
+ id,
30
30
  appId,
31
31
  appSecret,
32
32
  token,
@@ -170,6 +170,17 @@ export function buildTextReply(wechatMsg, content) {
170
170
  '</xml>',
171
171
  ].join('');
172
172
  }
173
+ /**
174
+ * 入站归一化:公众号只有粉丝单聊场景,一律 kind='private',id=openid(FromUserName),
175
+ * 无群/频道容器(parent 恒缺省)。
176
+ */
177
+ export function wechatMpInboundConversation(endpointKey, msg) {
178
+ return {
179
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
180
+ kind: 'private',
181
+ id: msg.FromUserName,
182
+ };
183
+ }
173
184
  /**
174
185
  * 入站消息 id:普通消息用 MsgId;无 MsgId 的事件消息拼 Event/EventKey,
175
186
  * 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
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, } from './protocol.js';
3
+ import { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, formatInboundContent, formatInboundId, 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;
@@ -132,17 +132,17 @@ export async function handleWeChatMpMessage(request, response, url, handler) {
132
132
  export async function collectPassiveReply(handler, wechatMsg) {
133
133
  const timeoutMs = handler.config.passiveReplyTimeoutMs;
134
134
  const text = await runWithPassiveReplyCapture(async () => {
135
+ const conversation = wechatMpInboundConversation(String(handler.id), wechatMsg);
135
136
  await Promise.race([
136
137
  handler.gateway.receive({
137
- adapter: handler.id,
138
- target: wechatMsg.FromUserName,
138
+ conversation,
139
+ message: { conversation, id: formatInboundId(wechatMsg) },
139
140
  content: formatInboundContent(wechatMsg),
140
- sender: wechatMsg.FromUserName,
141
- id: formatInboundId(wechatMsg),
141
+ sender: { id: wechatMsg.FromUserName },
142
+ endpointId: handler.config.id,
142
143
  metadata: Object.freeze({
143
144
  msgType: wechatMsg.MsgType,
144
145
  event: wechatMsg.Event,
145
- endpoint: handler.config.name,
146
146
  toUserName: wechatMsg.ToUserName,
147
147
  }),
148
148
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-wechat-mp",
3
- "version": "5.0.1",
3
+ "version": "5.0.4",
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,19 +8,20 @@
8
8
  "dependencies": {
9
9
  "axios": "^1.19.0",
10
10
  "xml2js": "^0.6.2",
11
- "@zhin.js/adapter": "1.1.4",
12
- "@zhin.js/command": "1.0.6",
13
- "@zhin.js/core": "1.5.1",
14
- "@zhin.js/host-http": "1.0.5",
15
- "@zhin.js/logger": "1.0.75",
16
- "@zhin.js/plugin-runtime": "1.1.2"
11
+ "@zhin.js/adapter": "1.1.7",
12
+ "@zhin.js/command": "1.0.9",
13
+ "@zhin.js/core": "1.5.4",
14
+ "@zhin.js/host-http": "1.0.8",
15
+ "@zhin.js/im-contract": "1.0.3",
16
+ "@zhin.js/logger": "1.0.76",
17
+ "@zhin.js/plugin-runtime": "1.1.5"
17
18
  },
18
19
  "peerDependencies": {
19
- "@zhin.js/adapter": "1.1.4",
20
- "@zhin.js/core": "1.5.1",
21
- "@zhin.js/host-http": "1.0.5",
22
- "@zhin.js/plugin-runtime": "1.1.2",
23
- "zhin.js": "6.0.1"
20
+ "@zhin.js/adapter": "1.1.7",
21
+ "@zhin.js/core": "1.5.4",
22
+ "@zhin.js/host-http": "1.0.8",
23
+ "@zhin.js/plugin-runtime": "1.1.5",
24
+ "zhin.js": "6.0.4"
24
25
  },
25
26
  "peerDependenciesMeta": {
26
27
  "zhin.js": {
package/schema.json CHANGED
@@ -32,16 +32,48 @@
32
32
  "type": "number",
33
33
  "default": 4500
34
34
  },
35
+ "master": {
36
+ "type": [
37
+ "string",
38
+ "number"
39
+ ],
40
+ "description": "框架 master(WeChat OpenID;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖"
41
+ },
42
+ "trusted": {
43
+ "type": "array",
44
+ "items": {
45
+ "type": [
46
+ "string",
47
+ "number"
48
+ ],
49
+ "description": "Trusted WeChat OpenID"
50
+ },
51
+ "description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加"
52
+ },
35
53
  "endpoints": {
36
54
  "type": "array",
37
- "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
55
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(id 必填,其余覆盖顶层)",
38
56
  "items": {
39
57
  "type": "object",
40
58
  "additionalProperties": true,
41
59
  "properties": {
42
- "name": {
43
- "type": "string",
44
- "description": "WeChat MP bot name"
60
+ "master": {
61
+ "type": [
62
+ "string",
63
+ "number"
64
+ ],
65
+ "description": "本 endpoint 的框架 master(WeChat OpenID);覆盖顶层 master"
66
+ },
67
+ "trusted": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": [
71
+ "string",
72
+ "number"
73
+ ],
74
+ "description": "Trusted WeChat OpenID"
75
+ },
76
+ "description": "本 endpoint 的 trusted 列表"
45
77
  },
46
78
  "appId": {
47
79
  "type": "string",
@@ -58,10 +90,14 @@
58
90
  "encodingAESKey": {
59
91
  "type": "string",
60
92
  "description": "WeChat MP encoding AES key"
93
+ },
94
+ "id": {
95
+ "type": "string",
96
+ "description": "WeChat MP bot name"
61
97
  }
62
98
  },
63
99
  "required": [
64
- "name",
100
+ "id",
65
101
  "appId",
66
102
  "appSecret",
67
103
  "token"
package/src/endpoint.ts CHANGED
@@ -2,16 +2,17 @@
2
2
  * WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
3
3
  */
4
4
  import axios from 'axios';
5
- import type { EndpointFriend, EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
5
+ import type { EndpointFriend, EndpointInstance, EndpointManagement, EndpointSendRequest } from '@zhin.js/adapter';
6
6
  import type { MessageGateway } from '@zhin.js/core/runtime';
7
7
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
- import { formatCompact, getLogger } from '@zhin.js/logger';
8
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
9
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
10
  import {
11
11
  extractOutboundText,
12
12
  formatCustomerServiceBody,
13
13
  formatInboundContent,
14
14
  formatInboundId,
15
+ wechatMpInboundConversation,
15
16
  type ResolvedWeChatMpConfig,
16
17
  type TokenResponse,
17
18
  type WeChatAPIResponse,
@@ -29,8 +30,6 @@ import {
29
30
  } from './passive-reply.js';
30
31
  import { registerWeChatMpWebhookRoutes } from './webhook.js';
31
32
 
32
- const logger = getLogger('wechat-mp');
33
-
34
33
  /** token 失效类错误码:40001/40014 invalid access_token、42001 access_token expired。 */
35
34
  const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
36
35
 
@@ -71,6 +70,8 @@ function defaultFetch(
71
70
  }
72
71
 
73
72
  export class WeChatMpEndpoint implements EndpointInstance {
73
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
74
+
74
75
  readonly #options: WeChatMpEndpointOptions;
75
76
  readonly #fetch: WeChatMpFetch;
76
77
  #routeReleases: HttpRouteRegistration[] = [];
@@ -85,6 +86,7 @@ export class WeChatMpEndpoint implements EndpointInstance {
85
86
  readonly management: EndpointManagement = createWeChatMpEndpointManagement(this);
86
87
 
87
88
  constructor(options: WeChatMpEndpointOptions) {
89
+ this.#logger = getAdapterLogger('wechat-mp', options.config.id);
88
90
  this.#options = options;
89
91
  this.#fetch = options.fetch ?? defaultFetch;
90
92
  }
@@ -128,14 +130,14 @@ export class WeChatMpEndpoint implements EndpointInstance {
128
130
  await this.#refreshAccessToken();
129
131
  this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, this));
130
132
  this.#startTokenRefreshTimer();
131
- logger.debug(formatCompact({
132
- endpoint: this.#options.config.name,
133
+ this.#logger.debug(formatCompact({
134
+ endpoint: this.#options.config.id,
133
135
  op: 'webhook',
134
136
  path: this.#options.config.path,
135
137
  }));
136
138
  } catch (error) {
137
139
  await this.stop();
138
- logger.error('Failed to connect WeChat MP bot:', error);
140
+ this.#logger.error('Failed to connect WeChat MP bot:', error);
139
141
  throw error;
140
142
  }
141
143
  }
@@ -156,10 +158,10 @@ export class WeChatMpEndpoint implements EndpointInstance {
156
158
  }
157
159
  for (const release of this.#routeReleases.splice(0)) release();
158
160
  this.#started = false;
159
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
161
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
160
162
  }
161
163
 
162
- async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
164
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
163
165
  if (getPassiveReplyCapture()) {
164
166
  const text = extractOutboundText(payload);
165
167
  recordPassiveReplyText(text);
@@ -167,14 +169,14 @@ export class WeChatMpEndpoint implements EndpointInstance {
167
169
  }
168
170
 
169
171
  if (this.#options.config.replyMode === 'customer_service') {
170
- return this.#sendCustomerService(target, payload);
172
+ return this.#sendCustomerService(conversation.id, payload);
171
173
  }
172
174
 
173
- logger.warn(formatCompact({
175
+ this.#logger.warn(formatCompact({
174
176
  op: 'send',
175
177
  skip: 'passive_outside_webhook',
176
- endpoint: this.#options.config.name,
177
- target,
178
+ endpoint: this.#options.config.id,
179
+ target: `${conversation.kind}:${conversation.id}`,
178
180
  }));
179
181
  return `passive_skipped_${Date.now()}`;
180
182
  }
@@ -182,22 +184,22 @@ export class WeChatMpEndpoint implements EndpointInstance {
182
184
  /** Test / internal: admit a parsed message when open (non-webhook path). */
183
185
  admit(msg: WeChatMessage): void {
184
186
  if (!this.#open) return;
187
+ const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
185
188
  void this.#options.gateway.receive({
186
- adapter: this.#options.id,
187
- target: msg.FromUserName,
189
+ conversation,
190
+ message: { conversation, id: formatInboundId(msg) },
188
191
  content: formatInboundContent(msg),
189
- sender: msg.FromUserName,
190
- id: formatInboundId(msg),
192
+ sender: { id: msg.FromUserName },
193
+ endpointId: this.#options.config.id,
191
194
  metadata: Object.freeze({
192
195
  msgType: msg.MsgType,
193
196
  event: msg.Event,
194
- endpoint: this.#options.config.name,
195
197
  toUserName: msg.ToUserName,
196
198
  }),
197
199
  }).catch((err) => {
198
- logger.warn(formatCompact({
200
+ this.#logger.warn(formatCompact({
199
201
  op: 'wechat_mp_gateway_receive_failed',
200
- target: msg.FromUserName,
202
+ target: `${conversation.kind}:${conversation.id}`,
201
203
  error: err instanceof Error ? err.message : String(err),
202
204
  }));
203
205
  });
@@ -213,7 +215,7 @@ export class WeChatMpEndpoint implements EndpointInstance {
213
215
  let result = await this.#postCustomerService(messageData);
214
216
  if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
215
217
  // 对端提前作废 token(多端共用等):刷新后重试一次。
216
- logger.warn(formatCompact({
218
+ this.#logger.warn(formatCompact({
217
219
  op: 'wechat_mp_token_invalid_retry',
218
220
  errcode: result.errcode,
219
221
  }));
@@ -223,7 +225,7 @@ export class WeChatMpEndpoint implements EndpointInstance {
223
225
  if (result.errcode && result.errcode !== 0) {
224
226
  throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
225
227
  }
226
- logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
228
+ this.#logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
227
229
  return result.msgid?.toString() || `cs_${Date.now()}`;
228
230
  }
229
231
 
@@ -249,9 +251,9 @@ export class WeChatMpEndpoint implements EndpointInstance {
249
251
  // 已物化(mediaId/media_id)的段透传;其余无 canonical 媒体引用,丢弃留痕
250
252
  if (typeof data.mediaId === 'string' && data.mediaId) return item;
251
253
  if (typeof data.media_id === 'string' && data.media_id) return item;
252
- logger.warn(formatCompact({
254
+ this.#logger.warn(formatCompact({
253
255
  op: 'wechat_mp_outbound_media_dropped',
254
- endpoint: this.#options.config.name,
256
+ endpoint: this.#options.config.id,
255
257
  type: seg.type,
256
258
  reason: 'missing_media_ref',
257
259
  }));
@@ -262,9 +264,9 @@ export class WeChatMpEndpoint implements EndpointInstance {
262
264
  return { type: seg.type, data: { mediaId: media.value } };
263
265
  }
264
266
  if (!uploadType) {
265
- logger.warn(formatCompact({
267
+ this.#logger.warn(formatCompact({
266
268
  op: 'wechat_mp_outbound_media_dropped',
267
- endpoint: this.#options.config.name,
269
+ endpoint: this.#options.config.id,
268
270
  type: seg.type,
269
271
  reason: 'unsupported_segment_type',
270
272
  }));
@@ -274,9 +276,9 @@ export class WeChatMpEndpoint implements EndpointInstance {
274
276
  const mediaId = await this.#uploadMedia(uploadType, media);
275
277
  return { type: seg.type, data: { mediaId } };
276
278
  } catch (error) {
277
- logger.warn(formatCompact({
279
+ this.#logger.warn(formatCompact({
278
280
  op: 'wechat_mp_media_upload_failed',
279
- endpoint: this.#options.config.name,
281
+ endpoint: this.#options.config.id,
280
282
  error: error instanceof Error ? error.message : String(error),
281
283
  }));
282
284
  const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
@@ -349,7 +351,7 @@ export class WeChatMpEndpoint implements EndpointInstance {
349
351
  if (data.access_token) {
350
352
  this.#accessToken = data.access_token;
351
353
  this.#tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000;
352
- logger.debug(formatCompact({ op: 'token_refresh' }));
354
+ this.#logger.debug(formatCompact({ op: 'token_refresh' }));
353
355
  return;
354
356
  }
355
357
  throw new Error(
@@ -363,7 +365,7 @@ export class WeChatMpEndpoint implements EndpointInstance {
363
365
  this.#tokenRefreshTimer = setInterval(() => {
364
366
  if (Date.now() >= this.#tokenExpireTime) {
365
367
  void this.#refreshAccessToken().catch((error) => {
366
- logger.error('Failed to refresh access token in timer:', error);
368
+ this.#logger.error('Failed to refresh access token in timer:', error);
367
369
  });
368
370
  }
369
371
  }, 3_600_000);
package/src/protocol.ts CHANGED
@@ -6,9 +6,10 @@
6
6
  import { createHash, createDecipheriv, createCipheriv, randomBytes } from 'node:crypto';
7
7
  import type { IncomingMessage } from 'node:http';
8
8
  import * as xml2js from 'xml2js';
9
+ import type { ConversationRef } from '@zhin.js/im-contract';
9
10
 
10
11
  export interface WeChatMpAdapterConfig {
11
- readonly name?: string;
12
+ readonly id?: string;
12
13
  readonly appId?: string;
13
14
  readonly appSecret?: string;
14
15
  readonly token?: string;
@@ -36,7 +37,7 @@ export interface WeChatMpAdapterConfig {
36
37
 
37
38
  export interface ResolvedWeChatMpConfig {
38
39
  readonly context: 'wechat-mp';
39
- readonly name: string;
40
+ readonly id: string;
40
41
  readonly appId: string;
41
42
  readonly appSecret: string;
42
43
  readonly token: string;
@@ -98,8 +99,8 @@ export function resolveWeChatMpConfig(config: WeChatMpAdapterConfig = {}): Resol
98
99
  'WeChat MP adapter requires appId + appSecret + token (plugins.<key> or endpoints with context: wechat-mp)',
99
100
  );
100
101
  }
101
- const name = (typeof config.name === 'string' && config.name)
102
- || (typeof entry?.name === 'string' && entry.name)
102
+ const id = (typeof config.id === 'string' && config.id)
103
+ || (typeof entry?.id === 'string' && entry.id)
103
104
  || process.env.WECHAT_BOT_NAME
104
105
  || 'wechat-mp-bot';
105
106
  const path = config.path ?? entry?.path ?? '/wechat/webhook';
@@ -112,7 +113,7 @@ export function resolveWeChatMpConfig(config: WeChatMpAdapterConfig = {}): Resol
112
113
  ?? 4500;
113
114
  return {
114
115
  context: 'wechat-mp',
115
- name,
116
+ id,
116
117
  appId,
117
118
  appSecret,
118
119
  token,
@@ -310,6 +311,21 @@ export function buildTextReply(
310
311
  ].join('');
311
312
  }
312
313
 
314
+ /**
315
+ * 入站归一化:公众号只有粉丝单聊场景,一律 kind='private',id=openid(FromUserName),
316
+ * 无群/频道容器(parent 恒缺省)。
317
+ */
318
+ export function wechatMpInboundConversation(
319
+ endpointKey: string,
320
+ msg: WeChatMessage,
321
+ ): ConversationRef {
322
+ return {
323
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
324
+ kind: 'private',
325
+ id: msg.FromUserName,
326
+ };
327
+ }
328
+
313
329
  /**
314
330
  * 入站消息 id:普通消息用 MsgId;无 MsgId 的事件消息拼 Event/EventKey,
315
331
  * 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
package/src/webhook.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  readTextBody,
26
26
  resolveEventPassiveReply,
27
27
  verifySignature,
28
+ wechatMpInboundConversation,
28
29
  type ResolvedWeChatMpConfig,
29
30
  type WeChatMessage,
30
31
  } from './protocol.js';
@@ -218,17 +219,17 @@ export async function collectPassiveReply(
218
219
  ): Promise<string> {
219
220
  const timeoutMs = handler.config.passiveReplyTimeoutMs;
220
221
  const text = await runWithPassiveReplyCapture(async () => {
222
+ const conversation = wechatMpInboundConversation(String(handler.id), wechatMsg);
221
223
  await Promise.race([
222
224
  handler.gateway.receive({
223
- adapter: handler.id,
224
- target: wechatMsg.FromUserName,
225
+ conversation,
226
+ message: { conversation, id: formatInboundId(wechatMsg) },
225
227
  content: formatInboundContent(wechatMsg),
226
- sender: wechatMsg.FromUserName,
227
- id: formatInboundId(wechatMsg),
228
+ sender: { id: wechatMsg.FromUserName },
229
+ endpointId: handler.config.id,
228
230
  metadata: Object.freeze({
229
231
  msgType: wechatMsg.MsgType,
230
232
  event: wechatMsg.Event,
231
- endpoint: handler.config.name,
232
233
  toUserName: wechatMsg.ToUserName,
233
234
  }),
234
235
  }),