@zhin.js/adapter-wechat-mp 4.0.4 → 5.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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # @zhin.js/adapter-wechat-mp
2
2
 
3
+ ## 5.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [c8f4d45]
8
+ - @zhin.js/plugin-runtime@1.1.2
9
+ - @zhin.js/host-http@1.0.5
10
+ - @zhin.js/adapter@1.1.4
11
+ - @zhin.js/command@1.0.6
12
+ - @zhin.js/core@1.5.1
13
+ - zhin.js@6.0.1
14
+
15
+ ## 5.0.0
16
+
17
+ ### Patch Changes
18
+
19
+ - 4fbff5d: feat!: 多模态双向 Segment 一贯制(BREAKING,无兼容层)
20
+
21
+ 全框架唯一媒体表达统一为 canonical `Segment` + `MediaRef{kind: url|path|base64|file, value, mime_type?, file_name?, size?}`,新增 audio/video/file 段类型;所有第二形状(legacy `data.url/file/base64` 字段、`mediaRefFromLegacyData`/`mediaRefToLegacyFields` 桥、双写)全部删除。
22
+
23
+ - **core**:`SendContent` 一等支持 `Segment[]`;endpoint 出站载荷只含 canonical 段;`resolveOutboundMediaPolicy` 改为纯声明驱动(adapter definition `segments.outboundMedia`),内置策略表删除,未声明回退 `url-or-text`;`ImageContent` 旧桥删除。
24
+ - **ai**:新增 `MediaContentBlock`/`MediaBlockRef`(Segment 同构)与 `UserMessage.media`(当前 turn 媒体,**不持久化**——存储层自动剥离);`createUserMessage(text, media?)` 签名变更(`ImageContent` 删除);provider 边界序列化器 `filterMediaBlocksForProvider` + 能力表(缺省 image-only,不支持类型降级占位文本);ai-sdk 桥媒体块 → SDK image/file parts。
25
+ - **agent**:入站 turn 注入(`turn/inbound-media.ts`)——commMessage 媒体段 → 当前 turn `UserMessage.media`;图片 path 物化、音频默认 STT(`@zhin.js/speech` 可选,失败降级占位)、视频/文件占位;`publishOutboundElements` 产出 canonical Segment;`transcribeAudioPayload` 导出。
26
+ - **cli**:`bridgeRuntimeMessage` 回复链路媒体段透传,不再压平为文本(`$reply` 直达 normalize → adapter)。
27
+ - **全部 20 个平台适配器**:出站媒体只消费 `data.media`(url 直发 / base64 直发 / 平台上传 / 读盘),入站媒体产出 canonical `data.media`;`segments.outboundMedia` 声明与实际消费逐一核对修正;QQ 入站新增 canonical segments(image/audio/video/file/mention/face/reply),图片/语音/视频不再丢失。
28
+
29
+ 迁移:适配器/插件产媒体一律用 `{ type, data: { media: MediaRef } }`;发送 legacy `data.url/file/base64` 形状的段会被 warn 丢弃。
30
+
31
+ - Updated dependencies [7c1e63a]
32
+ - Updated dependencies [4fbff5d]
33
+ - Updated dependencies [5b94d9c]
34
+ - @zhin.js/command@1.0.5
35
+ - @zhin.js/adapter@1.1.3
36
+ - @zhin.js/core@1.5.0
37
+ - zhin.js@6.0.0
38
+
3
39
  ## 4.0.4
4
40
 
5
41
  ### Patch Changes
@@ -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
- // 客服消息图片经 /cgi-bin/media/upload 物化为 media_id(url 下载后上传);
15
- // 公众号无卡片交互面,交互段降级纯文本。
14
+ // 客服消息媒体统一经 /cgi-bin/media/upload 物化为 media_id(url 下载后上传,
15
+ // kind=file 的 MediaRef 视为既有 media_id 直传);公众号无卡片交互面,交互段降级纯文本。
16
16
  segments: {
17
- outboundMedia: ['url', 'upload'],
17
+ outboundMedia: ['upload'],
18
18
  interactive: 'text',
19
19
  },
20
20
  create(context) {
21
21
  const config = resolveWeChatMpConfig(context.config);
22
- // 注册到插件运行时状态(wechat-mp endpoint list 的"运行中"数据源)
22
+ // 注册到插件运行时状态(wechat-mp.endpoint list 的"运行中"数据源)
23
23
  context.use(wechatMpRuntimeStateToken).endpoints.set(config.name, {
24
24
  name: config.name,
25
25
  mode: 'webhook',
@@ -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
- // 客服消息图片经 /cgi-bin/media/upload 物化为 media_id(url 下载后上传);
20
- // 公众号无卡片交互面,交互段降级纯文本。
19
+ // 客服消息媒体统一经 /cgi-bin/media/upload 物化为 media_id(url 下载后上传,
20
+ // kind=file 的 MediaRef 视为既有 media_id 直传);公众号无卡片交互面,交互段降级纯文本。
21
21
  segments: {
22
- outboundMedia: ['url', 'upload'],
22
+ outboundMedia: ['upload'],
23
23
  interactive: 'text',
24
24
  },
25
25
  create(context) {
26
26
  const config = resolveWeChatMpConfig(context.config);
27
- // 注册到插件运行时状态(wechat-mp endpoint list 的"运行中"数据源)
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, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
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
- * 客服消息 image 段只接受 media_id:canonical MediaRef(base64/本地路径/URL)
169
- * 先经 /cgi-bin/media/upload 物化;上传失败降级为文本(alt 优先),不阻断发送。
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
- return Promise.all(payload.map(async (item) => {
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 !== 'image')
191
+ if (typeof seg.type !== 'string')
179
192
  return item;
180
193
  const data = seg.data ?? {};
181
- const media = readOutboundImageMedia(data);
182
- if (!media)
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('image', media);
186
- return { type: 'image', data: { mediaId } };
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 : '[image]';
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, readOutboundImageMedia, resolveMediaBinary, type MediaBinary, type WeChatMediaUploadResult, } from './media-upload.js';
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, readOutboundImageMedia, resolveMediaBinary, } from './media-upload.js';
4
+ export { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
5
5
  export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, } from './webhook.js';
@@ -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
- * 出站 image 段的媒体引用:已有 media_id 的视为已物化;
20
- * 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
19
+ * 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
20
+ * 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
21
21
  */
22
- export declare function readOutboundImageMedia(data: Record<string, unknown>): MediaRef | undefined;
22
+ export declare function readOutboundMedia(data: Record<string, unknown>): MediaRef | undefined;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import { basename } from 'node:path';
9
- import { isMediaRef, mediaRefFromLegacyData } from '@zhin.js/core';
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
- * 出站 image 段的媒体引用:已有 media_id 的视为已物化;
50
- * 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
53
+ * 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
54
+ * 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
51
55
  */
52
- export function readOutboundImageMedia(data) {
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 mediaRefFromLegacyData(data);
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
- : typeof payload === 'object'
241
- ? JSON.stringify(payload)
242
- : String(payload);
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 endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
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 endpoint` 命令共享(同一 owner generation)。
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": "4.0.4",
3
+ "version": "5.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,19 +8,19 @@
8
8
  "dependencies": {
9
9
  "axios": "^1.19.0",
10
10
  "xml2js": "^0.6.2",
11
- "@zhin.js/adapter": "1.1.2",
12
- "@zhin.js/command": "1.0.4",
13
- "@zhin.js/core": "1.4.3",
14
- "@zhin.js/host-http": "1.0.4",
11
+ "@zhin.js/adapter": "1.1.4",
12
+ "@zhin.js/command": "1.0.6",
13
+ "@zhin.js/core": "1.5.1",
14
+ "@zhin.js/host-http": "1.0.5",
15
15
  "@zhin.js/logger": "1.0.75",
16
- "@zhin.js/plugin-runtime": "1.1.1"
16
+ "@zhin.js/plugin-runtime": "1.1.2"
17
17
  },
18
18
  "peerDependencies": {
19
- "@zhin.js/adapter": "1.1.2",
20
- "@zhin.js/core": "1.4.3",
21
- "@zhin.js/host-http": "1.0.4",
22
- "@zhin.js/plugin-runtime": "1.1.1",
23
- "zhin.js": "5.0.3"
19
+ "@zhin.js/adapter": "1.1.4",
20
+ "@zhin.js/core": "1.5.1",
21
+ "@zhin.js/host-http": "1.0.5",
22
+ "@zhin.js/plugin-runtime": "1.1.2",
23
+ "zhin.js": "6.0.1"
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 endpoint list 的"运行中"数据源)
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
- readOutboundImageMedia,
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
- * 客服消息 image 段只接受 media_id:canonical MediaRef(base64/本地路径/URL)
221
- * 先经 /cgi-bin/media/upload 物化;上传失败降级为文本(alt 优先),不阻断发送。
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
- return Promise.all(payload.map(async (item) => {
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 !== 'image') return item;
242
+ if (typeof seg.type !== 'string') return item;
229
243
  const data = seg.data ?? {};
230
- const media = readOutboundImageMedia(data);
231
- if (!media) return item;
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('image', media);
234
- return { type: 'image', data: { mediaId } };
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 : '[image]';
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(type: 'image', media: Parameters<typeof resolveMediaBinary>[0]): Promise<string> {
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
@@ -39,7 +39,7 @@ export {
39
39
 
40
40
  export {
41
41
  buildMediaUploadForm,
42
- readOutboundImageMedia,
42
+ readOutboundMedia,
43
43
  resolveMediaBinary,
44
44
  type MediaBinary,
45
45
  type WeChatMediaUploadResult,
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import { basename } from 'node:path';
9
- import { isMediaRef, mediaRefFromLegacyData, type MediaRef } from '@zhin.js/core';
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
- * 出站 image 段的媒体引用:已有 media_id 的视为已物化;
71
- * 否则读 canonical `data.media`,兼容旧 wire `{url,file,base64}` 字段。
74
+ * 出站媒体段的媒体引用:已有 media_id 的视为已物化(返回 undefined 透传);
75
+ * 否则只读 canonical `data.media`(MediaRef-only,无 legacy 字段回退)。
72
76
  */
73
- export function readOutboundImageMedia(data: Record<string, unknown>): MediaRef | undefined {
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 mediaRefFromLegacyData(data);
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
- : typeof payload === 'object'
388
- ? JSON.stringify(payload)
389
- : String(payload);
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 endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
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 endpoint` 命令共享(同一 owner generation)。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `wechat-mp.endpoint` 命令共享(同一 owner generation)。
4
4
  */
5
5
  import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
6