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

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 (59) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/README.md +40 -88
  3. package/adapters/wechat-mp.js +32 -0
  4. package/adapters/wechat-mp.ts +38 -0
  5. package/commands/endpoint/add/[id].js +3 -0
  6. package/commands/endpoint/add/[id].ts +3 -0
  7. package/commands/endpoint/list.js +3 -0
  8. package/commands/endpoint/list.ts +3 -0
  9. package/commands/endpoint/remove/[id].js +3 -0
  10. package/commands/endpoint/remove/[id].ts +3 -0
  11. package/lib/client.d.ts +32 -0
  12. package/lib/client.js +70 -0
  13. package/lib/endpoint.d.ts +31 -72
  14. package/lib/endpoint.js +226 -747
  15. package/lib/index.d.ts +6 -15
  16. package/lib/index.js +6 -25
  17. package/lib/media-upload.d.ts +22 -0
  18. package/lib/media-upload.js +64 -0
  19. package/lib/passive-reply.d.ts +0 -1
  20. package/lib/passive-reply.js +0 -1
  21. package/lib/protocol.d.ts +129 -0
  22. package/lib/protocol.js +349 -0
  23. package/lib/side-event-dispatch.d.ts +4 -0
  24. package/lib/side-event-dispatch.js +38 -0
  25. package/lib/webhook.d.ts +20 -0
  26. package/lib/webhook.js +152 -0
  27. package/lib/wechat-mp-endpoint-commands.d.ts +1 -0
  28. package/lib/wechat-mp-endpoint-commands.js +19 -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 +53 -13
  32. package/plugin.js +14 -0
  33. package/schema.json +116 -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 +507 -0
  39. package/src/side-event-dispatch.ts +45 -0
  40. package/src/webhook.ts +237 -0
  41. package/src/wechat-mp-endpoint-commands.ts +20 -0
  42. package/src/wechat-mp-runtime-state.ts +7 -0
  43. package/lib/adapter.d.ts +0 -14
  44. package/lib/adapter.d.ts.map +0 -1
  45. package/lib/adapter.js +0 -17
  46. package/lib/adapter.js.map +0 -1
  47. package/lib/endpoint.d.ts.map +0 -1
  48. package/lib/endpoint.js.map +0 -1
  49. package/lib/index.d.ts.map +0 -1
  50. package/lib/index.js.map +0 -1
  51. package/lib/passive-reply.d.ts.map +0 -1
  52. package/lib/passive-reply.js.map +0 -1
  53. package/lib/types.d.ts +0 -58
  54. package/lib/types.d.ts.map +0 -1
  55. package/lib/types.js +0 -2
  56. package/lib/types.js.map +0 -1
  57. package/src/adapter.ts +0 -22
  58. package/src/types.ts +0 -60
  59. /package/{skills/wechat-mp/SKILL.md → agent/skills/wechat-mp.md} +0 -0
package/lib/index.d.ts CHANGED
@@ -1,15 +1,6 @@
1
- import { WeChatMPAdapter } from "./adapter.js";
2
- declare module "zhin.js" {
3
- namespace Plugin {
4
- interface Contexts {
5
- router: import("@zhin.js/host-router").Router;
6
- }
7
- }
8
- interface Adapters {
9
- "wechat-mp": WeChatMPAdapter;
10
- }
11
- }
12
- export * from "./types.js";
13
- export { WeChatMPEndpoint } from "./endpoint.js";
14
- export { WeChatMPAdapter } from "./adapter.js";
15
- //# sourceMappingURL=index.d.ts.map
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
+ export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, type PassiveReplyCapture, } from './passive-reply.js';
3
+ export { WeChatMpClient, wechatMpClient, type WeChatMpClientEventMap, type WeChatMpFetch, } from './client.js';
4
+ export { WeChatMpEndpoint, type WeChatMpEndpointOptions, } from './endpoint.js';
5
+ export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, type MediaBinary, type WeChatMediaUploadResult, } from './media-upload.js';
6
+ export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, type WeChatMpWebhookHandler, } from './webhook.js';
package/lib/index.js CHANGED
@@ -1,25 +1,6 @@
1
- /**
2
- * 微信公众号适配器入口:类型扩展、导出、注册
3
- */
4
- import { usePlugin } from "zhin.js";
5
- import { WeChatMPAdapter } from "./adapter.js";
6
- export * from "./types.js";
7
- export { WeChatMPEndpoint } from "./endpoint.js";
8
- export { WeChatMPAdapter } from "./adapter.js";
9
- const plugin = usePlugin();
10
- const { provide, useContext } = plugin;
11
- useContext("router", (router) => {
12
- provide({
13
- name: "wechat-mp",
14
- description: "WeChat MP Endpoint Adapter",
15
- mounted: async (p) => {
16
- const adapter = new WeChatMPAdapter(p, router);
17
- await adapter.start();
18
- return adapter;
19
- },
20
- dispose: async (adapter) => {
21
- await adapter.stop();
22
- },
23
- });
24
- });
25
- //# sourceMappingURL=index.js.map
1
+ export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, } from './protocol.js';
2
+ export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, } from './passive-reply.js';
3
+ export { WeChatMpClient, wechatMpClient, } from './client.js';
4
+ export { WeChatMpEndpoint, } from './endpoint.js';
5
+ export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
6
+ export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, } from './webhook.js';
@@ -0,0 +1,22 @@
1
+ import { type MediaRef } from '@zhin.js/core';
2
+ export interface MediaBinary {
3
+ readonly data: Buffer;
4
+ readonly mimeType: string;
5
+ readonly fileName: string;
6
+ }
7
+ export interface WeChatMediaUploadResult {
8
+ readonly type?: string;
9
+ readonly media_id?: string;
10
+ readonly created_at?: number;
11
+ readonly errcode?: number;
12
+ readonly errmsg?: string;
13
+ }
14
+ /** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
15
+ export declare function resolveMediaBinary(media: MediaRef, download?: (url: string) => Promise<Buffer>): Promise<MediaBinary>;
16
+ /** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
17
+ export declare function buildMediaUploadForm(binary: MediaBinary): FormData;
18
+ /**
19
+ * 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
20
+ * 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
21
+ */
22
+ export declare function readOutboundMedia(data: Record<string, unknown>): MediaRef | undefined;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * WeChat MP 临时素材上传(客服消息 image 段需要 media_id)。
3
+ * 接口:POST https://api.weixin.qq.com/cgi-bin/media/upload?access_token=…&type=image
4
+ * 与客服消息同属公众号基础接口域,不引入额外授权域
5
+ * (客服消息本身要求已认证服务号;订阅号无客服消息权限,上传同样不可用)。
6
+ */
7
+ import { readFile } from 'node:fs/promises';
8
+ import { basename } from 'node:path';
9
+ import { isMediaRef } from '@zhin.js/core';
10
+ const MIME_EXT = {
11
+ 'image/jpeg': 'jpg',
12
+ 'image/png': 'png',
13
+ 'image/gif': 'gif',
14
+ 'image/webp': 'webp',
15
+ 'image/bmp': 'bmp',
16
+ };
17
+ /** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
18
+ export async function resolveMediaBinary(media, download = defaultDownload) {
19
+ const mimeType = media.mime_type ?? 'image/png';
20
+ const ext = MIME_EXT[mimeType] ?? 'png';
21
+ if (media.kind === 'base64') {
22
+ const value = media.value.startsWith('base64://')
23
+ ? media.value.slice('base64://'.length)
24
+ : media.value;
25
+ return { data: Buffer.from(value, 'base64'), mimeType, fileName: `image.${ext}` };
26
+ }
27
+ if (media.kind === 'path') {
28
+ const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
29
+ return { data: await readFile(path), mimeType, fileName: basename(path) };
30
+ }
31
+ if (media.kind === 'file') {
32
+ // 平台不透明引用(media_id):由调用方直接透传,不走上传。
33
+ throw new Error('MediaRef kind=file is a platform opaque reference; binary resolution not applicable');
34
+ }
35
+ return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
36
+ }
37
+ async function defaultDownload(url) {
38
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
39
+ if (!response.ok)
40
+ throw new Error(`download failed: HTTP ${response.status}`);
41
+ return Buffer.from(await response.arrayBuffer());
42
+ }
43
+ /** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
44
+ export function buildMediaUploadForm(binary) {
45
+ // Buffer 的 ArrayBufferLike 不满足 BlobPart(SharedArrayBuffer 分支),拷贝为 Uint8Array<ArrayBuffer>
46
+ const bytes = new Uint8Array(binary.data.byteLength);
47
+ bytes.set(binary.data);
48
+ const form = new FormData();
49
+ form.append('media', new Blob([bytes], { type: binary.mimeType }), binary.fileName);
50
+ return form;
51
+ }
52
+ /**
53
+ * 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
54
+ * 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
55
+ */
56
+ export function readOutboundMedia(data) {
57
+ if (typeof data.mediaId === 'string' && data.mediaId)
58
+ return undefined;
59
+ if (typeof data.media_id === 'string' && data.media_id)
60
+ return undefined;
61
+ if (isMediaRef(data.media))
62
+ return data.media;
63
+ return undefined;
64
+ }
@@ -5,4 +5,3 @@ export type PassiveReplyCapture = {
5
5
  export declare function getPassiveReplyCapture(): PassiveReplyCapture | undefined;
6
6
  export declare function runWithPassiveReplyCapture<T>(fn: () => Promise<T>): Promise<T>;
7
7
  export declare function recordPassiveReplyText(text: string): void;
8
- //# sourceMappingURL=passive-reply.d.ts.map
@@ -13,4 +13,3 @@ export function recordPassiveReplyText(text) {
13
13
  return;
14
14
  capture.text = text;
15
15
  }
16
- //# sourceMappingURL=passive-reply.js.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WeChat Official Account (MP) protocol helpers — no legacy Adapter/Endpoint.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+ import type { IncomingMessage } from 'node:http';
6
+ import type { ConversationRef } from '@zhin.js/im-contract';
7
+ export interface WeChatMpAdapterConfig {
8
+ readonly id?: string;
9
+ readonly appId?: string;
10
+ readonly appSecret?: string;
11
+ readonly token?: string;
12
+ readonly encodingAESKey?: string;
13
+ readonly path?: string;
14
+ readonly encrypt?: boolean;
15
+ /**
16
+ * plain:明文入站/出站
17
+ * compatible:入站可解密,被动回复用明文(微信兼容模式推荐)
18
+ * secure:入站/出站均加密
19
+ */
20
+ readonly encryptMode?: 'plain' | 'compatible' | 'secure';
21
+ /**
22
+ * passive:订阅号默认,在 webhook 响应内被动回复(5 秒内)
23
+ * customer_service:走客服消息 API(需接口权限)
24
+ */
25
+ readonly replyMode?: 'passive' | 'customer_service';
26
+ /** 被动回复等待入站处理的最长时间(毫秒),默认 4500 */
27
+ readonly passiveReplyTimeoutMs?: number;
28
+ /** Transitional: legacy root `endpoints[]` with `context: wechat-mp`. */
29
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedWeChatMpConfig> & {
30
+ readonly context?: string;
31
+ }>;
32
+ }
33
+ export interface ResolvedWeChatMpConfig {
34
+ readonly context: 'wechat-mp';
35
+ readonly id: string;
36
+ readonly appId: string;
37
+ readonly appSecret: string;
38
+ readonly token: string;
39
+ readonly encodingAESKey?: string;
40
+ readonly path: string;
41
+ readonly encrypt: boolean;
42
+ readonly encryptMode: 'plain' | 'compatible' | 'secure';
43
+ readonly replyMode: 'passive' | 'customer_service';
44
+ readonly passiveReplyTimeoutMs: number;
45
+ }
46
+ export interface WeChatMessage {
47
+ readonly ToUserName: string;
48
+ readonly FromUserName: string;
49
+ readonly CreateTime: number;
50
+ readonly MsgType: string;
51
+ readonly MsgId?: string;
52
+ readonly Content?: string;
53
+ readonly PicUrl?: string;
54
+ readonly MediaId?: string;
55
+ readonly Format?: string;
56
+ readonly Recognition?: string;
57
+ readonly ThumbMediaId?: string;
58
+ readonly Location_X?: string;
59
+ readonly Location_Y?: string;
60
+ readonly Scale?: string;
61
+ readonly Label?: string;
62
+ readonly Title?: string;
63
+ readonly Description?: string;
64
+ readonly Url?: string;
65
+ readonly Event?: string;
66
+ readonly EventKey?: string;
67
+ readonly Encrypt?: string;
68
+ }
69
+ export interface TokenResponse {
70
+ readonly access_token: string;
71
+ readonly expires_in: number;
72
+ }
73
+ export interface WeChatAPIResponse {
74
+ readonly errcode?: number;
75
+ readonly errmsg?: string;
76
+ readonly msgid?: number;
77
+ }
78
+ export interface WeChatWireSegment {
79
+ readonly type: string;
80
+ readonly data?: Record<string, unknown>;
81
+ }
82
+ export declare function resolveWeChatMpConfig(config?: WeChatMpAdapterConfig): ResolvedWeChatMpConfig;
83
+ export declare function queryParam(value: string | null | undefined): string;
84
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
85
+ export declare function normalizeEchostrParam(echostr: string): string;
86
+ export declare function computeSignatureHash(token: string, params: {
87
+ readonly timestamp: string;
88
+ readonly nonce: string;
89
+ readonly echostr?: string;
90
+ }): string;
91
+ export declare function verifySignature(token: string, params: {
92
+ readonly signature: string;
93
+ readonly timestamp: string;
94
+ readonly nonce: string;
95
+ readonly echostr?: string;
96
+ }): boolean;
97
+ export declare function getAESKey(encodingAESKey: string): Buffer;
98
+ /** 微信安全模式加密 echostr 为较长 Base64;明文/兼容模式多为短字符串 */
99
+ export declare function isEncryptedEchostr(echostr: string): boolean;
100
+ export declare function decryptEchostr(encrypted: string, encodingAESKey: string, appId: string): string;
101
+ export declare function parseXMLMessage(xmlString: string): Promise<WeChatMessage | null>;
102
+ export declare function decryptMessage(encryptedXml: string, msgSignature: string, timestamp: string, nonce: string, token: string, encodingAESKey: string, appId: string): Promise<string>;
103
+ export declare function encryptMessage(replyXml: string, token: string, encodingAESKey: string, appId: string, requestTimestamp?: string): string;
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;
110
+ /**
111
+ * 入站消息 id:普通消息用 MsgId;无 MsgId 的事件消息拼 Event/EventKey,
112
+ * 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
113
+ */
114
+ export declare function formatInboundId(msg: WeChatMessage): string;
115
+ /** Build inbound text for OutboundMessageService.receive. */
116
+ export declare function formatInboundContent(msg: WeChatMessage): string;
117
+ /**
118
+ * Built-in passive XML for subscribe etc. Empty string = fall through to gateway.
119
+ */
120
+ export declare function resolveEventPassiveReply(msg: WeChatMessage): string;
121
+ /**
122
+ * Wire-encode an already-rendered outbound payload into客服消息 JSON body.
123
+ */
124
+ export declare function formatCustomerServiceBody(target: string, payload: unknown): Record<string, unknown>;
125
+ /** Extract plain text from a rendered outbound payload (for passive XML). */
126
+ export declare function extractOutboundText(payload: unknown): string;
127
+ export declare function readTextBody(request: IncomingMessage, options?: {
128
+ readonly limit?: number;
129
+ }): Promise<string>;
@@ -0,0 +1,349 @@
1
+ /**
2
+ * WeChat Official Account (MP) protocol helpers — no legacy Adapter/Endpoint.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+ import { createHash, createDecipheriv, createCipheriv, randomBytes } from 'node:crypto';
6
+ import * as xml2js from 'xml2js';
7
+ export function resolveWeChatMpConfig(config = {}) {
8
+ const entry = config.endpoints?.find((item) => item.context === 'wechat-mp');
9
+ const appId = config.appId ?? entry?.appId ?? process.env.WECHAT_APP_ID;
10
+ const appSecret = config.appSecret ?? entry?.appSecret ?? process.env.WECHAT_APP_SECRET;
11
+ const token = config.token ?? entry?.token ?? process.env.WECHAT_TOKEN;
12
+ if (!appId || !appSecret || !token) {
13
+ throw new TypeError('WeChat MP adapter requires appId + appSecret + token (plugins.<key> or endpoints with context: wechat-mp)');
14
+ }
15
+ const id = (typeof config.id === 'string' && config.id)
16
+ || (typeof entry?.id === 'string' && entry.id)
17
+ || process.env.WECHAT_BOT_NAME
18
+ || 'wechat-mp-bot';
19
+ const path = config.path ?? entry?.path ?? '/wechat/webhook';
20
+ const encodingAESKey = config.encodingAESKey ?? entry?.encodingAESKey;
21
+ const encrypt = config.encrypt ?? entry?.encrypt ?? false;
22
+ const encryptMode = config.encryptMode ?? entry?.encryptMode ?? (encrypt ? 'compatible' : 'plain');
23
+ const replyMode = config.replyMode ?? entry?.replyMode ?? 'passive';
24
+ const passiveReplyTimeoutMs = config.passiveReplyTimeoutMs
25
+ ?? entry?.passiveReplyTimeoutMs
26
+ ?? 4500;
27
+ return {
28
+ context: 'wechat-mp',
29
+ id,
30
+ appId,
31
+ appSecret,
32
+ token,
33
+ encodingAESKey,
34
+ path: path.startsWith('/') ? path : `/${path}`,
35
+ encrypt: !!encrypt,
36
+ encryptMode,
37
+ replyMode,
38
+ passiveReplyTimeoutMs,
39
+ };
40
+ }
41
+ export function queryParam(value) {
42
+ return value ?? '';
43
+ }
44
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
45
+ export function normalizeEchostrParam(echostr) {
46
+ return echostr.replace(/ /g, '+');
47
+ }
48
+ export function computeSignatureHash(token, params) {
49
+ const { timestamp, nonce, echostr } = params;
50
+ const arr = echostr
51
+ ? [token, timestamp, nonce, echostr]
52
+ : [token, timestamp, nonce];
53
+ arr.sort();
54
+ return createHash('sha1').update(arr.join('')).digest('hex');
55
+ }
56
+ export function verifySignature(token, params) {
57
+ const { signature, timestamp, nonce, echostr } = params;
58
+ if (!signature || !timestamp || !nonce)
59
+ return false;
60
+ return computeSignatureHash(token, { timestamp, nonce, echostr }) === signature;
61
+ }
62
+ export function getAESKey(encodingAESKey) {
63
+ return Buffer.from(`${encodingAESKey}=`, 'base64');
64
+ }
65
+ /** 微信安全模式加密 echostr 为较长 Base64;明文/兼容模式多为短字符串 */
66
+ export function isEncryptedEchostr(echostr) {
67
+ if (echostr.length < 32)
68
+ return false;
69
+ return /^[A-Za-z0-9+/]+={0,2}$/.test(echostr);
70
+ }
71
+ export function decryptEchostr(encrypted, encodingAESKey, appId) {
72
+ const aesKey = getAESKey(encodingAESKey);
73
+ const iv = aesKey.subarray(0, 16);
74
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
75
+ decipher.setAutoPadding(false);
76
+ const decrypted = Buffer.concat([
77
+ decipher.update(Buffer.from(encrypted, 'base64')),
78
+ decipher.final(),
79
+ ]);
80
+ const pad = decrypted[decrypted.length - 1];
81
+ const content = decrypted.subarray(0, decrypted.length - pad);
82
+ const msgLen = content.readUInt32BE(16);
83
+ const plain = content.subarray(20, 20 + msgLen).toString('utf8');
84
+ const gotAppId = content.subarray(20 + msgLen).toString('utf8');
85
+ if (gotAppId !== appId) {
86
+ throw new Error(`AppID mismatch: expected ${appId}, got ${gotAppId}`);
87
+ }
88
+ return plain;
89
+ }
90
+ export async function parseXMLMessage(xmlString) {
91
+ try {
92
+ const parser = new xml2js.Parser({ explicitArray: false, ignoreAttrs: true });
93
+ const result = await parser.parseStringPromise(xmlString);
94
+ return result.xml;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ export async function decryptMessage(encryptedXml, msgSignature, timestamp, nonce, token, encodingAESKey, appId) {
101
+ const parsed = await parseXMLMessage(encryptedXml);
102
+ const encrypt = parsed?.Encrypt;
103
+ if (!encrypt)
104
+ throw new Error('Missing Encrypt field in encrypted message');
105
+ const expected = createHash('sha1')
106
+ .update([token, timestamp, nonce, encrypt].sort().join(''))
107
+ .digest('hex');
108
+ if (expected !== msgSignature) {
109
+ throw new Error('msg_signature verification failed');
110
+ }
111
+ const aesKey = getAESKey(encodingAESKey);
112
+ const iv = aesKey.subarray(0, 16);
113
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
114
+ decipher.setAutoPadding(false);
115
+ const decrypted = Buffer.concat([
116
+ decipher.update(Buffer.from(encrypt, 'base64')),
117
+ decipher.final(),
118
+ ]);
119
+ const pad = decrypted[decrypted.length - 1];
120
+ const content = decrypted.subarray(0, decrypted.length - pad);
121
+ const msgLen = content.readUInt32BE(16);
122
+ const xmlContent = content.subarray(20, 20 + msgLen).toString('utf8');
123
+ const gotAppId = content.subarray(20 + msgLen).toString('utf8');
124
+ if (gotAppId !== appId) {
125
+ throw new Error(`AppID mismatch: expected ${appId}, got ${gotAppId}`);
126
+ }
127
+ return xmlContent;
128
+ }
129
+ export function encryptMessage(replyXml, token, encodingAESKey, appId, requestTimestamp) {
130
+ const aesKey = getAESKey(encodingAESKey);
131
+ const iv = aesKey.subarray(0, 16);
132
+ const random = randomBytes(16);
133
+ const msgBuf = Buffer.from(replyXml, 'utf8');
134
+ const appIdBuf = Buffer.from(appId, 'utf8');
135
+ const lenBuf = Buffer.alloc(4);
136
+ lenBuf.writeUInt32BE(msgBuf.length, 0);
137
+ const plaintext = Buffer.concat([random, lenBuf, msgBuf, appIdBuf]);
138
+ const blockSize = 32;
139
+ const padLen = blockSize - (plaintext.length % blockSize);
140
+ const padBuf = Buffer.alloc(padLen, padLen);
141
+ const padded = Buffer.concat([plaintext, padBuf]);
142
+ const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
143
+ cipher.setAutoPadding(false);
144
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
145
+ const encryptStr = encrypted.toString('base64');
146
+ const timestamp = requestTimestamp || Math.floor(Date.now() / 1000).toString();
147
+ const nonce = randomBytes(8).toString('hex');
148
+ const signature = createHash('sha1')
149
+ .update([token, timestamp, nonce, encryptStr].sort().join(''))
150
+ .digest('hex');
151
+ return [
152
+ '<xml>',
153
+ `<Encrypt><![CDATA[${encryptStr}]]></Encrypt>`,
154
+ `<MsgSignature><![CDATA[${signature}]]></MsgSignature>`,
155
+ `<TimeStamp>${timestamp}</TimeStamp>`,
156
+ `<Nonce><![CDATA[${nonce}]]></Nonce>`,
157
+ '</xml>',
158
+ ].join('\n');
159
+ }
160
+ export function buildTextReply(wechatMsg, content) {
161
+ const cdata = (value) => value.replace(/]]>/g, ']]]]><![CDATA[>');
162
+ const createTime = Math.floor(Date.now() / 1000);
163
+ return [
164
+ '<xml>',
165
+ `<ToUserName><![CDATA[${cdata(wechatMsg.FromUserName)}]]></ToUserName>`,
166
+ `<FromUserName><![CDATA[${cdata(wechatMsg.ToUserName)}]]></FromUserName>`,
167
+ `<CreateTime>${createTime}</CreateTime>`,
168
+ `<MsgType><![CDATA[text]]></MsgType>`,
169
+ `<Content><![CDATA[${cdata(content)}]]></Content>`,
170
+ '</xml>',
171
+ ].join('');
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
+ }
184
+ /**
185
+ * 入站消息 id:普通消息用 MsgId;无 MsgId 的事件消息拼 Event/EventKey,
186
+ * 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
187
+ */
188
+ export function formatInboundId(msg) {
189
+ if (msg.MsgId)
190
+ return msg.MsgId;
191
+ return [msg.CreateTime, msg.Event, msg.EventKey]
192
+ .filter((part) => part != null && part !== '')
193
+ .join(':');
194
+ }
195
+ /** Build inbound text for OutboundMessageService.receive. */
196
+ export function formatInboundContent(msg) {
197
+ switch (msg.MsgType) {
198
+ case 'text':
199
+ return msg.Content || '(空消息)';
200
+ case 'image':
201
+ return msg.PicUrl ? `[image: ${msg.PicUrl}]` : '[image]';
202
+ case 'voice':
203
+ return msg.Recognition
204
+ ? msg.Recognition
205
+ : `[voice${msg.Format ? `: ${msg.Format}` : ''}]`;
206
+ case 'video':
207
+ case 'shortvideo':
208
+ return '[video]';
209
+ case 'location':
210
+ return `[location: ${msg.Location_X},${msg.Location_Y}${msg.Label ? ` ${msg.Label}` : ''}]`;
211
+ case 'link':
212
+ return `[link: ${msg.Title ?? ''}${msg.Url ? ` ${msg.Url}` : ''}]`;
213
+ case 'event':
214
+ return `[event: ${msg.Event ?? ''}${msg.EventKey ? ` ${msg.EventKey}` : ''}]`;
215
+ default:
216
+ return `[不支持的消息类型: ${msg.MsgType}]`;
217
+ }
218
+ }
219
+ /**
220
+ * Built-in passive XML for subscribe etc. Empty string = fall through to gateway.
221
+ */
222
+ export function resolveEventPassiveReply(msg) {
223
+ if (msg.MsgType !== 'event')
224
+ return '';
225
+ if (msg.Event === 'subscribe') {
226
+ return buildTextReply(msg, '感谢关注!');
227
+ }
228
+ return '';
229
+ }
230
+ /**
231
+ * Wire-encode an already-rendered outbound payload into客服消息 JSON body.
232
+ */
233
+ export function formatCustomerServiceBody(target, payload) {
234
+ const messageData = {
235
+ touser: target,
236
+ msgtype: 'text',
237
+ text: { content: '' },
238
+ };
239
+ if (typeof payload === 'string') {
240
+ messageData.text.content = payload;
241
+ return messageData;
242
+ }
243
+ const segments = Array.isArray(payload)
244
+ ? payload
245
+ : payload && typeof payload === 'object' && 'type' in payload
246
+ ? [payload]
247
+ : [];
248
+ if (segments.length === 0) {
249
+ messageData.text.content = payload == null
250
+ ? ''
251
+ : Array.isArray(payload)
252
+ ? ''
253
+ : typeof payload === 'object'
254
+ ? JSON.stringify(payload)
255
+ : String(payload);
256
+ return messageData;
257
+ }
258
+ const textParts = [];
259
+ let hasMedia = false;
260
+ for (const item of segments) {
261
+ if (typeof item === 'string') {
262
+ textParts.push(item);
263
+ continue;
264
+ }
265
+ const data = item.data ?? {};
266
+ switch (item.type) {
267
+ case 'text':
268
+ textParts.push(String(data.text ?? data.content ?? ''));
269
+ break;
270
+ case 'image':
271
+ if (!hasMedia && data.mediaId) {
272
+ messageData.msgtype = 'image';
273
+ messageData.image = { media_id: data.mediaId };
274
+ delete messageData.text;
275
+ hasMedia = true;
276
+ }
277
+ break;
278
+ case 'voice':
279
+ case 'audio':
280
+ if (!hasMedia && data.mediaId) {
281
+ messageData.msgtype = 'voice';
282
+ messageData.voice = { media_id: data.mediaId };
283
+ delete messageData.text;
284
+ hasMedia = true;
285
+ }
286
+ break;
287
+ case 'video':
288
+ if (!hasMedia && data.mediaId) {
289
+ messageData.msgtype = 'video';
290
+ messageData.video = {
291
+ media_id: data.mediaId,
292
+ title: data.title || '',
293
+ description: data.description || '',
294
+ };
295
+ delete messageData.text;
296
+ hasMedia = true;
297
+ }
298
+ break;
299
+ default:
300
+ break;
301
+ }
302
+ }
303
+ if (!hasMedia && textParts.length > 0 && messageData.text) {
304
+ messageData.text.content = textParts.join('\n');
305
+ }
306
+ return messageData;
307
+ }
308
+ /** Extract plain text from a rendered outbound payload (for passive XML). */
309
+ export function extractOutboundText(payload) {
310
+ if (typeof payload === 'string')
311
+ return payload;
312
+ if (!Array.isArray(payload)) {
313
+ if (payload && typeof payload === 'object' && 'type' in payload) {
314
+ const seg = payload;
315
+ if (seg.type === 'text')
316
+ return String(seg.data?.text ?? seg.data?.content ?? '');
317
+ }
318
+ return payload == null ? '' : String(payload);
319
+ }
320
+ const parts = [];
321
+ for (const item of payload) {
322
+ if (typeof item === 'string') {
323
+ parts.push(item);
324
+ continue;
325
+ }
326
+ if (item && typeof item === 'object' && 'type' in item) {
327
+ const seg = item;
328
+ if (seg.type === 'text') {
329
+ parts.push(String(seg.data?.text ?? seg.data?.content ?? ''));
330
+ }
331
+ }
332
+ }
333
+ return parts.join('\n');
334
+ }
335
+ export async function readTextBody(request, options = {}) {
336
+ const limit = options.limit ?? 1_048_576;
337
+ const chunks = [];
338
+ let size = 0;
339
+ for await (const chunk of request) {
340
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
341
+ size += buffer.length;
342
+ if (size > limit) {
343
+ request.destroy();
344
+ throw new Error(`Request body exceeds ${limit} bytes`);
345
+ }
346
+ chunks.push(buffer);
347
+ }
348
+ return Buffer.concat(chunks).toString('utf8');
349
+ }
@@ -0,0 +1,4 @@
1
+ import type { EndpointEventEmitter } from 'zhin.js/adapter';
2
+ import { type getAdapterLogger } from '@zhin.js/logger';
3
+ import { type WeChatMessage } from './protocol.js';
4
+ export declare function receiveWeChatMpSideEvent(emit: EndpointEventEmitter, configId: string, msg: WeChatMessage, logger: ReturnType<typeof getAdapterLogger>): boolean;
@@ -0,0 +1,38 @@
1
+ import { buildNotice, senderFromId } from '@zhin.js/core';
2
+ import { formatCompact } from '@zhin.js/logger';
3
+ import { formatInboundId } from './protocol.js';
4
+ function mapWeChatMpEventParts(eventName) {
5
+ switch (eventName) {
6
+ case 'subscribe':
7
+ return { scene_type: 'friend', sub_type: 'increase' };
8
+ case 'unsubscribe':
9
+ return { scene_type: 'friend', sub_type: 'decrease' };
10
+ default:
11
+ return { scene_type: 'wechat-mp', sub_type: eventName || 'unknown' };
12
+ }
13
+ }
14
+ export function receiveWeChatMpSideEvent(emit, configId, msg, logger) {
15
+ if (msg.MsgType !== 'event')
16
+ return false;
17
+ const eventName = msg.Event ?? 'unknown';
18
+ const parts = mapWeChatMpEventParts(eventName);
19
+ void emit('notice.receive', buildNotice(msg, {
20
+ $id: `wechat-mp:${formatInboundId(msg)}`,
21
+ $adapter: 'wechat-mp',
22
+ $endpoint: configId,
23
+ $type: 'notice',
24
+ $scene_id: msg.FromUserName,
25
+ $scene_type: parts.scene_type,
26
+ $sub_type: parts.sub_type,
27
+ $actor: senderFromId(msg.FromUserName),
28
+ $timestamp: msg.CreateTime ?? Date.now(),
29
+ })).catch((err) => {
30
+ logger.warn(formatCompact({
31
+ op: 'wechat_mp_side_event_failed',
32
+ endpoint: configId,
33
+ event: eventName,
34
+ error: err instanceof Error ? err.message : String(err),
35
+ }));
36
+ });
37
+ return true;
38
+ }