@zhin.js/adapter-wechat-mp 3.0.3 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +6 -5
- package/adapters/wechat-mp.ts +14 -1
- package/commands/endpoint/add/[name:string].ts +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/endpoint.d.ts +11 -1
- package/lib/endpoint.js +124 -7
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -1
- package/lib/media-upload.d.ts +22 -0
- package/lib/media-upload.js +60 -0
- package/lib/protocol.d.ts +5 -0
- package/lib/protocol.js +11 -0
- package/lib/webhook.d.ts +3 -0
- package/lib/webhook.js +16 -2
- package/lib/wechat-mp-endpoint-commands.d.ts +1 -0
- package/lib/wechat-mp-endpoint-commands.js +19 -0
- package/lib/wechat-mp-runtime-state.d.ts +1 -0
- package/lib/wechat-mp-runtime-state.js +6 -0
- package/package.json +16 -10
- package/plugin.ts +6 -0
- package/schema.json +53 -11
- package/src/endpoint.ts +134 -7
- package/src/index.ts +9 -0
- package/src/media-upload.ts +78 -0
- package/src/protocol.ts +11 -0
- package/src/webhook.ts +21 -1
- package/src/wechat-mp-endpoint-commands.ts +20 -0
- package/src/wechat-mp-runtime-state.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# @zhin.js/adapter-wechat-mp
|
|
2
2
|
|
|
3
|
+
## 4.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
|
|
8
|
+
|
|
9
|
+
- **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
|
|
10
|
+
- **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
|
|
11
|
+
- **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
|
|
12
|
+
- **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
|
|
13
|
+
- **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
|
|
14
|
+
- **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
|
|
15
|
+
|
|
16
|
+
- 74b035c: endpoint 管理命令扩展至 18/20 适配器:kook / discord / github(private_key 支持内联文件路径)/ icqq(bindFlow 登记式 add + `icqq login` 引导)/ dingtalk / lark / line / satori / wechat-mp / wecom / weixin-ilink 接入 `endpoint list/add/remove`(字段对齐各自 schema,凭据写 `.env`)。email(smtp/imap 嵌套对象)与 sandbox(无凭据)暂不接。
|
|
17
|
+
- 09d4f25: Console 社交读取面(management 语义端口)多平台落地:napcat/onebot11/onebot12/milky(好友+群+群成员,OneBot 标准动作);discord/kook/satori(guild+频道+成员,分页聚合,id 保精度留字符串);slack(workspace 成员+public channels+conversations.members);line(群/room 成员分页+profile 回退);wechat-mp(followers openid);weixin-ilink(context_token 对端推导);lark(chats+members 全分页)。`EndpointFriend.user_id`/`EndpointGroup.group_id` 放宽为 `number | string`(雪花 id 不丢精度)。telegram/wecom/dingtalk/github/email/sandbox 注明平台无列表面暂不接。
|
|
18
|
+
- 6cb6152: 统一消息元素通道(UNI-Channel)落地:
|
|
19
|
+
|
|
20
|
+
- **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
|
|
21
|
+
- **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
|
|
22
|
+
- **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
|
|
23
|
+
- **首批迁移**:icqq 全保真出入站(CQ ↔ canonical,quote→reply 段);milky/telegram/discord 入站媒体段恢复(附件/贴纸/callback action);napcat/onebot11/onebot12 出站 canonical→OneBot 数组段;wechat-mp/wecom `/cgi-bin/media/upload` 与 lark `/im/v1/images` 上传通路(base64/URL 图片不再静默丢图,失败降级文本)。
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [cdf64e7]
|
|
26
|
+
- Updated dependencies [2d0a159]
|
|
27
|
+
- Updated dependencies [5691aba]
|
|
28
|
+
- Updated dependencies [078e3f7]
|
|
29
|
+
- Updated dependencies [50497a5]
|
|
30
|
+
- Updated dependencies [9c997b2]
|
|
31
|
+
- Updated dependencies [09d4f25]
|
|
32
|
+
- Updated dependencies [43485a9]
|
|
33
|
+
- Updated dependencies [f0ec5ab]
|
|
34
|
+
- Updated dependencies [3e925d0]
|
|
35
|
+
- Updated dependencies [fa66c4c]
|
|
36
|
+
- Updated dependencies [fa66c4c]
|
|
37
|
+
- Updated dependencies [6cb6152]
|
|
38
|
+
- @zhin.js/command@1.0.3
|
|
39
|
+
- @zhin.js/plugin-runtime@1.1.1
|
|
40
|
+
- @zhin.js/host-http@1.0.3
|
|
41
|
+
- zhin.js@5.0.1
|
|
42
|
+
- @zhin.js/adapter@1.1.1
|
|
43
|
+
- @zhin.js/core@1.4.1
|
|
44
|
+
|
|
45
|
+
## 4.0.0
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- 7db69c1: 命令前缀改为适配器配置项:`MessageDispatcher` 不再硬编码 `/`,默认按消息所属适配器实例 config 的 `commandPrefix` 解析(默认 `''` 无前缀,任意文本按命令匹配),`endpoints[i].commandPrefix` 逐项覆盖;`ImRuntime({ commandPrefix })` 仍可设全局静态前缀。全部 20 个平台适配器 schema 新增 `commandPrefix` 属性。
|
|
50
|
+
|
|
51
|
+
BREAKING(行为变化):未配置时命令不再需要 `/` 前缀——原 `/zt` 写法不再命中,直接发 `zt` 即可;需要斜杠风格的适配器请在配置里显式设 `commandPrefix: '/'`。
|
|
52
|
+
|
|
53
|
+
- 713445c: 适配器配置格式定稿(不兼容旧格式):`plugins.<adapter>` 顶层仅共享字段 + `commandPrefix`,`endpoints[i]` 携带 endpoint 级字段(`name` + 凭据,各 schema 已类型化),`endpoints` 为必填(icqq 另需顶层 `master`);icqq 新增 `trusted` 列表(顶层/逐项均可)。scaffold-wizard 全部字段式与自定义 configure() 产出改为新格式,examples(full-bot / qq-games-bot)与 20 个适配器 README 同步迁移。
|
|
54
|
+
- Updated dependencies [7db69c1]
|
|
55
|
+
- Updated dependencies [e5c84ed]
|
|
56
|
+
- Updated dependencies [3ea84a0]
|
|
57
|
+
- Updated dependencies [1ddcd70]
|
|
58
|
+
- Updated dependencies [ac9da66]
|
|
59
|
+
- @zhin.js/core@1.4.0
|
|
60
|
+
- @zhin.js/adapter@1.1.0
|
|
61
|
+
- @zhin.js/plugin-runtime@1.1.0
|
|
62
|
+
- @zhin.js/host-http@1.0.2
|
|
63
|
+
- zhin.js@5.0.0
|
|
64
|
+
|
|
3
65
|
## 3.0.3
|
|
4
66
|
|
|
5
67
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ pnpm add @zhin.js/adapter-wechat-mp
|
|
|
38
38
|
| **回复模式** | 默认 `replyMode: passive`(订阅号被动回复);服务号可设 `customer_service` |
|
|
39
39
|
| **消息加密** | 可选;`encrypt: true` + `encodingAESKey`;`encryptMode: compatible`(默认)或 `secure` |
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
必填字段(`endpoints[i]`):`name`、`appId`、`appSecret`、`token`。
|
|
42
42
|
|
|
43
43
|
## 最小配置
|
|
44
44
|
|
|
@@ -46,11 +46,12 @@ pnpm add @zhin.js/adapter-wechat-mp
|
|
|
46
46
|
# zhin.config.yml(Plugin Runtime)
|
|
47
47
|
plugins:
|
|
48
48
|
wechat-mp:
|
|
49
|
-
name: my-wechat-bot
|
|
50
|
-
appId: "${WECHAT_APP_ID}"
|
|
51
|
-
appSecret: "${WECHAT_APP_SECRET}"
|
|
52
|
-
token: "${WECHAT_TOKEN}"
|
|
53
49
|
path: /wechat/webhook
|
|
50
|
+
endpoints:
|
|
51
|
+
- name: my-wechat-bot
|
|
52
|
+
appId: "${WECHAT_APP_ID}"
|
|
53
|
+
appSecret: "${WECHAT_APP_SECRET}"
|
|
54
|
+
token: "${WECHAT_TOKEN}"
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
根插件 `zhin.plugins`(或项目图)需引用 `@zhin.js/adapter-wechat-mp`(`instanceKey: wechat-mp`)。
|
package/adapters/wechat-mp.ts
CHANGED
|
@@ -9,18 +9,31 @@ import {
|
|
|
9
9
|
resolveWeChatMpConfig,
|
|
10
10
|
type WeChatMpAdapterConfig,
|
|
11
11
|
} from '../src/protocol.js';
|
|
12
|
+
import { wechatMpRuntimeStateToken } from '../src/wechat-mp-runtime-state.js';
|
|
12
13
|
|
|
13
14
|
export { WeChatMpEndpoint } from '../src/endpoint.js';
|
|
14
15
|
export type { WeChatMpEndpointOptions, WeChatMpFetch } from '../src/endpoint.js';
|
|
15
16
|
|
|
16
17
|
export default defineAdapter<WeChatMpAdapterConfig>({
|
|
17
18
|
capabilities: ['inbound', 'outbound'],
|
|
19
|
+
// 客服消息图片经 /cgi-bin/media/upload 物化为 media_id(url 下载后上传);
|
|
20
|
+
// 公众号无卡片交互面,交互段降级纯文本。
|
|
21
|
+
segments: {
|
|
22
|
+
outboundMedia: ['url', 'upload'],
|
|
23
|
+
interactive: 'text',
|
|
24
|
+
},
|
|
18
25
|
create(context) {
|
|
26
|
+
const config = resolveWeChatMpConfig(context.config);
|
|
27
|
+
// 注册到插件运行时状态(wechat-mp endpoint list 的"运行中"数据源)
|
|
28
|
+
context.use(wechatMpRuntimeStateToken).endpoints.set(config.name, {
|
|
29
|
+
name: config.name,
|
|
30
|
+
mode: 'webhook',
|
|
31
|
+
});
|
|
19
32
|
return new WeChatMpEndpoint({
|
|
20
33
|
id: context.id,
|
|
21
34
|
gateway: context.use(messageGatewayToken),
|
|
22
35
|
http: context.use(httpHostToken),
|
|
23
|
-
config
|
|
36
|
+
config,
|
|
24
37
|
});
|
|
25
38
|
},
|
|
26
39
|
});
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
1
|
+
import type { EndpointFriend, EndpointInstance, EndpointManagement } 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';
|
|
@@ -19,12 +19,16 @@ export interface WeChatMpEndpointOptions {
|
|
|
19
19
|
}
|
|
20
20
|
export declare class WeChatMpEndpoint implements EndpointInstance {
|
|
21
21
|
#private;
|
|
22
|
+
readonly management: EndpointManagement;
|
|
22
23
|
constructor(options: WeChatMpEndpointOptions);
|
|
23
24
|
/** Used by webhook handler. */
|
|
24
25
|
get isOpen(): boolean;
|
|
25
26
|
get config(): ResolvedWeChatMpConfig;
|
|
26
27
|
get id(): CapabilityId;
|
|
27
28
|
get gateway(): MessageGateway;
|
|
29
|
+
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
30
|
+
getCachedReply(msgId: string): string | undefined;
|
|
31
|
+
cacheReply(msgId: string, replyXML: string): void;
|
|
28
32
|
start(): Promise<void>;
|
|
29
33
|
open(): void;
|
|
30
34
|
close(): void;
|
|
@@ -35,4 +39,10 @@ export declare class WeChatMpEndpoint implements EndpointInstance {
|
|
|
35
39
|
}): Promise<string>;
|
|
36
40
|
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
37
41
|
admit(msg: WeChatMessage): void;
|
|
42
|
+
/**
|
|
43
|
+
* 关注者列表(GET /cgi-bin/user/get,按 next_openid 分页,每页最多 10000)。
|
|
44
|
+
* 该接口只回 openid 不回昵称;昵称需逐个调 user/info(成本高且依赖用户授权),
|
|
45
|
+
* 这里 nickname 用 openid 占位,由 Console 侧自行理解。
|
|
46
|
+
*/
|
|
47
|
+
getFollowers(): Promise<readonly EndpointFriend[]>;
|
|
38
48
|
}
|
package/lib/endpoint.js
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import axios from 'axios';
|
|
5
5
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
6
|
-
import { extractOutboundText, formatCustomerServiceBody, formatInboundContent, } from './protocol.js';
|
|
6
|
+
import { extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, } from './protocol.js';
|
|
7
|
+
import { buildMediaUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
|
|
7
8
|
import { getPassiveReplyCapture, recordPassiveReplyText, } from './passive-reply.js';
|
|
8
9
|
import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
9
10
|
const logger = getLogger('wechat-mp');
|
|
11
|
+
/** token 失效类错误码:40001/40014 invalid access_token、42001 access_token expired。 */
|
|
12
|
+
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
10
13
|
function defaultFetch(url, init) {
|
|
11
14
|
return axios({
|
|
12
15
|
url,
|
|
@@ -22,8 +25,12 @@ export class WeChatMpEndpoint {
|
|
|
22
25
|
#accessToken = null;
|
|
23
26
|
#tokenExpireTime = 0;
|
|
24
27
|
#tokenRefreshTimer;
|
|
28
|
+
/** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
|
|
29
|
+
#replyCache = new Map();
|
|
30
|
+
static #REPLY_CACHE_LIMIT = 1000;
|
|
25
31
|
#open = false;
|
|
26
32
|
#started = false;
|
|
33
|
+
management = createWeChatMpEndpointManagement(this);
|
|
27
34
|
constructor(options) {
|
|
28
35
|
this.#options = options;
|
|
29
36
|
this.#fetch = options.fetch ?? defaultFetch;
|
|
@@ -41,6 +48,21 @@ export class WeChatMpEndpoint {
|
|
|
41
48
|
get gateway() {
|
|
42
49
|
return this.#options.gateway;
|
|
43
50
|
}
|
|
51
|
+
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
52
|
+
getCachedReply(msgId) {
|
|
53
|
+
return this.#replyCache.get(msgId);
|
|
54
|
+
}
|
|
55
|
+
cacheReply(msgId, replyXML) {
|
|
56
|
+
if (this.#replyCache.has(msgId))
|
|
57
|
+
this.#replyCache.delete(msgId);
|
|
58
|
+
this.#replyCache.set(msgId, replyXML);
|
|
59
|
+
while (this.#replyCache.size > WeChatMpEndpoint.#REPLY_CACHE_LIMIT) {
|
|
60
|
+
const oldest = this.#replyCache.keys().next().value;
|
|
61
|
+
if (oldest === undefined)
|
|
62
|
+
break;
|
|
63
|
+
this.#replyCache.delete(oldest);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
44
66
|
async start() {
|
|
45
67
|
if (this.#started)
|
|
46
68
|
return;
|
|
@@ -104,7 +126,7 @@ export class WeChatMpEndpoint {
|
|
|
104
126
|
target: msg.FromUserName,
|
|
105
127
|
content: formatInboundContent(msg),
|
|
106
128
|
sender: msg.FromUserName,
|
|
107
|
-
id: msg
|
|
129
|
+
id: formatInboundId(msg),
|
|
108
130
|
metadata: Object.freeze({
|
|
109
131
|
msgType: msg.MsgType,
|
|
110
132
|
event: msg.Event,
|
|
@@ -120,18 +142,107 @@ export class WeChatMpEndpoint {
|
|
|
120
142
|
});
|
|
121
143
|
}
|
|
122
144
|
async #sendCustomerService(target, payload) {
|
|
123
|
-
|
|
145
|
+
// 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
|
|
146
|
+
if (!this.#accessToken || Date.now() >= this.#tokenExpireTime) {
|
|
124
147
|
await this.#refreshAccessToken();
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
148
|
+
}
|
|
149
|
+
const materialized = await this.#materializeOutboundMedia(payload);
|
|
150
|
+
const messageData = formatCustomerServiceBody(target, materialized);
|
|
151
|
+
let result = await this.#postCustomerService(messageData);
|
|
152
|
+
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
153
|
+
// 对端提前作废 token(多端共用等):刷新后重试一次。
|
|
154
|
+
logger.warn(formatCompact({
|
|
155
|
+
op: 'wechat_mp_token_invalid_retry',
|
|
156
|
+
errcode: result.errcode,
|
|
157
|
+
}));
|
|
158
|
+
await this.#refreshAccessToken();
|
|
159
|
+
result = await this.#postCustomerService(messageData);
|
|
160
|
+
}
|
|
129
161
|
if (result.errcode && result.errcode !== 0) {
|
|
130
162
|
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
|
|
131
163
|
}
|
|
132
164
|
logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
|
|
133
165
|
return result.msgid?.toString() || `cs_${Date.now()}`;
|
|
134
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* 客服消息 image 段只接受 media_id:canonical MediaRef(base64/本地路径/URL)
|
|
169
|
+
* 先经 /cgi-bin/media/upload 物化;上传失败降级为文本(alt 优先),不阻断发送。
|
|
170
|
+
*/
|
|
171
|
+
async #materializeOutboundMedia(payload) {
|
|
172
|
+
if (!Array.isArray(payload))
|
|
173
|
+
return payload;
|
|
174
|
+
return Promise.all(payload.map(async (item) => {
|
|
175
|
+
if (typeof item === 'string' || !item || typeof item !== 'object')
|
|
176
|
+
return item;
|
|
177
|
+
const seg = item;
|
|
178
|
+
if (seg.type !== 'image')
|
|
179
|
+
return item;
|
|
180
|
+
const data = seg.data ?? {};
|
|
181
|
+
const media = readOutboundImageMedia(data);
|
|
182
|
+
if (!media)
|
|
183
|
+
return item;
|
|
184
|
+
try {
|
|
185
|
+
const mediaId = await this.#uploadMedia('image', media);
|
|
186
|
+
return { type: 'image', data: { mediaId } };
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
logger.warn(formatCompact({
|
|
190
|
+
op: 'wechat_mp_media_upload_failed',
|
|
191
|
+
endpoint: this.#options.config.name,
|
|
192
|
+
error: error instanceof Error ? error.message : String(error),
|
|
193
|
+
}));
|
|
194
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
|
|
195
|
+
return { type: 'text', data: { text: alt } };
|
|
196
|
+
}
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
200
|
+
async #uploadMedia(type, media) {
|
|
201
|
+
const binary = await resolveMediaBinary(media);
|
|
202
|
+
const form = buildMediaUploadForm(binary);
|
|
203
|
+
const url = `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${this.#accessToken}&type=${type}`;
|
|
204
|
+
const response = await this.#fetch(url, { method: 'POST', body: form });
|
|
205
|
+
const data = response.data;
|
|
206
|
+
if (data.media_id)
|
|
207
|
+
return data.media_id;
|
|
208
|
+
throw new Error(`WeChat media upload failed: ${data.errcode ?? 'unknown'} ${data.errmsg ?? ''}`.trim());
|
|
209
|
+
}
|
|
210
|
+
async #postCustomerService(messageData) {
|
|
211
|
+
const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${this.#accessToken}`;
|
|
212
|
+
const response = await this.#fetch(url, { method: 'POST', body: messageData });
|
|
213
|
+
return response.data;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 关注者列表(GET /cgi-bin/user/get,按 next_openid 分页,每页最多 10000)。
|
|
217
|
+
* 该接口只回 openid 不回昵称;昵称需逐个调 user/info(成本高且依赖用户授权),
|
|
218
|
+
* 这里 nickname 用 openid 占位,由 Console 侧自行理解。
|
|
219
|
+
*/
|
|
220
|
+
async getFollowers() {
|
|
221
|
+
if (!this.#accessToken || Date.now() >= this.#tokenExpireTime) {
|
|
222
|
+
await this.#refreshAccessToken();
|
|
223
|
+
}
|
|
224
|
+
const friends = [];
|
|
225
|
+
let nextOpenid = '';
|
|
226
|
+
do {
|
|
227
|
+
const url = `https://api.weixin.qq.com/cgi-bin/user/get?access_token=${this.#accessToken}&next_openid=${encodeURIComponent(nextOpenid)}`;
|
|
228
|
+
const response = await this.#fetch(url);
|
|
229
|
+
const data = response.data;
|
|
230
|
+
if (data.errcode && data.errcode !== 0) {
|
|
231
|
+
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
232
|
+
}
|
|
233
|
+
for (const openid of data.data?.openid ?? []) {
|
|
234
|
+
if (typeof openid === 'string' && openid) {
|
|
235
|
+
friends.push({ user_id: openid, nickname: openid, remark: '' });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const fetched = Number(data.count ?? 0);
|
|
239
|
+
nextOpenid = typeof data.next_openid === 'string' ? data.next_openid : '';
|
|
240
|
+
// 满页(10000)才可能有下一页;不足一页即到底,避免依赖 next_openid 回显语义。
|
|
241
|
+
if (fetched < 10_000)
|
|
242
|
+
break;
|
|
243
|
+
} while (nextOpenid);
|
|
244
|
+
return friends;
|
|
245
|
+
}
|
|
135
246
|
async #refreshAccessToken() {
|
|
136
247
|
const { appId, appSecret } = this.#options.config;
|
|
137
248
|
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
@@ -157,3 +268,9 @@ export class WeChatMpEndpoint {
|
|
|
157
268
|
}, 3_600_000);
|
|
158
269
|
}
|
|
159
270
|
}
|
|
271
|
+
function createWeChatMpEndpointManagement(endpoint) {
|
|
272
|
+
return Object.freeze({
|
|
273
|
+
// 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
|
|
274
|
+
listFriends: () => endpoint.getFollowers(),
|
|
275
|
+
});
|
|
276
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, type ResolvedWeChatMpConfig, type TokenResponse, type WeChatAPIResponse, type WeChatMessage, type WeChatMpAdapterConfig, type WeChatWireSegment, } from './protocol.js';
|
|
1
|
+
export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, type ResolvedWeChatMpConfig, type TokenResponse, type WeChatAPIResponse, type WeChatMessage, type WeChatMpAdapterConfig, type WeChatWireSegment, } from './protocol.js';
|
|
2
2
|
export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, type PassiveReplyCapture, } from './passive-reply.js';
|
|
3
3
|
export { WeChatMpEndpoint, type WeChatMpEndpointOptions, type WeChatMpFetch, } from './endpoint.js';
|
|
4
|
+
export { buildMediaUploadForm, readOutboundImageMedia, resolveMediaBinary, type MediaBinary, type WeChatMediaUploadResult, } from './media-upload.js';
|
|
4
5
|
export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, type WeChatMpWebhookHandler, } from './webhook.js';
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, } from './protocol.js';
|
|
1
|
+
export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, } from './protocol.js';
|
|
2
2
|
export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, } from './passive-reply.js';
|
|
3
3
|
export { WeChatMpEndpoint, } from './endpoint.js';
|
|
4
|
+
export { buildMediaUploadForm, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
|
|
4
5
|
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
|
+
* 出站 image 段的媒体引用:已有 media_id 的视为已物化;
|
|
20
|
+
* 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
|
|
21
|
+
*/
|
|
22
|
+
export declare function readOutboundImageMedia(data: Record<string, unknown>): MediaRef | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
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, mediaRefFromLegacyData } 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
|
+
return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
|
|
32
|
+
}
|
|
33
|
+
async function defaultDownload(url) {
|
|
34
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
35
|
+
if (!response.ok)
|
|
36
|
+
throw new Error(`download failed: HTTP ${response.status}`);
|
|
37
|
+
return Buffer.from(await response.arrayBuffer());
|
|
38
|
+
}
|
|
39
|
+
/** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
|
|
40
|
+
export function buildMediaUploadForm(binary) {
|
|
41
|
+
// Buffer 的 ArrayBufferLike 不满足 BlobPart(SharedArrayBuffer 分支),拷贝为 Uint8Array<ArrayBuffer>
|
|
42
|
+
const bytes = new Uint8Array(binary.data.byteLength);
|
|
43
|
+
bytes.set(binary.data);
|
|
44
|
+
const form = new FormData();
|
|
45
|
+
form.append('media', new Blob([bytes], { type: binary.mimeType }), binary.fileName);
|
|
46
|
+
return form;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 出站 image 段的媒体引用:已有 media_id 的视为已物化;
|
|
50
|
+
* 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
|
|
51
|
+
*/
|
|
52
|
+
export function readOutboundImageMedia(data) {
|
|
53
|
+
if (typeof data.mediaId === 'string' && data.mediaId)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (typeof data.media_id === 'string' && data.media_id)
|
|
56
|
+
return undefined;
|
|
57
|
+
if (isMediaRef(data.media))
|
|
58
|
+
return data.media;
|
|
59
|
+
return mediaRefFromLegacyData(data);
|
|
60
|
+
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -101,6 +101,11 @@ export declare function parseXMLMessage(xmlString: string): Promise<WeChatMessag
|
|
|
101
101
|
export declare function decryptMessage(encryptedXml: string, msgSignature: string, timestamp: string, nonce: string, token: string, encodingAESKey: string, appId: string): Promise<string>;
|
|
102
102
|
export declare function encryptMessage(replyXml: string, token: string, encodingAESKey: string, appId: string, requestTimestamp?: string): string;
|
|
103
103
|
export declare function buildTextReply(wechatMsg: Pick<WeChatMessage, 'FromUserName' | 'ToUserName'>, content: string): string;
|
|
104
|
+
/**
|
|
105
|
+
* 入站消息 id:普通消息用 MsgId;无 MsgId 的事件消息拼 Event/EventKey,
|
|
106
|
+
* 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
|
|
107
|
+
*/
|
|
108
|
+
export declare function formatInboundId(msg: WeChatMessage): string;
|
|
104
109
|
/** Build inbound text for MessageGateway.receive. */
|
|
105
110
|
export declare function formatInboundContent(msg: WeChatMessage): string;
|
|
106
111
|
/**
|
package/lib/protocol.js
CHANGED
|
@@ -170,6 +170,17 @@ export function buildTextReply(wechatMsg, content) {
|
|
|
170
170
|
'</xml>',
|
|
171
171
|
].join('');
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* 入站消息 id:普通消息用 MsgId;无 MsgId 的事件消息拼 Event/EventKey,
|
|
175
|
+
* 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
|
|
176
|
+
*/
|
|
177
|
+
export function formatInboundId(msg) {
|
|
178
|
+
if (msg.MsgId)
|
|
179
|
+
return msg.MsgId;
|
|
180
|
+
return [msg.CreateTime, msg.Event, msg.EventKey]
|
|
181
|
+
.filter((part) => part != null && part !== '')
|
|
182
|
+
.join(':');
|
|
183
|
+
}
|
|
173
184
|
/** Build inbound text for MessageGateway.receive. */
|
|
174
185
|
export function formatInboundContent(msg) {
|
|
175
186
|
switch (msg.MsgType) {
|
package/lib/webhook.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export interface WeChatMpWebhookHandler {
|
|
|
12
12
|
readonly id: CapabilityId;
|
|
13
13
|
readonly gateway: MessageGateway;
|
|
14
14
|
admit(msg: WeChatMessage): void;
|
|
15
|
+
/** MsgId 去重缓存(可选;实现见 WeChatMpEndpoint)。 */
|
|
16
|
+
getCachedReply?(msgId: string): string | undefined;
|
|
17
|
+
cacheReply?(msgId: string, replyXML: string): void;
|
|
15
18
|
}
|
|
16
19
|
export declare function registerWeChatMpWebhookRoutes(http: HttpHost, handler: WeChatMpWebhookHandler): HttpRouteRegistration[];
|
|
17
20
|
export declare function handleWeChatMpVerification(_request: IncomingMessage, response: ServerResponse, url: URL, config: ResolvedWeChatMpConfig): void;
|
package/lib/webhook.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
2
|
import { getPassiveReplyCapture, runWithPassiveReplyCapture, } from './passive-reply.js';
|
|
3
|
-
import { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, formatInboundContent, 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, } from './protocol.js';
|
|
4
4
|
const logger = getLogger('wechat-mp');
|
|
5
5
|
export function registerWeChatMpWebhookRoutes(http, handler) {
|
|
6
6
|
const path = handler.config.path;
|
|
@@ -90,6 +90,17 @@ export async function handleWeChatMpMessage(request, response, url, handler) {
|
|
|
90
90
|
response.end('success');
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
+
// 微信 5s 重推去重:同一 MsgId 直接回放首次回复,避免二次 admit。
|
|
94
|
+
const msgId = wechatMessage.MsgId;
|
|
95
|
+
if (msgId && handler.getCachedReply) {
|
|
96
|
+
const cached = handler.getCachedReply(msgId);
|
|
97
|
+
if (cached !== undefined) {
|
|
98
|
+
logger.debug(formatCompact({ op: 'wechat_mp_dedup_replay', msgId }));
|
|
99
|
+
response.writeHead(200, { 'Content-Type': cached ? 'text/xml' : 'text/plain' });
|
|
100
|
+
response.end(cached || 'success');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
93
104
|
let replyXML = resolveEventPassiveReply(wechatMessage);
|
|
94
105
|
if (!replyXML && handler.isOpen) {
|
|
95
106
|
if (config.replyMode === 'passive') {
|
|
@@ -106,6 +117,9 @@ export async function handleWeChatMpMessage(request, response, url, handler) {
|
|
|
106
117
|
if (encryptReply && config.encodingAESKey) {
|
|
107
118
|
replyXML = encryptMessage(replyXML, config.token, config.encodingAESKey, config.appId, timestamp);
|
|
108
119
|
}
|
|
120
|
+
if (msgId && handler.cacheReply) {
|
|
121
|
+
handler.cacheReply(msgId, replyXML || '');
|
|
122
|
+
}
|
|
109
123
|
response.writeHead(200, { 'Content-Type': 'text/xml' });
|
|
110
124
|
response.end(replyXML || 'success');
|
|
111
125
|
}
|
|
@@ -124,7 +138,7 @@ export async function collectPassiveReply(handler, wechatMsg) {
|
|
|
124
138
|
target: wechatMsg.FromUserName,
|
|
125
139
|
content: formatInboundContent(wechatMsg),
|
|
126
140
|
sender: wechatMsg.FromUserName,
|
|
127
|
-
id: wechatMsg
|
|
141
|
+
id: formatInboundId(wechatMsg),
|
|
128
142
|
metadata: Object.freeze({
|
|
129
143
|
msgType: wechatMsg.MsgType,
|
|
130
144
|
event: wechatMsg.Event,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const wechatMpEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wechat-mp endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { wechatMpRuntimeStateToken } from './wechat-mp-runtime-state.js';
|
|
8
|
+
export const wechatMpEndpointCommands = createEndpointCommands({
|
|
9
|
+
adapterKey: 'wechat-mp',
|
|
10
|
+
adapterDisplayName: 'WeChat MP',
|
|
11
|
+
fields: [
|
|
12
|
+
{ key: 'appId', required: true, env: true, description: '公众号 AppID' },
|
|
13
|
+
{ key: 'appSecret', required: true, env: true, description: '公众号 AppSecret' },
|
|
14
|
+
{ key: 'token', required: true, env: true, description: '服务器配置 token' },
|
|
15
|
+
{ key: 'encodingAESKey', env: true, description: '消息加解密 key' },
|
|
16
|
+
],
|
|
17
|
+
running: (use) => use(wechatMpRuntimeStateToken).endpoints.values(),
|
|
18
|
+
describeEntry: (entry) => `appId: ${String(entry.appId)}`,
|
|
19
|
+
}, defineCommand);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const wechatMpRuntimeStateToken: import("@zhin.js/plugin-runtime").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat MP 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `wechat-mp endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
export const wechatMpRuntimeStateToken = defineEndpointRuntimeStateToken('wechat-mp');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-wechat-mp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zhin.js WeChat Official Account adapter for Plugin Runtime (HTTP webhook)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"axios": "^1.18.0",
|
|
10
10
|
"xml2js": "^0.6.2",
|
|
11
|
-
"@zhin.js/adapter": "1.
|
|
12
|
-
"@zhin.js/
|
|
13
|
-
"@zhin.js/
|
|
11
|
+
"@zhin.js/adapter": "1.1.1",
|
|
12
|
+
"@zhin.js/command": "1.0.3",
|
|
13
|
+
"@zhin.js/core": "1.4.1",
|
|
14
|
+
"@zhin.js/host-http": "1.0.3",
|
|
14
15
|
"@zhin.js/logger": "1.0.75",
|
|
15
|
-
"@zhin.js/plugin-runtime": "1.
|
|
16
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
16
17
|
},
|
|
17
18
|
"peerDependencies": {
|
|
18
|
-
"@zhin.js/adapter": "1.
|
|
19
|
-
"@zhin.js/core": "1.
|
|
20
|
-
"@zhin.js/host-http": "1.0.
|
|
21
|
-
"@zhin.js/plugin-runtime": "1.
|
|
22
|
-
"zhin.js": "
|
|
19
|
+
"@zhin.js/adapter": "1.1.1",
|
|
20
|
+
"@zhin.js/core": "1.4.1",
|
|
21
|
+
"@zhin.js/host-http": "1.0.3",
|
|
22
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
23
|
+
"zhin.js": "5.0.1"
|
|
23
24
|
},
|
|
24
25
|
"peerDependenciesMeta": {
|
|
25
26
|
"zhin.js": {
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
},
|
|
58
59
|
"files": [
|
|
59
60
|
"adapters",
|
|
61
|
+
"commands",
|
|
60
62
|
"plugin.ts",
|
|
61
63
|
"schema.json",
|
|
62
64
|
"src",
|
|
@@ -89,6 +91,10 @@
|
|
|
89
91
|
{
|
|
90
92
|
"package": "@zhin.js/adapter",
|
|
91
93
|
"api": "^1.0.0"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"package": "@zhin.js/command",
|
|
97
|
+
"api": "^1.0.0"
|
|
92
98
|
}
|
|
93
99
|
],
|
|
94
100
|
"plugins": []
|
package/plugin.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { createEndpointRuntimeState } from '@zhin.js/adapter';
|
|
1
2
|
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import { wechatMpRuntimeStateToken } from './src/wechat-mp-runtime-state.js';
|
|
2
4
|
|
|
3
5
|
export default definePlugin({
|
|
4
6
|
name: 'wechat-mp',
|
|
5
7
|
metadata: {
|
|
6
8
|
displayName: 'WeChat Official Account Adapter',
|
|
7
9
|
},
|
|
10
|
+
setup(context) {
|
|
11
|
+
// 运行中 endpoint 注册表(wechat-mp endpoint list 的"运行中"数据源)
|
|
12
|
+
context.resources.provide(wechatMpRuntimeStateToken, createEndpointRuntimeState());
|
|
13
|
+
},
|
|
8
14
|
});
|
package/schema.json
CHANGED
|
@@ -3,14 +3,6 @@
|
|
|
3
3
|
"type": "object",
|
|
4
4
|
"additionalProperties": false,
|
|
5
5
|
"properties": {
|
|
6
|
-
"name": {
|
|
7
|
-
"type": "string",
|
|
8
|
-
"default": "wechat-mp-bot"
|
|
9
|
-
},
|
|
10
|
-
"appId": { "type": "string" },
|
|
11
|
-
"appSecret": { "type": "string" },
|
|
12
|
-
"token": { "type": "string" },
|
|
13
|
-
"encodingAESKey": { "type": "string" },
|
|
14
6
|
"path": {
|
|
15
7
|
"type": "string",
|
|
16
8
|
"default": "/wechat/webhook"
|
|
@@ -21,18 +13,68 @@
|
|
|
21
13
|
},
|
|
22
14
|
"encryptMode": {
|
|
23
15
|
"type": "string",
|
|
24
|
-
"enum": [
|
|
16
|
+
"enum": [
|
|
17
|
+
"plain",
|
|
18
|
+
"compatible",
|
|
19
|
+
"secure"
|
|
20
|
+
],
|
|
25
21
|
"description": "Default follows encrypt: 'plain' when encrypt=false, 'compatible' when encrypt=true"
|
|
26
22
|
},
|
|
27
23
|
"replyMode": {
|
|
28
24
|
"type": "string",
|
|
29
|
-
"enum": [
|
|
25
|
+
"enum": [
|
|
26
|
+
"passive",
|
|
27
|
+
"customer_service"
|
|
28
|
+
],
|
|
30
29
|
"default": "passive"
|
|
31
30
|
},
|
|
32
31
|
"passiveReplyTimeoutMs": {
|
|
33
32
|
"type": "number",
|
|
34
33
|
"default": 4500
|
|
34
|
+
},
|
|
35
|
+
"endpoints": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"additionalProperties": true,
|
|
41
|
+
"properties": {
|
|
42
|
+
"name": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "WeChat MP bot name"
|
|
45
|
+
},
|
|
46
|
+
"appId": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "WeChat MP app ID"
|
|
49
|
+
},
|
|
50
|
+
"appSecret": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "WeChat MP app secret"
|
|
53
|
+
},
|
|
54
|
+
"token": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "WeChat MP callback token"
|
|
57
|
+
},
|
|
58
|
+
"encodingAESKey": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "WeChat MP encoding AES key"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"required": [
|
|
64
|
+
"name",
|
|
65
|
+
"appId",
|
|
66
|
+
"appSecret",
|
|
67
|
+
"token"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"commandPrefix": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"default": "",
|
|
74
|
+
"description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
|
|
35
75
|
}
|
|
36
76
|
},
|
|
37
|
-
"required": [
|
|
77
|
+
"required": [
|
|
78
|
+
"endpoints"
|
|
79
|
+
]
|
|
38
80
|
}
|
package/src/endpoint.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
|
|
3
3
|
*/
|
|
4
4
|
import axios from 'axios';
|
|
5
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
5
|
+
import type { EndpointFriend, EndpointInstance, EndpointManagement } 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
8
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
@@ -11,11 +11,18 @@ import {
|
|
|
11
11
|
extractOutboundText,
|
|
12
12
|
formatCustomerServiceBody,
|
|
13
13
|
formatInboundContent,
|
|
14
|
+
formatInboundId,
|
|
14
15
|
type ResolvedWeChatMpConfig,
|
|
15
16
|
type TokenResponse,
|
|
16
17
|
type WeChatAPIResponse,
|
|
17
18
|
type WeChatMessage,
|
|
18
19
|
} from './protocol.js';
|
|
20
|
+
import {
|
|
21
|
+
buildMediaUploadForm,
|
|
22
|
+
readOutboundImageMedia,
|
|
23
|
+
resolveMediaBinary,
|
|
24
|
+
type WeChatMediaUploadResult,
|
|
25
|
+
} from './media-upload.js';
|
|
19
26
|
import {
|
|
20
27
|
getPassiveReplyCapture,
|
|
21
28
|
recordPassiveReplyText,
|
|
@@ -24,6 +31,9 @@ import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
|
24
31
|
|
|
25
32
|
const logger = getLogger('wechat-mp');
|
|
26
33
|
|
|
34
|
+
/** token 失效类错误码:40001/40014 invalid access_token、42001 access_token expired。 */
|
|
35
|
+
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
36
|
+
|
|
27
37
|
export type WeChatMpFetch = (
|
|
28
38
|
url: string,
|
|
29
39
|
init?: { readonly method?: string; readonly body?: unknown; readonly headers?: Record<string, string> },
|
|
@@ -56,8 +66,12 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
56
66
|
#accessToken: string | null = null;
|
|
57
67
|
#tokenExpireTime = 0;
|
|
58
68
|
#tokenRefreshTimer?: ReturnType<typeof setInterval>;
|
|
69
|
+
/** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
|
|
70
|
+
readonly #replyCache = new Map<string, string>();
|
|
71
|
+
static readonly #REPLY_CACHE_LIMIT = 1000;
|
|
59
72
|
#open = false;
|
|
60
73
|
#started = false;
|
|
74
|
+
readonly management: EndpointManagement = createWeChatMpEndpointManagement(this);
|
|
61
75
|
|
|
62
76
|
constructor(options: WeChatMpEndpointOptions) {
|
|
63
77
|
this.#options = options;
|
|
@@ -81,6 +95,21 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
81
95
|
return this.#options.gateway;
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
99
|
+
getCachedReply(msgId: string): string | undefined {
|
|
100
|
+
return this.#replyCache.get(msgId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
cacheReply(msgId: string, replyXML: string): void {
|
|
104
|
+
if (this.#replyCache.has(msgId)) this.#replyCache.delete(msgId);
|
|
105
|
+
this.#replyCache.set(msgId, replyXML);
|
|
106
|
+
while (this.#replyCache.size > WeChatMpEndpoint.#REPLY_CACHE_LIMIT) {
|
|
107
|
+
const oldest = this.#replyCache.keys().next().value;
|
|
108
|
+
if (oldest === undefined) break;
|
|
109
|
+
this.#replyCache.delete(oldest);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
84
113
|
async start(): Promise<void> {
|
|
85
114
|
if (this.#started) return;
|
|
86
115
|
this.#started = true;
|
|
@@ -147,7 +176,7 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
147
176
|
target: msg.FromUserName,
|
|
148
177
|
content: formatInboundContent(msg),
|
|
149
178
|
sender: msg.FromUserName,
|
|
150
|
-
id: msg
|
|
179
|
+
id: formatInboundId(msg),
|
|
151
180
|
metadata: Object.freeze({
|
|
152
181
|
msgType: msg.MsgType,
|
|
153
182
|
event: msg.Event,
|
|
@@ -164,11 +193,22 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
164
193
|
}
|
|
165
194
|
|
|
166
195
|
async #sendCustomerService(target: string, payload: unknown): Promise<string> {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
196
|
+
// 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
|
|
197
|
+
if (!this.#accessToken || Date.now() >= this.#tokenExpireTime) {
|
|
198
|
+
await this.#refreshAccessToken();
|
|
199
|
+
}
|
|
200
|
+
const materialized = await this.#materializeOutboundMedia(payload);
|
|
201
|
+
const messageData = formatCustomerServiceBody(target, materialized);
|
|
202
|
+
let result = await this.#postCustomerService(messageData);
|
|
203
|
+
if (result.errcode && TOKEN_INVALID_ERRCODES.has(Number(result.errcode))) {
|
|
204
|
+
// 对端提前作废 token(多端共用等):刷新后重试一次。
|
|
205
|
+
logger.warn(formatCompact({
|
|
206
|
+
op: 'wechat_mp_token_invalid_retry',
|
|
207
|
+
errcode: result.errcode,
|
|
208
|
+
}));
|
|
209
|
+
await this.#refreshAccessToken();
|
|
210
|
+
result = await this.#postCustomerService(messageData);
|
|
211
|
+
}
|
|
172
212
|
if (result.errcode && result.errcode !== 0) {
|
|
173
213
|
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
|
|
174
214
|
}
|
|
@@ -176,6 +216,86 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
176
216
|
return result.msgid?.toString() || `cs_${Date.now()}`;
|
|
177
217
|
}
|
|
178
218
|
|
|
219
|
+
/**
|
|
220
|
+
* 客服消息 image 段只接受 media_id:canonical MediaRef(base64/本地路径/URL)
|
|
221
|
+
* 先经 /cgi-bin/media/upload 物化;上传失败降级为文本(alt 优先),不阻断发送。
|
|
222
|
+
*/
|
|
223
|
+
async #materializeOutboundMedia(payload: unknown): Promise<unknown> {
|
|
224
|
+
if (!Array.isArray(payload)) return payload;
|
|
225
|
+
return Promise.all(payload.map(async (item) => {
|
|
226
|
+
if (typeof item === 'string' || !item || typeof item !== 'object') return item;
|
|
227
|
+
const seg = item as { type?: unknown; data?: Record<string, unknown> };
|
|
228
|
+
if (seg.type !== 'image') return item;
|
|
229
|
+
const data = seg.data ?? {};
|
|
230
|
+
const media = readOutboundImageMedia(data);
|
|
231
|
+
if (!media) return item;
|
|
232
|
+
try {
|
|
233
|
+
const mediaId = await this.#uploadMedia('image', media);
|
|
234
|
+
return { type: 'image', data: { mediaId } };
|
|
235
|
+
} catch (error) {
|
|
236
|
+
logger.warn(formatCompact({
|
|
237
|
+
op: 'wechat_mp_media_upload_failed',
|
|
238
|
+
endpoint: this.#options.config.name,
|
|
239
|
+
error: error instanceof Error ? error.message : String(error),
|
|
240
|
+
}));
|
|
241
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : '[image]';
|
|
242
|
+
return { type: 'text', data: { text: alt } };
|
|
243
|
+
}
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
248
|
+
async #uploadMedia(type: 'image', media: Parameters<typeof resolveMediaBinary>[0]): Promise<string> {
|
|
249
|
+
const binary = await resolveMediaBinary(media);
|
|
250
|
+
const form = buildMediaUploadForm(binary);
|
|
251
|
+
const url = `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${this.#accessToken}&type=${type}`;
|
|
252
|
+
const response = await this.#fetch(url, { method: 'POST', body: form });
|
|
253
|
+
const data = response.data as WeChatMediaUploadResult;
|
|
254
|
+
if (data.media_id) return data.media_id;
|
|
255
|
+
throw new Error(`WeChat media upload failed: ${data.errcode ?? 'unknown'} ${data.errmsg ?? ''}`.trim());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async #postCustomerService(messageData: unknown): Promise<WeChatAPIResponse> {
|
|
259
|
+
const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${this.#accessToken}`;
|
|
260
|
+
const response = await this.#fetch(url, { method: 'POST', body: messageData });
|
|
261
|
+
return response.data as WeChatAPIResponse;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 关注者列表(GET /cgi-bin/user/get,按 next_openid 分页,每页最多 10000)。
|
|
266
|
+
* 该接口只回 openid 不回昵称;昵称需逐个调 user/info(成本高且依赖用户授权),
|
|
267
|
+
* 这里 nickname 用 openid 占位,由 Console 侧自行理解。
|
|
268
|
+
*/
|
|
269
|
+
async getFollowers(): Promise<readonly EndpointFriend[]> {
|
|
270
|
+
if (!this.#accessToken || Date.now() >= this.#tokenExpireTime) {
|
|
271
|
+
await this.#refreshAccessToken();
|
|
272
|
+
}
|
|
273
|
+
const friends: EndpointFriend[] = [];
|
|
274
|
+
let nextOpenid = '';
|
|
275
|
+
do {
|
|
276
|
+
const url = `https://api.weixin.qq.com/cgi-bin/user/get?access_token=${this.#accessToken}&next_openid=${encodeURIComponent(nextOpenid)}`;
|
|
277
|
+
const response = await this.#fetch(url);
|
|
278
|
+
const data = response.data as WeChatAPIResponse & {
|
|
279
|
+
count?: number;
|
|
280
|
+
data?: { openid?: string[] };
|
|
281
|
+
next_openid?: string;
|
|
282
|
+
};
|
|
283
|
+
if (data.errcode && data.errcode !== 0) {
|
|
284
|
+
throw new Error(`WeChat API error: ${data.errcode} - ${data.errmsg}`);
|
|
285
|
+
}
|
|
286
|
+
for (const openid of data.data?.openid ?? []) {
|
|
287
|
+
if (typeof openid === 'string' && openid) {
|
|
288
|
+
friends.push({ user_id: openid, nickname: openid, remark: '' });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const fetched = Number(data.count ?? 0);
|
|
292
|
+
nextOpenid = typeof data.next_openid === 'string' ? data.next_openid : '';
|
|
293
|
+
// 满页(10000)才可能有下一页;不足一页即到底,避免依赖 next_openid 回显语义。
|
|
294
|
+
if (fetched < 10_000) break;
|
|
295
|
+
} while (nextOpenid);
|
|
296
|
+
return friends;
|
|
297
|
+
}
|
|
298
|
+
|
|
179
299
|
async #refreshAccessToken(): Promise<void> {
|
|
180
300
|
const { appId, appSecret } = this.#options.config;
|
|
181
301
|
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
@@ -204,3 +324,10 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
204
324
|
}, 3_600_000);
|
|
205
325
|
}
|
|
206
326
|
}
|
|
327
|
+
|
|
328
|
+
function createWeChatMpEndpointManagement(endpoint: WeChatMpEndpoint): EndpointManagement {
|
|
329
|
+
return Object.freeze<EndpointManagement>({
|
|
330
|
+
// 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
|
|
331
|
+
listFriends: () => endpoint.getFollowers(),
|
|
332
|
+
});
|
|
333
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
extractOutboundText,
|
|
8
8
|
formatCustomerServiceBody,
|
|
9
9
|
formatInboundContent,
|
|
10
|
+
formatInboundId,
|
|
10
11
|
isEncryptedEchostr,
|
|
11
12
|
normalizeEchostrParam,
|
|
12
13
|
parseXMLMessage,
|
|
@@ -36,6 +37,14 @@ export {
|
|
|
36
37
|
type WeChatMpFetch,
|
|
37
38
|
} from './endpoint.js';
|
|
38
39
|
|
|
40
|
+
export {
|
|
41
|
+
buildMediaUploadForm,
|
|
42
|
+
readOutboundImageMedia,
|
|
43
|
+
resolveMediaBinary,
|
|
44
|
+
type MediaBinary,
|
|
45
|
+
type WeChatMediaUploadResult,
|
|
46
|
+
} from './media-upload.js';
|
|
47
|
+
|
|
39
48
|
export {
|
|
40
49
|
registerWeChatMpWebhookRoutes,
|
|
41
50
|
handleWeChatMpVerification,
|
|
@@ -0,0 +1,78 @@
|
|
|
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, mediaRefFromLegacyData, type MediaRef } from '@zhin.js/core';
|
|
10
|
+
|
|
11
|
+
export interface MediaBinary {
|
|
12
|
+
readonly data: Buffer;
|
|
13
|
+
readonly mimeType: string;
|
|
14
|
+
readonly fileName: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WeChatMediaUploadResult {
|
|
18
|
+
readonly type?: string;
|
|
19
|
+
readonly media_id?: string;
|
|
20
|
+
readonly created_at?: number;
|
|
21
|
+
readonly errcode?: number;
|
|
22
|
+
readonly errmsg?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MIME_EXT: Record<string, string> = {
|
|
26
|
+
'image/jpeg': 'jpg',
|
|
27
|
+
'image/png': 'png',
|
|
28
|
+
'image/gif': 'gif',
|
|
29
|
+
'image/webp': 'webp',
|
|
30
|
+
'image/bmp': 'bmp',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 从 canonical MediaRef 解析二进制:base64 解码 / 本地读盘 / URL 下载。 */
|
|
34
|
+
export async function resolveMediaBinary(
|
|
35
|
+
media: MediaRef,
|
|
36
|
+
download: (url: string) => Promise<Buffer> = defaultDownload,
|
|
37
|
+
): Promise<MediaBinary> {
|
|
38
|
+
const mimeType = media.mime_type ?? 'image/png';
|
|
39
|
+
const ext = MIME_EXT[mimeType] ?? 'png';
|
|
40
|
+
if (media.kind === 'base64') {
|
|
41
|
+
const value = media.value.startsWith('base64://')
|
|
42
|
+
? media.value.slice('base64://'.length)
|
|
43
|
+
: media.value;
|
|
44
|
+
return { data: Buffer.from(value, 'base64'), mimeType, fileName: `image.${ext}` };
|
|
45
|
+
}
|
|
46
|
+
if (media.kind === 'path') {
|
|
47
|
+
const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
|
|
48
|
+
return { data: await readFile(path), mimeType, fileName: basename(path) };
|
|
49
|
+
}
|
|
50
|
+
return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function defaultDownload(url: string): Promise<Buffer> {
|
|
54
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
55
|
+
if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
|
|
56
|
+
return Buffer.from(await response.arrayBuffer());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
|
|
60
|
+
export function buildMediaUploadForm(binary: MediaBinary): FormData {
|
|
61
|
+
// Buffer 的 ArrayBufferLike 不满足 BlobPart(SharedArrayBuffer 分支),拷贝为 Uint8Array<ArrayBuffer>
|
|
62
|
+
const bytes = new Uint8Array(binary.data.byteLength);
|
|
63
|
+
bytes.set(binary.data);
|
|
64
|
+
const form = new FormData();
|
|
65
|
+
form.append('media', new Blob([bytes], { type: binary.mimeType }), binary.fileName);
|
|
66
|
+
return form;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 出站 image 段的媒体引用:已有 media_id 的视为已物化;
|
|
71
|
+
* 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
|
|
72
|
+
*/
|
|
73
|
+
export function readOutboundImageMedia(data: Record<string, unknown>): MediaRef | undefined {
|
|
74
|
+
if (typeof data.mediaId === 'string' && data.mediaId) return undefined;
|
|
75
|
+
if (typeof data.media_id === 'string' && data.media_id) return undefined;
|
|
76
|
+
if (isMediaRef(data.media)) return data.media;
|
|
77
|
+
return mediaRefFromLegacyData(data);
|
|
78
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -310,6 +310,17 @@ export function buildTextReply(
|
|
|
310
310
|
].join('');
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* 入站消息 id:普通消息用 MsgId;无 MsgId 的事件消息拼 Event/EventKey,
|
|
315
|
+
* 避免只用秒级 CreateTime 时同秒多事件 id 碰撞。
|
|
316
|
+
*/
|
|
317
|
+
export function formatInboundId(msg: WeChatMessage): string {
|
|
318
|
+
if (msg.MsgId) return msg.MsgId;
|
|
319
|
+
return [msg.CreateTime, msg.Event, msg.EventKey]
|
|
320
|
+
.filter((part) => part != null && part !== '')
|
|
321
|
+
.join(':');
|
|
322
|
+
}
|
|
323
|
+
|
|
313
324
|
/** Build inbound text for MessageGateway.receive. */
|
|
314
325
|
export function formatInboundContent(msg: WeChatMessage): string {
|
|
315
326
|
switch (msg.MsgType) {
|
package/src/webhook.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
decryptMessage,
|
|
18
18
|
encryptMessage,
|
|
19
19
|
formatInboundContent,
|
|
20
|
+
formatInboundId,
|
|
20
21
|
isEncryptedEchostr,
|
|
21
22
|
normalizeEchostrParam,
|
|
22
23
|
parseXMLMessage,
|
|
@@ -36,6 +37,9 @@ export interface WeChatMpWebhookHandler {
|
|
|
36
37
|
readonly id: CapabilityId;
|
|
37
38
|
readonly gateway: MessageGateway;
|
|
38
39
|
admit(msg: WeChatMessage): void;
|
|
40
|
+
/** MsgId 去重缓存(可选;实现见 WeChatMpEndpoint)。 */
|
|
41
|
+
getCachedReply?(msgId: string): string | undefined;
|
|
42
|
+
cacheReply?(msgId: string, replyXML: string): void;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export function registerWeChatMpWebhookRoutes(
|
|
@@ -157,6 +161,18 @@ export async function handleWeChatMpMessage(
|
|
|
157
161
|
return;
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
// 微信 5s 重推去重:同一 MsgId 直接回放首次回复,避免二次 admit。
|
|
165
|
+
const msgId = wechatMessage.MsgId;
|
|
166
|
+
if (msgId && handler.getCachedReply) {
|
|
167
|
+
const cached = handler.getCachedReply(msgId);
|
|
168
|
+
if (cached !== undefined) {
|
|
169
|
+
logger.debug(formatCompact({ op: 'wechat_mp_dedup_replay', msgId }));
|
|
170
|
+
response.writeHead(200, { 'Content-Type': cached ? 'text/xml' : 'text/plain' });
|
|
171
|
+
response.end(cached || 'success');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
160
176
|
let replyXML = resolveEventPassiveReply(wechatMessage);
|
|
161
177
|
|
|
162
178
|
if (!replyXML && handler.isOpen) {
|
|
@@ -183,6 +199,10 @@ export async function handleWeChatMpMessage(
|
|
|
183
199
|
);
|
|
184
200
|
}
|
|
185
201
|
|
|
202
|
+
if (msgId && handler.cacheReply) {
|
|
203
|
+
handler.cacheReply(msgId, replyXML || '');
|
|
204
|
+
}
|
|
205
|
+
|
|
186
206
|
response.writeHead(200, { 'Content-Type': 'text/xml' });
|
|
187
207
|
response.end(replyXML || 'success');
|
|
188
208
|
} catch (error) {
|
|
@@ -204,7 +224,7 @@ export async function collectPassiveReply(
|
|
|
204
224
|
target: wechatMsg.FromUserName,
|
|
205
225
|
content: formatInboundContent(wechatMsg),
|
|
206
226
|
sender: wechatMsg.FromUserName,
|
|
207
|
-
id: wechatMsg
|
|
227
|
+
id: formatInboundId(wechatMsg),
|
|
208
228
|
metadata: Object.freeze({
|
|
209
229
|
msgType: wechatMsg.MsgType,
|
|
210
230
|
event: wechatMsg.Event,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wechat-mp endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { wechatMpRuntimeStateToken } from './wechat-mp-runtime-state.js';
|
|
8
|
+
|
|
9
|
+
export const wechatMpEndpointCommands = createEndpointCommands({
|
|
10
|
+
adapterKey: 'wechat-mp',
|
|
11
|
+
adapterDisplayName: 'WeChat MP',
|
|
12
|
+
fields: [
|
|
13
|
+
{ key: 'appId', required: true, env: true, description: '公众号 AppID' },
|
|
14
|
+
{ key: 'appSecret', required: true, env: true, description: '公众号 AppSecret' },
|
|
15
|
+
{ key: 'token', required: true, env: true, description: '服务器配置 token' },
|
|
16
|
+
{ key: 'encodingAESKey', env: true, description: '消息加解密 key' },
|
|
17
|
+
],
|
|
18
|
+
running: (use) => use(wechatMpRuntimeStateToken).endpoints.values(),
|
|
19
|
+
describeEntry: (entry) => `appId: ${String(entry.appId)}`,
|
|
20
|
+
}, defineCommand);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat MP 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `wechat-mp endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
|
|
7
|
+
export const wechatMpRuntimeStateToken = defineEndpointRuntimeStateToken('wechat-mp');
|