@zhin.js/adapter-wechat-mp 4.0.4 → 5.0.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 +24 -0
- package/adapters/wechat-mp.js +4 -4
- package/adapters/wechat-mp.ts +4 -4
- package/lib/endpoint.js +53 -10
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/media-upload.d.ts +3 -3
- package/lib/media-upload.js +9 -5
- package/lib/protocol.js +6 -3
- package/lib/wechat-mp-endpoint-commands.js +1 -1
- package/lib/wechat-mp-runtime-state.js +1 -1
- package/package.json +7 -7
- package/plugin.js +1 -1
- package/src/endpoint.ts +56 -11
- package/src/index.ts +1 -1
- package/src/media-upload.ts +9 -5
- package/src/protocol.ts +6 -3
- package/src/wechat-mp-endpoint-commands.ts +1 -1
- package/src/wechat-mp-runtime-state.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @zhin.js/adapter-wechat-mp
|
|
2
2
|
|
|
3
|
+
## 5.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4fbff5d: feat!: 多模态双向 Segment 一贯制(BREAKING,无兼容层)
|
|
8
|
+
|
|
9
|
+
全框架唯一媒体表达统一为 canonical `Segment` + `MediaRef{kind: url|path|base64|file, value, mime_type?, file_name?, size?}`,新增 audio/video/file 段类型;所有第二形状(legacy `data.url/file/base64` 字段、`mediaRefFromLegacyData`/`mediaRefToLegacyFields` 桥、双写)全部删除。
|
|
10
|
+
|
|
11
|
+
- **core**:`SendContent` 一等支持 `Segment[]`;endpoint 出站载荷只含 canonical 段;`resolveOutboundMediaPolicy` 改为纯声明驱动(adapter definition `segments.outboundMedia`),内置策略表删除,未声明回退 `url-or-text`;`ImageContent` 旧桥删除。
|
|
12
|
+
- **ai**:新增 `MediaContentBlock`/`MediaBlockRef`(Segment 同构)与 `UserMessage.media`(当前 turn 媒体,**不持久化**——存储层自动剥离);`createUserMessage(text, media?)` 签名变更(`ImageContent` 删除);provider 边界序列化器 `filterMediaBlocksForProvider` + 能力表(缺省 image-only,不支持类型降级占位文本);ai-sdk 桥媒体块 → SDK image/file parts。
|
|
13
|
+
- **agent**:入站 turn 注入(`turn/inbound-media.ts`)——commMessage 媒体段 → 当前 turn `UserMessage.media`;图片 path 物化、音频默认 STT(`@zhin.js/speech` 可选,失败降级占位)、视频/文件占位;`publishOutboundElements` 产出 canonical Segment;`transcribeAudioPayload` 导出。
|
|
14
|
+
- **cli**:`bridgeRuntimeMessage` 回复链路媒体段透传,不再压平为文本(`$reply` 直达 normalize → adapter)。
|
|
15
|
+
- **全部 20 个平台适配器**:出站媒体只消费 `data.media`(url 直发 / base64 直发 / 平台上传 / 读盘),入站媒体产出 canonical `data.media`;`segments.outboundMedia` 声明与实际消费逐一核对修正;QQ 入站新增 canonical segments(image/audio/video/file/mention/face/reply),图片/语音/视频不再丢失。
|
|
16
|
+
|
|
17
|
+
迁移:适配器/插件产媒体一律用 `{ type, data: { media: MediaRef } }`;发送 legacy `data.url/file/base64` 形状的段会被 warn 丢弃。
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [7c1e63a]
|
|
20
|
+
- Updated dependencies [4fbff5d]
|
|
21
|
+
- Updated dependencies [5b94d9c]
|
|
22
|
+
- @zhin.js/command@1.0.5
|
|
23
|
+
- @zhin.js/adapter@1.1.3
|
|
24
|
+
- @zhin.js/core@1.5.0
|
|
25
|
+
- zhin.js@6.0.0
|
|
26
|
+
|
|
3
27
|
## 4.0.4
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/adapters/wechat-mp.js
CHANGED
|
@@ -11,15 +11,15 @@ import { wechatMpRuntimeStateToken } from "../lib/wechat-mp-runtime-state.js";
|
|
|
11
11
|
export { WeChatMpEndpoint } from "../lib/endpoint.js";
|
|
12
12
|
export default defineAdapter({
|
|
13
13
|
capabilities: ['inbound', 'outbound'],
|
|
14
|
-
//
|
|
15
|
-
//
|
|
14
|
+
// 客服消息媒体统一经 /cgi-bin/media/upload 物化为 media_id(url 下载后上传,
|
|
15
|
+
// kind=file 的 MediaRef 视为既有 media_id 直传);公众号无卡片交互面,交互段降级纯文本。
|
|
16
16
|
segments: {
|
|
17
|
-
outboundMedia: ['
|
|
17
|
+
outboundMedia: ['upload'],
|
|
18
18
|
interactive: 'text',
|
|
19
19
|
},
|
|
20
20
|
create(context) {
|
|
21
21
|
const config = resolveWeChatMpConfig(context.config);
|
|
22
|
-
// 注册到插件运行时状态(wechat-mp
|
|
22
|
+
// 注册到插件运行时状态(wechat-mp.endpoint list 的"运行中"数据源)
|
|
23
23
|
context.use(wechatMpRuntimeStateToken).endpoints.set(config.name, {
|
|
24
24
|
name: config.name,
|
|
25
25
|
mode: 'webhook',
|
package/adapters/wechat-mp.ts
CHANGED
|
@@ -16,15 +16,15 @@ export type { WeChatMpEndpointOptions, WeChatMpFetch } from '../src/endpoint.js'
|
|
|
16
16
|
|
|
17
17
|
export default defineAdapter<WeChatMpAdapterConfig>({
|
|
18
18
|
capabilities: ['inbound', 'outbound'],
|
|
19
|
-
//
|
|
20
|
-
//
|
|
19
|
+
// 客服消息媒体统一经 /cgi-bin/media/upload 物化为 media_id(url 下载后上传,
|
|
20
|
+
// kind=file 的 MediaRef 视为既有 media_id 直传);公众号无卡片交互面,交互段降级纯文本。
|
|
21
21
|
segments: {
|
|
22
|
-
outboundMedia: ['
|
|
22
|
+
outboundMedia: ['upload'],
|
|
23
23
|
interactive: 'text',
|
|
24
24
|
},
|
|
25
25
|
create(context) {
|
|
26
26
|
const config = resolveWeChatMpConfig(context.config);
|
|
27
|
-
// 注册到插件运行时状态(wechat-mp
|
|
27
|
+
// 注册到插件运行时状态(wechat-mp.endpoint list 的"运行中"数据源)
|
|
28
28
|
context.use(wechatMpRuntimeStateToken).endpoints.set(config.name, {
|
|
29
29
|
name: config.name,
|
|
30
30
|
mode: 'webhook',
|
package/lib/endpoint.js
CHANGED
|
@@ -4,12 +4,22 @@
|
|
|
4
4
|
import axios from 'axios';
|
|
5
5
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
6
6
|
import { extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, } from './protocol.js';
|
|
7
|
-
import { buildMediaUploadForm,
|
|
7
|
+
import { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
|
|
8
8
|
import { getPassiveReplyCapture, recordPassiveReplyText, } from './passive-reply.js';
|
|
9
9
|
import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
10
10
|
const logger = getLogger('wechat-mp');
|
|
11
11
|
/** token 失效类错误码:40001/40014 invalid access_token、42001 access_token expired。 */
|
|
12
12
|
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
13
|
+
/**
|
|
14
|
+
* canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
|
|
15
|
+
* 客服消息无 file 投递面,file 段不可投递。
|
|
16
|
+
*/
|
|
17
|
+
const WECHAT_UPLOAD_TYPE = {
|
|
18
|
+
image: 'image',
|
|
19
|
+
audio: 'voice',
|
|
20
|
+
voice: 'voice',
|
|
21
|
+
video: 'video',
|
|
22
|
+
};
|
|
13
23
|
function defaultFetch(url, init) {
|
|
14
24
|
return axios({
|
|
15
25
|
url,
|
|
@@ -165,25 +175,57 @@ export class WeChatMpEndpoint {
|
|
|
165
175
|
return result.msgid?.toString() || `cs_${Date.now()}`;
|
|
166
176
|
}
|
|
167
177
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
178
|
+
* 客服消息媒体段只接受 media_id:canonical MediaRef 是唯一来源。
|
|
179
|
+
* - kind=file(平台不透明引用,即既有 media_id)→ 直接透传;
|
|
180
|
+
* - kind=base64 / path / url → 经 /cgi-bin/media/upload 物化;
|
|
181
|
+
* - 无 MediaRef / 类型不可投递(file 段)→ warn + 丢弃;
|
|
182
|
+
* - 上传失败降级为文本(alt 优先),不阻断发送。
|
|
170
183
|
*/
|
|
171
184
|
async #materializeOutboundMedia(payload) {
|
|
172
185
|
if (!Array.isArray(payload))
|
|
173
186
|
return payload;
|
|
174
|
-
|
|
187
|
+
const materialized = await Promise.all(payload.map(async (item) => {
|
|
175
188
|
if (typeof item === 'string' || !item || typeof item !== 'object')
|
|
176
189
|
return item;
|
|
177
190
|
const seg = item;
|
|
178
|
-
if (seg.type !== '
|
|
191
|
+
if (typeof seg.type !== 'string')
|
|
179
192
|
return item;
|
|
180
193
|
const data = seg.data ?? {};
|
|
181
|
-
const
|
|
182
|
-
|
|
194
|
+
const uploadType = WECHAT_UPLOAD_TYPE[seg.type];
|
|
195
|
+
const isMediaSegment = uploadType != null || seg.type === 'file';
|
|
196
|
+
if (!isMediaSegment)
|
|
183
197
|
return item;
|
|
198
|
+
const media = readOutboundMedia(data);
|
|
199
|
+
if (!media) {
|
|
200
|
+
// 已物化(mediaId/media_id)的段透传;其余无 canonical 媒体引用,丢弃留痕
|
|
201
|
+
if (typeof data.mediaId === 'string' && data.mediaId)
|
|
202
|
+
return item;
|
|
203
|
+
if (typeof data.media_id === 'string' && data.media_id)
|
|
204
|
+
return item;
|
|
205
|
+
logger.warn(formatCompact({
|
|
206
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
207
|
+
endpoint: this.#options.config.name,
|
|
208
|
+
type: seg.type,
|
|
209
|
+
reason: 'missing_media_ref',
|
|
210
|
+
}));
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
if (media.kind === 'file') {
|
|
214
|
+
// 平台不透明引用:value 即 media_id,直接透传不上传
|
|
215
|
+
return { type: seg.type, data: { mediaId: media.value } };
|
|
216
|
+
}
|
|
217
|
+
if (!uploadType) {
|
|
218
|
+
logger.warn(formatCompact({
|
|
219
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
220
|
+
endpoint: this.#options.config.name,
|
|
221
|
+
type: seg.type,
|
|
222
|
+
reason: 'unsupported_segment_type',
|
|
223
|
+
}));
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
184
226
|
try {
|
|
185
|
-
const mediaId = await this.#uploadMedia(
|
|
186
|
-
return { type:
|
|
227
|
+
const mediaId = await this.#uploadMedia(uploadType, media);
|
|
228
|
+
return { type: seg.type, data: { mediaId } };
|
|
187
229
|
}
|
|
188
230
|
catch (error) {
|
|
189
231
|
logger.warn(formatCompact({
|
|
@@ -191,10 +233,11 @@ export class WeChatMpEndpoint {
|
|
|
191
233
|
endpoint: this.#options.config.name,
|
|
192
234
|
error: error instanceof Error ? error.message : String(error),
|
|
193
235
|
}));
|
|
194
|
-
const alt = typeof data.alt === 'string' && data.alt ? data.alt :
|
|
236
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
|
|
195
237
|
return { type: 'text', data: { text: alt } };
|
|
196
238
|
}
|
|
197
239
|
}));
|
|
240
|
+
return materialized.filter((item) => item != null);
|
|
198
241
|
}
|
|
199
242
|
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
200
243
|
async #uploadMedia(type, media) {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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,
|
|
4
|
+
export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, type MediaBinary, type WeChatMediaUploadResult, } from './media-upload.js';
|
|
5
5
|
export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, type WeChatMpWebhookHandler, } from './webhook.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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,
|
|
4
|
+
export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
|
|
5
5
|
export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, } from './webhook.js';
|
package/lib/media-upload.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export declare function resolveMediaBinary(media: MediaRef, download?: (url: str
|
|
|
16
16
|
/** 临时素材上传的 multipart body(字段名固定为 `media`)。 */
|
|
17
17
|
export declare function buildMediaUploadForm(binary: MediaBinary): FormData;
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
|
|
20
|
+
* 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
|
|
21
21
|
*/
|
|
22
|
-
export declare function
|
|
22
|
+
export declare function readOutboundMedia(data: Record<string, unknown>): MediaRef | undefined;
|
package/lib/media-upload.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
8
|
import { basename } from 'node:path';
|
|
9
|
-
import { isMediaRef
|
|
9
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
10
10
|
const MIME_EXT = {
|
|
11
11
|
'image/jpeg': 'jpg',
|
|
12
12
|
'image/png': 'png',
|
|
@@ -28,6 +28,10 @@ export async function resolveMediaBinary(media, download = defaultDownload) {
|
|
|
28
28
|
const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
|
|
29
29
|
return { data: await readFile(path), mimeType, fileName: basename(path) };
|
|
30
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
|
+
}
|
|
31
35
|
return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
|
|
32
36
|
}
|
|
33
37
|
async function defaultDownload(url) {
|
|
@@ -46,15 +50,15 @@ export function buildMediaUploadForm(binary) {
|
|
|
46
50
|
return form;
|
|
47
51
|
}
|
|
48
52
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
53
|
+
* 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
|
|
54
|
+
* 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
|
|
51
55
|
*/
|
|
52
|
-
export function
|
|
56
|
+
export function readOutboundMedia(data) {
|
|
53
57
|
if (typeof data.mediaId === 'string' && data.mediaId)
|
|
54
58
|
return undefined;
|
|
55
59
|
if (typeof data.media_id === 'string' && data.media_id)
|
|
56
60
|
return undefined;
|
|
57
61
|
if (isMediaRef(data.media))
|
|
58
62
|
return data.media;
|
|
59
|
-
return
|
|
63
|
+
return undefined;
|
|
60
64
|
}
|
package/lib/protocol.js
CHANGED
|
@@ -237,9 +237,11 @@ export function formatCustomerServiceBody(target, payload) {
|
|
|
237
237
|
if (segments.length === 0) {
|
|
238
238
|
messageData.text.content = payload == null
|
|
239
239
|
? ''
|
|
240
|
-
:
|
|
241
|
-
?
|
|
242
|
-
:
|
|
240
|
+
: Array.isArray(payload)
|
|
241
|
+
? ''
|
|
242
|
+
: typeof payload === 'object'
|
|
243
|
+
? JSON.stringify(payload)
|
|
244
|
+
: String(payload);
|
|
243
245
|
return messageData;
|
|
244
246
|
}
|
|
245
247
|
const textParts = [];
|
|
@@ -263,6 +265,7 @@ export function formatCustomerServiceBody(target, payload) {
|
|
|
263
265
|
}
|
|
264
266
|
break;
|
|
265
267
|
case 'voice':
|
|
268
|
+
case 'audio':
|
|
266
269
|
if (!hasMedia && data.mediaId) {
|
|
267
270
|
messageData.msgtype = 'voice';
|
|
268
271
|
messageData.voice = { media_id: data.mediaId };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `wechat-mp
|
|
2
|
+
* `wechat-mp.endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
3
|
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
4
|
*/
|
|
5
5
|
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WeChat MP 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
-
* 由 plugin.ts setup() provide,adapter create 与 `wechat-mp
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `wechat-mp.endpoint` 命令共享(同一 owner generation)。
|
|
4
4
|
*/
|
|
5
5
|
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
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": "5.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zhin.js WeChat Official Account adapter for Plugin Runtime (HTTP webhook)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -8,19 +8,19 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"axios": "^1.19.0",
|
|
10
10
|
"xml2js": "^0.6.2",
|
|
11
|
-
"@zhin.js/adapter": "1.1.
|
|
12
|
-
"@zhin.js/command": "1.0.
|
|
13
|
-
"@zhin.js/core": "1.
|
|
11
|
+
"@zhin.js/adapter": "1.1.3",
|
|
12
|
+
"@zhin.js/command": "1.0.5",
|
|
13
|
+
"@zhin.js/core": "1.5.0",
|
|
14
14
|
"@zhin.js/host-http": "1.0.4",
|
|
15
15
|
"@zhin.js/logger": "1.0.75",
|
|
16
16
|
"@zhin.js/plugin-runtime": "1.1.1"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@zhin.js/adapter": "1.1.
|
|
20
|
-
"@zhin.js/core": "1.
|
|
19
|
+
"@zhin.js/adapter": "1.1.3",
|
|
20
|
+
"@zhin.js/core": "1.5.0",
|
|
21
21
|
"@zhin.js/host-http": "1.0.4",
|
|
22
22
|
"@zhin.js/plugin-runtime": "1.1.1",
|
|
23
|
-
"zhin.js": "
|
|
23
|
+
"zhin.js": "6.0.0"
|
|
24
24
|
},
|
|
25
25
|
"peerDependenciesMeta": {
|
|
26
26
|
"zhin.js": {
|
package/plugin.js
CHANGED
|
@@ -8,7 +8,7 @@ export default definePlugin({
|
|
|
8
8
|
displayName: 'WeChat Official Account Adapter',
|
|
9
9
|
},
|
|
10
10
|
setup(context) {
|
|
11
|
-
// 运行中 endpoint 注册表(wechat-mp
|
|
11
|
+
// 运行中 endpoint 注册表(wechat-mp.endpoint list 的"运行中"数据源)
|
|
12
12
|
context.resources.provide(wechatMpRuntimeStateToken, createEndpointRuntimeState());
|
|
13
13
|
},
|
|
14
14
|
});
|
package/src/endpoint.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from './protocol.js';
|
|
20
20
|
import {
|
|
21
21
|
buildMediaUploadForm,
|
|
22
|
-
|
|
22
|
+
readOutboundMedia,
|
|
23
23
|
resolveMediaBinary,
|
|
24
24
|
type WeChatMediaUploadResult,
|
|
25
25
|
} from './media-upload.js';
|
|
@@ -34,6 +34,17 @@ const logger = getLogger('wechat-mp');
|
|
|
34
34
|
/** token 失效类错误码:40001/40014 invalid access_token、42001 access_token expired。 */
|
|
35
35
|
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
|
|
39
|
+
* 客服消息无 file 投递面,file 段不可投递。
|
|
40
|
+
*/
|
|
41
|
+
const WECHAT_UPLOAD_TYPE: Readonly<Record<string, 'image' | 'voice' | 'video'>> = {
|
|
42
|
+
image: 'image',
|
|
43
|
+
audio: 'voice',
|
|
44
|
+
voice: 'voice',
|
|
45
|
+
video: 'video',
|
|
46
|
+
};
|
|
47
|
+
|
|
37
48
|
export type WeChatMpFetch = (
|
|
38
49
|
url: string,
|
|
39
50
|
init?: { readonly method?: string; readonly body?: unknown; readonly headers?: Record<string, string> },
|
|
@@ -217,35 +228,69 @@ export class WeChatMpEndpoint implements EndpointInstance {
|
|
|
217
228
|
}
|
|
218
229
|
|
|
219
230
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
231
|
+
* 客服消息媒体段只接受 media_id:canonical MediaRef 是唯一来源。
|
|
232
|
+
* - kind=file(平台不透明引用,即既有 media_id)→ 直接透传;
|
|
233
|
+
* - kind=base64 / path / url → 经 /cgi-bin/media/upload 物化;
|
|
234
|
+
* - 无 MediaRef / 类型不可投递(file 段)→ warn + 丢弃;
|
|
235
|
+
* - 上传失败降级为文本(alt 优先),不阻断发送。
|
|
222
236
|
*/
|
|
223
237
|
async #materializeOutboundMedia(payload: unknown): Promise<unknown> {
|
|
224
238
|
if (!Array.isArray(payload)) return payload;
|
|
225
|
-
|
|
239
|
+
const materialized = await Promise.all(payload.map(async (item) => {
|
|
226
240
|
if (typeof item === 'string' || !item || typeof item !== 'object') return item;
|
|
227
241
|
const seg = item as { type?: unknown; data?: Record<string, unknown> };
|
|
228
|
-
if (seg.type !== '
|
|
242
|
+
if (typeof seg.type !== 'string') return item;
|
|
229
243
|
const data = seg.data ?? {};
|
|
230
|
-
const
|
|
231
|
-
|
|
244
|
+
const uploadType = WECHAT_UPLOAD_TYPE[seg.type];
|
|
245
|
+
const isMediaSegment = uploadType != null || seg.type === 'file';
|
|
246
|
+
if (!isMediaSegment) return item;
|
|
247
|
+
const media = readOutboundMedia(data);
|
|
248
|
+
if (!media) {
|
|
249
|
+
// 已物化(mediaId/media_id)的段透传;其余无 canonical 媒体引用,丢弃留痕
|
|
250
|
+
if (typeof data.mediaId === 'string' && data.mediaId) return item;
|
|
251
|
+
if (typeof data.media_id === 'string' && data.media_id) return item;
|
|
252
|
+
logger.warn(formatCompact({
|
|
253
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
254
|
+
endpoint: this.#options.config.name,
|
|
255
|
+
type: seg.type,
|
|
256
|
+
reason: 'missing_media_ref',
|
|
257
|
+
}));
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
if (media.kind === 'file') {
|
|
261
|
+
// 平台不透明引用:value 即 media_id,直接透传不上传
|
|
262
|
+
return { type: seg.type, data: { mediaId: media.value } };
|
|
263
|
+
}
|
|
264
|
+
if (!uploadType) {
|
|
265
|
+
logger.warn(formatCompact({
|
|
266
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
267
|
+
endpoint: this.#options.config.name,
|
|
268
|
+
type: seg.type,
|
|
269
|
+
reason: 'unsupported_segment_type',
|
|
270
|
+
}));
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
232
273
|
try {
|
|
233
|
-
const mediaId = await this.#uploadMedia(
|
|
234
|
-
return { type:
|
|
274
|
+
const mediaId = await this.#uploadMedia(uploadType, media);
|
|
275
|
+
return { type: seg.type, data: { mediaId } };
|
|
235
276
|
} catch (error) {
|
|
236
277
|
logger.warn(formatCompact({
|
|
237
278
|
op: 'wechat_mp_media_upload_failed',
|
|
238
279
|
endpoint: this.#options.config.name,
|
|
239
280
|
error: error instanceof Error ? error.message : String(error),
|
|
240
281
|
}));
|
|
241
|
-
const alt = typeof data.alt === 'string' && data.alt ? data.alt :
|
|
282
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
|
|
242
283
|
return { type: 'text', data: { text: alt } };
|
|
243
284
|
}
|
|
244
285
|
}));
|
|
286
|
+
return materialized.filter((item) => item != null);
|
|
245
287
|
}
|
|
246
288
|
|
|
247
289
|
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
248
|
-
async #uploadMedia(
|
|
290
|
+
async #uploadMedia(
|
|
291
|
+
type: 'image' | 'voice' | 'video',
|
|
292
|
+
media: Parameters<typeof resolveMediaBinary>[0],
|
|
293
|
+
): Promise<string> {
|
|
249
294
|
const binary = await resolveMediaBinary(media);
|
|
250
295
|
const form = buildMediaUploadForm(binary);
|
|
251
296
|
const url = `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${this.#accessToken}&type=${type}`;
|
package/src/index.ts
CHANGED
package/src/media-upload.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
8
|
import { basename } from 'node:path';
|
|
9
|
-
import { isMediaRef,
|
|
9
|
+
import { isMediaRef, type MediaRef } from '@zhin.js/core';
|
|
10
10
|
|
|
11
11
|
export interface MediaBinary {
|
|
12
12
|
readonly data: Buffer;
|
|
@@ -47,6 +47,10 @@ export async function resolveMediaBinary(
|
|
|
47
47
|
const path = media.value.startsWith('file://') ? media.value.slice('file://'.length) : media.value;
|
|
48
48
|
return { data: await readFile(path), mimeType, fileName: basename(path) };
|
|
49
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
|
+
}
|
|
50
54
|
return { data: await download(media.value), mimeType, fileName: `image.${ext}` };
|
|
51
55
|
}
|
|
52
56
|
|
|
@@ -67,12 +71,12 @@ export function buildMediaUploadForm(binary: MediaBinary): FormData {
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
74
|
+
* 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
|
|
75
|
+
* 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
|
|
72
76
|
*/
|
|
73
|
-
export function
|
|
77
|
+
export function readOutboundMedia(data: Record<string, unknown>): MediaRef | undefined {
|
|
74
78
|
if (typeof data.mediaId === 'string' && data.mediaId) return undefined;
|
|
75
79
|
if (typeof data.media_id === 'string' && data.media_id) return undefined;
|
|
76
80
|
if (isMediaRef(data.media)) return data.media;
|
|
77
|
-
return
|
|
81
|
+
return undefined;
|
|
78
82
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -384,9 +384,11 @@ export function formatCustomerServiceBody(
|
|
|
384
384
|
if (segments.length === 0) {
|
|
385
385
|
(messageData.text as { content: string }).content = payload == null
|
|
386
386
|
? ''
|
|
387
|
-
:
|
|
388
|
-
?
|
|
389
|
-
:
|
|
387
|
+
: Array.isArray(payload)
|
|
388
|
+
? ''
|
|
389
|
+
: typeof payload === 'object'
|
|
390
|
+
? JSON.stringify(payload)
|
|
391
|
+
: String(payload);
|
|
390
392
|
return messageData;
|
|
391
393
|
}
|
|
392
394
|
|
|
@@ -412,6 +414,7 @@ export function formatCustomerServiceBody(
|
|
|
412
414
|
}
|
|
413
415
|
break;
|
|
414
416
|
case 'voice':
|
|
417
|
+
case 'audio':
|
|
415
418
|
if (!hasMedia && data.mediaId) {
|
|
416
419
|
messageData.msgtype = 'voice';
|
|
417
420
|
messageData.voice = { media_id: data.mediaId };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `wechat-mp
|
|
2
|
+
* `wechat-mp.endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
3
|
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
4
|
*/
|
|
5
5
|
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WeChat MP 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
-
* 由 plugin.ts setup() provide,adapter create 与 `wechat-mp
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `wechat-mp.endpoint` 命令共享(同一 owner generation)。
|
|
4
4
|
*/
|
|
5
5
|
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
6
|
|