@imweapp/openclaw-imwe 2026.4.12-alpha.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/LICENSE +21 -0
- package/README.md +172 -0
- package/index.ts +16 -0
- package/openclaw.plugin.json +58 -0
- package/package.json +73 -0
- package/proto/PbBoxPullProto.proto +43 -0
- package/proto/PbChatAudioContent.proto +23 -0
- package/proto/PbChatDeliverMsg.proto +38 -0
- package/proto/PbChatFileMeta.proto +34 -0
- package/proto/PbChatMsg.proto +93 -0
- package/proto/PbChatRichMediaContent.proto +31 -0
- package/proto/PbChatTextContent.proto +38 -0
- package/proto/PbMarkdownContent.proto +18 -0
- package/proto/PbMsgReadStampContent.proto +11 -0
- package/proto/PbPacket.proto +61 -0
- package/proto/PbSingleChatMsg.proto +60 -0
- package/setup-entry.ts +17 -0
- package/src/accounts.ts +109 -0
- package/src/api-client.ts +740 -0
- package/src/bot-info-cache.ts +49 -0
- package/src/channel.runtime.ts +29 -0
- package/src/channel.ts +456 -0
- package/src/config-schema.ts +26 -0
- package/src/e2ee/api.ts +261 -0
- package/src/e2ee/canonical.ts +59 -0
- package/src/e2ee/errors.ts +103 -0
- package/src/e2ee/index.ts +8 -0
- package/src/e2ee/proper-lockfile.d.ts +61 -0
- package/src/e2ee/service.ts +1273 -0
- package/src/e2ee/store.ts +174 -0
- package/src/e2ee/types.ts +113 -0
- package/src/e2ee/vodozemac.ts +373 -0
- package/src/file-transfer/api.ts +364 -0
- package/src/file-transfer/concurrency.ts +77 -0
- package/src/file-transfer/download.ts +261 -0
- package/src/file-transfer/file-crypto.ts +93 -0
- package/src/file-transfer/index.ts +18 -0
- package/src/file-transfer/scheduler.ts +185 -0
- package/src/file-transfer/types.ts +195 -0
- package/src/file-transfer/upload.ts +656 -0
- package/src/markdown-detect.ts +119 -0
- package/src/media-upload.ts +338 -0
- package/src/media-utils.ts +110 -0
- package/src/monitor.ts +838 -0
- package/src/proto/codec.ts +54 -0
- package/src/proto/inbound.codec.ts +624 -0
- package/src/proto/proto-types.ts +291 -0
- package/src/proto/registry.ts +226 -0
- package/src/proto/send.codec.ts +535 -0
- package/src/recent-message-cache.ts +350 -0
- package/src/send.ts +792 -0
- package/src/setup-core.ts +62 -0
- package/src/types.ts +153 -0
- package/src/vodozemackit/index.ts +297 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm.d.ts +109 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown-detect.ts — Markdown 内容判定(纯函数)
|
|
3
|
+
*
|
|
4
|
+
* 为 channel.ts 的 outbound adapter 提供 `isMarkdownContent` 判定,
|
|
5
|
+
* 用于在 `sendText` 路径上决定是否走 `sendImweMarkdown`。
|
|
6
|
+
*
|
|
7
|
+
* 设计约束:
|
|
8
|
+
* - 纯函数:同一输入必得同一输出,不读写任何外部状态。
|
|
9
|
+
* - 无 I/O:不访问 `fs` / `fetch` / `path`,不触发任何网络或磁盘操作。
|
|
10
|
+
* - O(n):最坏情况对 9 条正则各扫描一次字符串,命中即短路。
|
|
11
|
+
* - 不引入第三方 markdown parser,仅使用原生 `RegExp`。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** ATX 标题:`# ` 至 `###### `(行首 1–6 个 `#` + 空白) */
|
|
15
|
+
const ATX_HEADING = /^#{1,6}\s+/m;
|
|
16
|
+
|
|
17
|
+
/** 粗体:配对的 `**...**`,内部不含 `*`,避免单个 `*` 的误判 */
|
|
18
|
+
const BOLD = /\*\*[^*]+\*\*/;
|
|
19
|
+
|
|
20
|
+
/** Fenced code block:配对的三反引号围栏 */
|
|
21
|
+
const FENCED_CODE = /```[\s\S]*?```/;
|
|
22
|
+
|
|
23
|
+
/** Markdown 链接:方括号 + 圆括号配对,裸 URL 不命中 */
|
|
24
|
+
const LINK = /\[[^\]]+\]\([^)]+\)/;
|
|
25
|
+
|
|
26
|
+
/** Markdown 图片:`!` 前缀 + 方括号 + 圆括号 */
|
|
27
|
+
const IMAGE = /!\[[^\]]*\]\([^)]+\)/;
|
|
28
|
+
|
|
29
|
+
/** Markdown 表格:表头 + 分隔行 + 至少一行数据,必须成块出现 */
|
|
30
|
+
const TABLE = /^\|(.+)\|[\r\n]+\|[-:\s|]+\|[\r\n]+((?:\|.+\|[\r\n]*)+)/m;
|
|
31
|
+
|
|
32
|
+
/** 行内代码:配对的单反引号,内部不含反引号或换行 */
|
|
33
|
+
const INLINE_CODE = /`[^`\n]+`/;
|
|
34
|
+
|
|
35
|
+
/** Blockquote:行首 `> ` */
|
|
36
|
+
const BLOCKQUOTE = /^>\s+/m;
|
|
37
|
+
|
|
38
|
+
/** 删除线:配对的 `~~...~~` */
|
|
39
|
+
const STRIKETHROUGH = /~~[^~]+~~/;
|
|
40
|
+
|
|
41
|
+
/** 用于提取 ATX 标题文本的正则(捕获 `#` 后面的内容) */
|
|
42
|
+
const ATX_HEADING_CAPTURE = /^#{1,6}\s+(.+)/m;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 用于去除常见 markdown 语法标记的正则集合。
|
|
46
|
+
* 按顺序应用:fenced code → 图片 → 链接 → 粗体/斜体 → 行内代码 → 删除线 → blockquote → 标题标记。
|
|
47
|
+
*/
|
|
48
|
+
const STRIP_PATTERNS: Array<[RegExp, string]> = [
|
|
49
|
+
[/```[\s\S]*?```/g, ''], // fenced code block → 移除
|
|
50
|
+
[/!\[[^\]]*\]\([^)]+\)/g, ''], // 图片 → 移除
|
|
51
|
+
[/\[([^\]]+)\]\([^)]+\)/g, '$1'], // 链接 → 保留文字
|
|
52
|
+
[/\*\*([^*]+)\*\*/g, '$1'], // 粗体 → 保留文字
|
|
53
|
+
[/\*([^*]+)\*/g, '$1'], // 斜体 → 保留文字
|
|
54
|
+
[/`([^`\n]+)`/g, '$1'], // 行内代码 → 保留文字
|
|
55
|
+
[/~~([^~]+)~~/g, '$1'], // 删除线 → 保留文字
|
|
56
|
+
[/^>\s+/gm, ''], // blockquote 标记 → 移除
|
|
57
|
+
[/^#{1,6}\s+/gm, ''], // ATX 标题标记 → 移除
|
|
58
|
+
[/\|/g, ' '], // 表格竖线 → 空格
|
|
59
|
+
[/[-:]{3,}/g, ''], // 表格分隔行 → 移除
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 从 markdown 文本中提取摘要(digest)。
|
|
64
|
+
*
|
|
65
|
+
* 提取策略:
|
|
66
|
+
* 1. 优先提取第一个 ATX 标题的文本内容(去除 `#` 前缀)
|
|
67
|
+
* 2. 若无标题,则去除 markdown 语法标记后取前 15 个字符
|
|
68
|
+
*
|
|
69
|
+
* 纯函数、无 I/O、O(n)。
|
|
70
|
+
*/
|
|
71
|
+
export function extractMarkdownDigest(markdown: string): string {
|
|
72
|
+
const trimmed = markdown.trim();
|
|
73
|
+
if (!trimmed) return '';
|
|
74
|
+
|
|
75
|
+
// 策略 1:提取第一个 ATX 标题
|
|
76
|
+
const headingMatch = ATX_HEADING_CAPTURE.exec(trimmed);
|
|
77
|
+
if (headingMatch && headingMatch[1]) {
|
|
78
|
+
const title = headingMatch[1].trim();
|
|
79
|
+
if (title) return title;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 策略 2:去除语法标记后取前 15 个字符
|
|
83
|
+
let plain = trimmed;
|
|
84
|
+
for (const [pattern, replacement] of STRIP_PATTERNS) {
|
|
85
|
+
plain = plain.replace(pattern, replacement);
|
|
86
|
+
}
|
|
87
|
+
// 合并多余空白
|
|
88
|
+
plain = plain.replace(/\s+/g, ' ').trim();
|
|
89
|
+
|
|
90
|
+
return plain.slice(0, 15).trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 判断文本是否为 markdown 格式。
|
|
95
|
+
*
|
|
96
|
+
* 纯函数、无 I/O、O(n) 复杂度。按预计命中率顺序逐一短路:
|
|
97
|
+
* ATX 标题 → 粗体 → fenced code → 链接 → 图片 → 表格 → 行内代码 →
|
|
98
|
+
* blockquote → 删除线;命中任一特征立即返回 `true`。
|
|
99
|
+
*
|
|
100
|
+
* 所有正则均要求语法配对(配对的 `**`、方括号+圆括号、成块的表格分隔行、
|
|
101
|
+
* 三反引号围栏等),天然排除最常见的误判(单个 `*`、裸 URL、散乱短横线等)。
|
|
102
|
+
*/
|
|
103
|
+
export function isMarkdownContent(text: string): boolean {
|
|
104
|
+
// Step 0: 快路径 — 空串或纯空白直接 false
|
|
105
|
+
if (text.trim().length === 0) return false;
|
|
106
|
+
|
|
107
|
+
// Step 1: 命中任一判定特征即 true(按命中率排序,逐一短路)
|
|
108
|
+
if (ATX_HEADING.test(text)) return true;
|
|
109
|
+
if (BOLD.test(text)) return true;
|
|
110
|
+
if (FENCED_CODE.test(text)) return true;
|
|
111
|
+
if (LINK.test(text)) return true;
|
|
112
|
+
if (IMAGE.test(text)) return true;
|
|
113
|
+
if (TABLE.test(text)) return true;
|
|
114
|
+
if (INLINE_CODE.test(text)) return true;
|
|
115
|
+
if (BLOCKQUOTE.test(text)) return true;
|
|
116
|
+
if (STRIKETHROUGH.test(text)) return true;
|
|
117
|
+
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* media-upload.ts — 媒体文件上传服务
|
|
3
|
+
*
|
|
4
|
+
* @deprecated 本模块已废弃,E2EE 加密分片上传功能已迁移到 `./file-transfer/upload.ts`。
|
|
5
|
+
* 新代码请使用 `./file-transfer/index.js` 导出的 `uploadEncryptedFile`。
|
|
6
|
+
* 本文件保留仅为兼容可能的外部引用,后续版本将移除。
|
|
7
|
+
*
|
|
8
|
+
* 将 OpenClaw 提供的媒体文件(本地路径或远程 URL)上传到 imwe 平台存储,
|
|
9
|
+
* 获取平台可访问的 URL。
|
|
10
|
+
*
|
|
11
|
+
* 三步预签名上传流程(对应接口文档 §六、§七):
|
|
12
|
+
* 1. POST /api/im/open/bot/uploadPreSignedUrl → { fileId, preSignedUrl }
|
|
13
|
+
* 2. PUT preSignedUrl ← 文件二进制(直传对象存储)
|
|
14
|
+
* 3. POST /api/im/open/bot/uploadConfirm → { fileUrl }
|
|
15
|
+
*
|
|
16
|
+
* 大文件优化:
|
|
17
|
+
* 文件大小 ≤ 10MB → Buffer 模式(全量加载)
|
|
18
|
+
* 文件大小 > 10MB → Stream 模式(流式上传,低内存占用)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { uploadPreSignedUrl, uploadConfirm } from './api-client.js';
|
|
22
|
+
import { inferMimeType, computeBlurhash } from './media-utils.js';
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// 常量
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** 流式上传阈值(字节),超过此大小使用 Stream 模式 */
|
|
29
|
+
const STREAM_THRESHOLD = 10 * 1024 * 1024; // 10MB
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// 类型
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** 上传结果
|
|
36
|
+
* @deprecated 请使用 `./file-transfer/types.ts` 中的 `UploadResult` 替代。
|
|
37
|
+
*/
|
|
38
|
+
export type MediaUploadResult = {
|
|
39
|
+
/** 平台可访问的文件 URL */
|
|
40
|
+
url: string;
|
|
41
|
+
/** 文件 MIME 类型 */
|
|
42
|
+
contentType?: string;
|
|
43
|
+
/** 文件大小(字节) */
|
|
44
|
+
fileSize?: number;
|
|
45
|
+
/** 图片 blurhash(仅图片 Buffer 模式) */
|
|
46
|
+
blurHash?: string;
|
|
47
|
+
/** 图片宽度(仅图片,像素) */
|
|
48
|
+
width?: number;
|
|
49
|
+
/** 图片高度(仅图片,像素) */
|
|
50
|
+
height?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** 解析后的媒体源(Buffer 模式) */
|
|
54
|
+
type BufferMediaSource = {
|
|
55
|
+
kind: 'buffer';
|
|
56
|
+
buffer: Buffer;
|
|
57
|
+
fileName: string;
|
|
58
|
+
mimeType: string;
|
|
59
|
+
fileSize: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** 解析后的媒体源(Stream 模式) */
|
|
63
|
+
type StreamMediaSource = {
|
|
64
|
+
kind: 'stream';
|
|
65
|
+
stream: ReadableStream | NodeJS.ReadableStream;
|
|
66
|
+
fileName: string;
|
|
67
|
+
mimeType: string;
|
|
68
|
+
fileSize: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type ResolvedMediaSource = BufferMediaSource | StreamMediaSource;
|
|
72
|
+
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
// 公开 API
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 将媒体文件上传到平台存储。
|
|
79
|
+
*
|
|
80
|
+
* @deprecated 请使用 `./file-transfer/index.js` 导出的 `uploadEncryptedFile` 替代。
|
|
81
|
+
* 本函数仅保留用于兼容旧的明文上传场景(如 deliver callback),后续版本将移除。
|
|
82
|
+
*
|
|
83
|
+
* 小文件(≤10MB)全量加载到 Buffer 后上传;
|
|
84
|
+
* 大文件(>10MB)使用 Stream 流式上传,避免大量内存占用。
|
|
85
|
+
*
|
|
86
|
+
* @param mediaUrl 待上传的媒体 URL 或本地路径
|
|
87
|
+
* @param auth 鉴权信息
|
|
88
|
+
* @param apiBaseUrl API 基础地址
|
|
89
|
+
* @param options 可选参数
|
|
90
|
+
*/
|
|
91
|
+
export async function uploadMediaToStorage(
|
|
92
|
+
mediaUrl: string,
|
|
93
|
+
auth: { apiKey: string; apiSecret: string },
|
|
94
|
+
apiBaseUrl: string,
|
|
95
|
+
options?: {
|
|
96
|
+
/** 接收方 IM 主账号 ID(用于预签名请求) */
|
|
97
|
+
imMainAccId?: string;
|
|
98
|
+
/** 出站媒体访问控制(由 OpenClaw core 提供) */
|
|
99
|
+
mediaAccess?: unknown;
|
|
100
|
+
/** 允许读取的本地目录列表 */
|
|
101
|
+
mediaLocalRoots?: readonly string[];
|
|
102
|
+
/** 自定义文件读取函数(仅支持 Buffer 模式,不走 Stream) */
|
|
103
|
+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
|
104
|
+
/** 日志接口(可选,由调用方传入) */
|
|
105
|
+
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
|
106
|
+
},
|
|
107
|
+
): Promise<MediaUploadResult> {
|
|
108
|
+
const log = options?.log;
|
|
109
|
+
|
|
110
|
+
// 1. 解析媒体源 → Buffer 或 Stream + 元数据
|
|
111
|
+
log?.info?.(`[media-upload] 解析媒体源: url=${mediaUrl}`);
|
|
112
|
+
const media = await resolveMediaSource(mediaUrl, options);
|
|
113
|
+
log?.info?.(
|
|
114
|
+
`[media-upload] 媒体源解析完成: mode=${media.kind}, fileName=${media.fileName}, mimeType=${media.mimeType}, fileSize=${media.fileSize}`,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// 2. 获取预签名上传地址
|
|
118
|
+
log?.info?.(`[media-upload] 请求预签名上传地址: imMainAccId=${options?.imMainAccId ?? ''}`);
|
|
119
|
+
const preSign = await uploadPreSignedUrl(apiBaseUrl, auth, {
|
|
120
|
+
imMainAccId: options?.imMainAccId ?? '',
|
|
121
|
+
fileName: media.fileName,
|
|
122
|
+
fileSize: media.fileSize,
|
|
123
|
+
mimeType: media.mimeType,
|
|
124
|
+
});
|
|
125
|
+
log?.info?.(`[media-upload] 预签名地址获取成功: fileId=${preSign.fileId}`);
|
|
126
|
+
|
|
127
|
+
// 3. PUT 直传到对象存储
|
|
128
|
+
log?.info?.(`[media-upload] 开始直传到对象存储: fileSize=${media.fileSize}, mode=${media.kind}`);
|
|
129
|
+
const putBody = media.kind === 'buffer' ? media.buffer : media.stream;
|
|
130
|
+
const putRes = await fetch(preSign.preSignedUrl, {
|
|
131
|
+
method: 'PUT',
|
|
132
|
+
headers: {
|
|
133
|
+
'Content-Type': media.mimeType,
|
|
134
|
+
'Content-Length': String(media.fileSize),
|
|
135
|
+
},
|
|
136
|
+
body: putBody as BodyInit,
|
|
137
|
+
// @ts-expect-error Node.js fetch 流式上传需要 duplex: 'half'
|
|
138
|
+
duplex: media.kind === 'stream' ? 'half' : undefined,
|
|
139
|
+
});
|
|
140
|
+
if (!putRes.ok) {
|
|
141
|
+
const errText = await putRes.text().catch(() => '');
|
|
142
|
+
const errMsg = `文件直传失败: HTTP ${putRes.status} ${errText}`;
|
|
143
|
+
log?.warn?.(`[media-upload] ${errMsg}`);
|
|
144
|
+
throw new Error(errMsg);
|
|
145
|
+
}
|
|
146
|
+
log?.info?.(`[media-upload] 直传成功: HTTP ${putRes.status}`);
|
|
147
|
+
|
|
148
|
+
// 4. 确认上传,获取文件访问 URL
|
|
149
|
+
log?.info?.(`[media-upload] 确认上传: fileId=${preSign.fileId}`);
|
|
150
|
+
const confirmed = await uploadConfirm(apiBaseUrl, auth, {
|
|
151
|
+
imMainAccId: options?.imMainAccId ?? '',
|
|
152
|
+
fileId: preSign.fileId,
|
|
153
|
+
});
|
|
154
|
+
log?.info?.(`[media-upload] 上传确认成功: ${JSON.stringify(confirmed)}`);
|
|
155
|
+
|
|
156
|
+
// 5. 图片 Buffer 模式下计算 blurhash(异步,失败不阻断)
|
|
157
|
+
let blurHash: string | undefined;
|
|
158
|
+
let width: number | undefined;
|
|
159
|
+
let height: number | undefined;
|
|
160
|
+
if (media.kind === 'buffer') {
|
|
161
|
+
const bh = await computeBlurhash(media.buffer, media.mimeType);
|
|
162
|
+
if (bh) {
|
|
163
|
+
blurHash = bh.blurHash;
|
|
164
|
+
width = bh.width;
|
|
165
|
+
height = bh.height;
|
|
166
|
+
log?.info?.(
|
|
167
|
+
`[media-upload] blurhash 计算完成: ${blurHash.slice(0, 20)}... ${width}x${height}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
url: confirmed.fileUrl,
|
|
174
|
+
contentType: media.mimeType,
|
|
175
|
+
fileSize: media.fileSize,
|
|
176
|
+
blurHash,
|
|
177
|
+
width,
|
|
178
|
+
height,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
// 内部:媒体源解析
|
|
184
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 解析媒体源,根据文件大小选择 Buffer 或 Stream 模式。
|
|
188
|
+
*
|
|
189
|
+
* - 远程 URL:先 HEAD 获取 Content-Length,大文件走 Stream(GET body 直接 pipe)
|
|
190
|
+
* - 本地路径:先 stat 获取大小,大文件走 createReadStream
|
|
191
|
+
* - 自定义 mediaReadFile:只返回 Buffer,无法走 Stream
|
|
192
|
+
*/
|
|
193
|
+
async function resolveMediaSource(
|
|
194
|
+
mediaUrl: string,
|
|
195
|
+
options?: {
|
|
196
|
+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
|
197
|
+
mediaLocalRoots?: readonly string[];
|
|
198
|
+
},
|
|
199
|
+
): Promise<ResolvedMediaSource> {
|
|
200
|
+
if (mediaUrl.startsWith('data:')) {
|
|
201
|
+
return resolveDataUriMediaSource(mediaUrl);
|
|
202
|
+
}
|
|
203
|
+
if (mediaUrl.startsWith('http://') || mediaUrl.startsWith('https://')) {
|
|
204
|
+
return resolveRemoteMediaSource(mediaUrl);
|
|
205
|
+
}
|
|
206
|
+
return resolveLocalMediaSource(mediaUrl, options);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 解析 data URI 媒体源(如 data:image/png;base64,iVBOR...) */
|
|
210
|
+
function resolveDataUriMediaSource(dataUri: string): ResolvedMediaSource {
|
|
211
|
+
const match = dataUri.match(/^data:([^;,]+)?(?:;base64)?,(.*)$/s);
|
|
212
|
+
if (!match) {
|
|
213
|
+
throw new Error(`无效的 data URI: ${dataUri.slice(0, 40)}...`);
|
|
214
|
+
}
|
|
215
|
+
const mimeType = match[1] || 'application/octet-stream';
|
|
216
|
+
const buffer = Buffer.from(match[2], 'base64');
|
|
217
|
+
const ext = extensionFromMime(mimeType);
|
|
218
|
+
const fileName = `media${ext}`;
|
|
219
|
+
return { kind: 'buffer', buffer, fileName, mimeType, fileSize: buffer.length };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 从 MIME type 推断文件扩展名 */
|
|
223
|
+
const MIME_TO_EXT: Record<string, string> = {
|
|
224
|
+
'image/jpeg': '.jpg',
|
|
225
|
+
'image/png': '.png',
|
|
226
|
+
'image/gif': '.gif',
|
|
227
|
+
'image/webp': '.webp',
|
|
228
|
+
'video/mp4': '.mp4',
|
|
229
|
+
'audio/mpeg': '.mp3',
|
|
230
|
+
'audio/wav': '.wav',
|
|
231
|
+
'application/pdf': '.pdf',
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function extensionFromMime(mimeType: string): string {
|
|
235
|
+
return MIME_TO_EXT[mimeType] ?? '';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 解析远程 URL 媒体源 */
|
|
239
|
+
async function resolveRemoteMediaSource(mediaUrl: string): Promise<ResolvedMediaSource> {
|
|
240
|
+
const fileName = extractFileNameFromUrl(mediaUrl);
|
|
241
|
+
|
|
242
|
+
// HEAD 请求探测文件大小和 MIME type
|
|
243
|
+
let fileSize = 0;
|
|
244
|
+
let headMimeType = '';
|
|
245
|
+
try {
|
|
246
|
+
const head = await fetch(mediaUrl, { method: 'HEAD' });
|
|
247
|
+
if (head.ok) {
|
|
248
|
+
fileSize = Number(head.headers.get('content-length') ?? 0);
|
|
249
|
+
headMimeType = head.headers.get('content-type')?.split(';')[0]?.trim() ?? '';
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// HEAD 失败时回退到 Buffer 模式(GET 下载全量)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const mimeType = headMimeType || inferMimeType(fileName);
|
|
256
|
+
|
|
257
|
+
// 大文件且已知大小 → Stream 模式
|
|
258
|
+
if (fileSize > STREAM_THRESHOLD) {
|
|
259
|
+
const getRes = await fetch(mediaUrl);
|
|
260
|
+
if (!getRes.ok) {
|
|
261
|
+
throw new Error(`下载远程媒体失败: HTTP ${getRes.status} ${mediaUrl}`);
|
|
262
|
+
}
|
|
263
|
+
return { kind: 'stream', stream: getRes.body!, fileName, mimeType, fileSize };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 小文件或未知大小 → Buffer 模式
|
|
267
|
+
const getRes = await fetch(mediaUrl);
|
|
268
|
+
if (!getRes.ok) {
|
|
269
|
+
throw new Error(`下载远程媒体失败: HTTP ${getRes.status} ${mediaUrl}`);
|
|
270
|
+
}
|
|
271
|
+
const buffer = Buffer.from(await getRes.arrayBuffer());
|
|
272
|
+
const resolvedMimeType = getRes.headers.get('content-type')?.split(';')[0]?.trim() || mimeType;
|
|
273
|
+
return { kind: 'buffer', buffer, fileName, mimeType: resolvedMimeType, fileSize: buffer.length };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 解析本地路径媒体源 */
|
|
277
|
+
async function resolveLocalMediaSource(
|
|
278
|
+
mediaUrl: string,
|
|
279
|
+
options?: {
|
|
280
|
+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
|
281
|
+
mediaLocalRoots?: readonly string[];
|
|
282
|
+
},
|
|
283
|
+
): Promise<ResolvedMediaSource> {
|
|
284
|
+
const fileName = mediaUrl.split('/').pop() ?? 'file';
|
|
285
|
+
const mimeType = inferMimeType(fileName);
|
|
286
|
+
|
|
287
|
+
// 优先使用 openclaw core 的 loadWebMedia 加载本地文件
|
|
288
|
+
// loadWebMedia 内部处理了 localRoots 安全检查和默认根目录
|
|
289
|
+
try {
|
|
290
|
+
const { loadWebMedia } = await import('openclaw/plugin-sdk/web-media');
|
|
291
|
+
const localRoots = options?.mediaLocalRoots?.length ? options.mediaLocalRoots : undefined;
|
|
292
|
+
const loaded = await loadWebMedia(mediaUrl, {
|
|
293
|
+
localRoots,
|
|
294
|
+
readFile: options?.mediaReadFile,
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
kind: 'buffer',
|
|
298
|
+
buffer: loaded.buffer,
|
|
299
|
+
fileName: loaded.fileName ?? fileName,
|
|
300
|
+
mimeType: loaded.contentType ?? mimeType,
|
|
301
|
+
fileSize: loaded.buffer.length,
|
|
302
|
+
};
|
|
303
|
+
} catch {
|
|
304
|
+
// loadWebMedia 不可用时回退到直接读取
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 自定义 mediaReadFile 只返回 Buffer,无法走 Stream
|
|
308
|
+
if (options?.mediaReadFile) {
|
|
309
|
+
const buffer = await options.mediaReadFile(mediaUrl);
|
|
310
|
+
return { kind: 'buffer', buffer, fileName, mimeType, fileSize: buffer.length };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const { stat } = await import('node:fs/promises');
|
|
314
|
+
const fileStat = await stat(mediaUrl);
|
|
315
|
+
|
|
316
|
+
// 大文件 → Stream 模式
|
|
317
|
+
if (fileStat.size > STREAM_THRESHOLD) {
|
|
318
|
+
const { createReadStream } = await import('node:fs');
|
|
319
|
+
const stream = createReadStream(mediaUrl);
|
|
320
|
+
return { kind: 'stream', stream, fileName, mimeType, fileSize: fileStat.size };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 小文件 → Buffer 模式
|
|
324
|
+
const { readFile } = await import('node:fs/promises');
|
|
325
|
+
const buffer = await readFile(mediaUrl);
|
|
326
|
+
return { kind: 'buffer', buffer, fileName, mimeType, fileSize: buffer.length };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 从 URL 中提取文件名,去除查询参数和 hash */
|
|
330
|
+
function extractFileNameFromUrl(url: string): string {
|
|
331
|
+
try {
|
|
332
|
+
const pathname = new URL(url).pathname;
|
|
333
|
+
const name = pathname.split('/').pop() ?? '';
|
|
334
|
+
return name || 'file';
|
|
335
|
+
} catch {
|
|
336
|
+
return url.split('/').pop()?.split('?')[0] ?? 'file';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* media-utils.ts — 媒体相关工具函数
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 从 URL 或文件路径推断媒体类型。
|
|
7
|
+
* 简单的后缀匹配,无法确定时回退为 'file'。
|
|
8
|
+
*/
|
|
9
|
+
export function inferMediaType(url: string): 'image' | 'video' | 'audio' | 'file' {
|
|
10
|
+
const lower = url.toLowerCase().split('?')[0];
|
|
11
|
+
if (/\.(jpe?g|png|gif|webp|bmp|svg|heic|heif)$/.test(lower)) return 'image';
|
|
12
|
+
if (/\.(mp4|mov|avi|mkv|webm|flv)$/.test(lower)) return 'video';
|
|
13
|
+
if (/\.(mp3|wav|ogg|aac|m4a|flac|opus|amr)$/.test(lower)) return 'audio';
|
|
14
|
+
return 'file';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 常见扩展名 → MIME type 映射 */
|
|
18
|
+
const MIME_MAP: Record<string, string> = {
|
|
19
|
+
// 图片
|
|
20
|
+
jpg: 'image/jpeg',
|
|
21
|
+
jpeg: 'image/jpeg',
|
|
22
|
+
png: 'image/png',
|
|
23
|
+
gif: 'image/gif',
|
|
24
|
+
webp: 'image/webp',
|
|
25
|
+
bmp: 'image/bmp',
|
|
26
|
+
svg: 'image/svg+xml',
|
|
27
|
+
heic: 'image/heic',
|
|
28
|
+
heif: 'image/heif',
|
|
29
|
+
// 视频
|
|
30
|
+
mp4: 'video/mp4',
|
|
31
|
+
mov: 'video/quicktime',
|
|
32
|
+
avi: 'video/x-msvideo',
|
|
33
|
+
mkv: 'video/x-matroska',
|
|
34
|
+
webm: 'video/webm',
|
|
35
|
+
flv: 'video/x-flv',
|
|
36
|
+
// 音频
|
|
37
|
+
mp3: 'audio/mpeg',
|
|
38
|
+
wav: 'audio/wav',
|
|
39
|
+
ogg: 'audio/ogg',
|
|
40
|
+
aac: 'audio/aac',
|
|
41
|
+
m4a: 'audio/mp4',
|
|
42
|
+
flac: 'audio/flac',
|
|
43
|
+
opus: 'audio/opus',
|
|
44
|
+
amr: 'audio/amr',
|
|
45
|
+
// 文档
|
|
46
|
+
pdf: 'application/pdf',
|
|
47
|
+
doc: 'application/msword',
|
|
48
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
49
|
+
xls: 'application/vnd.ms-excel',
|
|
50
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
51
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
52
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
53
|
+
zip: 'application/zip',
|
|
54
|
+
txt: 'text/plain',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 从文件名推断 MIME type。
|
|
59
|
+
* 无法确定时回退为 'application/octet-stream'。
|
|
60
|
+
*/
|
|
61
|
+
export function inferMimeType(fileName: string): string {
|
|
62
|
+
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
|
|
63
|
+
return MIME_MAP[ext] ?? 'application/octet-stream';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 从 MIME type 反推媒体类型。
|
|
68
|
+
* 适用于 CDN URL 无扩展名、但已知 content-type 的场景。
|
|
69
|
+
*/
|
|
70
|
+
export function inferMediaTypeFromMime(mimeType: string): 'image' | 'video' | 'audio' | 'file' {
|
|
71
|
+
if (mimeType.startsWith('image/')) return 'image';
|
|
72
|
+
if (mimeType.startsWith('video/')) return 'video';
|
|
73
|
+
if (mimeType.startsWith('audio/')) return 'audio';
|
|
74
|
+
return 'file';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** blurhash 计算结果 */
|
|
78
|
+
export type BlurhashResult = {
|
|
79
|
+
blurHash: string;
|
|
80
|
+
width: number;
|
|
81
|
+
height: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 计算图片的 blurhash。
|
|
86
|
+
* 仅处理图片类型,非图片或计算失败时返回 undefined。
|
|
87
|
+
* 内部将图片缩小到 32px 以内再计算,性能开销极小。
|
|
88
|
+
*/
|
|
89
|
+
export async function computeBlurhash(
|
|
90
|
+
buffer: Buffer,
|
|
91
|
+
mimeType: string,
|
|
92
|
+
): Promise<BlurhashResult | undefined> {
|
|
93
|
+
if (!mimeType.startsWith('image/')) return undefined;
|
|
94
|
+
try {
|
|
95
|
+
const sharp = (await import('sharp')).default;
|
|
96
|
+
const image = sharp(buffer).resize(32, 32, { fit: 'inside' }).ensureAlpha().raw();
|
|
97
|
+
const { data, info } = await image.toBuffer({ resolveWithObject: true });
|
|
98
|
+
const { encode } = await import('blurhash');
|
|
99
|
+
const blurHash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 3);
|
|
100
|
+
// 同时获取原始图片尺寸
|
|
101
|
+
const meta = await sharp(buffer).metadata();
|
|
102
|
+
return {
|
|
103
|
+
blurHash,
|
|
104
|
+
width: meta.width ?? info.width,
|
|
105
|
+
height: meta.height ?? info.height,
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|