@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,440 @@
1
+ import type { MessageEvent, MessageSegment, OneBotApi } from "@naplink/naplink"
2
+ import type { ChatTarget } from "../chat"
3
+ import { targetKey } from "../chat"
4
+ import type { Logger } from "../logger"
5
+ import {
6
+ MAX_IMAGE_BYTES,
7
+ inlineImageBytes,
8
+ truncateCodePoints,
9
+ type MediaStore,
10
+ type PromptFile,
11
+ type StoredImage,
12
+ } from "./media"
13
+
14
+ export { MAX_IMAGE_BYTES, inlineImageBytes }
15
+ export type { PromptFile } from "./media"
16
+
17
+ /** 合并转发的解析上限,避免撑爆上下文。 */
18
+ export const MAX_FORWARD_DEPTH = 3
19
+ export const MAX_FORWARD_NODES = 50
20
+ export const MAX_FORWARD_CHARS = 4000
21
+ /** 整棵转发树最多解析的节点数,避免嵌套转发把 API 调用放大。 */
22
+ export const MAX_FORWARD_TOTAL_NODES = 100
23
+ /** 整棵转发树最多保留的字符数。 */
24
+ export const MAX_FORWARD_TOTAL_CHARS = 8000
25
+ /** 单轮提示里最多引用的图片数量,超出的图片只保留占位文本。 */
26
+ export const MAX_PROMPT_IMAGES = 8
27
+ /** 单轮提示里引用图片的原始字节总量上限,超出的图片只保留占位文本。 */
28
+ export const MAX_PROMPT_IMAGE_BYTES = 16 * 1024 * 1024
29
+
30
+ export interface RenderedMessage {
31
+ /** 纯文本版本,用于会话历史与提示词。 */
32
+ text: string
33
+ /** 图片附件,交给 OpenCode 作为多模态输入。 */
34
+ files: PromptFile[]
35
+ }
36
+
37
+ export interface RenderContext {
38
+ api: OneBotApi
39
+ target: ChatTarget
40
+ logger: Logger
41
+ /** 受控临时图片存储。 */
42
+ media: MediaStore
43
+ /** 用户显示名缓存,避免每条消息都请求一次 API。 */
44
+ names?: Map<string, { name: string; expires: number }>
45
+ }
46
+
47
+ /** NapCat 的 forward 段不在 SDK 类型里,这里补进判别联合。 */
48
+ interface ForwardSegment {
49
+ type: "forward"
50
+ data: { id?: string }
51
+ }
52
+
53
+ type Segment = MessageSegment | ForwardSegment
54
+
55
+ function segments(message: readonly MessageSegment[]): readonly Segment[] {
56
+ return message as readonly Segment[]
57
+ }
58
+
59
+ /** 判断消息是否含有值得交给模型的内容。 */
60
+ export function shouldProcess(message: readonly MessageSegment[], userId?: string): boolean {
61
+ if (userId && mentionsUser(message, userId)) return true
62
+ return segments(message).some((segment) => {
63
+ switch (segment.type) {
64
+ case "text":
65
+ return segment.data.text.trim().length > 0
66
+ case "image":
67
+ case "record":
68
+ case "forward":
69
+ case "reply":
70
+ return true
71
+ default:
72
+ return false
73
+ }
74
+ })
75
+ }
76
+
77
+ /** 判断消息里是否 @ 了指定 QQ 号。 */
78
+ export function mentionsUser(
79
+ message: readonly MessageSegment[],
80
+ userId: string | undefined,
81
+ ): boolean {
82
+ if (!userId) return false
83
+ return segments(message).some(
84
+ (segment) => segment.type === "at" && String(segment.data.qq) === userId,
85
+ )
86
+ }
87
+
88
+ /** 图片引用的预算:数量与原始字节总量,渲染过程中边用边扣。 */
89
+ export interface ImageBudget {
90
+ images: number
91
+ bytes: number
92
+ }
93
+
94
+ /** 创建一份默认图片预算。 */
95
+ export function createImageBudget(): ImageBudget {
96
+ return { images: MAX_PROMPT_IMAGES, bytes: MAX_PROMPT_IMAGE_BYTES }
97
+ }
98
+
99
+ /** 从预算里扣掉一张图片;预算不足或单图过大时不扣。 */
100
+ function takeImage(budget: ImageBudget, file: PromptFile): boolean {
101
+ const bytes = file.bytes ?? inlineImageBytes(file.uri)
102
+ if (bytes > MAX_IMAGE_BYTES) return false
103
+ if (budget.images <= 0 || budget.bytes < bytes) return false
104
+ budget.images -= 1
105
+ budget.bytes -= bytes
106
+ return true
107
+ }
108
+
109
+ /** 按预算裁剪图片附件列表(数量与字节总量)。 */
110
+ export function limitFiles(files: readonly PromptFile[]): PromptFile[] {
111
+ const budget = createImageBudget()
112
+ const kept: PromptFile[] = []
113
+ for (const file of files) {
114
+ if (!takeImage(budget, file)) continue
115
+ kept.push(file)
116
+ }
117
+ return kept
118
+ }
119
+
120
+ const USER_NAME_TTL_MS = 5 * 60 * 1000
121
+
122
+ /** 查询用户显示名:群聊优先群名片,私聊使用 QQ 昵称。 */
123
+ export async function resolveUserName(ctx: RenderContext, userId: string): Promise<string> {
124
+ if (userId === "all") return "全体成员"
125
+ const key = `${targetKey(ctx.target)}:${userId}`
126
+ const cached = ctx.names?.get(key)
127
+ if (cached && cached.expires > Date.now()) return cached.name
128
+ try {
129
+ const info = (
130
+ ctx.target.kind === "group"
131
+ ? await ctx.api.getGroupMemberInfo(ctx.target.id, userId)
132
+ : await ctx.api.getStrangerInfo(userId)
133
+ ) as { card?: string; nickname?: string } | undefined
134
+ const name = info?.card?.trim() || info?.nickname?.trim() || "未知用户"
135
+ ctx.names?.set(key, { name, expires: Date.now() + USER_NAME_TTL_MS })
136
+ return name
137
+ } catch (error) {
138
+ ctx.logger.warn(`获取用户信息失败 user_id=${userId}`, error)
139
+ return "未知用户"
140
+ }
141
+ }
142
+
143
+ interface ForwardContent {
144
+ text: string
145
+ images: StoredImage[]
146
+ }
147
+
148
+ interface ForwardBudget {
149
+ nodes: number
150
+ chars: number
151
+ }
152
+
153
+ function createForwardBudget(): ForwardBudget {
154
+ return { nodes: MAX_FORWARD_TOTAL_NODES, chars: MAX_FORWARD_TOTAL_CHARS }
155
+ }
156
+
157
+ async function renderForwardSegments(
158
+ ctx: RenderContext,
159
+ message: readonly MessageSegment[],
160
+ depth: number,
161
+ budget: ImageBudget,
162
+ forwardBudget: ForwardBudget,
163
+ ): Promise<ForwardContent> {
164
+ const parts: string[] = []
165
+ const images: StoredImage[] = []
166
+ for (const segment of segments(message)) {
167
+ try {
168
+ switch (segment.type) {
169
+ case "text":
170
+ parts.push(segment.data.text)
171
+ break
172
+ case "at":
173
+ parts.push(`@${segment.data.qq} `)
174
+ break
175
+ case "image": {
176
+ parts.push("[图片]")
177
+ const url = segment.data.url || segment.data.file
178
+ if (!url || budget.images <= 0) break
179
+ const stored = await ctx.media.storeImage(url)
180
+ if (!stored || !takeImage(budget, stored)) break
181
+ images.push(stored)
182
+ break
183
+ }
184
+ case "record":
185
+ parts.push("[语音]")
186
+ break
187
+ case "video":
188
+ parts.push("[视频]")
189
+ break
190
+ case "audio":
191
+ parts.push("[音频]")
192
+ break
193
+ case "file":
194
+ parts.push(`[文件]${segment.data.name || segment.data.file}`)
195
+ break
196
+ case "face":
197
+ parts.push("[表情]")
198
+ break
199
+ case "json":
200
+ parts.push("[卡片消息]")
201
+ break
202
+ case "xml":
203
+ parts.push("[XML消息]")
204
+ break
205
+ case "markdown":
206
+ parts.push("[Markdown消息]")
207
+ break
208
+ case "reply":
209
+ // 转发节点里的引用没有上下文,跳过
210
+ break
211
+ case "forward": {
212
+ if (depth >= MAX_FORWARD_DEPTH) {
213
+ parts.push("[嵌套合并转发]")
214
+ break
215
+ }
216
+ const nested = await renderForward(ctx, segment.data.id, depth + 1, budget, forwardBudget)
217
+ parts.push(nested.text)
218
+ images.push(...nested.images)
219
+ break
220
+ }
221
+ default:
222
+ break
223
+ }
224
+ } catch (error) {
225
+ ctx.logger.warn(`解析转发消息段失败 type=${segment.type}`, error)
226
+ parts.push("[无法解析的消息]")
227
+ }
228
+ }
229
+ return { text: parts.join("").trim(), images }
230
+ }
231
+
232
+ /** 把合并转发解析成 ``<forward>`` 文本块,并附带其中的图片。 */
233
+ export async function renderForward(
234
+ ctx: RenderContext,
235
+ id: string | undefined,
236
+ depth = 0,
237
+ budget: ImageBudget = createImageBudget(),
238
+ forwardBudget: ForwardBudget = createForwardBudget(),
239
+ ): Promise<ForwardContent> {
240
+ if (!id) return { text: "[合并转发]", images: [] }
241
+ if (forwardBudget.nodes <= 0 || forwardBudget.chars <= 0) {
242
+ return { text: "[合并转发(内容过多,已截断)]", images: [] }
243
+ }
244
+ let nodes: { name: string; segments: MessageSegment[] }[] = []
245
+ try {
246
+ const data = (await ctx.api.getForwardMessage(id)) as {
247
+ messages?: {
248
+ sender?: { user_id?: number; nickname?: string; card?: string }
249
+ message?: unknown[]
250
+ }[]
251
+ }
252
+ nodes = (data.messages ?? []).map((entry) => ({
253
+ name:
254
+ entry.sender?.card || entry.sender?.nickname || String(entry.sender?.user_id ?? "未知用户"),
255
+ segments: (entry.message ?? []) as MessageSegment[],
256
+ }))
257
+ } catch (error) {
258
+ ctx.logger.warn(`解析合并转发失败 id=${id}`, error)
259
+ return { text: "[合并转发(获取失败)]", images: [] }
260
+ }
261
+ if (nodes.length === 0) return { text: "[合并转发]", images: [] }
262
+
263
+ const body: string[] = []
264
+ const images: StoredImage[] = []
265
+ let consumed = 0
266
+ for (const node of nodes) {
267
+ if (consumed >= MAX_FORWARD_NODES || forwardBudget.nodes <= 0 || forwardBudget.chars <= 0) {
268
+ break
269
+ }
270
+ forwardBudget.nodes -= 1
271
+ consumed += 1
272
+ const rendered = await renderForwardSegments(ctx, node.segments, depth, budget, forwardBudget)
273
+ const line = `${node.name}:${rendered.text || "[空消息]"}`
274
+ const limited = truncateCodePoints(line, Math.max(0, forwardBudget.chars))
275
+ forwardBudget.chars -= Array.from(limited).length
276
+ body.push(limited)
277
+ images.push(...rendered.images)
278
+ }
279
+ if (consumed < nodes.length) {
280
+ body.push(`...(还有 ${nodes.length - consumed} 条)`)
281
+ }
282
+
283
+ let rendered = body.join("\n")
284
+ if (Array.from(rendered).length > MAX_FORWARD_CHARS) {
285
+ rendered = `${truncateCodePoints(rendered, MAX_FORWARD_CHARS)}\n...(合并转发过长,已截断)`
286
+ }
287
+ return { text: `<forward>\n${rendered}\n</forward>`, images }
288
+ }
289
+
290
+ /** 把语音消息转成文字,转写失败时返回空字符串。 */
291
+ export async function fetchVoiceText(
292
+ ctx: RenderContext,
293
+ messageId: number | undefined,
294
+ ): Promise<string> {
295
+ if (messageId === undefined) return ""
296
+ try {
297
+ const result = await ctx.api.fetchPttText(messageId)
298
+ return result?.text?.trim() ?? ""
299
+ } catch (error) {
300
+ ctx.logger.warn(`语音转文字失败 message_id=${messageId}`, error)
301
+ return ""
302
+ }
303
+ }
304
+
305
+ /**
306
+ * 把一条 QQ 消息拆成 (纯文本, 图片附件)。
307
+ *
308
+ * 引用消息会展开成 ``<quote>`` 块,合并转发展开成 ``<forward>`` 块,
309
+ * 图片既留占位文本又内联成附件,语音先转写成文字。
310
+ */
311
+ export async function renderMessage(
312
+ ctx: RenderContext,
313
+ event: MessageEvent,
314
+ ): Promise<RenderedMessage> {
315
+ const textBuffer: string[] = []
316
+ const files: PromptFile[] = []
317
+ const message = segments(event.message)
318
+ const budget = createImageBudget()
319
+ const forwardBudget = createForwardBudget()
320
+ const voiceCache = new Map<string, string>()
321
+ let imageIndex = 0
322
+
323
+ const replySegments = message.filter((segment) => segment.type === "reply")
324
+ if (replySegments.length > 0) {
325
+ const quoteLines = ["<quote>"]
326
+ for (const segment of replySegments) {
327
+ if (!segment.data.id) continue
328
+ try {
329
+ const data = (await ctx.api.getMessage(segment.data.id)) as
330
+ { sender?: { user_id?: number }; message?: MessageSegment[] } | undefined
331
+ if (!data) continue
332
+ const name = data.sender?.user_id
333
+ ? await resolveUserName(ctx, String(data.sender.user_id))
334
+ : "未知用户"
335
+ const quoted = await renderForwardSegments(
336
+ ctx,
337
+ data.message ?? [],
338
+ MAX_FORWARD_DEPTH,
339
+ budget,
340
+ forwardBudget,
341
+ )
342
+ quoteLines.push(`${name}:${quoted.text || "[空消息]"}`)
343
+ for (const image of quoted.images) {
344
+ imageIndex += 1
345
+ files.push({ ...image, name: `qq-quote-${imageIndex}.${image.ext}` })
346
+ }
347
+ } catch (error) {
348
+ ctx.logger.warn(`获取引用消息失败 id=${segment.data.id}`, error)
349
+ }
350
+ }
351
+ quoteLines.push("</quote>")
352
+ textBuffer.push(`${quoteLines.join("\n")}\n`)
353
+ }
354
+
355
+ for (const segment of message) {
356
+ try {
357
+ switch (segment.type) {
358
+ case "text":
359
+ textBuffer.push(segment.data.text)
360
+ break
361
+ case "at":
362
+ textBuffer.push(`@${await resolveUserName(ctx, String(segment.data.qq))} `)
363
+ break
364
+ case "image": {
365
+ textBuffer.push("[图片] ")
366
+ const url = segment.data.url || segment.data.file
367
+ if (!url || budget.images <= 0) break
368
+ const stored = await ctx.media.storeImage(url)
369
+ if (!stored || !takeImage(budget, stored)) break
370
+ imageIndex += 1
371
+ files.push({ ...stored, name: `qq-image-${imageIndex}.${stored.ext}` })
372
+ break
373
+ }
374
+ case "record": {
375
+ const rawId = (segment.data as { message_id?: number | string }).message_id
376
+ const messageId =
377
+ rawId === undefined
378
+ ? event.message_id
379
+ : Number.isFinite(Number(rawId))
380
+ ? Number(rawId)
381
+ : undefined
382
+ const key = String(messageId ?? "unknown")
383
+ let voiceText = voiceCache.get(key)
384
+ if (voiceText === undefined) {
385
+ voiceText = await fetchVoiceText(ctx, messageId)
386
+ voiceCache.set(key, voiceText)
387
+ }
388
+ textBuffer.push(voiceText ? `[语音] ${voiceText}` : "[语音]")
389
+ break
390
+ }
391
+ case "reply":
392
+ // 已在外层 quote 块处理过
393
+ break
394
+ case "forward": {
395
+ const forward = await renderForward(ctx, segment.data.id, 0, budget, forwardBudget)
396
+ const prefix =
397
+ textBuffer.length > 0 && !textBuffer[textBuffer.length - 1]?.endsWith("\n") ? "\n" : ""
398
+ textBuffer.push(`${prefix}${forward.text}\n`)
399
+ for (const image of forward.images) {
400
+ imageIndex += 1
401
+ files.push({
402
+ ...image,
403
+ name: `qq-forward-${imageIndex}.${image.ext}`,
404
+ })
405
+ }
406
+ break
407
+ }
408
+ case "video":
409
+ textBuffer.push("[视频]")
410
+ break
411
+ case "audio":
412
+ textBuffer.push("[音频]")
413
+ break
414
+ case "file":
415
+ textBuffer.push(`[文件]${segment.data.name || segment.data.file}`)
416
+ break
417
+ case "face":
418
+ textBuffer.push("[表情]")
419
+ break
420
+ case "json":
421
+ textBuffer.push("[卡片消息]")
422
+ break
423
+ case "xml":
424
+ textBuffer.push("[XML消息]")
425
+ break
426
+ case "markdown":
427
+ textBuffer.push("[Markdown消息]")
428
+ break
429
+ default:
430
+ ctx.logger.debug(`跳过未知消息段 type=${(segment as { type: string }).type}`)
431
+ break
432
+ }
433
+ } catch (error) {
434
+ ctx.logger.warn(`解析消息段失败 type=${segment.type}`, error)
435
+ textBuffer.push("[无法解析的消息]")
436
+ }
437
+ }
438
+
439
+ return { text: textBuffer.join(""), files }
440
+ }