@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,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
|
+
}
|