@cxyhhhhh/openclaw-qqbot 1.6.7-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.
Files changed (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +470 -0
  3. package/README.zh.md +465 -0
  4. package/bin/qqbot-cli.js +243 -0
  5. package/dist/index.d.ts +17 -0
  6. package/dist/index.js +26 -0
  7. package/dist/src/admin-resolver.d.ts +33 -0
  8. package/dist/src/admin-resolver.js +157 -0
  9. package/dist/src/api.d.ts +264 -0
  10. package/dist/src/api.js +777 -0
  11. package/dist/src/channel.d.ts +29 -0
  12. package/dist/src/channel.js +452 -0
  13. package/dist/src/config.d.ts +56 -0
  14. package/dist/src/config.js +278 -0
  15. package/dist/src/credential-backup.d.ts +31 -0
  16. package/dist/src/credential-backup.js +66 -0
  17. package/dist/src/deliver-debounce.d.ts +74 -0
  18. package/dist/src/deliver-debounce.js +174 -0
  19. package/dist/src/gateway.d.ts +18 -0
  20. package/dist/src/gateway.js +2021 -0
  21. package/dist/src/group-history.d.ts +136 -0
  22. package/dist/src/group-history.js +226 -0
  23. package/dist/src/image-server.d.ts +87 -0
  24. package/dist/src/image-server.js +570 -0
  25. package/dist/src/inbound-attachments.d.ts +60 -0
  26. package/dist/src/inbound-attachments.js +248 -0
  27. package/dist/src/known-users.d.ts +100 -0
  28. package/dist/src/known-users.js +263 -0
  29. package/dist/src/message-gating.d.ts +53 -0
  30. package/dist/src/message-gating.js +107 -0
  31. package/dist/src/message-queue.d.ts +86 -0
  32. package/dist/src/message-queue.js +257 -0
  33. package/dist/src/onboarding.d.ts +10 -0
  34. package/dist/src/onboarding.js +203 -0
  35. package/dist/src/outbound-deliver.d.ts +48 -0
  36. package/dist/src/outbound-deliver.js +392 -0
  37. package/dist/src/outbound.d.ts +205 -0
  38. package/dist/src/outbound.js +926 -0
  39. package/dist/src/proactive.d.ts +170 -0
  40. package/dist/src/proactive.js +399 -0
  41. package/dist/src/ref-index-store.d.ts +70 -0
  42. package/dist/src/ref-index-store.js +250 -0
  43. package/dist/src/reply-dispatcher.d.ts +35 -0
  44. package/dist/src/reply-dispatcher.js +311 -0
  45. package/dist/src/request-context.d.ts +18 -0
  46. package/dist/src/request-context.js +30 -0
  47. package/dist/src/runtime.d.ts +3 -0
  48. package/dist/src/runtime.js +10 -0
  49. package/dist/src/session-store.d.ts +52 -0
  50. package/dist/src/session-store.js +254 -0
  51. package/dist/src/slash-commands.d.ts +77 -0
  52. package/dist/src/slash-commands.js +1461 -0
  53. package/dist/src/startup-greeting.d.ts +30 -0
  54. package/dist/src/startup-greeting.js +97 -0
  55. package/dist/src/streaming.d.ts +250 -0
  56. package/dist/src/streaming.js +914 -0
  57. package/dist/src/stt.d.ts +21 -0
  58. package/dist/src/stt.js +70 -0
  59. package/dist/src/tools/channel.d.ts +16 -0
  60. package/dist/src/tools/channel.js +234 -0
  61. package/dist/src/tools/remind.d.ts +2 -0
  62. package/dist/src/tools/remind.js +248 -0
  63. package/dist/src/types.d.ts +364 -0
  64. package/dist/src/types.js +17 -0
  65. package/dist/src/typing-keepalive.d.ts +27 -0
  66. package/dist/src/typing-keepalive.js +64 -0
  67. package/dist/src/update-checker.d.ts +34 -0
  68. package/dist/src/update-checker.js +160 -0
  69. package/dist/src/utils/audio-convert.d.ts +98 -0
  70. package/dist/src/utils/audio-convert.js +755 -0
  71. package/dist/src/utils/chunked-upload.d.ts +59 -0
  72. package/dist/src/utils/chunked-upload.js +289 -0
  73. package/dist/src/utils/file-utils.d.ts +61 -0
  74. package/dist/src/utils/file-utils.js +172 -0
  75. package/dist/src/utils/image-size.d.ts +51 -0
  76. package/dist/src/utils/image-size.js +234 -0
  77. package/dist/src/utils/media-send.d.ts +148 -0
  78. package/dist/src/utils/media-send.js +456 -0
  79. package/dist/src/utils/media-tags.d.ts +14 -0
  80. package/dist/src/utils/media-tags.js +164 -0
  81. package/dist/src/utils/payload.d.ts +112 -0
  82. package/dist/src/utils/payload.js +186 -0
  83. package/dist/src/utils/pkg-version.d.ts +5 -0
  84. package/dist/src/utils/pkg-version.js +51 -0
  85. package/dist/src/utils/platform.d.ts +137 -0
  86. package/dist/src/utils/platform.js +390 -0
  87. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  88. package/dist/src/utils/ssrf-guard.js +91 -0
  89. package/dist/src/utils/text-parsing.d.ts +32 -0
  90. package/dist/src/utils/text-parsing.js +69 -0
  91. package/dist/src/utils/upload-cache.d.ts +34 -0
  92. package/dist/src/utils/upload-cache.js +93 -0
  93. package/index.ts +31 -0
  94. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  95. package/node_modules/@eshaz/web-worker/README.md +134 -0
  96. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  97. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  98. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  99. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  100. package/node_modules/@eshaz/web-worker/node.js +223 -0
  101. package/node_modules/@eshaz/web-worker/package.json +54 -0
  102. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  103. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  104. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  105. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  106. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  107. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  108. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  109. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  110. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  111. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  112. package/node_modules/mpg123-decoder/README.md +265 -0
  113. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  114. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  115. package/node_modules/mpg123-decoder/index.js +8 -0
  116. package/node_modules/mpg123-decoder/package.json +58 -0
  117. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  118. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  119. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  120. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  121. package/node_modules/silk-wasm/LICENSE +21 -0
  122. package/node_modules/silk-wasm/README.md +85 -0
  123. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  124. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  125. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  126. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  127. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  128. package/node_modules/silk-wasm/package.json +39 -0
  129. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  130. package/node_modules/simple-yenc/.prettierignore +1 -0
  131. package/node_modules/simple-yenc/LICENSE +7 -0
  132. package/node_modules/simple-yenc/README.md +163 -0
  133. package/node_modules/simple-yenc/dist/esm.js +1 -0
  134. package/node_modules/simple-yenc/dist/index.js +1 -0
  135. package/node_modules/simple-yenc/package.json +50 -0
  136. package/node_modules/simple-yenc/rollup.config.js +27 -0
  137. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  138. package/node_modules/ws/LICENSE +20 -0
  139. package/node_modules/ws/README.md +548 -0
  140. package/node_modules/ws/browser.js +8 -0
  141. package/node_modules/ws/index.js +13 -0
  142. package/node_modules/ws/lib/buffer-util.js +131 -0
  143. package/node_modules/ws/lib/constants.js +19 -0
  144. package/node_modules/ws/lib/event-target.js +292 -0
  145. package/node_modules/ws/lib/extension.js +203 -0
  146. package/node_modules/ws/lib/limiter.js +55 -0
  147. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  148. package/node_modules/ws/lib/receiver.js +706 -0
  149. package/node_modules/ws/lib/sender.js +602 -0
  150. package/node_modules/ws/lib/stream.js +161 -0
  151. package/node_modules/ws/lib/subprotocol.js +62 -0
  152. package/node_modules/ws/lib/validation.js +152 -0
  153. package/node_modules/ws/lib/websocket-server.js +554 -0
  154. package/node_modules/ws/lib/websocket.js +1393 -0
  155. package/node_modules/ws/package.json +69 -0
  156. package/node_modules/ws/wrapper.mjs +8 -0
  157. package/openclaw.plugin.json +17 -0
  158. package/package.json +67 -0
  159. package/preload.cjs +33 -0
  160. package/scripts/cleanup-legacy-plugins.sh +124 -0
  161. package/scripts/link-sdk-core.cjs +185 -0
  162. package/scripts/postinstall-link-sdk.js +113 -0
  163. package/scripts/proactive-api-server.ts +369 -0
  164. package/scripts/send-proactive.ts +293 -0
  165. package/scripts/set-markdown.sh +156 -0
  166. package/scripts/test-sendmedia.ts +116 -0
  167. package/scripts/upgrade-via-npm.ps1 +451 -0
  168. package/scripts/upgrade-via-npm.sh +528 -0
  169. package/scripts/upgrade-via-source.sh +916 -0
  170. package/skills/qqbot-channel/SKILL.md +263 -0
  171. package/skills/qqbot-channel/references/api_references.md +521 -0
  172. package/skills/qqbot-media/SKILL.md +60 -0
  173. package/skills/qqbot-remind/SKILL.md +149 -0
  174. package/src/admin-resolver.ts +181 -0
  175. package/src/api.ts +1138 -0
  176. package/src/channel.ts +477 -0
  177. package/src/config.ts +347 -0
  178. package/src/credential-backup.ts +72 -0
  179. package/src/deliver-debounce.ts +229 -0
  180. package/src/gateway.ts +2257 -0
  181. package/src/group-history.ts +328 -0
  182. package/src/image-server.ts +675 -0
  183. package/src/inbound-attachments.ts +321 -0
  184. package/src/known-users.ts +353 -0
  185. package/src/message-gating.ts +190 -0
  186. package/src/message-queue.ts +349 -0
  187. package/src/onboarding.ts +274 -0
  188. package/src/openclaw-plugin-sdk.d.ts +587 -0
  189. package/src/outbound-deliver.ts +473 -0
  190. package/src/outbound.ts +1119 -0
  191. package/src/proactive.ts +530 -0
  192. package/src/ref-index-store.ts +335 -0
  193. package/src/reply-dispatcher.ts +334 -0
  194. package/src/request-context.ts +39 -0
  195. package/src/runtime.ts +14 -0
  196. package/src/session-store.ts +303 -0
  197. package/src/slash-commands.ts +1615 -0
  198. package/src/startup-greeting.ts +120 -0
  199. package/src/streaming.ts +1102 -0
  200. package/src/stt.ts +86 -0
  201. package/src/tools/channel.ts +281 -0
  202. package/src/tools/remind.ts +300 -0
  203. package/src/types.ts +386 -0
  204. package/src/typing-keepalive.ts +59 -0
  205. package/src/update-checker.ts +174 -0
  206. package/src/utils/audio-convert.ts +859 -0
  207. package/src/utils/chunked-upload.ts +419 -0
  208. package/src/utils/file-utils.ts +193 -0
  209. package/src/utils/image-size.ts +266 -0
  210. package/src/utils/media-send.ts +585 -0
  211. package/src/utils/media-tags.ts +182 -0
  212. package/src/utils/payload.ts +265 -0
  213. package/src/utils/pkg-version.ts +54 -0
  214. package/src/utils/platform.ts +435 -0
  215. package/src/utils/ssrf-guard.ts +102 -0
  216. package/src/utils/text-parsing.ts +75 -0
  217. package/src/utils/upload-cache.ts +128 -0
  218. package/tsconfig.json +16 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * 本地图床服务器
3
+ * 提供安全的图片存储和访问服务
4
+ */
5
+
6
+ import http from "node:http";
7
+ import fs from "node:fs";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { Readable } from "node:stream";
10
+ import path from "node:path";
11
+ import crypto from "node:crypto";
12
+ import { validateRemoteUrl } from "./utils/ssrf-guard.js";
13
+ import { getQQBotMediaDir } from "./utils/platform.js";
14
+
15
+ export interface ImageServerConfig {
16
+ /** 监听端口 */
17
+ port: number;
18
+ /** 图片存储目录 */
19
+ storageDir: string;
20
+ /** 外部访问的基础 URL(如 http://your-server:port),留空则自动生成 */
21
+ baseUrl?: string;
22
+ /** 图片过期时间(秒),0 表示不过期 */
23
+ ttlSeconds?: number;
24
+ /** 允许的图片格式 */
25
+ allowedFormats?: string[];
26
+ }
27
+
28
+ interface StoredImage {
29
+ id: string;
30
+ filename: string;
31
+ mimeType: string;
32
+ createdAt: number;
33
+ ttl: number;
34
+ }
35
+
36
+ const DEFAULT_CONFIG: Required<ImageServerConfig> = {
37
+ port: 18765,
38
+ storageDir: "./qqbot-images",
39
+ baseUrl: "",
40
+ ttlSeconds: 3600, // 默认 1 小时过期
41
+ allowedFormats: ["png", "jpg", "jpeg", "gif", "webp"],
42
+ };
43
+
44
+ let serverInstance: http.Server | null = null;
45
+ let currentConfig: Required<ImageServerConfig> = { ...DEFAULT_CONFIG };
46
+ let imageIndex = new Map<string, StoredImage>();
47
+
48
+ /**
49
+ * 生成安全的随机 ID
50
+ */
51
+ function generateImageId(): string {
52
+ return crypto.randomBytes(16).toString("hex");
53
+ }
54
+
55
+ /**
56
+ * 验证请求路径是否安全(防止目录遍历攻击)
57
+ */
58
+ function isPathSafe(requestPath: string, baseDir: string): boolean {
59
+ const normalizedBase = path.resolve(baseDir);
60
+ const normalizedPath = path.resolve(baseDir, requestPath);
61
+ return normalizedPath.startsWith(normalizedBase + path.sep) || normalizedPath === normalizedBase;
62
+ }
63
+
64
+ /**
65
+ * 获取 MIME 类型
66
+ */
67
+ function getMimeType(ext: string): string {
68
+ const mimeTypes: Record<string, string> = {
69
+ png: "image/png",
70
+ jpg: "image/jpeg",
71
+ jpeg: "image/jpeg",
72
+ gif: "image/gif",
73
+ webp: "image/webp",
74
+ };
75
+ return mimeTypes[ext.toLowerCase()] || "application/octet-stream";
76
+ }
77
+
78
+ /**
79
+ * 从 MIME 类型获取扩展名
80
+ */
81
+ function getExtFromMime(mimeType: string): string | null {
82
+ const extMap: Record<string, string> = {
83
+ "image/png": "png",
84
+ "image/jpeg": "jpg",
85
+ "image/gif": "gif",
86
+ "image/webp": "webp",
87
+ "application/pdf": "pdf",
88
+ "application/json": "json",
89
+ "text/plain": "txt",
90
+ "text/csv": "csv",
91
+ "application/msword": "doc",
92
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
93
+ "application/vnd.ms-excel": "xls",
94
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
95
+ };
96
+ return extMap[mimeType] || null;
97
+ }
98
+
99
+ /**
100
+ * 清理过期图片
101
+ */
102
+ function cleanupExpiredImages(): void {
103
+ const now = Date.now();
104
+ const expiredIds: string[] = [];
105
+
106
+ for (const [id, image] of imageIndex) {
107
+ if (image.ttl > 0 && now - image.createdAt > image.ttl * 1000) {
108
+ expiredIds.push(id);
109
+ }
110
+ }
111
+
112
+ for (const id of expiredIds) {
113
+ const image = imageIndex.get(id);
114
+ if (image) {
115
+ const filePath = path.join(currentConfig.storageDir, image.filename);
116
+ try {
117
+ if (fs.existsSync(filePath)) {
118
+ fs.unlinkSync(filePath);
119
+ }
120
+ } catch {
121
+ // 忽略删除错误
122
+ }
123
+ imageIndex.delete(id);
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 加载已有的图片索引
130
+ */
131
+ function loadImageIndex(): void {
132
+ const indexPath = path.join(currentConfig.storageDir, ".index.json");
133
+ try {
134
+ if (fs.existsSync(indexPath)) {
135
+ const data = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
136
+ imageIndex = new Map(Object.entries(data));
137
+ }
138
+ } catch {
139
+ imageIndex = new Map();
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 保存图片索引
145
+ */
146
+ function saveImageIndex(): void {
147
+ const indexPath = path.join(currentConfig.storageDir, ".index.json");
148
+ try {
149
+ const data = Object.fromEntries(imageIndex);
150
+ fs.writeFileSync(indexPath, JSON.stringify(data, null, 2));
151
+ } catch {
152
+ // 忽略保存错误
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 处理 HTTP 请求
158
+ */
159
+ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
160
+ const url = new URL(req.url || "/", `http://localhost:${currentConfig.port}`);
161
+ const pathname = url.pathname;
162
+
163
+ // 设置 CORS 头(允许 QQ 服务器访问)
164
+ res.setHeader("Access-Control-Allow-Origin", "*");
165
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
166
+
167
+ if (req.method === "OPTIONS") {
168
+ res.writeHead(204);
169
+ res.end();
170
+ return;
171
+ }
172
+
173
+ // 只允许 GET 请求访问图片
174
+ if (req.method !== "GET") {
175
+ res.writeHead(405, { "Content-Type": "text/plain" });
176
+ res.end("Method Not Allowed");
177
+ return;
178
+ }
179
+
180
+ // 解析图片 ID(路径格式: /images/{id}.{ext})
181
+ const match = pathname.match(/^\/images\/([a-f0-9]{32})\.(\w+)$/);
182
+ if (!match) {
183
+ res.writeHead(404, { "Content-Type": "text/plain" });
184
+ res.end("Not Found");
185
+ return;
186
+ }
187
+
188
+ const [, imageId, requestedExt] = match;
189
+ const image = imageIndex.get(imageId);
190
+
191
+ if (!image) {
192
+ res.writeHead(404, { "Content-Type": "text/plain" });
193
+ res.end("Image Not Found");
194
+ return;
195
+ }
196
+
197
+ // 检查是否过期
198
+ if (image.ttl > 0 && Date.now() - image.createdAt > image.ttl * 1000) {
199
+ res.writeHead(410, { "Content-Type": "text/plain" });
200
+ res.end("Image Expired");
201
+ return;
202
+ }
203
+
204
+ // 安全检查:确保文件路径在存储目录内
205
+ const filePath = path.join(currentConfig.storageDir, image.filename);
206
+ if (!isPathSafe(image.filename, currentConfig.storageDir)) {
207
+ res.writeHead(403, { "Content-Type": "text/plain" });
208
+ res.end("Forbidden");
209
+ return;
210
+ }
211
+
212
+ // 读取并返回图片
213
+ try {
214
+ if (!fs.existsSync(filePath)) {
215
+ res.writeHead(404, { "Content-Type": "text/plain" });
216
+ res.end("File Not Found");
217
+ return;
218
+ }
219
+
220
+ const imageData = fs.readFileSync(filePath);
221
+ res.writeHead(200, {
222
+ "Content-Type": image.mimeType,
223
+ "Content-Length": imageData.length,
224
+ "Cache-Control": image.ttl > 0 ? `max-age=${image.ttl}` : "max-age=31536000",
225
+ });
226
+ res.end(imageData);
227
+ } catch (err) {
228
+ res.writeHead(500, { "Content-Type": "text/plain" });
229
+ res.end("Internal Server Error");
230
+ }
231
+ }
232
+
233
+ /**
234
+ * 启动图床服务器
235
+ */
236
+ export function startImageServer(config?: Partial<ImageServerConfig>): Promise<string> {
237
+ return new Promise((resolve, reject) => {
238
+ if (serverInstance) {
239
+ const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`;
240
+ resolve(baseUrl);
241
+ return;
242
+ }
243
+
244
+ currentConfig = { ...DEFAULT_CONFIG, ...config };
245
+
246
+ // 确保存储目录存在
247
+ if (!fs.existsSync(currentConfig.storageDir)) {
248
+ fs.mkdirSync(currentConfig.storageDir, { recursive: true });
249
+ }
250
+
251
+ // 加载图片索引
252
+ loadImageIndex();
253
+
254
+ // 启动定期清理
255
+ const cleanupInterval = setInterval(cleanupExpiredImages, 60000); // 每分钟清理一次
256
+
257
+ serverInstance = http.createServer(handleRequest);
258
+
259
+ serverInstance.on("error", (err) => {
260
+ clearInterval(cleanupInterval);
261
+ reject(err);
262
+ });
263
+
264
+ serverInstance.listen(currentConfig.port, () => {
265
+ const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`;
266
+ resolve(baseUrl);
267
+ });
268
+ });
269
+ }
270
+
271
+ /**
272
+ * 停止图床服务器
273
+ */
274
+ export function stopImageServer(): Promise<void> {
275
+ return new Promise((resolve) => {
276
+ if (serverInstance) {
277
+ serverInstance.close(() => {
278
+ serverInstance = null;
279
+ saveImageIndex();
280
+ resolve();
281
+ });
282
+ } else {
283
+ resolve();
284
+ }
285
+ });
286
+ }
287
+
288
+ /**
289
+ * 保存图片并返回访问 URL
290
+ * @param imageData 图片数据(Buffer 或 base64 字符串)
291
+ * @param mimeType 图片 MIME 类型
292
+ * @param ttlSeconds 过期时间(秒),默认使用配置值
293
+ * @returns 图片访问 URL
294
+ */
295
+ export function saveImage(
296
+ imageData: Buffer | string,
297
+ mimeType: string = "image/png",
298
+ ttlSeconds?: number
299
+ ): string {
300
+ // 转换 base64 为 Buffer
301
+ let buffer: Buffer;
302
+ if (typeof imageData === "string") {
303
+ // 处理 data URL 格式
304
+ const base64Match = imageData.match(/^data:([^;]+);base64,(.+)$/);
305
+ if (base64Match) {
306
+ mimeType = base64Match[1];
307
+ buffer = Buffer.from(base64Match[2], "base64");
308
+ } else {
309
+ buffer = Buffer.from(imageData, "base64");
310
+ }
311
+ } else {
312
+ buffer = imageData;
313
+ }
314
+
315
+ // 生成唯一 ID 和文件名
316
+ const imageId = generateImageId();
317
+ const ext = getExtFromMime(mimeType) || "png";
318
+ const filename = `${imageId}.${ext}`;
319
+
320
+ // 确保存储目录存在
321
+ if (!fs.existsSync(currentConfig.storageDir)) {
322
+ fs.mkdirSync(currentConfig.storageDir, { recursive: true });
323
+ }
324
+
325
+ // 保存文件
326
+ const filePath = path.join(currentConfig.storageDir, filename);
327
+ fs.writeFileSync(filePath, buffer);
328
+
329
+ // 记录到索引
330
+ const image: StoredImage = {
331
+ id: imageId,
332
+ filename,
333
+ mimeType,
334
+ createdAt: Date.now(),
335
+ ttl: ttlSeconds ?? currentConfig.ttlSeconds,
336
+ };
337
+ imageIndex.set(imageId, image);
338
+ saveImageIndex();
339
+
340
+ // 返回访问 URL
341
+ const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`;
342
+ return `${baseUrl}/images/${imageId}.${ext}`;
343
+ }
344
+
345
+ /**
346
+ * 从本地文件路径保存图片到图床
347
+ * @param filePath 本地文件路径
348
+ * @param ttlSeconds 过期时间(秒),默认使用配置值
349
+ * @returns 图片访问 URL,如果文件不存在或不是图片则返回 null
350
+ */
351
+ export function saveImageFromPath(filePath: string, ttlSeconds?: number): string | null {
352
+ try {
353
+ console.log(`[image-server] saveImageFromPath: ${filePath}`);
354
+
355
+ // 检查文件是否存在
356
+ if (!fs.existsSync(filePath)) {
357
+ console.log(`[image-server] File not found: ${filePath}`);
358
+ return null;
359
+ }
360
+
361
+ // 读取文件
362
+ const buffer = fs.readFileSync(filePath);
363
+ console.log(`[image-server] File size: ${buffer.length}`);
364
+
365
+ // 根据扩展名获取 MIME 类型
366
+ const ext = path.extname(filePath).toLowerCase().replace(".", "");
367
+ console.log(`[image-server] Extension: "${ext}"`);
368
+ const mimeType = getMimeType(ext);
369
+ console.log(`[image-server] MIME type: ${mimeType}`);
370
+
371
+ // 只处理图片文件
372
+ if (!mimeType.startsWith("image/")) {
373
+ console.log(`[image-server] Not an image file`);
374
+ return null;
375
+ }
376
+
377
+ // 使用 saveImage 保存
378
+ return saveImage(buffer, mimeType, ttlSeconds);
379
+ } catch (err) {
380
+ console.error(`[image-server] saveImageFromPath error:`, err);
381
+ return null;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * 检查图床服务器是否运行中
387
+ */
388
+ export function isImageServerRunning(): boolean {
389
+ return serverInstance !== null;
390
+ }
391
+
392
+ /**
393
+ * 确保图床服务器正在运行
394
+ * 如果未运行,则自动启动
395
+ * @param publicBaseUrl 公网访问的基础 URL(如 http://your-server:18765)
396
+ * @returns 基础 URL,启动失败返回 null
397
+ */
398
+ export async function ensureImageServer(publicBaseUrl?: string): Promise<string | null> {
399
+ if (isImageServerRunning()) {
400
+ return publicBaseUrl || currentConfig.baseUrl || `http://0.0.0.0:${currentConfig.port}`;
401
+ }
402
+
403
+ try {
404
+ const config: Partial<ImageServerConfig> = {
405
+ port: DEFAULT_CONFIG.port,
406
+ storageDir: DEFAULT_CONFIG.storageDir,
407
+ // 使用用户配置的公网地址
408
+ baseUrl: publicBaseUrl || `http://0.0.0.0:${DEFAULT_CONFIG.port}`,
409
+ ttlSeconds: 3600, // 1 小时过期
410
+ };
411
+ await startImageServer(config);
412
+ console.log(`[image-server] Auto-started on port ${config.port}, baseUrl: ${config.baseUrl}`);
413
+ return config.baseUrl!;
414
+ } catch (err) {
415
+ console.error(`[image-server] Failed to auto-start: ${err}`);
416
+ return null;
417
+ }
418
+ }
419
+
420
+ /** downloadFile 的返回结果 */
421
+ export interface DownloadResult {
422
+ /** 下载成功时的本地文件路径(位于系统临时目录,调用方用完后应删除) */
423
+ filePath: string | null;
424
+ /** 下载失败时的错误信息(用于兜底消息展示) */
425
+ error?: string;
426
+ }
427
+
428
+ /** 默认下载目录:与入站附件统一放在 ~/.openclaw/media/qqbot/downloads/ */
429
+ const DEFAULT_DOWNLOAD_DIR = getQQBotMediaDir("downloads");
430
+
431
+ /**
432
+ * 下载远程文件到系统临时目录。
433
+ *
434
+ * 文件名采用 UUID 保证不重名不覆盖,调用方用完后应自行删除。
435
+ *
436
+ * 安全措施:
437
+ * 1. SSRF 防护 — DNS 解析后校验 IP,拒绝私有/保留网段
438
+ * 2. Content-Type 黑名单 — 拦截 text/html(登录页/错误页/人机验证页)
439
+ * 3. 超时控制 — 默认 30 秒,传 0 表示不限时
440
+ * 4. 大小限制 — 可选,通过 Content-Length 预检 + 流式字节计数双重保护
441
+ *
442
+ * @param url 远程文件 URL
443
+ * @param originalFilename 原始文件名(可选,仅用于推导扩展名)
444
+ * @param options 下载选项
445
+ * @returns DownloadResult,filePath 为 null 表示失败,error 包含失败原因
446
+ */
447
+ export async function downloadFile(
448
+ url: string,
449
+ originalFilename?: string,
450
+ options?: {
451
+ /** 超时时间(毫秒),默认 30000(30 秒)。传 0 表示不限时 */
452
+ timeoutMs?: number;
453
+ /** 指定下载目标目录。不传则使用系统临时目录(调用方用完后应删除) */
454
+ destDir?: string;
455
+ /** 下载大小上限(字节)。超过此值中断下载并返回错误。不传则不限制 */
456
+ maxSizeBytes?: number;
457
+ /** 网络错误时的最大重试次数,默认 2(即最多尝试 3 次) */
458
+ maxRetries?: number;
459
+ },
460
+ ): Promise<DownloadResult> {
461
+ const timeoutMs = options?.timeoutMs ?? 30_000;
462
+ const destDir = options?.destDir ?? DEFAULT_DOWNLOAD_DIR;
463
+ const maxSizeBytes = options?.maxSizeBytes ?? 0; // 0 = 不限制
464
+ const maxRetries = options?.maxRetries ?? 2;
465
+
466
+ // ---- SSRF 防护(只做一次,不需要重试) ----
467
+ try {
468
+ await validateRemoteUrl(url);
469
+ } catch (err) {
470
+ const msg = err instanceof Error ? err.message : String(err);
471
+ console.error(`[image-server] SSRF check failed: ${msg}`);
472
+ return { filePath: null, error: `URL 安全检查未通过: ${msg}` };
473
+ }
474
+
475
+ // 确保目标目录存在(只做一次)
476
+ if (!fs.existsSync(destDir)) {
477
+ fs.mkdirSync(destDir, { recursive: true });
478
+ }
479
+
480
+ let lastError: DownloadResult | null = null;
481
+
482
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
483
+ if (attempt > 0) {
484
+ // 指数退避:1s, 2s
485
+ const delayMs = attempt * 1000;
486
+ console.log(`[image-server] Retry ${attempt}/${maxRetries} after ${delayMs}ms: ${url.slice(0, 120)}`);
487
+ await new Promise(r => setTimeout(r, delayMs));
488
+ }
489
+
490
+ const result = await downloadFileOnce(url, originalFilename, { timeoutMs, destDir, maxSizeBytes });
491
+
492
+ // 成功 或 不可重试的错误 → 直接返回
493
+ if (result.filePath || !result.retryable) {
494
+ return { filePath: result.filePath, error: result.error };
495
+ }
496
+
497
+ // 可重试的错误,记录后继续
498
+ lastError = { filePath: null, error: result.error };
499
+ console.error(`[image-server] Attempt ${attempt + 1}/${maxRetries + 1} failed (retryable): ${result.error}`);
500
+ }
501
+
502
+ // 所有重试用完
503
+ return lastError ?? { filePath: null, error: "下载失败(重试次数耗尽)" };
504
+ }
505
+
506
+ /** downloadFileOnce 内部返回类型,增加 retryable 标记 */
507
+ interface DownloadOnceResult {
508
+ filePath: string | null;
509
+ error?: string;
510
+ /** 是否可重试(网络层临时错误 = true,业务错误如文件过大 = false) */
511
+ retryable?: boolean;
512
+ }
513
+
514
+ /**
515
+ * 执行一次下载尝试(无重试逻辑)。
516
+ */
517
+ async function downloadFileOnce(
518
+ url: string,
519
+ originalFilename: string | undefined,
520
+ opts: { timeoutMs: number; destDir: string; maxSizeBytes: number },
521
+ ): Promise<DownloadOnceResult> {
522
+ const { timeoutMs, destDir, maxSizeBytes } = opts;
523
+
524
+ const controller = new AbortController();
525
+ // timeoutMs > 0 时启用超时;为 0 表示不限时
526
+ const timeoutId = timeoutMs > 0
527
+ ? setTimeout(() => controller.abort(), timeoutMs)
528
+ : null;
529
+
530
+ let tempPath: string | null = null;
531
+
532
+ try {
533
+ // 下载文件(带超时控制)
534
+ const response = await fetch(url, { signal: controller.signal });
535
+ if (!response.ok) {
536
+ const reason = `HTTP ${response.status} ${response.statusText}`;
537
+ console.error(`[image-server] Download failed: ${reason}`);
538
+ // 5xx 服务端错误可重试,4xx 不可重试
539
+ const retryable = response.status >= 500;
540
+ return { filePath: null, error: `下载失败 (${reason})`, retryable };
541
+ }
542
+
543
+ if (!response.body) {
544
+ console.error(`[image-server] Download failed: empty response body`);
545
+ return { filePath: null, error: `下载失败 (响应体为空)`, retryable: false };
546
+ }
547
+
548
+ // ---- 预检 Content-Length(如果服务端返回了该头) ----
549
+ if (maxSizeBytes > 0) {
550
+ const contentLength = Number(response.headers.get("content-length"));
551
+ if (contentLength > 0 && contentLength > maxSizeBytes) {
552
+ const sizeMB = (contentLength / (1024 * 1024)).toFixed(1);
553
+ const limitMB = Math.round(maxSizeBytes / (1024 * 1024));
554
+ console.error(`[image-server] File too large (Content-Length: ${sizeMB}MB, limit: ${limitMB}MB): ${url}`);
555
+ return { filePath: null, error: `文件过大(${sizeMB}MB),超过了${limitMB}M的下载限制`, retryable: false };
556
+ }
557
+ }
558
+
559
+ // 推导扩展名:originalFilename > Content-Disposition > Content-Type > .bin
560
+ const contentType = response.headers.get("content-type") ?? "";
561
+ let ext = "";
562
+ if (originalFilename) {
563
+ try { ext = path.extname(decodeURIComponent(originalFilename)); } catch { ext = path.extname(originalFilename); }
564
+ }
565
+ if (!ext) {
566
+ const disposition = response.headers.get("content-disposition");
567
+ if (disposition) {
568
+ const m = disposition.match(/filename\*?=(?:UTF-8''|")?([^";]+)"?/i);
569
+ if (m?.[1]) {
570
+ try { ext = path.extname(decodeURIComponent(m[1])); } catch { /* keep empty */ }
571
+ }
572
+ }
573
+ }
574
+ if (!ext) {
575
+ const mime = contentType.split(";")[0]?.trim() ?? "";
576
+ ext = mime ? (`.${getExtFromMime(mime) ?? "bin"}`) : ".bin";
577
+ }
578
+
579
+ // UUID 文件名,绝对不会重名
580
+ const uniqueName = `${crypto.randomUUID()}${ext}`;
581
+ const filePath = path.join(destDir, uniqueName);
582
+ tempPath = filePath + ".tmp";
583
+
584
+ // ---- 流式写入临时文件(内存占用恒定,不会 OOM) ----
585
+ const nodeStream = Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream);
586
+
587
+
588
+ // 如果设置了大小限制,包装一个 Transform 流来监控已写入字节数
589
+ if (maxSizeBytes > 0) {
590
+ const { Transform } = await import("node:stream");
591
+ let bytesWritten = 0;
592
+ const sizeGuard = new Transform({
593
+ transform(chunk, _encoding, callback) {
594
+ bytesWritten += chunk.length;
595
+ if (bytesWritten > maxSizeBytes) {
596
+ const sizeMB = (bytesWritten / (1024 * 1024)).toFixed(1);
597
+ const limitMB = Math.round(maxSizeBytes / (1024 * 1024));
598
+ callback(new Error(`DOWNLOAD_SIZE_EXCEEDED: ${sizeMB}MB > ${limitMB}MB`));
599
+ } else {
600
+ callback(null, chunk);
601
+ }
602
+ },
603
+ });
604
+ const writeStream = fs.createWriteStream(tempPath);
605
+ await pipeline(nodeStream, sizeGuard, writeStream);
606
+ } else {
607
+ const writeStream = fs.createWriteStream(tempPath);
608
+ await pipeline(nodeStream, writeStream);
609
+ }
610
+
611
+ // 流式写入完成,原子重命名为最终文件
612
+ const stat = await fs.promises.stat(tempPath);
613
+ fs.renameSync(tempPath, filePath);
614
+ tempPath = null; // 重命名成功,不再需要清理
615
+
616
+ console.log(`[image-server] Downloaded file: ${filePath} (${stat.size} bytes)`);
617
+ return { filePath };
618
+ } catch (err) {
619
+ // 清理不完整的临时文件
620
+ if (tempPath) {
621
+ try { fs.unlinkSync(tempPath); } catch { /* ignore cleanup error */ }
622
+ }
623
+
624
+ if (err instanceof Error && err.name === "AbortError") {
625
+ console.error(`[image-server] Download timeout after ${timeoutMs}ms: ${url}`);
626
+ return { filePath: null, error: `下载超时(${Math.round(timeoutMs / 1000)}秒)`, retryable: true };
627
+ }
628
+ // 大小超限错误 — 不可重试
629
+ if (err instanceof Error && err.message.startsWith("DOWNLOAD_SIZE_EXCEEDED:")) {
630
+ const limitMB = maxSizeBytes > 0 ? Math.round(maxSizeBytes / (1024 * 1024)) : 0;
631
+ console.error(`[image-server] Download size exceeded ${limitMB}MB: ${url}`);
632
+ return { filePath: null, error: `文件过大,超过了${limitMB}M的下载限制`, retryable: false };
633
+ }
634
+ // 网络层临时错误 — 可重试
635
+ const retryable = isRetryableNetworkError(err);
636
+ const msg = err instanceof Error ? err.message : String(err);
637
+ console.error(`[image-server] Download error (retryable=${retryable}):`, err);
638
+ return { filePath: null, error: `下载出错: ${msg}`, retryable };
639
+ } finally {
640
+ if (timeoutId) clearTimeout(timeoutId);
641
+ }
642
+ }
643
+
644
+ /**
645
+ * 判断错误是否为可重试的网络临时错误。
646
+ *
647
+ * 覆盖常见的 TCP/DNS 层面临时故障:
648
+ * - ETIMEDOUT: TCP 连接超时
649
+ * - ECONNRESET: 连接被对端重置
650
+ * - ECONNREFUSED: 连接被拒绝
651
+ * - ENOTFOUND: DNS 解析失败
652
+ * - EAI_AGAIN: DNS 临时失败
653
+ * - UND_ERR_CONNECT_TIMEOUT: undici 连接超时
654
+ * - fetch failed: Node.js fetch 底层网络错误的通用消息
655
+ */
656
+ function isRetryableNetworkError(err: unknown): boolean {
657
+ if (!(err instanceof Error)) return false;
658
+ const code = (err as NodeJS.ErrnoException).code;
659
+ if (code && ["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"].includes(code)) {
660
+ return true;
661
+ }
662
+ // Node.js fetch 抛出 TypeError: fetch failed 时,真正的网络错误在 cause 中
663
+ if (err.message === "fetch failed" && err.cause) {
664
+ return isRetryableNetworkError(err.cause);
665
+ }
666
+ return false;
667
+ }
668
+
669
+
670
+ /**
671
+ * 获取图床服务器配置
672
+ */
673
+ export function getImageServerConfig(): Required<ImageServerConfig> {
674
+ return { ...currentConfig };
675
+ }