@ryantest/openclaw-qqbot 0.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.
Files changed (197) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +483 -0
  3. package/README.zh.md +478 -0
  4. package/bin/qqbot-cli.js +243 -0
  5. package/clawdbot.plugin.json +16 -0
  6. package/dist/index.d.ts +17 -0
  7. package/dist/index.js +26 -0
  8. package/dist/src/admin-resolver.d.ts +27 -0
  9. package/dist/src/admin-resolver.js +122 -0
  10. package/dist/src/api.d.ts +156 -0
  11. package/dist/src/api.js +599 -0
  12. package/dist/src/channel.d.ts +11 -0
  13. package/dist/src/channel.js +354 -0
  14. package/dist/src/config.d.ts +25 -0
  15. package/dist/src/config.js +161 -0
  16. package/dist/src/credential-backup.d.ts +31 -0
  17. package/dist/src/credential-backup.js +66 -0
  18. package/dist/src/gateway.d.ts +18 -0
  19. package/dist/src/gateway.js +1265 -0
  20. package/dist/src/image-server.d.ts +68 -0
  21. package/dist/src/image-server.js +462 -0
  22. package/dist/src/inbound-attachments.d.ts +58 -0
  23. package/dist/src/inbound-attachments.js +234 -0
  24. package/dist/src/known-users.d.ts +100 -0
  25. package/dist/src/known-users.js +263 -0
  26. package/dist/src/message-queue.d.ts +50 -0
  27. package/dist/src/message-queue.js +115 -0
  28. package/dist/src/onboarding.d.ts +10 -0
  29. package/dist/src/onboarding.js +203 -0
  30. package/dist/src/outbound-deliver.d.ts +48 -0
  31. package/dist/src/outbound-deliver.js +462 -0
  32. package/dist/src/outbound.d.ts +203 -0
  33. package/dist/src/outbound.js +1102 -0
  34. package/dist/src/proactive.d.ts +170 -0
  35. package/dist/src/proactive.js +399 -0
  36. package/dist/src/ref-index-store.d.ts +70 -0
  37. package/dist/src/ref-index-store.js +273 -0
  38. package/dist/src/reply-dispatcher.d.ts +35 -0
  39. package/dist/src/reply-dispatcher.js +311 -0
  40. package/dist/src/runtime.d.ts +3 -0
  41. package/dist/src/runtime.js +10 -0
  42. package/dist/src/session-store.d.ts +52 -0
  43. package/dist/src/session-store.js +254 -0
  44. package/dist/src/slash-commands.d.ts +71 -0
  45. package/dist/src/slash-commands.js +1179 -0
  46. package/dist/src/startup-greeting.d.ts +30 -0
  47. package/dist/src/startup-greeting.js +78 -0
  48. package/dist/src/stt.d.ts +21 -0
  49. package/dist/src/stt.js +70 -0
  50. package/dist/src/tools/channel.d.ts +16 -0
  51. package/dist/src/tools/channel.js +234 -0
  52. package/dist/src/tools/remind.d.ts +2 -0
  53. package/dist/src/tools/remind.js +247 -0
  54. package/dist/src/types.d.ts +175 -0
  55. package/dist/src/types.js +1 -0
  56. package/dist/src/typing-keepalive.d.ts +27 -0
  57. package/dist/src/typing-keepalive.js +64 -0
  58. package/dist/src/update-checker.d.ts +34 -0
  59. package/dist/src/update-checker.js +166 -0
  60. package/dist/src/user-messages.d.ts +8 -0
  61. package/dist/src/user-messages.js +8 -0
  62. package/dist/src/utils/audio-convert.d.ts +89 -0
  63. package/dist/src/utils/audio-convert.js +704 -0
  64. package/dist/src/utils/file-utils.d.ts +55 -0
  65. package/dist/src/utils/file-utils.js +150 -0
  66. package/dist/src/utils/image-size.d.ts +51 -0
  67. package/dist/src/utils/image-size.js +234 -0
  68. package/dist/src/utils/media-tags.d.ts +14 -0
  69. package/dist/src/utils/media-tags.js +164 -0
  70. package/dist/src/utils/payload.d.ts +112 -0
  71. package/dist/src/utils/payload.js +186 -0
  72. package/dist/src/utils/platform.d.ts +137 -0
  73. package/dist/src/utils/platform.js +390 -0
  74. package/dist/src/utils/text-parsing.d.ts +32 -0
  75. package/dist/src/utils/text-parsing.js +80 -0
  76. package/dist/src/utils/upload-cache.d.ts +34 -0
  77. package/dist/src/utils/upload-cache.js +93 -0
  78. package/index.ts +31 -0
  79. package/moltbot.plugin.json +16 -0
  80. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  81. package/node_modules/@eshaz/web-worker/README.md +134 -0
  82. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  83. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  84. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  85. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  86. package/node_modules/@eshaz/web-worker/node.js +223 -0
  87. package/node_modules/@eshaz/web-worker/package.json +54 -0
  88. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  89. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  90. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  91. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  92. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  93. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  94. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  95. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  96. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  97. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  98. package/node_modules/mpg123-decoder/README.md +265 -0
  99. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  100. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  101. package/node_modules/mpg123-decoder/index.js +8 -0
  102. package/node_modules/mpg123-decoder/package.json +58 -0
  103. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  104. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  105. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  106. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  107. package/node_modules/silk-wasm/LICENSE +21 -0
  108. package/node_modules/silk-wasm/README.md +85 -0
  109. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  110. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  111. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  112. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  113. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  114. package/node_modules/silk-wasm/package.json +39 -0
  115. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  116. package/node_modules/simple-yenc/.prettierignore +1 -0
  117. package/node_modules/simple-yenc/LICENSE +7 -0
  118. package/node_modules/simple-yenc/README.md +163 -0
  119. package/node_modules/simple-yenc/dist/esm.js +1 -0
  120. package/node_modules/simple-yenc/dist/index.js +1 -0
  121. package/node_modules/simple-yenc/package.json +50 -0
  122. package/node_modules/simple-yenc/rollup.config.js +27 -0
  123. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  124. package/node_modules/ws/LICENSE +20 -0
  125. package/node_modules/ws/README.md +548 -0
  126. package/node_modules/ws/browser.js +8 -0
  127. package/node_modules/ws/index.js +13 -0
  128. package/node_modules/ws/lib/buffer-util.js +131 -0
  129. package/node_modules/ws/lib/constants.js +19 -0
  130. package/node_modules/ws/lib/event-target.js +292 -0
  131. package/node_modules/ws/lib/extension.js +203 -0
  132. package/node_modules/ws/lib/limiter.js +55 -0
  133. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  134. package/node_modules/ws/lib/receiver.js +706 -0
  135. package/node_modules/ws/lib/sender.js +602 -0
  136. package/node_modules/ws/lib/stream.js +161 -0
  137. package/node_modules/ws/lib/subprotocol.js +62 -0
  138. package/node_modules/ws/lib/validation.js +152 -0
  139. package/node_modules/ws/lib/websocket-server.js +554 -0
  140. package/node_modules/ws/lib/websocket.js +1393 -0
  141. package/node_modules/ws/package.json +69 -0
  142. package/node_modules/ws/wrapper.mjs +8 -0
  143. package/openclaw.plugin.json +16 -0
  144. package/package.json +76 -0
  145. package/scripts/cleanup-legacy-plugins.sh +124 -0
  146. package/scripts/proactive-api-server.ts +369 -0
  147. package/scripts/send-proactive.ts +293 -0
  148. package/scripts/set-markdown.sh +156 -0
  149. package/scripts/test-sendmedia.ts +116 -0
  150. package/scripts/upgrade-via-alt-pkg.sh +307 -0
  151. package/scripts/upgrade-via-npm.ps1 +296 -0
  152. package/scripts/upgrade-via-npm.sh +301 -0
  153. package/scripts/upgrade-via-source.sh +774 -0
  154. package/skills/qqbot-channel/SKILL.md +263 -0
  155. package/skills/qqbot-channel/references/api_references.md +521 -0
  156. package/skills/qqbot-media/SKILL.md +56 -0
  157. package/skills/qqbot-remind/SKILL.md +149 -0
  158. package/src/admin-resolver.ts +140 -0
  159. package/src/api.ts +819 -0
  160. package/src/bot-logs-2026-03-21T11-21-47(2).txt +46 -0
  161. package/src/channel.ts +381 -0
  162. package/src/config.ts +187 -0
  163. package/src/credential-backup.ts +72 -0
  164. package/src/gateway.log +43 -0
  165. package/src/gateway.ts +1404 -0
  166. package/src/image-server.ts +539 -0
  167. package/src/inbound-attachments.ts +304 -0
  168. package/src/known-users.ts +353 -0
  169. package/src/message-queue.ts +169 -0
  170. package/src/onboarding.ts +274 -0
  171. package/src/openclaw-2026-03-21.log +3729 -0
  172. package/src/openclaw-plugin-sdk.d.ts +522 -0
  173. package/src/outbound-deliver.ts +552 -0
  174. package/src/outbound.ts +1266 -0
  175. package/src/proactive.ts +530 -0
  176. package/src/ref-index-store.ts +357 -0
  177. package/src/reply-dispatcher.ts +334 -0
  178. package/src/runtime.ts +14 -0
  179. package/src/session-store.ts +303 -0
  180. package/src/slash-commands.ts +1305 -0
  181. package/src/startup-greeting.ts +98 -0
  182. package/src/stt.ts +86 -0
  183. package/src/tools/channel.ts +281 -0
  184. package/src/tools/remind.ts +296 -0
  185. package/src/types.ts +183 -0
  186. package/src/typing-keepalive.ts +59 -0
  187. package/src/update-checker.ts +179 -0
  188. package/src/user-messages.ts +7 -0
  189. package/src/utils/audio-convert.ts +803 -0
  190. package/src/utils/file-utils.ts +167 -0
  191. package/src/utils/image-size.ts +266 -0
  192. package/src/utils/media-tags.ts +182 -0
  193. package/src/utils/payload.ts +265 -0
  194. package/src/utils/platform.ts +435 -0
  195. package/src/utils/text-parsing.ts +82 -0
  196. package/src/utils/upload-cache.ts +128 -0
  197. package/tsconfig.json +16 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 文件操作工具 — 异步读取 + 大小校验 + 进度提示
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import crypto from "node:crypto";
8
+
9
+ /** QQ Bot API 最大上传文件大小:20MB */
10
+ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
11
+
12
+ /** 大文件阈值(超过此值发送进度提示):5MB */
13
+ export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
14
+
15
+ /**
16
+ * 文件大小校验结果
17
+ */
18
+ export interface FileSizeCheckResult {
19
+ ok: boolean;
20
+ size: number;
21
+ error?: string;
22
+ }
23
+
24
+ /**
25
+ * 校验文件大小是否在上传限制内
26
+ * @param filePath 文件路径
27
+ * @param maxSize 最大允许大小(字节),默认 20MB
28
+ */
29
+ export function checkFileSize(filePath: string, maxSize = MAX_UPLOAD_SIZE): FileSizeCheckResult {
30
+ try {
31
+ const stat = fs.statSync(filePath);
32
+ if (stat.size > maxSize) {
33
+ const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
34
+ const limitMB = (maxSize / (1024 * 1024)).toFixed(0);
35
+ return {
36
+ ok: false,
37
+ size: stat.size,
38
+ error: `文件过大 (${sizeMB}MB),QQ Bot API 上传限制为 ${limitMB}MB`,
39
+ };
40
+ }
41
+ return { ok: true, size: stat.size };
42
+ } catch (err) {
43
+ return {
44
+ ok: false,
45
+ size: 0,
46
+ error: `无法读取文件信息: ${err instanceof Error ? err.message : String(err)}`,
47
+ };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * 异步读取文件内容
53
+ * 替代 fs.readFileSync,避免阻塞事件循环
54
+ */
55
+ export async function readFileAsync(filePath: string): Promise<Buffer> {
56
+ return fs.promises.readFile(filePath);
57
+ }
58
+
59
+ /**
60
+ * 异步检查文件是否存在
61
+ */
62
+ export async function fileExistsAsync(filePath: string): Promise<boolean> {
63
+ try {
64
+ await fs.promises.access(filePath, fs.constants.R_OK);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 异步获取文件大小
73
+ */
74
+ export async function getFileSizeAsync(filePath: string): Promise<number> {
75
+ const stat = await fs.promises.stat(filePath);
76
+ return stat.size;
77
+ }
78
+
79
+ /**
80
+ * 判断文件是否为"大文件"(需要进度提示)
81
+ */
82
+ export function isLargeFile(sizeBytes: number): boolean {
83
+ return sizeBytes >= LARGE_FILE_THRESHOLD;
84
+ }
85
+
86
+ /**
87
+ * 格式化文件大小为人类可读的字符串
88
+ */
89
+ export function formatFileSize(bytes: number): string {
90
+ if (bytes < 1024) return `${bytes}B`;
91
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
92
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
93
+ }
94
+
95
+ /**
96
+ * 根据文件扩展名获取 MIME 类型
97
+ */
98
+ export function getMimeType(filePath: string): string {
99
+ const ext = path.extname(filePath).toLowerCase();
100
+ const mimeTypes: Record<string, string> = {
101
+ ".jpg": "image/jpeg",
102
+ ".jpeg": "image/jpeg",
103
+ ".png": "image/png",
104
+ ".gif": "image/gif",
105
+ ".webp": "image/webp",
106
+ ".bmp": "image/bmp",
107
+ ".mp4": "video/mp4",
108
+ ".mov": "video/quicktime",
109
+ ".avi": "video/x-msvideo",
110
+ ".mkv": "video/x-matroska",
111
+ ".webm": "video/webm",
112
+ ".pdf": "application/pdf",
113
+ ".doc": "application/msword",
114
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
115
+ ".xls": "application/vnd.ms-excel",
116
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
117
+ ".zip": "application/zip",
118
+ ".tar": "application/x-tar",
119
+ ".gz": "application/gzip",
120
+ ".txt": "text/plain",
121
+ };
122
+ return mimeTypes[ext] ?? "application/octet-stream";
123
+ }
124
+
125
+ /**
126
+ * 将远端文件下载到本地目录。
127
+ *
128
+ * @param url 远端 URL
129
+ * @param destDir 目标目录(不存在时自动创建)
130
+ * @param originalFilename 可选的原始文件名(覆盖 URL 推断)
131
+ * @returns 本地文件完整路径;下载失败返回 null
132
+ */
133
+ export async function downloadFile(url: string, destDir: string, originalFilename?: string): Promise<string | null> {
134
+ try {
135
+ if (!fs.existsSync(destDir)) {
136
+ fs.mkdirSync(destDir, { recursive: true });
137
+ }
138
+
139
+ const resp = await fetch(url, { redirect: "follow" });
140
+ if (!resp.ok || !resp.body) return null;
141
+
142
+ // 确定文件名:优先使用 originalFilename,否则从 URL 推断
143
+ let filename = originalFilename?.trim() || "";
144
+ if (!filename) {
145
+ try {
146
+ const urlPath = new URL(url).pathname;
147
+ filename = path.basename(urlPath) || "download";
148
+ } catch {
149
+ filename = "download";
150
+ }
151
+ }
152
+
153
+ // 加上时间戳避免同名冲突
154
+ const ts = Date.now();
155
+ const ext = path.extname(filename);
156
+ const base = path.basename(filename, ext) || "file";
157
+ const rand = crypto.randomBytes(3).toString("hex");
158
+ const safeFilename = `${base}_${ts}_${rand}${ext}`;
159
+
160
+ const destPath = path.join(destDir, safeFilename);
161
+ const buffer = Buffer.from(await resp.arrayBuffer());
162
+ await fs.promises.writeFile(destPath, buffer);
163
+ return destPath;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * 图片尺寸工具
3
+ * 用于获取图片尺寸,生成 QQBot 的 markdown 图片格式
4
+ *
5
+ * QQBot markdown 图片格式: ![#宽px #高px](url)
6
+ */
7
+
8
+ import { Buffer } from "buffer";
9
+
10
+ export interface ImageSize {
11
+ width: number;
12
+ height: number;
13
+ }
14
+
15
+ /** 默认图片尺寸(当无法获取时使用) */
16
+ export const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 };
17
+
18
+ /**
19
+ * 从 PNG 文件头解析图片尺寸
20
+ * PNG 文件头结构: 前 8 字节是签名,IHDR 块从第 8 字节开始
21
+ * IHDR 块: 长度(4) + 类型(4, "IHDR") + 宽度(4) + 高度(4) + ...
22
+ */
23
+ function parsePngSize(buffer: Buffer): ImageSize | null {
24
+ // PNG 签名: 89 50 4E 47 0D 0A 1A 0A
25
+ if (buffer.length < 24) return null;
26
+ if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47) {
27
+ return null;
28
+ }
29
+ // IHDR 块从第 8 字节开始,宽度在第 16-19 字节,高度在第 20-23 字节
30
+ const width = buffer.readUInt32BE(16);
31
+ const height = buffer.readUInt32BE(20);
32
+ return { width, height };
33
+ }
34
+
35
+ /**
36
+ * 从 JPEG 文件解析图片尺寸
37
+ * JPEG 尺寸在 SOF0/SOF2 块中
38
+ */
39
+ function parseJpegSize(buffer: Buffer): ImageSize | null {
40
+ // JPEG 签名: FF D8 FF
41
+ if (buffer.length < 4) return null;
42
+ if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) {
43
+ return null;
44
+ }
45
+
46
+ let offset = 2;
47
+ while (offset < buffer.length - 9) {
48
+ if (buffer[offset] !== 0xFF) {
49
+ offset++;
50
+ continue;
51
+ }
52
+
53
+ const marker = buffer[offset + 1];
54
+ // SOF0 (0xC0) 或 SOF2 (0xC2) 包含图片尺寸
55
+ if (marker === 0xC0 || marker === 0xC2) {
56
+ // 格式: FF C0 长度(2) 精度(1) 高度(2) 宽度(2)
57
+ if (offset + 9 <= buffer.length) {
58
+ const height = buffer.readUInt16BE(offset + 5);
59
+ const width = buffer.readUInt16BE(offset + 7);
60
+ return { width, height };
61
+ }
62
+ }
63
+
64
+ // 跳过当前块
65
+ if (offset + 3 < buffer.length) {
66
+ const blockLength = buffer.readUInt16BE(offset + 2);
67
+ offset += 2 + blockLength;
68
+ } else {
69
+ break;
70
+ }
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * 从 GIF 文件头解析图片尺寸
78
+ * GIF 文件头: GIF87a 或 GIF89a (6字节) + 宽度(2) + 高度(2)
79
+ */
80
+ function parseGifSize(buffer: Buffer): ImageSize | null {
81
+ if (buffer.length < 10) return null;
82
+ const signature = buffer.toString("ascii", 0, 6);
83
+ if (signature !== "GIF87a" && signature !== "GIF89a") {
84
+ return null;
85
+ }
86
+ const width = buffer.readUInt16LE(6);
87
+ const height = buffer.readUInt16LE(8);
88
+ return { width, height };
89
+ }
90
+
91
+ /**
92
+ * 从 WebP 文件解析图片尺寸
93
+ * WebP 文件头: RIFF(4) + 文件大小(4) + WEBP(4) + VP8/VP8L/VP8X(4) + ...
94
+ */
95
+ function parseWebpSize(buffer: Buffer): ImageSize | null {
96
+ if (buffer.length < 30) return null;
97
+
98
+ // 检查 RIFF 和 WEBP 签名
99
+ const riff = buffer.toString("ascii", 0, 4);
100
+ const webp = buffer.toString("ascii", 8, 12);
101
+ if (riff !== "RIFF" || webp !== "WEBP") {
102
+ return null;
103
+ }
104
+
105
+ const chunkType = buffer.toString("ascii", 12, 16);
106
+
107
+ // VP8 (有损压缩)
108
+ if (chunkType === "VP8 ") {
109
+ // VP8 帧头从第 23 字节开始,检查签名 9D 01 2A
110
+ if (buffer.length >= 30 && buffer[23] === 0x9D && buffer[24] === 0x01 && buffer[25] === 0x2A) {
111
+ const width = buffer.readUInt16LE(26) & 0x3FFF;
112
+ const height = buffer.readUInt16LE(28) & 0x3FFF;
113
+ return { width, height };
114
+ }
115
+ }
116
+
117
+ // VP8L (无损压缩)
118
+ if (chunkType === "VP8L") {
119
+ // VP8L 签名: 0x2F
120
+ if (buffer.length >= 25 && buffer[20] === 0x2F) {
121
+ const bits = buffer.readUInt32LE(21);
122
+ const width = (bits & 0x3FFF) + 1;
123
+ const height = ((bits >> 14) & 0x3FFF) + 1;
124
+ return { width, height };
125
+ }
126
+ }
127
+
128
+ // VP8X (扩展格式)
129
+ if (chunkType === "VP8X") {
130
+ if (buffer.length >= 30) {
131
+ // 宽度和高度在第 24-26 和 27-29 字节(24位小端)
132
+ const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
133
+ const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
134
+ return { width, height };
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * 从图片数据 Buffer 解析尺寸
143
+ */
144
+ export function parseImageSize(buffer: Buffer): ImageSize | null {
145
+ // 尝试各种格式
146
+ return parsePngSize(buffer)
147
+ ?? parseJpegSize(buffer)
148
+ ?? parseGifSize(buffer)
149
+ ?? parseWebpSize(buffer);
150
+ }
151
+
152
+ /**
153
+ * 从公网 URL 获取图片尺寸
154
+ * 只下载前 64KB 数据,足够解析大部分图片格式的头部
155
+ */
156
+ export async function getImageSizeFromUrl(url: string, timeoutMs = 5000): Promise<ImageSize | null> {
157
+ try {
158
+ const controller = new AbortController();
159
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
160
+
161
+ // 使用 Range 请求只获取前 64KB
162
+ const response = await fetch(url, {
163
+ signal: controller.signal,
164
+ headers: {
165
+ "Range": "bytes=0-65535",
166
+ "User-Agent": "QQBot-Image-Size-Detector/1.0",
167
+ },
168
+ });
169
+
170
+ clearTimeout(timeoutId);
171
+
172
+ if (!response.ok && response.status !== 206) {
173
+ console.log(`[image-size] Failed to fetch ${url}: ${response.status}`);
174
+ return null;
175
+ }
176
+
177
+ const arrayBuffer = await response.arrayBuffer();
178
+ const buffer = Buffer.from(arrayBuffer);
179
+
180
+ const size = parseImageSize(buffer);
181
+ if (size) {
182
+ console.log(`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`);
183
+ }
184
+
185
+ return size;
186
+ } catch (err) {
187
+ console.log(`[image-size] Error fetching ${url.slice(0, 60)}...: ${err}`);
188
+ return null;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * 从 Base64 Data URL 获取图片尺寸
194
+ */
195
+ export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null {
196
+ try {
197
+ // 格式: data:image/png;base64,xxxxx
198
+ const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
199
+ if (!matches) {
200
+ return null;
201
+ }
202
+
203
+ const base64Data = matches[1];
204
+ const buffer = Buffer.from(base64Data, "base64");
205
+
206
+ const size = parseImageSize(buffer);
207
+ if (size) {
208
+ console.log(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
209
+ }
210
+
211
+ return size;
212
+ } catch (err) {
213
+ console.log(`[image-size] Error parsing Base64: ${err}`);
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * 获取图片尺寸(自动判断来源)
220
+ * @param source - 图片 URL 或 Base64 Data URL
221
+ * @returns 图片尺寸,失败返回 null
222
+ */
223
+ export async function getImageSize(source: string): Promise<ImageSize | null> {
224
+ if (source.startsWith("data:")) {
225
+ return getImageSizeFromDataUrl(source);
226
+ }
227
+
228
+ if (source.startsWith("http://") || source.startsWith("https://")) {
229
+ return getImageSizeFromUrl(source);
230
+ }
231
+
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * 生成 QQBot markdown 图片格式
237
+ * 格式: ![#宽px #高px](url)
238
+ *
239
+ * @param url - 图片 URL
240
+ * @param size - 图片尺寸,如果为 null 则使用默认尺寸
241
+ * @returns QQBot markdown 图片字符串
242
+ */
243
+ export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string {
244
+ const { width, height } = size ?? DEFAULT_IMAGE_SIZE;
245
+ return `![#${width}px #${height}px](${url})`;
246
+ }
247
+
248
+ /**
249
+ * 检查 markdown 图片是否已经包含 QQBot 格式的尺寸信息
250
+ * 格式: ![#宽px #高px](url)
251
+ */
252
+ export function hasQQBotImageSize(markdownImage: string): boolean {
253
+ return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
254
+ }
255
+
256
+ /**
257
+ * 从已有的 QQBot 格式 markdown 图片中提取尺寸
258
+ * 格式: ![#宽px #高px](url)
259
+ */
260
+ export function extractQQBotImageSize(markdownImage: string): ImageSize | null {
261
+ const match = markdownImage.match(/!\[#(\d+)px\s+#(\d+)px\]/);
262
+ if (match) {
263
+ return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
264
+ }
265
+ return null;
266
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 富媒体标签预处理与纠错
3
+ *
4
+ * 小模型常见的标签拼写错误及变体,在正则匹配前统一修正为标准格式。
5
+ */
6
+
7
+ import { expandTilde } from "./platform.js";
8
+
9
+ // 标准标签名(qqmedia = 统一标签,系统根据文件扩展名自动路由)
10
+ const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"] as const;
11
+
12
+ // 开头标签别名映射(key 全部小写)
13
+ const TAG_ALIASES: Record<string, typeof VALID_TAGS[number]> = {
14
+ // ---- qqimg 变体 ----
15
+ "qq_img": "qqimg",
16
+ "qqimage": "qqimg",
17
+ "qq_image": "qqimg",
18
+ "qqpic": "qqimg",
19
+ "qq_pic": "qqimg",
20
+ "qqpicture": "qqimg",
21
+ "qq_picture": "qqimg",
22
+ "qqphoto": "qqimg",
23
+ "qq_photo": "qqimg",
24
+ "img": "qqimg",
25
+ "image": "qqimg",
26
+ "pic": "qqimg",
27
+ "picture": "qqimg",
28
+ "photo": "qqimg",
29
+ // ---- qqvoice 变体 ----
30
+ "qq_voice": "qqvoice",
31
+ "qqaudio": "qqvoice",
32
+ "qq_audio": "qqvoice",
33
+ "voice": "qqvoice",
34
+ "audio": "qqvoice",
35
+ // ---- qqvideo 变体 ----
36
+ "qq_video": "qqvideo",
37
+ "video": "qqvideo",
38
+ // ---- qqfile 变体 ----
39
+ "qq_file": "qqfile",
40
+ "qqdoc": "qqfile",
41
+ "qq_doc": "qqfile",
42
+ "file": "qqfile",
43
+ "doc": "qqfile",
44
+ "document": "qqfile",
45
+ // ---- qqmedia 变体(统一标签,根据扩展名自动路由) ----
46
+ "qq_media": "qqmedia",
47
+ "media": "qqmedia",
48
+ "attachment": "qqmedia",
49
+ "attach": "qqmedia",
50
+ "qqattachment": "qqmedia",
51
+ "qq_attachment": "qqmedia",
52
+ "qqsend": "qqmedia",
53
+ "qq_send": "qqmedia",
54
+ "send": "qqmedia",
55
+ };
56
+
57
+ // 构建所有可识别的标签名列表(标准名 + 别名)
58
+ const ALL_TAG_NAMES = [...VALID_TAGS, ...Object.keys(TAG_ALIASES)];
59
+ // 按长度降序排列,优先匹配更长的名称(避免 "img" 抢先匹配 "qqimg" 的子串)
60
+ ALL_TAG_NAMES.sort((a, b) => b.length - a.length);
61
+
62
+ const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|");
63
+
64
+ /**
65
+ * 自闭合属性语法的正则:
66
+ * <qqmedia file="/path/to/file.png" />
67
+ * <qqimg src="/path" />
68
+ * <image file="..." />
69
+ * <qqmedia type="file" path="/path/to/file.zip" /> ← 多属性
70
+ * 支持 file= / src= / path= / url= 属性名,引号可选
71
+ * 也支持前面有其他属性(如 type="file")的多属性写法
72
+ */
73
+ const SELF_CLOSING_TAG_REGEX = new RegExp(
74
+ "`?" +
75
+ "[<<<]\\s*(" + TAG_NAME_PATTERN + ")" +
76
+ // 允许前面有任意其他属性(如 type="file"),非贪婪跳过
77
+ "(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'/>>>]*?[\"']?)*" +
78
+ "\\s+(?:file|src|path|url)\\s*=\\s*" +
79
+ "[\"']?" +
80
+ "([^\"'/>>>]+?)" +
81
+ "[\"']?" +
82
+ // 允许后面还有其他属性
83
+ "(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'/>>>]*?[\"']?)*" +
84
+ "\\s*/?" +
85
+ "\\s*[>>>]" +
86
+ "`?",
87
+ "gi"
88
+ );
89
+
90
+ /**
91
+ * 构建一个宽容的正则,能匹配各种畸形标签写法:
92
+ *
93
+ * 常见错误模式:
94
+ * 1. 标签名拼错:<qq_img>, <qqimage>, <image>, <img>, <pic> ...
95
+ * 2. 标签内多余空格:<qqimg >, < qqimg>, <qqimg >
96
+ * 3. 闭合标签不匹配:<qqimg>url</qqvoice>, <qqimg>url</img>
97
+ * 4. 闭合标签缺失斜杠:<qqimg>url<qqimg> (用开头标签代替闭合标签)
98
+ * 5. 闭合标签缺失尖括号:<qqimg>url/qqimg>
99
+ * 6. 中文尖括号:<qqimg>url</qqimg> 或 <qqimg>url</qqimg>
100
+ * 7. 多余引号包裹路径:<qqimg>"path"</qqimg>
101
+ * 8. Markdown 代码块包裹:`<qqimg>path</qqimg>`
102
+ * 9. 自闭合属性语法:<qqmedia file="/path" /> (由 SELF_CLOSING_TAG_REGEX 处理)
103
+ */
104
+ const FUZZY_MEDIA_TAG_REGEX = new RegExp(
105
+ // 可选 Markdown 行内代码反引号
106
+ "`?" +
107
+ // 开头标签:允许中文/英文尖括号,标签名前后可有空格
108
+ "[<<<]\\s*(" + TAG_NAME_PATTERN + ")\\s*[>>>]" +
109
+ // 内容:非贪婪匹配,允许引号包裹
110
+ "[\"']?\\s*" +
111
+ "([^<<<>>\"'`]+?)" +
112
+ "\\s*[\"']?" +
113
+ // 闭合标签:允许各种不规范写法
114
+ "[<<<]\\s*/?\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>]" +
115
+ // 可选结尾反引号
116
+ "`?",
117
+ "gi"
118
+ );
119
+
120
+ /**
121
+ * 将标签名映射为标准名称
122
+ */
123
+ function resolveTagName(raw: string): typeof VALID_TAGS[number] {
124
+ const lower = raw.toLowerCase();
125
+ if ((VALID_TAGS as readonly string[]).includes(lower)) {
126
+ return lower as typeof VALID_TAGS[number];
127
+ }
128
+ return TAG_ALIASES[lower] ?? "qqimg";
129
+ }
130
+
131
+ /**
132
+ * 预清理:将富媒体标签内部的换行/回车/制表符压缩为单个空格。
133
+ *
134
+ * 部分模型会在标签内部插入 \n \r \t 等空白字符,例如:
135
+ * <qqimg>\n /path/to/file.png\n</qqimg>
136
+ * <qqimg>/path/to/\nfile.png</qqimg>
137
+ *
138
+ * 此正则匹配从开标签到闭标签之间的内容(允许跨行),
139
+ * 将内部所有 [\r\n\t] 替换为空格,然后压缩连续空格。
140
+ */
141
+ const MULTILINE_TAG_CLEANUP = new RegExp(
142
+ "([<<<]\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>])" +
143
+ "([\\s\\S]*?)" +
144
+ "([<<<]\\s*/?\\s*(?:" + TAG_NAME_PATTERN + ")\\s*[>>>])",
145
+ "gi"
146
+ );
147
+
148
+ /**
149
+ * 预处理 LLM 输出文本,将各种畸形/错误的富媒体标签修正为标准格式。
150
+ *
151
+ * 标准格式:<qqimg>/path/to/file</qqimg>
152
+ *
153
+ * @param text LLM 原始输出
154
+ * @returns 修正后的文本(如果没有匹配到任何标签则原样返回)
155
+ */
156
+ export function normalizeMediaTags(text: string): string {
157
+ // 第 0 步:将自闭合属性语法转换为标准包裹语法
158
+ // <qqmedia file="/path/to/file.png" /> → <qqmedia>/path/to/file.png</qqmedia>
159
+ let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, (_match, rawTag: string, content: string) => {
160
+ const tag = resolveTagName(rawTag);
161
+ const trimmed = content.trim();
162
+ if (!trimmed) return _match;
163
+ const expanded = expandTilde(trimmed);
164
+ return `<${tag}>${expanded}</${tag}>`;
165
+ });
166
+
167
+ // 第 1 步:将标签内部的换行/回车/制表符压缩为空格
168
+ cleaned = cleaned.replace(MULTILINE_TAG_CLEANUP, (_m, open: string, body: string, close: string) => {
169
+ const flat = body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ");
170
+ return open + flat + close;
171
+ });
172
+
173
+ // 第 2 步:将各种畸形标签统一为标准格式
174
+ return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, (_match, rawTag: string, content: string) => {
175
+ const tag = resolveTagName(rawTag);
176
+ const trimmed = content.trim();
177
+ if (!trimmed) return _match; // 空内容不处理
178
+ // 展开波浪线路径:~/Desktop/file.png → /Users/xxx/Desktop/file.png
179
+ const expanded = expandTilde(trimmed);
180
+ return `<${tag}>${expanded}</${tag}>`;
181
+ });
182
+ }