@tencent-connect/openclaw-qqbot 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +393 -0
- package/README.zh.md +390 -0
- package/bin/qqbot-cli.js +243 -0
- package/clawdbot.plugin.json +16 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +22 -0
- package/dist/src/api.d.ts +138 -0
- package/dist/src/api.js +523 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/channel.js +337 -0
- package/dist/src/config.d.ts +25 -0
- package/dist/src/config.js +156 -0
- package/dist/src/gateway.d.ts +18 -0
- package/dist/src/gateway.js +2315 -0
- package/dist/src/image-server.d.ts +62 -0
- package/dist/src/image-server.js +401 -0
- package/dist/src/known-users.d.ts +100 -0
- package/dist/src/known-users.js +263 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +203 -0
- package/dist/src/outbound.d.ts +150 -0
- package/dist/src/outbound.js +1175 -0
- package/dist/src/proactive.d.ts +170 -0
- package/dist/src/proactive.js +399 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/session-store.d.ts +52 -0
- package/dist/src/session-store.js +254 -0
- package/dist/src/types.d.ts +145 -0
- package/dist/src/types.js +1 -0
- package/dist/src/utils/audio-convert.d.ts +73 -0
- package/dist/src/utils/audio-convert.js +645 -0
- package/dist/src/utils/file-utils.d.ts +46 -0
- package/dist/src/utils/file-utils.js +107 -0
- package/dist/src/utils/image-size.d.ts +51 -0
- package/dist/src/utils/image-size.js +234 -0
- package/dist/src/utils/media-tags.d.ts +14 -0
- package/dist/src/utils/media-tags.js +120 -0
- package/dist/src/utils/payload.d.ts +112 -0
- package/dist/src/utils/payload.js +186 -0
- package/dist/src/utils/platform.d.ts +126 -0
- package/dist/src/utils/platform.js +358 -0
- package/dist/src/utils/upload-cache.d.ts +34 -0
- package/dist/src/utils/upload-cache.js +93 -0
- package/index.ts +27 -0
- package/moltbot.plugin.json +16 -0
- package/node_modules/@eshaz/web-worker/LICENSE +201 -0
- package/node_modules/@eshaz/web-worker/README.md +134 -0
- package/node_modules/@eshaz/web-worker/browser.js +17 -0
- package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
- package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
- package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
- package/node_modules/@eshaz/web-worker/node.js +223 -0
- package/node_modules/@eshaz/web-worker/package.json +54 -0
- package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
- package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
- package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
- package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
- package/node_modules/mpg123-decoder/README.md +265 -0
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
- package/node_modules/mpg123-decoder/index.js +8 -0
- package/node_modules/mpg123-decoder/package.json +58 -0
- package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
- package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
- package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
- package/node_modules/mpg123-decoder/types.d.ts +30 -0
- package/node_modules/silk-wasm/LICENSE +21 -0
- package/node_modules/silk-wasm/README.md +85 -0
- package/node_modules/silk-wasm/lib/index.cjs +16 -0
- package/node_modules/silk-wasm/lib/index.d.ts +70 -0
- package/node_modules/silk-wasm/lib/index.mjs +16 -0
- package/node_modules/silk-wasm/lib/silk.wasm +0 -0
- package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
- package/node_modules/silk-wasm/package.json +39 -0
- package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
- package/node_modules/simple-yenc/.prettierignore +1 -0
- package/node_modules/simple-yenc/LICENSE +7 -0
- package/node_modules/simple-yenc/README.md +163 -0
- package/node_modules/simple-yenc/dist/esm.js +1 -0
- package/node_modules/simple-yenc/dist/index.js +1 -0
- package/node_modules/simple-yenc/package.json +50 -0
- package/node_modules/simple-yenc/rollup.config.js +27 -0
- package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +13 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +528 -0
- package/node_modules/ws/lib/receiver.js +706 -0
- package/node_modules/ws/lib/sender.js +602 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +554 -0
- package/node_modules/ws/lib/websocket.js +1393 -0
- package/node_modules/ws/package.json +69 -0
- package/node_modules/ws/wrapper.mjs +8 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +76 -0
- package/scripts/proactive-api-server.ts +356 -0
- package/scripts/pull-latest.sh +316 -0
- package/scripts/send-proactive.ts +273 -0
- package/scripts/set-markdown.sh +156 -0
- package/scripts/upgrade-and-run.sh +525 -0
- package/scripts/upgrade.sh +127 -0
- package/skills/qqbot-cron/SKILL.md +513 -0
- package/skills/qqbot-media/SKILL.md +194 -0
- package/src/api.ts +704 -0
- package/src/channel.ts +368 -0
- package/src/config.ts +182 -0
- package/src/gateway.ts +2459 -0
- package/src/image-server.ts +474 -0
- package/src/known-users.ts +353 -0
- package/src/onboarding.ts +274 -0
- package/src/openclaw-plugin-sdk.d.ts +483 -0
- package/src/outbound.ts +1301 -0
- package/src/proactive.ts +530 -0
- package/src/runtime.ts +14 -0
- package/src/session-store.ts +303 -0
- package/src/types.ts +153 -0
- package/src/utils/audio-convert.ts +738 -0
- package/src/utils/file-utils.ts +122 -0
- package/src/utils/image-size.ts +266 -0
- package/src/utils/media-tags.ts +134 -0
- package/src/utils/payload.ts +265 -0
- package/src/utils/platform.ts +404 -0
- package/src/utils/upload-cache.ts +128 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件操作工具 — 异步读取 + 大小校验 + 进度提示
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
/** QQ Bot API 最大上传文件大小:20MB */
|
|
7
|
+
export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
|
|
8
|
+
/** 大文件阈值(超过此值发送进度提示):5MB */
|
|
9
|
+
export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* 校验文件大小是否在上传限制内
|
|
12
|
+
* @param filePath 文件路径
|
|
13
|
+
* @param maxSize 最大允许大小(字节),默认 20MB
|
|
14
|
+
*/
|
|
15
|
+
export function checkFileSize(filePath, maxSize = MAX_UPLOAD_SIZE) {
|
|
16
|
+
try {
|
|
17
|
+
const stat = fs.statSync(filePath);
|
|
18
|
+
if (stat.size > maxSize) {
|
|
19
|
+
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
|
20
|
+
const limitMB = (maxSize / (1024 * 1024)).toFixed(0);
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
size: stat.size,
|
|
24
|
+
error: `文件过大 (${sizeMB}MB),QQ Bot API 上传限制为 ${limitMB}MB`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, size: stat.size };
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
size: 0,
|
|
33
|
+
error: `无法读取文件信息: ${err instanceof Error ? err.message : String(err)}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 异步读取文件内容
|
|
39
|
+
* 替代 fs.readFileSync,避免阻塞事件循环
|
|
40
|
+
*/
|
|
41
|
+
export async function readFileAsync(filePath) {
|
|
42
|
+
return fs.promises.readFile(filePath);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 异步检查文件是否存在
|
|
46
|
+
*/
|
|
47
|
+
export async function fileExistsAsync(filePath) {
|
|
48
|
+
try {
|
|
49
|
+
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 异步获取文件大小
|
|
58
|
+
*/
|
|
59
|
+
export async function getFileSizeAsync(filePath) {
|
|
60
|
+
const stat = await fs.promises.stat(filePath);
|
|
61
|
+
return stat.size;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 判断文件是否为"大文件"(需要进度提示)
|
|
65
|
+
*/
|
|
66
|
+
export function isLargeFile(sizeBytes) {
|
|
67
|
+
return sizeBytes >= LARGE_FILE_THRESHOLD;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 格式化文件大小为人类可读的字符串
|
|
71
|
+
*/
|
|
72
|
+
export function formatFileSize(bytes) {
|
|
73
|
+
if (bytes < 1024)
|
|
74
|
+
return `${bytes}B`;
|
|
75
|
+
if (bytes < 1024 * 1024)
|
|
76
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
77
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 根据文件扩展名获取 MIME 类型
|
|
81
|
+
*/
|
|
82
|
+
export function getMimeType(filePath) {
|
|
83
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
84
|
+
const mimeTypes = {
|
|
85
|
+
".jpg": "image/jpeg",
|
|
86
|
+
".jpeg": "image/jpeg",
|
|
87
|
+
".png": "image/png",
|
|
88
|
+
".gif": "image/gif",
|
|
89
|
+
".webp": "image/webp",
|
|
90
|
+
".bmp": "image/bmp",
|
|
91
|
+
".mp4": "video/mp4",
|
|
92
|
+
".mov": "video/quicktime",
|
|
93
|
+
".avi": "video/x-msvideo",
|
|
94
|
+
".mkv": "video/x-matroska",
|
|
95
|
+
".webm": "video/webm",
|
|
96
|
+
".pdf": "application/pdf",
|
|
97
|
+
".doc": "application/msword",
|
|
98
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
99
|
+
".xls": "application/vnd.ms-excel",
|
|
100
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
101
|
+
".zip": "application/zip",
|
|
102
|
+
".tar": "application/x-tar",
|
|
103
|
+
".gz": "application/gzip",
|
|
104
|
+
".txt": "text/plain",
|
|
105
|
+
};
|
|
106
|
+
return mimeTypes[ext] ?? "application/octet-stream";
|
|
107
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图片尺寸工具
|
|
3
|
+
* 用于获取图片尺寸,生成 QQBot 的 markdown 图片格式
|
|
4
|
+
*
|
|
5
|
+
* QQBot markdown 图片格式: 
|
|
6
|
+
*/
|
|
7
|
+
import { Buffer } from "buffer";
|
|
8
|
+
export interface ImageSize {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
/** 默认图片尺寸(当无法获取时使用) */
|
|
13
|
+
export declare const DEFAULT_IMAGE_SIZE: ImageSize;
|
|
14
|
+
/**
|
|
15
|
+
* 从图片数据 Buffer 解析尺寸
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseImageSize(buffer: Buffer): ImageSize | null;
|
|
18
|
+
/**
|
|
19
|
+
* 从公网 URL 获取图片尺寸
|
|
20
|
+
* 只下载前 64KB 数据,足够解析大部分图片格式的头部
|
|
21
|
+
*/
|
|
22
|
+
export declare function getImageSizeFromUrl(url: string, timeoutMs?: number): Promise<ImageSize | null>;
|
|
23
|
+
/**
|
|
24
|
+
* 从 Base64 Data URL 获取图片尺寸
|
|
25
|
+
*/
|
|
26
|
+
export declare function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null;
|
|
27
|
+
/**
|
|
28
|
+
* 获取图片尺寸(自动判断来源)
|
|
29
|
+
* @param source - 图片 URL 或 Base64 Data URL
|
|
30
|
+
* @returns 图片尺寸,失败返回 null
|
|
31
|
+
*/
|
|
32
|
+
export declare function getImageSize(source: string): Promise<ImageSize | null>;
|
|
33
|
+
/**
|
|
34
|
+
* 生成 QQBot markdown 图片格式
|
|
35
|
+
* 格式: 
|
|
36
|
+
*
|
|
37
|
+
* @param url - 图片 URL
|
|
38
|
+
* @param size - 图片尺寸,如果为 null 则使用默认尺寸
|
|
39
|
+
* @returns QQBot markdown 图片字符串
|
|
40
|
+
*/
|
|
41
|
+
export declare function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string;
|
|
42
|
+
/**
|
|
43
|
+
* 检查 markdown 图片是否已经包含 QQBot 格式的尺寸信息
|
|
44
|
+
* 格式: 
|
|
45
|
+
*/
|
|
46
|
+
export declare function hasQQBotImageSize(markdownImage: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* 从已有的 QQBot 格式 markdown 图片中提取尺寸
|
|
49
|
+
* 格式: 
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractQQBotImageSize(markdownImage: string): ImageSize | null;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图片尺寸工具
|
|
3
|
+
* 用于获取图片尺寸,生成 QQBot 的 markdown 图片格式
|
|
4
|
+
*
|
|
5
|
+
* QQBot markdown 图片格式: 
|
|
6
|
+
*/
|
|
7
|
+
import { Buffer } from "buffer";
|
|
8
|
+
/** 默认图片尺寸(当无法获取时使用) */
|
|
9
|
+
export const DEFAULT_IMAGE_SIZE = { width: 512, height: 512 };
|
|
10
|
+
/**
|
|
11
|
+
* 从 PNG 文件头解析图片尺寸
|
|
12
|
+
* PNG 文件头结构: 前 8 字节是签名,IHDR 块从第 8 字节开始
|
|
13
|
+
* IHDR 块: 长度(4) + 类型(4, "IHDR") + 宽度(4) + 高度(4) + ...
|
|
14
|
+
*/
|
|
15
|
+
function parsePngSize(buffer) {
|
|
16
|
+
// PNG 签名: 89 50 4E 47 0D 0A 1A 0A
|
|
17
|
+
if (buffer.length < 24)
|
|
18
|
+
return null;
|
|
19
|
+
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
// IHDR 块从第 8 字节开始,宽度在第 16-19 字节,高度在第 20-23 字节
|
|
23
|
+
const width = buffer.readUInt32BE(16);
|
|
24
|
+
const height = buffer.readUInt32BE(20);
|
|
25
|
+
return { width, height };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 从 JPEG 文件解析图片尺寸
|
|
29
|
+
* JPEG 尺寸在 SOF0/SOF2 块中
|
|
30
|
+
*/
|
|
31
|
+
function parseJpegSize(buffer) {
|
|
32
|
+
// JPEG 签名: FF D8 FF
|
|
33
|
+
if (buffer.length < 4)
|
|
34
|
+
return null;
|
|
35
|
+
if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
let offset = 2;
|
|
39
|
+
while (offset < buffer.length - 9) {
|
|
40
|
+
if (buffer[offset] !== 0xFF) {
|
|
41
|
+
offset++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const marker = buffer[offset + 1];
|
|
45
|
+
// SOF0 (0xC0) 或 SOF2 (0xC2) 包含图片尺寸
|
|
46
|
+
if (marker === 0xC0 || marker === 0xC2) {
|
|
47
|
+
// 格式: FF C0 长度(2) 精度(1) 高度(2) 宽度(2)
|
|
48
|
+
if (offset + 9 <= buffer.length) {
|
|
49
|
+
const height = buffer.readUInt16BE(offset + 5);
|
|
50
|
+
const width = buffer.readUInt16BE(offset + 7);
|
|
51
|
+
return { width, height };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// 跳过当前块
|
|
55
|
+
if (offset + 3 < buffer.length) {
|
|
56
|
+
const blockLength = buffer.readUInt16BE(offset + 2);
|
|
57
|
+
offset += 2 + blockLength;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 从 GIF 文件头解析图片尺寸
|
|
67
|
+
* GIF 文件头: GIF87a 或 GIF89a (6字节) + 宽度(2) + 高度(2)
|
|
68
|
+
*/
|
|
69
|
+
function parseGifSize(buffer) {
|
|
70
|
+
if (buffer.length < 10)
|
|
71
|
+
return null;
|
|
72
|
+
const signature = buffer.toString("ascii", 0, 6);
|
|
73
|
+
if (signature !== "GIF87a" && signature !== "GIF89a") {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const width = buffer.readUInt16LE(6);
|
|
77
|
+
const height = buffer.readUInt16LE(8);
|
|
78
|
+
return { width, height };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 从 WebP 文件解析图片尺寸
|
|
82
|
+
* WebP 文件头: RIFF(4) + 文件大小(4) + WEBP(4) + VP8/VP8L/VP8X(4) + ...
|
|
83
|
+
*/
|
|
84
|
+
function parseWebpSize(buffer) {
|
|
85
|
+
if (buffer.length < 30)
|
|
86
|
+
return null;
|
|
87
|
+
// 检查 RIFF 和 WEBP 签名
|
|
88
|
+
const riff = buffer.toString("ascii", 0, 4);
|
|
89
|
+
const webp = buffer.toString("ascii", 8, 12);
|
|
90
|
+
if (riff !== "RIFF" || webp !== "WEBP") {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const chunkType = buffer.toString("ascii", 12, 16);
|
|
94
|
+
// VP8 (有损压缩)
|
|
95
|
+
if (chunkType === "VP8 ") {
|
|
96
|
+
// VP8 帧头从第 23 字节开始,检查签名 9D 01 2A
|
|
97
|
+
if (buffer.length >= 30 && buffer[23] === 0x9D && buffer[24] === 0x01 && buffer[25] === 0x2A) {
|
|
98
|
+
const width = buffer.readUInt16LE(26) & 0x3FFF;
|
|
99
|
+
const height = buffer.readUInt16LE(28) & 0x3FFF;
|
|
100
|
+
return { width, height };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// VP8L (无损压缩)
|
|
104
|
+
if (chunkType === "VP8L") {
|
|
105
|
+
// VP8L 签名: 0x2F
|
|
106
|
+
if (buffer.length >= 25 && buffer[20] === 0x2F) {
|
|
107
|
+
const bits = buffer.readUInt32LE(21);
|
|
108
|
+
const width = (bits & 0x3FFF) + 1;
|
|
109
|
+
const height = ((bits >> 14) & 0x3FFF) + 1;
|
|
110
|
+
return { width, height };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// VP8X (扩展格式)
|
|
114
|
+
if (chunkType === "VP8X") {
|
|
115
|
+
if (buffer.length >= 30) {
|
|
116
|
+
// 宽度和高度在第 24-26 和 27-29 字节(24位小端)
|
|
117
|
+
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
|
|
118
|
+
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
|
|
119
|
+
return { width, height };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 从图片数据 Buffer 解析尺寸
|
|
126
|
+
*/
|
|
127
|
+
export function parseImageSize(buffer) {
|
|
128
|
+
// 尝试各种格式
|
|
129
|
+
return parsePngSize(buffer)
|
|
130
|
+
?? parseJpegSize(buffer)
|
|
131
|
+
?? parseGifSize(buffer)
|
|
132
|
+
?? parseWebpSize(buffer);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 从公网 URL 获取图片尺寸
|
|
136
|
+
* 只下载前 64KB 数据,足够解析大部分图片格式的头部
|
|
137
|
+
*/
|
|
138
|
+
export async function getImageSizeFromUrl(url, timeoutMs = 5000) {
|
|
139
|
+
try {
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
142
|
+
// 使用 Range 请求只获取前 64KB
|
|
143
|
+
const response = await fetch(url, {
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
headers: {
|
|
146
|
+
"Range": "bytes=0-65535",
|
|
147
|
+
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
clearTimeout(timeoutId);
|
|
151
|
+
if (!response.ok && response.status !== 206) {
|
|
152
|
+
console.log(`[image-size] Failed to fetch ${url}: ${response.status}`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
156
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
157
|
+
const size = parseImageSize(buffer);
|
|
158
|
+
if (size) {
|
|
159
|
+
console.log(`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`);
|
|
160
|
+
}
|
|
161
|
+
return size;
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
console.log(`[image-size] Error fetching ${url.slice(0, 60)}...: ${err}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 从 Base64 Data URL 获取图片尺寸
|
|
170
|
+
*/
|
|
171
|
+
export function getImageSizeFromDataUrl(dataUrl) {
|
|
172
|
+
try {
|
|
173
|
+
// 格式: data:image/png;base64,xxxxx
|
|
174
|
+
const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
|
|
175
|
+
if (!matches) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const base64Data = matches[1];
|
|
179
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
180
|
+
const size = parseImageSize(buffer);
|
|
181
|
+
if (size) {
|
|
182
|
+
console.log(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
|
|
183
|
+
}
|
|
184
|
+
return size;
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
console.log(`[image-size] Error parsing Base64: ${err}`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 获取图片尺寸(自动判断来源)
|
|
193
|
+
* @param source - 图片 URL 或 Base64 Data URL
|
|
194
|
+
* @returns 图片尺寸,失败返回 null
|
|
195
|
+
*/
|
|
196
|
+
export async function getImageSize(source) {
|
|
197
|
+
if (source.startsWith("data:")) {
|
|
198
|
+
return getImageSizeFromDataUrl(source);
|
|
199
|
+
}
|
|
200
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
201
|
+
return getImageSizeFromUrl(source);
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 生成 QQBot markdown 图片格式
|
|
207
|
+
* 格式: 
|
|
208
|
+
*
|
|
209
|
+
* @param url - 图片 URL
|
|
210
|
+
* @param size - 图片尺寸,如果为 null 则使用默认尺寸
|
|
211
|
+
* @returns QQBot markdown 图片字符串
|
|
212
|
+
*/
|
|
213
|
+
export function formatQQBotMarkdownImage(url, size) {
|
|
214
|
+
const { width, height } = size ?? DEFAULT_IMAGE_SIZE;
|
|
215
|
+
return ``;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 检查 markdown 图片是否已经包含 QQBot 格式的尺寸信息
|
|
219
|
+
* 格式: 
|
|
220
|
+
*/
|
|
221
|
+
export function hasQQBotImageSize(markdownImage) {
|
|
222
|
+
return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 从已有的 QQBot 格式 markdown 图片中提取尺寸
|
|
226
|
+
* 格式: 
|
|
227
|
+
*/
|
|
228
|
+
export function extractQQBotImageSize(markdownImage) {
|
|
229
|
+
const match = markdownImage.match(/!\[#(\d+)px\s+#(\d+)px\]/);
|
|
230
|
+
if (match) {
|
|
231
|
+
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 富媒体标签预处理与纠错
|
|
3
|
+
*
|
|
4
|
+
* 小模型常见的标签拼写错误及变体,在正则匹配前统一修正为标准格式。
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* 预处理 LLM 输出文本,将各种畸形/错误的富媒体标签修正为标准格式。
|
|
8
|
+
*
|
|
9
|
+
* 标准格式:<qqimg>/path/to/file</qqimg>
|
|
10
|
+
*
|
|
11
|
+
* @param text LLM 原始输出
|
|
12
|
+
* @returns 修正后的文本(如果没有匹配到任何标签则原样返回)
|
|
13
|
+
*/
|
|
14
|
+
export declare function normalizeMediaTags(text: string): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 富媒体标签预处理与纠错
|
|
3
|
+
*
|
|
4
|
+
* 小模型常见的标签拼写错误及变体,在正则匹配前统一修正为标准格式。
|
|
5
|
+
*/
|
|
6
|
+
import { expandTilde } from "./platform.js";
|
|
7
|
+
// 标准标签名
|
|
8
|
+
const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile"];
|
|
9
|
+
// 开头标签别名映射(key 全部小写)
|
|
10
|
+
const TAG_ALIASES = {
|
|
11
|
+
// ---- qqimg 变体 ----
|
|
12
|
+
"qq_img": "qqimg",
|
|
13
|
+
"qqimage": "qqimg",
|
|
14
|
+
"qq_image": "qqimg",
|
|
15
|
+
"qqpic": "qqimg",
|
|
16
|
+
"qq_pic": "qqimg",
|
|
17
|
+
"qqpicture": "qqimg",
|
|
18
|
+
"qq_picture": "qqimg",
|
|
19
|
+
"qqphoto": "qqimg",
|
|
20
|
+
"qq_photo": "qqimg",
|
|
21
|
+
"img": "qqimg",
|
|
22
|
+
"image": "qqimg",
|
|
23
|
+
"pic": "qqimg",
|
|
24
|
+
"picture": "qqimg",
|
|
25
|
+
"photo": "qqimg",
|
|
26
|
+
// ---- qqvoice 变体 ----
|
|
27
|
+
"qq_voice": "qqvoice",
|
|
28
|
+
"qqaudio": "qqvoice",
|
|
29
|
+
"qq_audio": "qqvoice",
|
|
30
|
+
"voice": "qqvoice",
|
|
31
|
+
"audio": "qqvoice",
|
|
32
|
+
// ---- qqvideo 变体 ----
|
|
33
|
+
"qq_video": "qqvideo",
|
|
34
|
+
"video": "qqvideo",
|
|
35
|
+
// ---- qqfile 变体 ----
|
|
36
|
+
"qq_file": "qqfile",
|
|
37
|
+
"qqdoc": "qqfile",
|
|
38
|
+
"qq_doc": "qqfile",
|
|
39
|
+
"file": "qqfile",
|
|
40
|
+
"doc": "qqfile",
|
|
41
|
+
"document": "qqfile",
|
|
42
|
+
};
|
|
43
|
+
// 构建所有可识别的标签名列表(标准名 + 别名)
|
|
44
|
+
const ALL_TAG_NAMES = [...VALID_TAGS, ...Object.keys(TAG_ALIASES)];
|
|
45
|
+
// 按长度降序排列,优先匹配更长的名称(避免 "img" 抢先匹配 "qqimg" 的子串)
|
|
46
|
+
ALL_TAG_NAMES.sort((a, b) => b.length - a.length);
|
|
47
|
+
const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|");
|
|
48
|
+
/**
|
|
49
|
+
* 构建一个宽容的正则,能匹配各种畸形标签写法:
|
|
50
|
+
*
|
|
51
|
+
* 常见错误模式:
|
|
52
|
+
* 1. 标签名拼错:<qq_img>, <qqimage>, <image>, <img>, <pic> ...
|
|
53
|
+
* 2. 标签内多余空格:<qqimg >, < qqimg>, <qqimg >
|
|
54
|
+
* 3. 闭合标签不匹配:<qqimg>url</qqvoice>, <qqimg>url</img>
|
|
55
|
+
* 4. 闭合标签缺失斜杠:<qqimg>url<qqimg> (用开头标签代替闭合标签)
|
|
56
|
+
* 5. 闭合标签缺失尖括号:<qqimg>url/qqimg>
|
|
57
|
+
* 6. 中文尖括号:<qqimg>url</qqimg> 或 <qqimg>url</qqimg>
|
|
58
|
+
* 7. 多余引号包裹路径:<qqimg>"path"</qqimg>
|
|
59
|
+
* 8. Markdown 代码块包裹:`<qqimg>path</qqimg>`
|
|
60
|
+
*/
|
|
61
|
+
const FUZZY_MEDIA_TAG_REGEX = new RegExp(
|
|
62
|
+
// 可选 Markdown 行内代码反引号
|
|
63
|
+
"`?" +
|
|
64
|
+
// 开头标签:允许中文/英文尖括号,标签名前后可有空格
|
|
65
|
+
"[<<<]\\s*(" + TAG_NAME_PATTERN + ")\\s*[>>>]" +
|
|
66
|
+
// 内容:非贪婪匹配,允许引号包裹
|
|
67
|
+
"[\"']?\\s*" +
|
|
68
|
+
"([^<<<>>\"'`]+?)" +
|
|
69
|
+
"\\s*[\"']?" +
|
|
70
|
+
// 闭合标签:允许各种不规范写法
|
|
71
|
+
"[<<<]\\s*/?\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>]" +
|
|
72
|
+
// 可选结尾反引号
|
|
73
|
+
"`?", "gi");
|
|
74
|
+
/**
|
|
75
|
+
* 将标签名映射为标准名称
|
|
76
|
+
*/
|
|
77
|
+
function resolveTagName(raw) {
|
|
78
|
+
const lower = raw.toLowerCase();
|
|
79
|
+
if (VALID_TAGS.includes(lower)) {
|
|
80
|
+
return lower;
|
|
81
|
+
}
|
|
82
|
+
return TAG_ALIASES[lower] ?? "qqimg";
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 预清理:将富媒体标签内部的换行/回车/制表符压缩为单个空格。
|
|
86
|
+
*
|
|
87
|
+
* 部分模型会在标签内部插入 \n \r \t 等空白字符,例如:
|
|
88
|
+
* <qqimg>\n /path/to/file.png\n</qqimg>
|
|
89
|
+
* <qqimg>/path/to/\nfile.png</qqimg>
|
|
90
|
+
*
|
|
91
|
+
* 此正则匹配从开标签到闭标签之间的内容(允许跨行),
|
|
92
|
+
* 将内部所有 [\r\n\t] 替换为空格,然后压缩连续空格。
|
|
93
|
+
*/
|
|
94
|
+
const MULTILINE_TAG_CLEANUP = new RegExp("([<<<]\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>])" +
|
|
95
|
+
"([\\s\\S]*?)" +
|
|
96
|
+
"([<<<]\\s*/?\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>])", "gi");
|
|
97
|
+
/**
|
|
98
|
+
* 预处理 LLM 输出文本,将各种畸形/错误的富媒体标签修正为标准格式。
|
|
99
|
+
*
|
|
100
|
+
* 标准格式:<qqimg>/path/to/file</qqimg>
|
|
101
|
+
*
|
|
102
|
+
* @param text LLM 原始输出
|
|
103
|
+
* @returns 修正后的文本(如果没有匹配到任何标签则原样返回)
|
|
104
|
+
*/
|
|
105
|
+
export function normalizeMediaTags(text) {
|
|
106
|
+
// 先将标签内部的换行/回车/制表符压缩为空格
|
|
107
|
+
let cleaned = text.replace(MULTILINE_TAG_CLEANUP, (_m, open, body, close) => {
|
|
108
|
+
const flat = body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ");
|
|
109
|
+
return open + flat + close;
|
|
110
|
+
});
|
|
111
|
+
return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, (_match, rawTag, content) => {
|
|
112
|
+
const tag = resolveTagName(rawTag);
|
|
113
|
+
const trimmed = content.trim();
|
|
114
|
+
if (!trimmed)
|
|
115
|
+
return _match; // 空内容不处理
|
|
116
|
+
// 展开波浪线路径:~/Desktop/file.png → /Users/xxx/Desktop/file.png
|
|
117
|
+
const expanded = expandTilde(trimmed);
|
|
118
|
+
return `<${tag}>${expanded}</${tag}>`;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot 结构化消息载荷工具
|
|
3
|
+
*
|
|
4
|
+
* 用于处理 AI 输出的结构化消息载荷,包括:
|
|
5
|
+
* - 定时提醒载荷 (cron_reminder)
|
|
6
|
+
* - 媒体消息载荷 (media)
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* 定时提醒载荷
|
|
10
|
+
*/
|
|
11
|
+
export interface CronReminderPayload {
|
|
12
|
+
type: 'cron_reminder';
|
|
13
|
+
/** 提醒内容 */
|
|
14
|
+
content: string;
|
|
15
|
+
/** 目标类型:c2c (私聊) 或 group (群聊) */
|
|
16
|
+
targetType: 'c2c' | 'group';
|
|
17
|
+
/** 目标地址:user_openid 或 group_openid */
|
|
18
|
+
targetAddress: string;
|
|
19
|
+
/** 原始消息 ID(可选) */
|
|
20
|
+
originalMessageId?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 媒体消息载荷
|
|
24
|
+
*/
|
|
25
|
+
export interface MediaPayload {
|
|
26
|
+
type: 'media';
|
|
27
|
+
/** 媒体类型:image, audio, video, file */
|
|
28
|
+
mediaType: 'image' | 'audio' | 'video' | 'file';
|
|
29
|
+
/** 来源类型:url 或 file */
|
|
30
|
+
source: 'url' | 'file';
|
|
31
|
+
/** 媒体路径或 URL */
|
|
32
|
+
path: string;
|
|
33
|
+
/** 媒体描述(可选) */
|
|
34
|
+
caption?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* QQBot 载荷联合类型
|
|
38
|
+
*/
|
|
39
|
+
export type QQBotPayload = CronReminderPayload | MediaPayload;
|
|
40
|
+
/**
|
|
41
|
+
* 解析结果
|
|
42
|
+
*/
|
|
43
|
+
export interface ParseResult {
|
|
44
|
+
/** 是否为结构化载荷 */
|
|
45
|
+
isPayload: boolean;
|
|
46
|
+
/** 解析后的载荷对象(如果是结构化载荷) */
|
|
47
|
+
payload?: QQBotPayload;
|
|
48
|
+
/** 原始文本(如果不是结构化载荷) */
|
|
49
|
+
text?: string;
|
|
50
|
+
/** 解析错误信息(如果解析失败) */
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 解析 AI 输出的结构化载荷
|
|
55
|
+
*
|
|
56
|
+
* 检测消息是否以 QQBOT_PAYLOAD: 前缀开头,如果是则提取并解析 JSON
|
|
57
|
+
*
|
|
58
|
+
* @param text AI 输出的原始文本
|
|
59
|
+
* @returns 解析结果
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const result = parseQQBotPayload('QQBOT_PAYLOAD:\n{"type": "media", "mediaType": "image", ...}');
|
|
63
|
+
* if (result.isPayload && result.payload) {
|
|
64
|
+
* // 处理结构化载荷
|
|
65
|
+
* }
|
|
66
|
+
*/
|
|
67
|
+
export declare function parseQQBotPayload(text: string): ParseResult;
|
|
68
|
+
/**
|
|
69
|
+
* 将定时提醒载荷编码为 Cron 消息格式
|
|
70
|
+
*
|
|
71
|
+
* 将 JSON 编码为 Base64,并添加 QQBOT_CRON: 前缀
|
|
72
|
+
*
|
|
73
|
+
* @param payload 定时提醒载荷
|
|
74
|
+
* @returns 编码后的消息字符串,格式为 QQBOT_CRON:{base64}
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* const message = encodePayloadForCron({
|
|
78
|
+
* type: 'cron_reminder',
|
|
79
|
+
* content: '喝水时间到!',
|
|
80
|
+
* targetType: 'c2c',
|
|
81
|
+
* targetAddress: 'user_openid_xxx'
|
|
82
|
+
* });
|
|
83
|
+
* // 返回: QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...
|
|
84
|
+
*/
|
|
85
|
+
export declare function encodePayloadForCron(payload: CronReminderPayload): string;
|
|
86
|
+
/**
|
|
87
|
+
* 解码 Cron 消息中的载荷
|
|
88
|
+
*
|
|
89
|
+
* 检测 QQBOT_CRON: 前缀,解码 Base64 并解析 JSON
|
|
90
|
+
*
|
|
91
|
+
* @param message Cron 触发时收到的消息
|
|
92
|
+
* @returns 解码结果,包含是否为 Cron 载荷、解析后的载荷对象或错误信息
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* const result = decodeCronPayload('QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs...');
|
|
96
|
+
* if (result.isCronPayload && result.payload) {
|
|
97
|
+
* // 处理定时提醒
|
|
98
|
+
* }
|
|
99
|
+
*/
|
|
100
|
+
export declare function decodeCronPayload(message: string): {
|
|
101
|
+
isCronPayload: boolean;
|
|
102
|
+
payload?: CronReminderPayload;
|
|
103
|
+
error?: string;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* 判断载荷是否为定时提醒类型
|
|
107
|
+
*/
|
|
108
|
+
export declare function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload;
|
|
109
|
+
/**
|
|
110
|
+
* 判断载荷是否为媒体消息类型
|
|
111
|
+
*/
|
|
112
|
+
export declare function isMediaPayload(payload: QQBotPayload): payload is MediaPayload;
|