@cmach_socket/opencode-qq-bot 0.0.0-stage → 0.3.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 +443 -0
- package/README.md +210 -2
- package/package.json +47 -4
- package/souls/default.txt +8 -0
- package/src/chat.ts +44 -0
- package/src/config.ts +449 -0
- package/src/index.ts +251 -0
- package/src/instance.ts +60 -0
- package/src/logger.ts +30 -0
- package/src/naplink/client.ts +118 -0
- package/src/naplink/media.ts +311 -0
- package/src/naplink/render.ts +440 -0
- package/src/session/manager.ts +628 -0
- package/src/session/proactive.ts +22 -0
- package/src/session/prompt.ts +41 -0
- package/src/session/soul.ts +180 -0
- package/src/tools/ask-guard.ts +32 -0
- package/src/tools/qq.ts +421 -0
|
@@ -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 }
|