@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 +35 -0
- package/adapters/weixin-ilink.js +4 -3
- package/adapters/weixin-ilink.ts +4 -3
- package/lib/outbound-media.d.ts +1 -2
- package/lib/outbound-media.js +63 -66
- package/lib/protocol.d.ts +1 -0
- package/lib/protocol.js +5 -6
- package/lib/weixin-ilink-endpoint-commands.js +1 -1
- package/lib/weixin-ilink-runtime-state.js +1 -1
- package/package.json +10 -10
- package/plugin.js +1 -1
- package/src/outbound-media.ts +69 -75
- package/src/protocol.ts +5 -9
- package/src/weixin-ilink-endpoint-commands.ts +1 -1
- package/src/weixin-ilink-runtime-state.ts +1 -1
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
|
package/adapters/weixin-ilink.js
CHANGED
|
@@ -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 /
|
|
13
|
+
// 所有媒体(url 下载 / base64 落盘 / 本地 path)统一物化后走 CDN 上传(sendWeixinMediaFile),
|
|
14
|
+
// 无直发面;微信无卡片交互面,交互段降级纯文本。
|
|
14
15
|
segments: {
|
|
15
|
-
outboundMedia: ['
|
|
16
|
+
outboundMedia: ['upload'],
|
|
16
17
|
interactive: 'text',
|
|
17
18
|
},
|
|
18
19
|
create(context) {
|
|
19
20
|
const config = resolveWeixinIlinkConfig(context.config);
|
|
20
|
-
// 注册到插件运行时状态(weixin-ilink
|
|
21
|
+
// 注册到插件运行时状态(weixin-ilink.endpoint list 的"运行中"数据源)
|
|
21
22
|
context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.name, {
|
|
22
23
|
name: config.name,
|
|
23
24
|
mode: config.connection,
|
package/adapters/weixin-ilink.ts
CHANGED
|
@@ -21,14 +21,15 @@ export type {
|
|
|
21
21
|
|
|
22
22
|
export default defineAdapter<WeixinIlinkAdapterConfig>({
|
|
23
23
|
capabilities: ['inbound', 'outbound'],
|
|
24
|
-
// base64 /
|
|
24
|
+
// 所有媒体(url 下载 / base64 落盘 / 本地 path)统一物化后走 CDN 上传(sendWeixinMediaFile),
|
|
25
|
+
// 无直发面;微信无卡片交互面,交互段降级纯文本。
|
|
25
26
|
segments: {
|
|
26
|
-
outboundMedia: ['
|
|
27
|
+
outboundMedia: ['upload'],
|
|
27
28
|
interactive: 'text',
|
|
28
29
|
},
|
|
29
30
|
create(context) {
|
|
30
31
|
const config = resolveWeixinIlinkConfig(context.config);
|
|
31
|
-
// 注册到插件运行时状态(weixin-ilink
|
|
32
|
+
// 注册到插件运行时状态(weixin-ilink.endpoint list 的"运行中"数据源)
|
|
32
33
|
context.use(weixinIlinkRuntimeStateToken).endpoints.set(config.name, {
|
|
33
34
|
name: config.name,
|
|
34
35
|
mode: config.connection,
|
package/lib/outbound-media.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { WeixinWireSegment } from './protocol.js';
|
|
2
2
|
export type OutboundWireContent = string | WeixinWireSegment | ReadonlyArray<string | WeixinWireSegment>;
|
|
3
|
-
|
|
4
|
-
/** 将 base64 / 远程图落盘,供 sendWeixinMediaFile 上传 */
|
|
3
|
+
/** 将 canonical MediaRef 媒体段物化为本地文件(kind=path),供 sendWeixinMediaFile 上传 */
|
|
5
4
|
export declare function materializeOutboundMedia(content: OutboundWireContent, outboundDir: string): Promise<OutboundWireContent>;
|
package/lib/outbound-media.js
CHANGED
|
@@ -1,59 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 微信 iLink
|
|
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
|
|
11
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
return
|
|
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
|
-
/** 将
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
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
|
|
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
|
|
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": "
|
|
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.
|
|
52
|
-
"@zhin.js/command": "1.0.
|
|
53
|
-
"@zhin.js/core": "1.
|
|
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.
|
|
55
|
+
"@zhin.js/plugin-runtime": "1.1.2"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@zhin.js/adapter": "1.1.
|
|
59
|
-
"@zhin.js/core": "1.
|
|
60
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
61
|
-
"zhin.js": "
|
|
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.
|
|
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
|
|
11
|
+
// 运行中 endpoint 注册表(weixin-ilink.endpoint list 的"运行中"数据源)
|
|
12
12
|
context.resources.provide(weixinIlinkRuntimeStateToken, createEndpointRuntimeState());
|
|
13
13
|
},
|
|
14
14
|
});
|
package/src/outbound-media.ts
CHANGED
|
@@ -1,70 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 微信 iLink
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
99
|
-
if (
|
|
100
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
/** 将
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `weixin-ilink.endpoint` 命令共享(同一 owner generation)。
|
|
4
4
|
*/
|
|
5
5
|
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
6
|
|