@zhin.js/adapter-weixin-ilink 4.0.3 → 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,40 @@
1
1
  # @zhin.js/adapter-weixin-ilink
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/adapter@1.1.4
10
+ - @zhin.js/command@1.0.6
11
+ - @zhin.js/core@1.5.1
12
+ - zhin.js@6.0.1
13
+
14
+ ## 5.0.0
15
+
16
+ ### Patch Changes
17
+
18
+ - 4fbff5d: feat!: 多模态双向 Segment 一贯制(BREAKING,无兼容层)
19
+
20
+ 全框架唯一媒体表达统一为 canonical `Segment` + `MediaRef{kind: url|path|base64|file, value, mime_type?, file_name?, size?}`,新增 audio/video/file 段类型;所有第二形状(legacy `data.url/file/base64` 字段、`mediaRefFromLegacyData`/`mediaRefToLegacyFields` 桥、双写)全部删除。
21
+
22
+ - **core**:`SendContent` 一等支持 `Segment[]`;endpoint 出站载荷只含 canonical 段;`resolveOutboundMediaPolicy` 改为纯声明驱动(adapter definition `segments.outboundMedia`),内置策略表删除,未声明回退 `url-or-text`;`ImageContent` 旧桥删除。
23
+ - **ai**:新增 `MediaContentBlock`/`MediaBlockRef`(Segment 同构)与 `UserMessage.media`(当前 turn 媒体,**不持久化**——存储层自动剥离);`createUserMessage(text, media?)` 签名变更(`ImageContent` 删除);provider 边界序列化器 `filterMediaBlocksForProvider` + 能力表(缺省 image-only,不支持类型降级占位文本);ai-sdk 桥媒体块 → SDK image/file parts。
24
+ - **agent**:入站 turn 注入(`turn/inbound-media.ts`)——commMessage 媒体段 → 当前 turn `UserMessage.media`;图片 path 物化、音频默认 STT(`@zhin.js/speech` 可选,失败降级占位)、视频/文件占位;`publishOutboundElements` 产出 canonical Segment;`transcribeAudioPayload` 导出。
25
+ - **cli**:`bridgeRuntimeMessage` 回复链路媒体段透传,不再压平为文本(`$reply` 直达 normalize → adapter)。
26
+ - **全部 20 个平台适配器**:出站媒体只消费 `data.media`(url 直发 / base64 直发 / 平台上传 / 读盘),入站媒体产出 canonical `data.media`;`segments.outboundMedia` 声明与实际消费逐一核对修正;QQ 入站新增 canonical segments(image/audio/video/file/mention/face/reply),图片/语音/视频不再丢失。
27
+
28
+ 迁移:适配器/插件产媒体一律用 `{ type, data: { media: MediaRef } }`;发送 legacy `data.url/file/base64` 形状的段会被 warn 丢弃。
29
+
30
+ - Updated dependencies [7c1e63a]
31
+ - Updated dependencies [4fbff5d]
32
+ - Updated dependencies [5b94d9c]
33
+ - @zhin.js/command@1.0.5
34
+ - @zhin.js/adapter@1.1.3
35
+ - @zhin.js/core@1.5.0
36
+ - zhin.js@6.0.0
37
+
3
38
  ## 4.0.3
4
39
 
5
40
  ### Patch Changes
@@ -10,14 +10,15 @@ import { weixinIlinkRuntimeStateToken } from "../lib/weixin-ilink-runtime-state.
10
10
  export { WeixinIlinkEndpoint } from "../lib/endpoint.js";
11
11
  export default defineAdapter({
12
12
  capabilities: ['inbound', 'outbound'],
13
- // base64 / 远程 URL 落盘后走 CDN 上传(sendWeixinMediaFile);微信无卡片交互面,交互段降级纯文本。
13
+ // 所有媒体(url 下载 / base64 落盘 / 本地 path)统一物化后走 CDN 上传(sendWeixinMediaFile),
14
+ // 无直发面;微信无卡片交互面,交互段降级纯文本。
14
15
  segments: {
15
- outboundMedia: ['base64', 'url', 'upload'],
16
+ outboundMedia: ['upload'],
16
17
  interactive: 'text',
17
18
  },
18
19
  create(context) {
19
20
  const config = resolveWeixinIlinkConfig(context.config);
20
- // 注册到插件运行时状态(weixin-ilink endpoint list 的"运行中"数据源)
21
+ // 注册到插件运行时状态(weixin-ilink.endpoint list 的"运行中"数据源)
21
22
  context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.name, {
22
23
  name: config.name,
23
24
  mode: config.connection,
@@ -21,14 +21,15 @@ export type {
21
21
 
22
22
  export default defineAdapter<WeixinIlinkAdapterConfig>({
23
23
  capabilities: ['inbound', 'outbound'],
24
- // base64 / 远程 URL 落盘后走 CDN 上传(sendWeixinMediaFile);微信无卡片交互面,交互段降级纯文本。
24
+ // 所有媒体(url 下载 / base64 落盘 / 本地 path)统一物化后走 CDN 上传(sendWeixinMediaFile),
25
+ // 无直发面;微信无卡片交互面,交互段降级纯文本。
25
26
  segments: {
26
- outboundMedia: ['base64', 'url', 'upload'],
27
+ outboundMedia: ['upload'],
27
28
  interactive: 'text',
28
29
  },
29
30
  create(context) {
30
31
  const config = resolveWeixinIlinkConfig(context.config);
31
- // 注册到插件运行时状态(weixin-ilink endpoint list 的"运行中"数据源)
32
+ // 注册到插件运行时状态(weixin-ilink.endpoint list 的"运行中"数据源)
32
33
  context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.name, {
33
34
  name: config.name,
34
35
  mode: config.connection,
@@ -1,5 +1,4 @@
1
1
  import type { WeixinWireSegment } from './protocol.js';
2
2
  export type OutboundWireContent = string | WeixinWireSegment | ReadonlyArray<string | WeixinWireSegment>;
3
- export declare function segmentMediaRef(seg: WeixinWireSegment): string | undefined;
4
- /** 将 base64 / 远程图落盘,供 sendWeixinMediaFile 上传 */
3
+ /** 将 canonical MediaRef 媒体段物化为本地文件(kind=path),供 sendWeixinMediaFile 上传 */
5
4
  export declare function materializeOutboundMedia(content: OutboundWireContent, outboundDir: string): Promise<OutboundWireContent>;
@@ -1,59 +1,37 @@
1
1
  /**
2
- * 微信 iLink 出站媒体:base64 / 远程 URL 须落盘后再走 CDN 上传。
2
+ * 微信 iLink 出站媒体物化:canonical MediaRef(`data.media`,MediaRef-only)→ 本地文件,
3
+ * 再由 sendWeixinMediaFile 走 CDN 上传。
4
+ * - kind=url → 下载落盘后上传;
5
+ * - kind=base64 → 解码落盘后上传;
6
+ * - kind=path → 本地文件直接上传;
7
+ * - kind=file → 微信无不透明平台引用投递面,warn 丢弃。
8
+ * 无 media 或物化失败的媒体段 warn 丢弃(weixin_ilink_outbound_media_dropped)。
3
9
  */
4
10
  import crypto from 'node:crypto';
5
11
  import fs from 'node:fs';
6
12
  import path from 'node:path';
13
+ import { isMediaRef } from '@zhin.js/core';
14
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
15
  import { downloadRemoteImageToTemp } from './upload.js';
8
16
  import { getExtensionFromMime } from './mime.js';
17
+ const logger = getLogger('weixin-ilink');
9
18
  const MEDIA_TYPES = new Set(['image', 'video', 'file', 'record', 'audio']);
10
- function isRemoteUrl(value) {
11
- return /^https?:\/\//i.test(value);
19
+ function dropMediaSegment(type, reason) {
20
+ logger.warn(formatCompact({
21
+ op: 'weixin_ilink_outbound_media_dropped',
22
+ type,
23
+ reason,
24
+ }));
25
+ return null;
12
26
  }
13
- function isInlineMediaRef(value) {
14
- return value.startsWith('base64://') || /^data:[^/]+\/[^;]+;base64,/i.test(value);
15
- }
16
- function normalizeLocalPath(value) {
17
- return value.replace(/^file:\/\//, '');
18
- }
19
- export function segmentMediaRef(seg) {
20
- const data = seg.data;
21
- if (!data)
22
- return undefined;
23
- const candidates = [data.file, data.path, data.url].filter((v) => typeof v === 'string' && Boolean(v));
24
- return candidates[0];
25
- }
26
- function extractBase64Buffer(data) {
27
- const url = typeof data.url === 'string' ? data.url : undefined;
28
- const file = typeof data.file === 'string' ? data.file : undefined;
29
- const base64 = typeof data.base64 === 'string' ? data.base64 : undefined;
30
- if (url?.startsWith('base64://')) {
31
- return Buffer.from(url.slice(9), 'base64');
32
- }
33
- if (url && /^data:[^/]+\/[^;]+;base64,/i.test(url)) {
34
- return Buffer.from(url.replace(/^data:[^/]+\/[^;]+;base64,/, ''), 'base64');
35
- }
36
- if (file?.startsWith('base64://')) {
37
- return Buffer.from(file.slice(9), 'base64');
38
- }
39
- if (base64) {
40
- const raw = base64.startsWith('base64://') ? base64.slice(9) : base64;
41
- return Buffer.from(raw, 'base64');
42
- }
43
- return undefined;
44
- }
45
- function resolveExt(seg, mime) {
46
- const data = (seg.data ?? {});
47
- const name = typeof data.name === 'string' ? data.name : undefined;
48
- if (name) {
49
- const ext = path.extname(name);
27
+ function resolveExt(seg, media) {
28
+ if (media.file_name) {
29
+ const ext = path.extname(media.file_name);
50
30
  if (ext)
51
31
  return ext;
52
32
  }
53
- const mimeHint = mime
54
- ?? (typeof data.mime === 'string' ? data.mime : typeof data.mimeType === 'string' ? data.mimeType : undefined);
55
- if (mimeHint) {
56
- return getExtensionFromMime(mimeHint) ?? '.bin';
33
+ if (media.mime_type) {
34
+ return getExtensionFromMime(media.mime_type) ?? '.bin';
57
35
  }
58
36
  if (seg.type === 'image')
59
37
  return '.png';
@@ -63,14 +41,15 @@ function resolveExt(seg, mime) {
63
41
  return '.mp3';
64
42
  return '.bin';
65
43
  }
66
- function withLocalFile(seg, filePath) {
67
- const data = { ...(seg.data ?? {}), file: filePath };
68
- delete data.base64;
69
- return { type: seg.type, data };
44
+ function withLocalPath(seg, media, filePath) {
45
+ return {
46
+ type: seg.type,
47
+ data: { ...(seg.data ?? {}), media: { ...media, kind: 'path', value: filePath } },
48
+ };
70
49
  }
71
- function writeOutboundBuffer(buffer, outboundDir, seg) {
50
+ function writeOutboundBuffer(buffer, outboundDir, seg, media) {
72
51
  fs.mkdirSync(outboundDir, { recursive: true });
73
- const ext = resolveExt(seg);
52
+ const ext = resolveExt(seg, media);
74
53
  const filePath = path.join(outboundDir, `out-${seg.type}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`);
75
54
  fs.writeFileSync(filePath, buffer);
76
55
  return filePath;
@@ -78,22 +57,38 @@ function writeOutboundBuffer(buffer, outboundDir, seg) {
78
57
  async function materializeSegment(seg, outboundDir) {
79
58
  if (!MEDIA_TYPES.has(seg.type))
80
59
  return seg;
81
- const ref = segmentMediaRef(seg);
82
- if (ref && !isInlineMediaRef(ref)) {
83
- const local = normalizeLocalPath(ref);
84
- if (isRemoteUrl(local)) {
85
- const downloaded = await downloadRemoteImageToTemp(local, outboundDir);
86
- return withLocalFile(seg, downloaded);
60
+ const media = (seg.data ?? {}).media;
61
+ if (!isMediaRef(media)) {
62
+ return dropMediaSegment(seg.type, 'missing_media_ref');
63
+ }
64
+ switch (media.kind) {
65
+ case 'url': {
66
+ try {
67
+ const downloaded = await downloadRemoteImageToTemp(media.value, outboundDir);
68
+ return withLocalPath(seg, media, downloaded);
69
+ }
70
+ catch (err) {
71
+ logger.warn(formatCompact({
72
+ op: 'weixin_ilink_outbound_media_dropped',
73
+ type: seg.type,
74
+ reason: 'download_failed',
75
+ error: err instanceof Error ? err.message : String(err),
76
+ }));
77
+ return null;
78
+ }
87
79
  }
88
- if (fs.existsSync(local)) {
89
- return withLocalFile(seg, local);
80
+ case 'base64':
81
+ return withLocalPath(seg, media, writeOutboundBuffer(Buffer.from(media.value, 'base64'), outboundDir, seg, media));
82
+ case 'path': {
83
+ if (!fs.existsSync(media.value)) {
84
+ return dropMediaSegment(seg.type, 'missing_source');
85
+ }
86
+ return withLocalPath(seg, media, media.value);
90
87
  }
88
+ case 'file':
89
+ // 平台不透明引用(如 Telegram file_id):微信 iLink 无对应投递面
90
+ return dropMediaSegment(seg.type, 'unsupported_media_kind');
91
91
  }
92
- const buffer = extractBase64Buffer((seg.data ?? {}));
93
- if (buffer) {
94
- return withLocalFile(seg, writeOutboundBuffer(buffer, outboundDir, seg));
95
- }
96
- return seg;
97
92
  }
98
93
  function asSegments(content) {
99
94
  if (typeof content === 'string') {
@@ -110,15 +105,17 @@ function asSegments(content) {
110
105
  return typeof item === 'object' && item !== null && 'type' in item ? [item] : [];
111
106
  });
112
107
  }
113
- /** 将 base64 / 远程图落盘,供 sendWeixinMediaFile 上传 */
108
+ /** 将 canonical MediaRef 媒体段物化为本地文件(kind=path),供 sendWeixinMediaFile 上传 */
114
109
  export async function materializeOutboundMedia(content, outboundDir) {
115
110
  const segments = asSegments(content);
116
- const out = await Promise.all(segments.map((seg) => materializeSegment(seg, outboundDir)));
111
+ const out = (await Promise.all(segments.map((seg) => materializeSegment(seg, outboundDir))))
112
+ .filter((seg) => seg !== null);
117
113
  if (typeof content === 'string') {
118
114
  return out[0]?.type === 'text' ? String(out[0].data?.text ?? content) : content;
119
115
  }
120
116
  if (!Array.isArray(content)) {
121
- return out[0] ?? content;
117
+ // 单个媒体段被丢弃时降级为空文本,避免未物化段漏到 endpoint
118
+ return out[0] ?? { type: 'text', data: { text: '' } };
122
119
  }
123
120
  return out;
124
121
  }
package/lib/protocol.d.ts CHANGED
@@ -48,5 +48,6 @@ export declare function inboundMessageId(msg: WeixinMessage): string;
48
48
  * Segment canonicalization is intentionally not done here.
49
49
  */
50
50
  export declare function formatOutboundSegments(payload: unknown): WeixinWireSegment[];
51
+ /** 物化后的媒体段(data.media kind=path)→ 本地文件路径;其余返回 undefined。 */
51
52
  export declare function segmentLocalPath(seg: WeixinWireSegment): string | undefined;
52
53
  export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
package/lib/protocol.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
5
  import { pickCredential } from '@zhin.js/adapter';
6
+ import { isMediaRef } from '@zhin.js/core';
6
7
  import { bodyFromItemList, isMediaItem } from './weixin-inbound.js';
7
8
  import { DEFAULT_API_BASE_URL, DEFAULT_CDN_BASE_URL } from './ilink-meta.js';
8
9
  const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
@@ -85,14 +86,12 @@ export function formatOutboundSegments(payload) {
85
86
  }
86
87
  return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
87
88
  }
89
+ /** 物化后的媒体段(data.media kind=path)→ 本地文件路径;其余返回 undefined。 */
88
90
  export function segmentLocalPath(seg) {
89
- const data = seg.data ?? {};
90
- const candidates = [data.file, data.path, data.url].filter((value) => typeof value === 'string' && Boolean(value));
91
- const ref = candidates[0];
92
- if (!ref || ref.startsWith('base64://') || /^data:/.test(ref) || /^https?:\/\//i.test(ref)) {
91
+ const media = (seg.data ?? {}).media;
92
+ if (!isMediaRef(media) || media.kind !== 'path')
93
93
  return undefined;
94
- }
95
- return ref.replace(/^file:\/\//, '');
94
+ return media.value;
96
95
  }
97
96
  export function sleep(ms, signal) {
98
97
  return new Promise((resolve) => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `weixin-ilink endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
2
+ * `weixin-ilink.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
  * Weixin iLink 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
- * 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink endpoint` 命令共享(同一 owner generation)。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink.endpoint` 命令共享(同一 owner generation)。
4
4
  */
5
5
  import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
6
  export const weixinIlinkRuntimeStateToken = defineEndpointRuntimeStateToken('weixin-ilink');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-weixin-ilink",
3
- "version": "4.0.3",
3
+ "version": "5.0.1",
4
4
  "type": "module",
5
5
  "description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
6
6
  "main": "./lib/index.js",
@@ -48,17 +48,17 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@zhin.js/adapter": "1.1.2",
52
- "@zhin.js/command": "1.0.4",
53
- "@zhin.js/core": "1.4.3",
51
+ "@zhin.js/adapter": "1.1.4",
52
+ "@zhin.js/command": "1.0.6",
53
+ "@zhin.js/core": "1.5.1",
54
54
  "@zhin.js/logger": "1.0.75",
55
- "@zhin.js/plugin-runtime": "1.1.1"
55
+ "@zhin.js/plugin-runtime": "1.1.2"
56
56
  },
57
57
  "peerDependencies": {
58
- "@zhin.js/adapter": "1.1.2",
59
- "@zhin.js/core": "1.4.3",
60
- "@zhin.js/plugin-runtime": "1.1.1",
61
- "zhin.js": "5.0.3"
58
+ "@zhin.js/adapter": "1.1.4",
59
+ "@zhin.js/core": "1.5.1",
60
+ "@zhin.js/plugin-runtime": "1.1.2",
61
+ "zhin.js": "6.0.1"
62
62
  },
63
63
  "peerDependenciesMeta": {
64
64
  "zhin.js": {
@@ -66,7 +66,7 @@
66
66
  }
67
67
  },
68
68
  "devDependencies": {
69
- "@types/node": "^26.1.0",
69
+ "@types/node": "^26.1.2",
70
70
  "typescript": "^6.0.3",
71
71
  "vitest": "^4.1.10"
72
72
  },
package/plugin.js CHANGED
@@ -8,7 +8,7 @@ export default definePlugin({
8
8
  displayName: 'Weixin iLink Adapter',
9
9
  },
10
10
  setup(context) {
11
- // 运行中 endpoint 注册表(weixin-ilink endpoint list 的"运行中"数据源)
11
+ // 运行中 endpoint 注册表(weixin-ilink.endpoint list 的"运行中"数据源)
12
12
  context.resources.provide(weixinIlinkRuntimeStateToken, createEndpointRuntimeState());
13
13
  },
14
14
  });
@@ -1,70 +1,43 @@
1
1
  /**
2
- * 微信 iLink 出站媒体:base64 / 远程 URL 须落盘后再走 CDN 上传。
2
+ * 微信 iLink 出站媒体物化:canonical MediaRef(`data.media`,MediaRef-only)→ 本地文件,
3
+ * 再由 sendWeixinMediaFile 走 CDN 上传。
4
+ * - kind=url → 下载落盘后上传;
5
+ * - kind=base64 → 解码落盘后上传;
6
+ * - kind=path → 本地文件直接上传;
7
+ * - kind=file → 微信无不透明平台引用投递面,warn 丢弃。
8
+ * 无 media 或物化失败的媒体段 warn 丢弃(weixin_ilink_outbound_media_dropped)。
3
9
  */
4
10
  import crypto from 'node:crypto';
5
11
  import fs from 'node:fs';
6
12
  import path from 'node:path';
13
+ import { isMediaRef, type MediaRef } from '@zhin.js/core';
14
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
15
  import { downloadRemoteImageToTemp } from './upload.js';
8
16
  import { getExtensionFromMime } from './mime.js';
9
17
  import type { WeixinWireSegment } from './protocol.js';
10
18
 
19
+ const logger = getLogger('weixin-ilink');
20
+
11
21
  const MEDIA_TYPES = new Set(['image', 'video', 'file', 'record', 'audio']);
12
22
 
13
23
  export type OutboundWireContent = string | WeixinWireSegment | ReadonlyArray<string | WeixinWireSegment>;
14
24
 
15
- function isRemoteUrl(value: string): boolean {
16
- return /^https?:\/\//i.test(value);
17
- }
18
-
19
- function isInlineMediaRef(value: string): boolean {
20
- return value.startsWith('base64://') || /^data:[^/]+\/[^;]+;base64,/i.test(value);
21
- }
22
-
23
- function normalizeLocalPath(value: string): string {
24
- return value.replace(/^file:\/\//, '');
25
- }
26
-
27
- export function segmentMediaRef(seg: WeixinWireSegment): string | undefined {
28
- const data = seg.data as Record<string, unknown> | undefined;
29
- if (!data) return undefined;
30
- const candidates = [data.file, data.path, data.url].filter(
31
- (v): v is string => typeof v === 'string' && Boolean(v),
32
- );
33
- return candidates[0];
34
- }
35
-
36
- function extractBase64Buffer(data: Record<string, unknown>): Buffer | undefined {
37
- const url = typeof data.url === 'string' ? data.url : undefined;
38
- const file = typeof data.file === 'string' ? data.file : undefined;
39
- const base64 = typeof data.base64 === 'string' ? data.base64 : undefined;
40
-
41
- if (url?.startsWith('base64://')) {
42
- return Buffer.from(url.slice(9), 'base64');
43
- }
44
- if (url && /^data:[^/]+\/[^;]+;base64,/i.test(url)) {
45
- return Buffer.from(url.replace(/^data:[^/]+\/[^;]+;base64,/, ''), 'base64');
46
- }
47
- if (file?.startsWith('base64://')) {
48
- return Buffer.from(file.slice(9), 'base64');
49
- }
50
- if (base64) {
51
- const raw = base64.startsWith('base64://') ? base64.slice(9) : base64;
52
- return Buffer.from(raw, 'base64');
53
- }
54
- return undefined;
25
+ function dropMediaSegment(type: string, reason: string): null {
26
+ logger.warn(formatCompact({
27
+ op: 'weixin_ilink_outbound_media_dropped',
28
+ type,
29
+ reason,
30
+ }));
31
+ return null;
55
32
  }
56
33
 
57
- function resolveExt(seg: WeixinWireSegment, mime?: string): string {
58
- const data = (seg.data ?? {}) as Record<string, unknown>;
59
- const name = typeof data.name === 'string' ? data.name : undefined;
60
- if (name) {
61
- const ext = path.extname(name);
34
+ function resolveExt(seg: WeixinWireSegment, media: MediaRef): string {
35
+ if (media.file_name) {
36
+ const ext = path.extname(media.file_name);
62
37
  if (ext) return ext;
63
38
  }
64
- const mimeHint = mime
65
- ?? (typeof data.mime === 'string' ? data.mime : typeof data.mimeType === 'string' ? data.mimeType : undefined);
66
- if (mimeHint) {
67
- return getExtensionFromMime(mimeHint) ?? '.bin';
39
+ if (media.mime_type) {
40
+ return getExtensionFromMime(media.mime_type) ?? '.bin';
68
41
  }
69
42
  if (seg.type === 'image') return '.png';
70
43
  if (seg.type === 'video') return '.mp4';
@@ -72,15 +45,16 @@ function resolveExt(seg: WeixinWireSegment, mime?: string): string {
72
45
  return '.bin';
73
46
  }
74
47
 
75
- function withLocalFile(seg: WeixinWireSegment, filePath: string): WeixinWireSegment {
76
- const data = { ...(seg.data ?? {}), file: filePath } as Record<string, unknown>;
77
- delete data.base64;
78
- return { type: seg.type, data };
48
+ function withLocalPath(seg: WeixinWireSegment, media: MediaRef, filePath: string): WeixinWireSegment {
49
+ return {
50
+ type: seg.type,
51
+ data: { ...(seg.data ?? {}), media: { ...media, kind: 'path', value: filePath } },
52
+ };
79
53
  }
80
54
 
81
- function writeOutboundBuffer(buffer: Buffer, outboundDir: string, seg: WeixinWireSegment): string {
55
+ function writeOutboundBuffer(buffer: Buffer, outboundDir: string, seg: WeixinWireSegment, media: MediaRef): string {
82
56
  fs.mkdirSync(outboundDir, { recursive: true });
83
- const ext = resolveExt(seg);
57
+ const ext = resolveExt(seg, media);
84
58
  const filePath = path.join(
85
59
  outboundDir,
86
60
  `out-${seg.type}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`,
@@ -92,27 +66,45 @@ function writeOutboundBuffer(buffer: Buffer, outboundDir: string, seg: WeixinWir
92
66
  async function materializeSegment(
93
67
  seg: WeixinWireSegment,
94
68
  outboundDir: string,
95
- ): Promise<WeixinWireSegment> {
69
+ ): Promise<WeixinWireSegment | null> {
96
70
  if (!MEDIA_TYPES.has(seg.type)) return seg;
97
71
 
98
- const ref = segmentMediaRef(seg);
99
- if (ref && !isInlineMediaRef(ref)) {
100
- const local = normalizeLocalPath(ref);
101
- if (isRemoteUrl(local)) {
102
- const downloaded = await downloadRemoteImageToTemp(local, outboundDir);
103
- return withLocalFile(seg, downloaded);
104
- }
105
- if (fs.existsSync(local)) {
106
- return withLocalFile(seg, local);
107
- }
72
+ const media = (seg.data ?? {}).media;
73
+ if (!isMediaRef(media)) {
74
+ return dropMediaSegment(seg.type, 'missing_media_ref');
108
75
  }
109
76
 
110
- const buffer = extractBase64Buffer((seg.data ?? {}) as Record<string, unknown>);
111
- if (buffer) {
112
- return withLocalFile(seg, writeOutboundBuffer(buffer, outboundDir, seg));
77
+ switch (media.kind) {
78
+ case 'url': {
79
+ try {
80
+ const downloaded = await downloadRemoteImageToTemp(media.value, outboundDir);
81
+ return withLocalPath(seg, media, downloaded);
82
+ } catch (err) {
83
+ logger.warn(formatCompact({
84
+ op: 'weixin_ilink_outbound_media_dropped',
85
+ type: seg.type,
86
+ reason: 'download_failed',
87
+ error: err instanceof Error ? err.message : String(err),
88
+ }));
89
+ return null;
90
+ }
91
+ }
92
+ case 'base64':
93
+ return withLocalPath(
94
+ seg,
95
+ media,
96
+ writeOutboundBuffer(Buffer.from(media.value, 'base64'), outboundDir, seg, media),
97
+ );
98
+ case 'path': {
99
+ if (!fs.existsSync(media.value)) {
100
+ return dropMediaSegment(seg.type, 'missing_source');
101
+ }
102
+ return withLocalPath(seg, media, media.value);
103
+ }
104
+ case 'file':
105
+ // 平台不透明引用(如 Telegram file_id):微信 iLink 无对应投递面
106
+ return dropMediaSegment(seg.type, 'unsupported_media_kind');
113
107
  }
114
-
115
- return seg;
116
108
  }
117
109
 
118
110
  function asSegments(content: OutboundWireContent): WeixinWireSegment[] {
@@ -130,18 +122,20 @@ function asSegments(content: OutboundWireContent): WeixinWireSegment[] {
130
122
  });
131
123
  }
132
124
 
133
- /** 将 base64 / 远程图落盘,供 sendWeixinMediaFile 上传 */
125
+ /** 将 canonical MediaRef 媒体段物化为本地文件(kind=path),供 sendWeixinMediaFile 上传 */
134
126
  export async function materializeOutboundMedia(
135
127
  content: OutboundWireContent,
136
128
  outboundDir: string,
137
129
  ): Promise<OutboundWireContent> {
138
130
  const segments = asSegments(content);
139
- const out = await Promise.all(segments.map((seg) => materializeSegment(seg, outboundDir)));
131
+ const out = (await Promise.all(segments.map((seg) => materializeSegment(seg, outboundDir))))
132
+ .filter((seg): seg is WeixinWireSegment => seg !== null);
140
133
  if (typeof content === 'string') {
141
134
  return out[0]?.type === 'text' ? String((out[0].data as { text?: string })?.text ?? content) : content;
142
135
  }
143
136
  if (!Array.isArray(content)) {
144
- return out[0] ?? content;
137
+ // 单个媒体段被丢弃时降级为空文本,避免未物化段漏到 endpoint
138
+ return out[0] ?? { type: 'text', data: { text: '' } };
145
139
  }
146
140
  return out;
147
141
  }
package/src/protocol.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { pickCredential } from '@zhin.js/adapter';
7
+ import { isMediaRef } from '@zhin.js/core';
7
8
  import { bodyFromItemList, isMediaItem } from './weixin-inbound.js';
8
9
  import { DEFAULT_API_BASE_URL, DEFAULT_CDN_BASE_URL } from './ilink-meta.js';
9
10
  import type { WeixinMessage } from './ilink-types.js';
@@ -141,16 +142,11 @@ export function formatOutboundSegments(payload: unknown): WeixinWireSegment[] {
141
142
  return segs.length ? segs : [{ type: 'text', data: { text: '' } }];
142
143
  }
143
144
 
145
+ /** 物化后的媒体段(data.media kind=path)→ 本地文件路径;其余返回 undefined。 */
144
146
  export function segmentLocalPath(seg: WeixinWireSegment): string | undefined {
145
- const data = seg.data ?? {};
146
- const candidates = [data.file, data.path, data.url].filter(
147
- (value): value is string => typeof value === 'string' && Boolean(value),
148
- );
149
- const ref = candidates[0];
150
- if (!ref || ref.startsWith('base64://') || /^data:/.test(ref) || /^https?:\/\//i.test(ref)) {
151
- return undefined;
152
- }
153
- return ref.replace(/^file:\/\//, '');
147
+ const media = (seg.data ?? {}).media;
148
+ if (!isMediaRef(media) || media.kind !== 'path') return undefined;
149
+ return media.value;
154
150
  }
155
151
 
156
152
  export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `weixin-ilink endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
2
+ * `weixin-ilink.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
  * Weixin iLink 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
- * 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink endpoint` 命令共享(同一 owner generation)。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink.endpoint` 命令共享(同一 owner generation)。
4
4
  */
5
5
  import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
6