@zhin.js/adapter-wechat-mp 1.0.0 → 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.
- package/CHANGELOG.md +478 -0
- package/README.md +40 -88
- package/adapters/wechat-mp.js +32 -0
- package/adapters/wechat-mp.ts +38 -0
- package/commands/endpoint/add/[id].js +3 -0
- package/commands/endpoint/add/[id].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +32 -0
- package/lib/client.js +70 -0
- package/lib/endpoint.d.ts +32 -0
- package/lib/endpoint.js +264 -0
- package/lib/index.d.ts +6 -15
- package/lib/index.js +6 -25
- package/lib/media-upload.d.ts +22 -0
- package/lib/media-upload.js +64 -0
- package/lib/passive-reply.d.ts +0 -1
- package/lib/passive-reply.js +0 -1
- package/lib/protocol.d.ts +129 -0
- package/lib/protocol.js +349 -0
- package/lib/side-event-dispatch.d.ts +4 -0
- package/lib/side-event-dispatch.js +38 -0
- package/lib/webhook.d.ts +20 -0
- package/lib/webhook.js +152 -0
- 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 +53 -12
- package/plugin.js +14 -0
- package/schema.json +116 -0
- package/src/client.ts +121 -0
- package/src/endpoint.ts +313 -0
- package/src/index.ts +56 -35
- package/src/media-upload.ts +82 -0
- package/src/protocol.ts +507 -0
- package/src/side-event-dispatch.ts +45 -0
- package/src/webhook.ts +237 -0
- package/src/wechat-mp-endpoint-commands.ts +20 -0
- package/src/wechat-mp-runtime-state.ts +7 -0
- package/lib/adapter.d.ts +0 -13
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -16
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -73
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -783
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/passive-reply.d.ts.map +0 -1
- package/lib/passive-reply.js.map +0 -1
- package/lib/types.d.ts +0 -58
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/src/adapter.ts +0 -20
- package/src/bot.ts +0 -934
- package/src/types.ts +0 -60
- /package/{skills/wechat-mp/SKILL.md → agent/skills/wechat-mp.md} +0 -0
package/src/endpoint.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
/**
|
|
3
|
+
* WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
|
|
4
|
+
*/
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import type { EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
|
|
7
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
8
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
9
|
+
import type { CapabilityId } from 'zhin.js';
|
|
10
|
+
import {
|
|
11
|
+
extractOutboundText,
|
|
12
|
+
formatCustomerServiceBody,
|
|
13
|
+
formatInboundContent,
|
|
14
|
+
formatInboundId,
|
|
15
|
+
wechatMpInboundConversation,
|
|
16
|
+
type ResolvedWeChatMpConfig,
|
|
17
|
+
type WeChatMessage,
|
|
18
|
+
} from './protocol.js';
|
|
19
|
+
import {
|
|
20
|
+
buildMediaUploadForm,
|
|
21
|
+
readOutboundMedia,
|
|
22
|
+
resolveMediaBinary,
|
|
23
|
+
type WeChatMediaUploadResult,
|
|
24
|
+
} from './media-upload.js';
|
|
25
|
+
import {
|
|
26
|
+
getPassiveReplyCapture,
|
|
27
|
+
recordPassiveReplyText,
|
|
28
|
+
} from './passive-reply.js';
|
|
29
|
+
import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
30
|
+
import { receiveWeChatMpSideEvent } from './side-event-dispatch.js';
|
|
31
|
+
import { WeChatMpClient, type WeChatMpFetch } from './client.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
|
|
35
|
+
* 客服消息无 file 投递面,file 段不可投递。
|
|
36
|
+
*/
|
|
37
|
+
const WECHAT_UPLOAD_TYPE: Readonly<Record<string, 'image' | 'voice' | 'video'>> = {
|
|
38
|
+
image: 'image',
|
|
39
|
+
audio: 'voice',
|
|
40
|
+
voice: 'voice',
|
|
41
|
+
video: 'video',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface WeChatMpEndpointOptions {
|
|
45
|
+
readonly id: CapabilityId;
|
|
46
|
+
readonly http: HttpHost;
|
|
47
|
+
readonly config: ResolvedWeChatMpConfig;
|
|
48
|
+
readonly fetch?: WeChatMpFetch;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultFetch(
|
|
52
|
+
url: string,
|
|
53
|
+
init?: { readonly method?: string; readonly body?: unknown; readonly headers?: Record<string, string> },
|
|
54
|
+
): Promise<{ data: unknown }> {
|
|
55
|
+
return axios({
|
|
56
|
+
url,
|
|
57
|
+
method: (init?.method ?? 'GET') as 'GET' | 'POST',
|
|
58
|
+
data: init?.body,
|
|
59
|
+
headers: init?.headers,
|
|
60
|
+
}).then((response) => ({ data: response.data }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class WeChatMpEndpoint extends Endpoint<WeChatMpClient> {
|
|
64
|
+
readonly client: WeChatMpClient;
|
|
65
|
+
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
66
|
+
|
|
67
|
+
readonly #options: WeChatMpEndpointOptions;
|
|
68
|
+
readonly #fetch: WeChatMpFetch;
|
|
69
|
+
#routeReleases: HttpRouteRegistration[] = [];
|
|
70
|
+
#tokenRefreshTimer?: ReturnType<typeof setInterval>;
|
|
71
|
+
/** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
|
|
72
|
+
readonly #replyCache = new Map<string, string>();
|
|
73
|
+
static readonly #REPLY_CACHE_LIMIT = 1000;
|
|
74
|
+
#open = false;
|
|
75
|
+
#started = false;
|
|
76
|
+
readonly management: EndpointManagement = createWeChatMpEndpointManagement(() => this.client);
|
|
77
|
+
|
|
78
|
+
constructor(options: WeChatMpEndpointOptions) {
|
|
79
|
+
super();
|
|
80
|
+
this.#logger = getAdapterLogger('wechat-mp', options.config.id);
|
|
81
|
+
this.#options = options;
|
|
82
|
+
this.#fetch = options.fetch ?? defaultFetch;
|
|
83
|
+
this.client = new WeChatMpClient(options.config, this.#fetch);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Used by webhook handler. */
|
|
87
|
+
get isOpen(): boolean {
|
|
88
|
+
return this.#open;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get config(): ResolvedWeChatMpConfig {
|
|
92
|
+
return this.#options.config;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get id(): CapabilityId {
|
|
96
|
+
return this.#options.id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
100
|
+
getCachedReply(msgId: string): string | undefined {
|
|
101
|
+
return this.#replyCache.get(msgId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
cacheReply(msgId: string, replyXML: string): void {
|
|
105
|
+
if (this.#replyCache.has(msgId)) this.#replyCache.delete(msgId);
|
|
106
|
+
this.#replyCache.set(msgId, replyXML);
|
|
107
|
+
while (this.#replyCache.size > WeChatMpEndpoint.#REPLY_CACHE_LIMIT) {
|
|
108
|
+
const oldest = this.#replyCache.keys().next().value;
|
|
109
|
+
if (oldest === undefined) break;
|
|
110
|
+
this.#replyCache.delete(oldest);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async start(): Promise<void> {
|
|
115
|
+
if (this.#started) return;
|
|
116
|
+
this.#started = true;
|
|
117
|
+
try {
|
|
118
|
+
await this.client.refreshAccessToken();
|
|
119
|
+
this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, this));
|
|
120
|
+
this.#startTokenRefreshTimer();
|
|
121
|
+
this.#logger.debug(formatCompact({
|
|
122
|
+
endpoint: this.#options.config.id,
|
|
123
|
+
op: 'webhook',
|
|
124
|
+
path: this.#options.config.path,
|
|
125
|
+
}));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
await this.stop();
|
|
128
|
+
this.#logger.error('Failed to connect WeChat MP bot:', error);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
open(): void {
|
|
134
|
+
this.#open = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
close(): void {
|
|
138
|
+
this.#open = false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async stop(): Promise<void> {
|
|
142
|
+
this.#open = false;
|
|
143
|
+
if (this.#tokenRefreshTimer) {
|
|
144
|
+
clearInterval(this.#tokenRefreshTimer);
|
|
145
|
+
this.#tokenRefreshTimer = undefined;
|
|
146
|
+
}
|
|
147
|
+
for (const release of this.#routeReleases.splice(0)) release();
|
|
148
|
+
this.#started = false;
|
|
149
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
153
|
+
if (getPassiveReplyCapture()) {
|
|
154
|
+
const text = extractOutboundText(payload);
|
|
155
|
+
recordPassiveReplyText(text);
|
|
156
|
+
return `passive_${Date.now()}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (this.#options.config.replyMode === 'customer_service') {
|
|
160
|
+
return this.#sendCustomerService(conversation.id, payload);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.#logger.warn(formatCompact({
|
|
164
|
+
op: 'send',
|
|
165
|
+
skip: 'passive_outside_webhook',
|
|
166
|
+
endpoint: this.#options.config.id,
|
|
167
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
168
|
+
}));
|
|
169
|
+
return `passive_skipped_${Date.now()}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
173
|
+
admit(msg: WeChatMessage): void | Promise<unknown> {
|
|
174
|
+
if (!this.#open) return;
|
|
175
|
+
void this.emitPlatform(msg.Event ? `${msg.MsgType}.${msg.Event}` : msg.MsgType, msg).catch((error) => {
|
|
176
|
+
this.#logger.warn(formatCompact({
|
|
177
|
+
op: 'wechat_mp_platform_event_failed',
|
|
178
|
+
error: error instanceof Error ? error.message : String(error),
|
|
179
|
+
}));
|
|
180
|
+
});
|
|
181
|
+
if (receiveWeChatMpSideEvent(
|
|
182
|
+
(name, payload) => this.emit(name, payload),
|
|
183
|
+
this.#options.config.id,
|
|
184
|
+
msg,
|
|
185
|
+
this.#logger,
|
|
186
|
+
)) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
|
|
190
|
+
return this.emit('message.receive', {
|
|
191
|
+
conversation,
|
|
192
|
+
message: { conversation, id: formatInboundId(msg) },
|
|
193
|
+
content: formatInboundContent(msg),
|
|
194
|
+
sender: { id: msg.FromUserName },
|
|
195
|
+
endpointId: this.#options.config.id,
|
|
196
|
+
metadata: Object.freeze({
|
|
197
|
+
msgType: msg.MsgType,
|
|
198
|
+
event: msg.Event,
|
|
199
|
+
toUserName: msg.ToUserName,
|
|
200
|
+
}),
|
|
201
|
+
}).catch((err) => {
|
|
202
|
+
this.#logger.warn(formatCompact({
|
|
203
|
+
op: 'wechat_mp_gateway_receive_failed',
|
|
204
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
205
|
+
error: err instanceof Error ? err.message : String(err),
|
|
206
|
+
}));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async #sendCustomerService(target: string, payload: unknown): Promise<string> {
|
|
211
|
+
// 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
|
|
212
|
+
const materialized = await this.#materializeOutboundMedia(payload);
|
|
213
|
+
const messageData = formatCustomerServiceBody(target, materialized);
|
|
214
|
+
const result = await this.client.sendCustomerService(messageData);
|
|
215
|
+
if (result.errcode && result.errcode !== 0) {
|
|
216
|
+
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
|
|
217
|
+
}
|
|
218
|
+
this.#logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
|
|
219
|
+
return result.msgid?.toString() || `cs_${Date.now()}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 客服消息媒体段只接受 media_id:canonical MediaRef 是唯一来源。
|
|
224
|
+
* - kind=file(平台不透明引用,即既有 media_id)→ 直接透传;
|
|
225
|
+
* - kind=base64 / path / url → 经 /cgi-bin/media/upload 物化;
|
|
226
|
+
* - 无 MediaRef / 类型不可投递(file 段)→ warn + 丢弃;
|
|
227
|
+
* - 上传失败降级为文本(alt 优先),不阻断发送。
|
|
228
|
+
*/
|
|
229
|
+
async #materializeOutboundMedia(payload: unknown): Promise<unknown> {
|
|
230
|
+
if (!Array.isArray(payload)) return payload;
|
|
231
|
+
const materialized = await Promise.all(payload.map(async (item) => {
|
|
232
|
+
if (typeof item === 'string' || !item || typeof item !== 'object') return item;
|
|
233
|
+
const seg = item as { type?: unknown; data?: Record<string, unknown> };
|
|
234
|
+
if (typeof seg.type !== 'string') return item;
|
|
235
|
+
const data = seg.data ?? {};
|
|
236
|
+
const uploadType = WECHAT_UPLOAD_TYPE[seg.type];
|
|
237
|
+
const isMediaSegment = uploadType != null || seg.type === 'file';
|
|
238
|
+
if (!isMediaSegment) return item;
|
|
239
|
+
const media = readOutboundMedia(data);
|
|
240
|
+
if (!media) {
|
|
241
|
+
// 已物化(mediaId/media_id)的段透传;其余无 canonical 媒体引用,丢弃留痕
|
|
242
|
+
if (typeof data.mediaId === 'string' && data.mediaId) return item;
|
|
243
|
+
if (typeof data.media_id === 'string' && data.media_id) return item;
|
|
244
|
+
this.#logger.warn(formatCompact({
|
|
245
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
246
|
+
endpoint: this.#options.config.id,
|
|
247
|
+
type: seg.type,
|
|
248
|
+
reason: 'missing_media_ref',
|
|
249
|
+
}));
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
if (media.kind === 'file') {
|
|
253
|
+
// 平台不透明引用:value 即 media_id,直接透传不上传
|
|
254
|
+
return { type: seg.type, data: { mediaId: media.value } };
|
|
255
|
+
}
|
|
256
|
+
if (!uploadType) {
|
|
257
|
+
this.#logger.warn(formatCompact({
|
|
258
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
259
|
+
endpoint: this.#options.config.id,
|
|
260
|
+
type: seg.type,
|
|
261
|
+
reason: 'unsupported_segment_type',
|
|
262
|
+
}));
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const mediaId = await this.#uploadMedia(uploadType, media);
|
|
267
|
+
return { type: seg.type, data: { mediaId } };
|
|
268
|
+
} catch (error) {
|
|
269
|
+
this.#logger.warn(formatCompact({
|
|
270
|
+
op: 'wechat_mp_media_upload_failed',
|
|
271
|
+
endpoint: this.#options.config.id,
|
|
272
|
+
error: error instanceof Error ? error.message : String(error),
|
|
273
|
+
}));
|
|
274
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
|
|
275
|
+
return { type: 'text', data: { text: alt } };
|
|
276
|
+
}
|
|
277
|
+
}));
|
|
278
|
+
return materialized.filter((item) => item != null);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
282
|
+
async #uploadMedia(
|
|
283
|
+
type: 'image' | 'voice' | 'video',
|
|
284
|
+
media: Parameters<typeof resolveMediaBinary>[0],
|
|
285
|
+
): Promise<string> {
|
|
286
|
+
const binary = await resolveMediaBinary(media);
|
|
287
|
+
const form = buildMediaUploadForm(binary);
|
|
288
|
+
return this.client.uploadMedia(type, form);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#startTokenRefreshTimer(): void {
|
|
292
|
+
this.#tokenRefreshTimer = setInterval(() => {
|
|
293
|
+
if (this.client.tokenExpired) {
|
|
294
|
+
void this.client.refreshAccessToken().catch((error) => {
|
|
295
|
+
this.#logger.error('Failed to refresh access token in timer:', error);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}, 3_600_000);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function createWeChatMpEndpointManagement(
|
|
303
|
+
requireClient: () => WeChatMpClient,
|
|
304
|
+
): EndpointManagement {
|
|
305
|
+
return Object.freeze<EndpointManagement>({
|
|
306
|
+
// 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
|
|
307
|
+
listFriends: async () => (await requireClient().getFollowerIds()).map((openid) => ({
|
|
308
|
+
user_id: openid,
|
|
309
|
+
nickname: openid,
|
|
310
|
+
remark: '',
|
|
311
|
+
})),
|
|
312
|
+
});
|
|
313
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,39 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
export {
|
|
2
|
+
buildTextReply,
|
|
3
|
+
computeSignatureHash,
|
|
4
|
+
decryptEchostr,
|
|
5
|
+
decryptMessage,
|
|
6
|
+
encryptMessage,
|
|
7
|
+
extractOutboundText,
|
|
8
|
+
formatCustomerServiceBody,
|
|
9
|
+
formatInboundContent,
|
|
10
|
+
formatInboundId,
|
|
11
|
+
isEncryptedEchostr,
|
|
12
|
+
normalizeEchostrParam,
|
|
13
|
+
parseXMLMessage,
|
|
14
|
+
queryParam,
|
|
15
|
+
readTextBody,
|
|
16
|
+
resolveEventPassiveReply,
|
|
17
|
+
resolveWeChatMpConfig,
|
|
18
|
+
verifySignature,
|
|
19
|
+
type ResolvedWeChatMpConfig,
|
|
20
|
+
type TokenResponse,
|
|
21
|
+
type WeChatAPIResponse,
|
|
22
|
+
type WeChatMessage,
|
|
23
|
+
type WeChatMpAdapterConfig,
|
|
24
|
+
type WeChatWireSegment,
|
|
25
|
+
} from './protocol.js';
|
|
7
26
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
interface Adapters {
|
|
15
|
-
"wechat-mp": WeChatMPAdapter;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
27
|
+
export {
|
|
28
|
+
getPassiveReplyCapture,
|
|
29
|
+
recordPassiveReplyText,
|
|
30
|
+
runWithPassiveReplyCapture,
|
|
31
|
+
type PassiveReplyCapture,
|
|
32
|
+
} from './passive-reply.js';
|
|
18
33
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
export {
|
|
35
|
+
WeChatMpClient,
|
|
36
|
+
wechatMpClient,
|
|
37
|
+
type WeChatMpClientEventMap,
|
|
38
|
+
type WeChatMpFetch,
|
|
39
|
+
} from './client.js';
|
|
22
40
|
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
export {
|
|
42
|
+
WeChatMpEndpoint,
|
|
43
|
+
type WeChatMpEndpointOptions,
|
|
44
|
+
} from './endpoint.js';
|
|
25
45
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
export {
|
|
47
|
+
buildMediaUploadForm,
|
|
48
|
+
readOutboundMedia,
|
|
49
|
+
resolveMediaBinary,
|
|
50
|
+
type MediaBinary,
|
|
51
|
+
type WeChatMediaUploadResult,
|
|
52
|
+
} from './media-upload.js';
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
registerWeChatMpWebhookRoutes,
|
|
56
|
+
handleWeChatMpVerification,
|
|
57
|
+
handleWeChatMpMessage,
|
|
58
|
+
collectPassiveReply,
|
|
59
|
+
type WeChatMpWebhookHandler,
|
|
60
|
+
} from './webhook.js';
|
|
@@ -0,0 +1,82 @@
|
|
|
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, 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
|
+
if (media.kind === 'file') {
|
|
51
|
+
// 平台不透明引用(media_id):由调用方直接透传,不走上传。
|
|
52
|
+
throw new Error('MediaRef kind=file is a platform opaque reference; binary resolution not applicable');
|
|
53
|
+
}
|
|
54
|
+
return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function defaultDownload(url: string): Promise<Buffer> {
|
|
58
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
59
|
+
if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
|
|
60
|
+
return Buffer.from(await response.arrayBuffer());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
|
|
64
|
+
export function buildMediaUploadForm(binary: MediaBinary): FormData {
|
|
65
|
+
// Buffer 的 ArrayBufferLike 不满足 BlobPart(SharedArrayBuffer 分支),拷贝为 Uint8Array<ArrayBuffer>
|
|
66
|
+
const bytes = new Uint8Array(binary.data.byteLength);
|
|
67
|
+
bytes.set(binary.data);
|
|
68
|
+
const form = new FormData();
|
|
69
|
+
form.append('media', new Blob([bytes], { type: binary.mimeType }), binary.fileName);
|
|
70
|
+
return form;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
|
|
75
|
+
* 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
|
|
76
|
+
*/
|
|
77
|
+
export function readOutboundMedia(data: Record<string, unknown>): MediaRef | undefined {
|
|
78
|
+
if (typeof data.mediaId === 'string' && data.mediaId) return undefined;
|
|
79
|
+
if (typeof data.media_id === 'string' && data.media_id) return undefined;
|
|
80
|
+
if (isMediaRef(data.media)) return data.media;
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|