@cmach_socket/opencode-qq-bot 0.0.0-stage → 0.2.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.
@@ -0,0 +1,311 @@
1
+ import { createHash } from "node:crypto"
2
+ import { access, mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { pathToFileURL } from "node:url"
6
+ import type { Logger } from "../logger"
7
+
8
+ /** 单张图片下载与落盘的字节上限。 */
9
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024
10
+ /** 下载远端图片的超时时间。 */
11
+ export const IMAGE_FETCH_TIMEOUT_MS = 15_000
12
+ /** 临时图片的保留时间。 */
13
+ export const MEDIA_TTL_MS = 7 * 24 * 60 * 60 * 1000
14
+
15
+ export interface PromptFile {
16
+ uri: string
17
+ name: string
18
+ /** 原始字节数,用于图片预算与缓存上限。 */
19
+ bytes?: number
20
+ /** 文件来源;file: 引用会标记为 uri。 */
21
+ source?: { type: "inline" } | { type: "uri"; uri: string }
22
+ }
23
+
24
+ export interface StoredImage extends PromptFile {
25
+ /** 图片扩展名,不含点。 */
26
+ ext: string
27
+ }
28
+
29
+ interface ImageBytes {
30
+ bytes: Uint8Array
31
+ mime: string
32
+ }
33
+
34
+ const MIME_EXTENSIONS: Record<string, string> = {
35
+ "image/png": "png",
36
+ "image/jpeg": "jpg",
37
+ "image/gif": "gif",
38
+ "image/webp": "webp",
39
+ }
40
+
41
+ /** 从魔数判断图片类型。 */
42
+ export function mimeFromBytes(bytes: Uint8Array): string | undefined {
43
+ if (
44
+ bytes.length >= 8 &&
45
+ bytes[0] === 0x89 &&
46
+ bytes[1] === 0x50 &&
47
+ bytes[2] === 0x4e &&
48
+ bytes[3] === 0x47 &&
49
+ bytes[4] === 0x0d &&
50
+ bytes[5] === 0x0a &&
51
+ bytes[6] === 0x1a &&
52
+ bytes[7] === 0x0a
53
+ ) {
54
+ return "image/png"
55
+ }
56
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
57
+ return "image/jpeg"
58
+ }
59
+ if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
60
+ return "image/gif"
61
+ }
62
+ if (
63
+ bytes.length >= 12 &&
64
+ bytes[8] === 0x57 &&
65
+ bytes[9] === 0x45 &&
66
+ bytes[10] === 0x42 &&
67
+ bytes[11] === 0x50
68
+ ) {
69
+ return "image/webp"
70
+ }
71
+ return undefined
72
+ }
73
+
74
+ /** 估算 data URL 里原始图片的字节数。 */
75
+ export function inlineImageBytes(uri: string): number {
76
+ const comma = uri.indexOf(",")
77
+ if (comma < 0) return 0
78
+ return Math.floor(((uri.length - comma - 1) * 3) / 4)
79
+ }
80
+
81
+ /** 按 Unicode 码点截断,避免切断代理对。 */
82
+ function truncateCodePoints(text: string, max: number): string {
83
+ return Array.from(text).slice(0, max).join("")
84
+ }
85
+
86
+ /** 判断主机名是否指向本机或内网,避免下载请求打内网服务。 */
87
+ export function isPrivateHost(hostname: string): boolean {
88
+ let host = hostname.trim().toLowerCase()
89
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1)
90
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true
91
+ if (host === "::1" || host === "::") return true
92
+ if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) return true
93
+
94
+ const parts = host.split(".")
95
+ if (parts.length !== 4) return false
96
+ const octets = parts.map(Number)
97
+ if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false
98
+ const [first, second] = octets
99
+ return (
100
+ first === 0 ||
101
+ first === 10 ||
102
+ first === 127 ||
103
+ (first === 169 && second === 254) ||
104
+ (first === 172 && second !== undefined && second >= 16 && second <= 31) ||
105
+ (first === 192 && second === 168)
106
+ )
107
+ }
108
+
109
+ async function readCapped(response: Response, limit: number): Promise<Uint8Array | undefined> {
110
+ const body = response.body
111
+ if (!body) {
112
+ const buffer = new Uint8Array(await response.arrayBuffer())
113
+ return buffer.byteLength > limit ? undefined : buffer
114
+ }
115
+ const reader = body.getReader()
116
+ const chunks: Uint8Array[] = []
117
+ let total = 0
118
+ try {
119
+ for (;;) {
120
+ const { done, value } = await reader.read()
121
+ if (done) break
122
+ if (!value) continue
123
+ total += value.byteLength
124
+ if (total > limit) {
125
+ await reader.cancel().catch(() => {})
126
+ return undefined
127
+ }
128
+ chunks.push(value)
129
+ }
130
+ } finally {
131
+ reader.releaseLock()
132
+ }
133
+ const buffer = new Uint8Array(total)
134
+ let offset = 0
135
+ for (const chunk of chunks) {
136
+ buffer.set(chunk, offset)
137
+ offset += chunk.byteLength
138
+ }
139
+ return buffer
140
+ }
141
+
142
+ function decodeBase64(value: string): Uint8Array | undefined {
143
+ if (!/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) return undefined
144
+ const bytes = Buffer.from(value, "base64")
145
+ return bytes.byteLength > MAX_IMAGE_BYTES ? undefined : new Uint8Array(bytes)
146
+ }
147
+
148
+ function parseDataUrl(uri: string): ImageBytes | undefined {
149
+ const match = /^data:([^;,]+)?(?:;[^,]*)*;base64,(.*)$/s.exec(uri)
150
+ if (!match) return undefined
151
+ const bytes = decodeBase64(match[2] ?? "")
152
+ if (!bytes) return undefined
153
+ const declared = match[1]?.trim()
154
+ const mime =
155
+ declared?.startsWith("image/") === true ? declared : (mimeFromBytes(bytes) ?? undefined)
156
+ if (!mime) return undefined
157
+ return { bytes, mime }
158
+ }
159
+
160
+ /**
161
+ * 受控的图片文件存储。
162
+ *
163
+ * 图片下载后写入临时目录,prompt 里只传 `file:` 引用,
164
+ * 避免 base64 字符串进入插件缓存与会话参数。
165
+ */
166
+ export class MediaStore {
167
+ private ready: Promise<void> | undefined
168
+
169
+ constructor(
170
+ private readonly root: string,
171
+ private readonly logger: Logger,
172
+ private readonly ttlMs = MEDIA_TTL_MS,
173
+ ) {}
174
+
175
+ private async ensureRoot(): Promise<void> {
176
+ this.ready ??= mkdir(this.root, { recursive: true, mode: 0o700 }).then(() => undefined)
177
+ await this.ready
178
+ }
179
+
180
+ /** 下载或解码图片并写入临时文件;失败时返回 undefined。 */
181
+ async storeImage(url: string): Promise<StoredImage | undefined> {
182
+ const image = await this.readImage(url)
183
+ if (!image) return undefined
184
+ return this.storeBytes(image.bytes, image.mime)
185
+ }
186
+
187
+ private async readImage(url: string): Promise<ImageBytes | undefined> {
188
+ if (url.startsWith("base64://")) {
189
+ const bytes = decodeBase64(url.slice("base64://".length))
190
+ if (!bytes) {
191
+ this.logger.warn("base64 图片无效或超过大小上限")
192
+ return undefined
193
+ }
194
+ const mime = mimeFromBytes(bytes)
195
+ if (!mime) {
196
+ this.logger.warn("base64 图片类型无法识别")
197
+ return undefined
198
+ }
199
+ return { bytes, mime }
200
+ }
201
+ if (url.startsWith("data:")) return parseDataUrl(url)
202
+ if (!url.startsWith("http://") && !url.startsWith("https://")) return undefined
203
+
204
+ let parsed: URL
205
+ try {
206
+ parsed = new URL(url)
207
+ } catch {
208
+ return undefined
209
+ }
210
+ if (isPrivateHost(parsed.hostname)) {
211
+ this.logger.warn(`拒绝下载内网图片 host=${parsed.hostname}`)
212
+ return undefined
213
+ }
214
+
215
+ const controller = new AbortController()
216
+ const timer = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS)
217
+ try {
218
+ const response = await fetch(url, {
219
+ signal: controller.signal,
220
+ redirect: "error",
221
+ })
222
+ if (!response.ok) {
223
+ this.logger.warn(`下载图片失败 status=${response.status} url=${url}`)
224
+ return undefined
225
+ }
226
+ const lengthHeader = response.headers.get("content-length")
227
+ const declaredLength = lengthHeader === null ? undefined : Number(lengthHeader)
228
+ if (declaredLength !== undefined && Number.isFinite(declaredLength)) {
229
+ if (declaredLength > MAX_IMAGE_BYTES) {
230
+ this.logger.warn(`图片过大,跳过下载 bytes=${declaredLength} url=${url}`)
231
+ return undefined
232
+ }
233
+ }
234
+ const bytes = await readCapped(response, MAX_IMAGE_BYTES)
235
+ if (!bytes) {
236
+ this.logger.warn(`图片过大,跳过下载 url=${url}`)
237
+ return undefined
238
+ }
239
+ const declared = response.headers.get("content-type")?.split(";")[0]?.trim()
240
+ const mime =
241
+ declared?.startsWith("image/") === true ? declared : (mimeFromBytes(bytes) ?? undefined)
242
+ if (!mime) {
243
+ this.logger.warn(`图片类型无法识别 url=${url}`)
244
+ return undefined
245
+ }
246
+ return { bytes, mime }
247
+ } catch (error) {
248
+ this.logger.warn(`下载图片异常 url=${url}`, error)
249
+ return undefined
250
+ } finally {
251
+ clearTimeout(timer)
252
+ }
253
+ }
254
+
255
+ private async storeBytes(bytes: Uint8Array, mime: string): Promise<StoredImage> {
256
+ await this.ensureRoot()
257
+ const hash = createHash("sha256").update(bytes).digest("hex")
258
+ const ext = MIME_EXTENSIONS[mime] ?? "bin"
259
+ const path = join(this.root, `${hash}.${ext}`)
260
+ try {
261
+ await access(path)
262
+ } catch {
263
+ await writeFile(path, bytes, { mode: 0o600 })
264
+ }
265
+ const uri = pathToFileURL(path).href
266
+ return {
267
+ uri,
268
+ name: `qq-image-${hash.slice(0, 8)}.${ext}`,
269
+ bytes: bytes.byteLength,
270
+ source: { type: "uri", uri },
271
+ ext,
272
+ }
273
+ }
274
+
275
+ /** 删除超过 TTL 的临时图片。 */
276
+ async cleanup(now = Date.now()): Promise<number> {
277
+ let removed = 0
278
+ try {
279
+ const entries = await readdir(this.root)
280
+ for (const entry of entries) {
281
+ const path = join(this.root, entry)
282
+ try {
283
+ const info = await stat(path)
284
+ if (!info.isFile() || now - info.mtimeMs <= this.ttlMs) continue
285
+ await unlink(path)
286
+ removed += 1
287
+ } catch {
288
+ // 文件可能已被其它实例删除。
289
+ }
290
+ }
291
+ } catch {
292
+ // 目录不存在时无需清理。
293
+ }
294
+ if (removed > 0) this.logger.debug(`已清理 ${removed} 个过期图片文件`)
295
+ return removed
296
+ }
297
+ }
298
+
299
+ /** 创建默认的临时图片存储,并立即清理一次过期文件。 */
300
+ export async function createMediaStore(
301
+ logger: Logger,
302
+ options: { root?: string; ttlMs?: number } = {},
303
+ ): Promise<MediaStore> {
304
+ const root = options.root ?? join(tmpdir(), "opencode-qq-bot-media")
305
+ const store = new MediaStore(root, logger, options.ttlMs)
306
+ await store.cleanup()
307
+ return store
308
+ }
309
+
310
+ /** 供需要按码点截断的渲染代码复用。 */
311
+ export { truncateCodePoints }