@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.
@@ -0,0 +1,180 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { existsSync, statSync, watch, type FSWatcher } from "node:fs"
3
+ import { dirname, isAbsolute, join, resolve } from "node:path"
4
+ import type { Logger } from "../logger"
5
+ import { buildSystemPrompt } from "./prompt"
6
+
7
+ /** 角色设定目录名。 */
8
+ export const SOUL_DIR = "souls"
9
+
10
+ /** 默认角色设定文件名。 */
11
+ export const DEFAULT_SOUL_NAME = "default.txt"
12
+
13
+ /** 文件变化后的合并等待时间,避免编辑器连续写盘时重复读取。 */
14
+ const RELOAD_DEBOUNCE_MS = 150
15
+
16
+ export interface SoulOptions {
17
+ /** 显式指定的角色设定文件或目录,优先于 soulName。 */
18
+ soulPath?: string
19
+ /** souls 目录里的文件名。 */
20
+ soulName?: string
21
+ /** 插件所在目录,用于向上查找包内 souls 目录。 */
22
+ moduleDir: string
23
+ /** 相对路径的基准目录。 */
24
+ cwd: string
25
+ }
26
+
27
+ /** 从 startDir 向上逐级查找 souls/<name>。 */
28
+ export function findSoulFile(
29
+ startDir: string,
30
+ name: string = DEFAULT_SOUL_NAME,
31
+ maxDepth = 5,
32
+ ): string | undefined {
33
+ let current = resolve(startDir)
34
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
35
+ const candidate = join(current, SOUL_DIR, name)
36
+ if (existsSync(candidate)) return candidate
37
+ const parent = dirname(current)
38
+ if (parent === current) break
39
+ current = parent
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ /** 找不到文件时给出预期路径,文件补齐后可被监听器捡到。 */
45
+ export function expectedSoulPath(startDir: string, name: string, maxDepth = 5): string {
46
+ let current = resolve(startDir)
47
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
48
+ const directory = join(current, SOUL_DIR)
49
+ if (existsSync(directory)) return join(directory, name)
50
+ const parent = dirname(current)
51
+ if (parent === current) break
52
+ current = parent
53
+ }
54
+ return join(resolve(startDir), SOUL_DIR, name)
55
+ }
56
+
57
+ /** 定位角色设定文件:显式路径优先,其次 souls 目录里的指定文件。 */
58
+ export function resolveSoulPath(options: SoulOptions): string {
59
+ const name = options.soulName?.trim() || DEFAULT_SOUL_NAME
60
+ const explicit = options.soulPath?.trim()
61
+ if (!explicit) {
62
+ return findSoulFile(options.moduleDir, name) ?? expectedSoulPath(options.moduleDir, name)
63
+ }
64
+
65
+ const target = isAbsolute(explicit) ? explicit : resolve(options.cwd, explicit)
66
+ if (existsSync(target) && statSync(target).isDirectory()) return join(target, name)
67
+ return target
68
+ }
69
+
70
+ /** 读取角色设定,失败时返回空字符串。 */
71
+ export async function loadSoul(path: string | undefined): Promise<string> {
72
+ if (!path) return ""
73
+ try {
74
+ return await readFile(path, "utf-8")
75
+ } catch {
76
+ return ""
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 角色设定持有者:读取生效的设定文件,并在文件变化后热更新。
82
+ *
83
+ * 提示词在每轮组装时读取当前内容,所以文件改动会在下一轮立即生效。
84
+ */
85
+ export class SoulStore {
86
+ #path: string | undefined
87
+ #text = ""
88
+ #watcher: FSWatcher | undefined
89
+ #timer: ReturnType<typeof setTimeout> | undefined
90
+ #logger: Logger
91
+
92
+ constructor(
93
+ private readonly options: SoulOptions,
94
+ logger: Logger,
95
+ ) {
96
+ this.#logger = logger
97
+ }
98
+
99
+ /** 生效的角色设定文件路径。 */
100
+ get path(): string | undefined {
101
+ return this.#path
102
+ }
103
+
104
+ /** 当前角色设定文本。 */
105
+ get text(): string {
106
+ return this.#text
107
+ }
108
+
109
+ /** 角色设定与固定守则组装出的系统提示词。 */
110
+ get prompt(): string {
111
+ return buildSystemPrompt(this.#text)
112
+ }
113
+
114
+ /** 解析路径、读取内容并开始监听文件变化。 */
115
+ async load(): Promise<void> {
116
+ this.#path = resolveSoulPath(this.options)
117
+ this.#text = await loadSoul(this.#path)
118
+ if (!this.#text) this.#logger.warn(`角色设定读取失败:${this.#path}`)
119
+ else this.#logger.info(`角色设定已加载:${this.#path}`)
120
+ this.#watch()
121
+ }
122
+
123
+ /** 重新读取角色设定文件,内容有变化时返回 true。 */
124
+ async reload(): Promise<boolean> {
125
+ if (!this.#path) return false
126
+ const text = await loadSoul(this.#path)
127
+ if (text === this.#text) return false
128
+ this.#text = text
129
+ this.#logger.info(`角色设定已更新:${this.#path}`)
130
+ return true
131
+ }
132
+
133
+ dispose(): void {
134
+ if (this.#timer) {
135
+ clearTimeout(this.#timer)
136
+ this.#timer = undefined
137
+ }
138
+ this.#watcher?.close()
139
+ this.#watcher = undefined
140
+ }
141
+
142
+ /** 监听所在目录:编辑器多为替换文件,只盯目录才能捕捉到。 */
143
+ #watch(): void {
144
+ if (!this.#path) return
145
+ const directory = dirname(this.#path)
146
+ if (!existsSync(directory)) return
147
+ try {
148
+ this.#watcher = watch(directory, (_event, filename) => {
149
+ const name = filename ? String(filename) : undefined
150
+ if (name && !this.#matches(name, directory)) return
151
+ this.#scheduleReload()
152
+ })
153
+ } catch (error) {
154
+ this.#logger.warn("角色设定监听失败,改动后需要重载插件", error)
155
+ return
156
+ }
157
+ const handle = this.#watcher as { unref?: () => void }
158
+ handle.unref?.()
159
+ }
160
+
161
+ #matches(filename: string, directory: string): boolean {
162
+ if (!this.#path) return false
163
+ return resolve(directory, filename) === this.#path
164
+ }
165
+
166
+ #scheduleReload(): void {
167
+ if (this.#timer) clearTimeout(this.#timer)
168
+ this.#timer = setTimeout(() => {
169
+ this.#timer = undefined
170
+ void this.reload()
171
+ }, RELOAD_DEBOUNCE_MS)
172
+ const handle = this.#timer as { unref?: () => void }
173
+ handle.unref?.()
174
+ }
175
+ }
176
+
177
+ /** 供测试与其它模块定位插件包目录。 */
178
+ export function packageDirectory(): string {
179
+ return import.meta.dirname
180
+ }
@@ -0,0 +1,32 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import { targetLabel } from "../chat"
3
+ import type { Logger } from "../logger"
4
+ import type { SessionManager } from "../session/manager"
5
+
6
+ /** OpenCode 内置的“向用户提问”工具 id。 */
7
+ export const QUESTION_TOOL = "question"
8
+
9
+ /** 拒绝提问工具时回给模型的说明。 */
10
+ export const ASK_REFUSAL =
11
+ "不允许调用 question 工具:QQ 会话里请直接用 qq_send_message 询问用户(Code Mode 下调用方式为 tools.qq.send_message)。"
12
+
13
+ export interface AskGuardDeps {
14
+ ctx: Plugin.Context
15
+ logger: Logger
16
+ manager: SessionManager
17
+ }
18
+
19
+ /**
20
+ * 在 QQ 会话里拦截内置 question 工具,要求模型改用 qq_send_message 询问用户。
21
+ * 非 QQ 会话不受影响。
22
+ */
23
+ export async function registerAskGuard(deps: AskGuardDeps): Promise<{ dispose(): Promise<void> }> {
24
+ const { ctx, logger, manager } = deps
25
+ return await ctx.tool.hook("execute.before", async (event) => {
26
+ if (event.tool !== QUESTION_TOOL) return
27
+ const target = manager.targetForSession(event.sessionID)
28
+ if (!target) return
29
+ logger.warn(`${targetLabel(target)} 会话里模型尝试调用 ${QUESTION_TOOL} 工具,已拒绝`)
30
+ throw new Error(ASK_REFUSAL)
31
+ })
32
+ }
@@ -0,0 +1,421 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import { type ChatTarget, isTargetEnabled, targetLabel } from "../chat"
3
+ import type { QQBotConfig } from "../config"
4
+ import type { Logger } from "../logger"
5
+ import type { QQClient } from "../naplink/client"
6
+ import type { SessionManager } from "../session/manager"
7
+
8
+ /** 工具命名空间,最终工具名为 qq_send_message 等。 */
9
+ export const TOOL_NAMESPACE = "qq"
10
+
11
+ export interface ToolDeps {
12
+ ctx: Plugin.Context
13
+ config: QQBotConfig
14
+ logger: Logger
15
+ manager: SessionManager
16
+ client: QQClient
17
+ }
18
+
19
+ interface SendMessageInput {
20
+ content?: string
21
+ reply?: boolean
22
+ unlimit_length?: boolean
23
+ }
24
+
25
+ interface MediaInput {
26
+ file?: string
27
+ base64?: string
28
+ }
29
+
30
+ interface ForwardNodeInput {
31
+ user_id?: number | string
32
+ nickname?: string
33
+ content?: string
34
+ }
35
+
36
+ interface ForwardInput {
37
+ nodes?: ForwardNodeInput[]
38
+ }
39
+
40
+ interface UserInput {
41
+ user_id?: number | string
42
+ }
43
+
44
+ interface MessageIdInput {
45
+ message_id?: number | string
46
+ }
47
+
48
+ /** QQ 单条消息的保守长度上限,超过时按段发送。 */
49
+ export const MAX_MESSAGE_CHARS = 4000
50
+
51
+ function text(value: string) {
52
+ return { content: value }
53
+ }
54
+
55
+ function delay(ms: number): Promise<void> {
56
+ return new Promise((resolve) => setTimeout(resolve, ms))
57
+ }
58
+
59
+ /** 从 OneBot 发送结果里取出 message_id。 */
60
+ function messageIdOf(response: unknown): string | undefined {
61
+ if (!response || typeof response !== "object") return undefined
62
+ const record = response as { message_id?: unknown; data?: { message_id?: unknown } }
63
+ const value = record.message_id ?? record.data?.message_id
64
+ return value === undefined || value === null ? undefined : String(value)
65
+ }
66
+
67
+ /** 按长度切分消息,保留原有换行与空行。 */
68
+ export function splitMessage(content: string, max = MAX_MESSAGE_CHARS): string[] {
69
+ if (Array.from(content).length <= max) return [content]
70
+ const chunks: string[] = []
71
+ let current = ""
72
+ for (const line of content.split("\n")) {
73
+ const candidate = current ? `${current}\n${line}` : line
74
+ if (Array.from(candidate).length <= max) {
75
+ current = candidate
76
+ continue
77
+ }
78
+ if (current) chunks.push(current)
79
+ let rest = line
80
+ while (Array.from(rest).length > max) {
81
+ const part = Array.from(rest).slice(0, max).join("")
82
+ chunks.push(part)
83
+ rest = Array.from(rest).slice(max).join("")
84
+ }
85
+ current = rest
86
+ }
87
+ if (current) chunks.push(current)
88
+ return chunks.length > 0 ? chunks : [content]
89
+ }
90
+
91
+ /**
92
+ * 注册 QQ 命名空间下的工具。
93
+ *
94
+ * 全部工具都是对 NapCat OneBot API 的透传,
95
+ * 只暴露消息收发与只读查询,不提供封禁/踢人等破坏性操作。
96
+ */
97
+ export async function registerTools(deps: ToolDeps): Promise<{ dispose(): Promise<void> }> {
98
+ const { ctx, config, logger, manager, client } = deps
99
+
100
+ const requireTarget = (sessionID: string): ChatTarget | undefined => {
101
+ const target = manager.targetForSession(sessionID)
102
+ if (!target) return undefined
103
+ if (!isTargetEnabled(config, target)) {
104
+ logger.warn(`${targetLabel(target)} 已不在配置中,拒绝工具调用`)
105
+ return undefined
106
+ }
107
+ if (!manager.hasActiveTurn(sessionID)) {
108
+ logger.warn(`会话 ${sessionID} 当前没有 QQ 消息轮次,拒绝工具调用`)
109
+ return undefined
110
+ }
111
+ return target
112
+ }
113
+
114
+ const sendText = (target: ChatTarget, chunk: string) =>
115
+ target.kind === "group"
116
+ ? client.api.sendGroupMessage(target.id, chunk)
117
+ : client.api.sendPrivateMessage(target.id, chunk)
118
+
119
+ const sendSegments = (target: ChatTarget, segments: unknown[]) =>
120
+ target.kind === "group"
121
+ ? client.api.sendGroupMessage(target.id, segments)
122
+ : client.api.sendPrivateMessage(target.id, segments)
123
+
124
+ const sendForward = (target: ChatTarget, nodes: unknown[]) =>
125
+ target.kind === "group"
126
+ ? client.api.sendGroupForwardMessage(target.id, nodes)
127
+ : client.api.sendPrivateForwardMessage({ user_id: target.id, messages: nodes })
128
+
129
+ const sendPoke = (target: ChatTarget, userId: string | number) =>
130
+ target.kind === "group"
131
+ ? client.api.sendGroupPoke(target.id, userId)
132
+ : client.api.sendFriendPoke(userId)
133
+
134
+ return await ctx.tool.transform((editor) => {
135
+ editor.namespace({
136
+ name: TOOL_NAMESPACE,
137
+ description:
138
+ "QQ 聊天工具:发送消息、图片、语音、合并转发,以及查询用户信息。仅在 QQ 群聊或私聊会话里可用,其他会话调用会返回错误。",
139
+ })
140
+
141
+ editor.add({
142
+ name: "send_message",
143
+ description:
144
+ "向当前 QQ 群聊或私聊发送一条消息。每轮必须调用一个发送工具。content: 要发送的文本;reply: true 表示发送,false 表示选择不发送(已读不回);unlimit_length: 设为 true 可解除默认的长度告警限制。",
145
+ input: {
146
+ type: "object",
147
+ properties: {
148
+ content: { type: "string", description: "要发送的文本内容;reply=false 时可省略" },
149
+ reply: { type: "boolean", description: "true 表示发送,false 表示本轮不回复" },
150
+ unlimit_length: { type: "boolean", description: "解除默认的消息长度限制" },
151
+ },
152
+ additionalProperties: false,
153
+ },
154
+ options: { namespace: TOOL_NAMESPACE },
155
+ execute: async (raw, context) => {
156
+ const input = (raw ?? {}) as SendMessageInput
157
+ const target = requireTarget(context.sessionID)
158
+ if (!target) return text("error: 当前会话没有绑定 QQ 群聊或私聊")
159
+ const label = targetLabel(target)
160
+
161
+ if (input.reply === false) {
162
+ logger.info(`${label} 模型选择不回复`)
163
+ manager.markMessageSent(context.sessionID)
164
+ return text("skipped")
165
+ }
166
+ const content = (input.content ?? "").trim()
167
+ if (!content) {
168
+ logger.info(`${label} 回复内容为空`)
169
+ manager.markMessageSent(context.sessionID)
170
+ return text("skipped")
171
+ }
172
+
173
+ if (content.length > config.warnLength && input.unlimit_length !== true) {
174
+ logger.warn(`send_message 内容长度 ${content.length} 超过 ${config.warnLength}`)
175
+ }
176
+
177
+ const chunks = splitMessage(content)
178
+ let sent = 0
179
+ try {
180
+ for (const [index, chunk] of chunks.entries()) {
181
+ const response = await sendText(target, chunk)
182
+ const messageId = messageIdOf(response)
183
+ if (messageId) manager.rememberOutgoing(target, messageId)
184
+ sent += 1
185
+ if (index < chunks.length - 1 && config.sendDelayMs > 0) {
186
+ await delay(config.sendDelayMs)
187
+ }
188
+ }
189
+ } catch (error) {
190
+ if (sent > 0) manager.markMessageSent(context.sessionID)
191
+ logger.error(`${label} 发送消息失败,已发送 ${sent}/${chunks.length} 段`, error)
192
+ return text(`error: 消息只发送了 ${sent}/${chunks.length} 段`)
193
+ }
194
+ manager.markMessageSent(context.sessionID)
195
+ logger.info(`${label} 已发送 ${content.length} 字`)
196
+ return text("sent")
197
+ },
198
+ })
199
+
200
+ editor.add({
201
+ name: "send_image",
202
+ description:
203
+ "向当前 QQ 群聊或私聊发送一张图片。file 可以是本地路径或 http(s) 图片地址;base64 传 base64:// 形式的数据。",
204
+ input: {
205
+ type: "object",
206
+ properties: {
207
+ file: { type: "string", description: "本地文件路径或图片 URL" },
208
+ base64: { type: "string", description: "base64 编码的图片数据" },
209
+ },
210
+ additionalProperties: false,
211
+ },
212
+ options: { namespace: TOOL_NAMESPACE },
213
+ execute: async (raw, context) => {
214
+ const input = (raw ?? {}) as MediaInput
215
+ const target = requireTarget(context.sessionID)
216
+ if (!target) return text("error: 当前会话没有绑定 QQ 群聊或私聊")
217
+ const file = input.file ?? (input.base64 ? `base64://${input.base64}` : undefined)
218
+ if (!file) return text("error: 缺少 file 或 base64")
219
+ const response = await sendSegments(target, [{ type: "image", data: { file } }])
220
+ const messageId = messageIdOf(response)
221
+ if (messageId) manager.rememberOutgoing(target, messageId)
222
+ manager.markMessageSent(context.sessionID)
223
+ return text("sent")
224
+ },
225
+ })
226
+
227
+ editor.add({
228
+ name: "send_record",
229
+ description: "向当前 QQ 群聊或私聊发送一条语音。file 可以是本地路径或 http(s) 音频地址。",
230
+ input: {
231
+ type: "object",
232
+ properties: {
233
+ file: { type: "string", description: "本地文件路径或音频 URL" },
234
+ },
235
+ additionalProperties: false,
236
+ },
237
+ options: { namespace: TOOL_NAMESPACE },
238
+ execute: async (raw, context) => {
239
+ const input = (raw ?? {}) as MediaInput
240
+ const target = requireTarget(context.sessionID)
241
+ if (!target) return text("error: 当前会话没有绑定 QQ 群聊或私聊")
242
+ if (!input.file) return text("error: 缺少 file")
243
+ const response = await sendSegments(target, [
244
+ { type: "record", data: { file: input.file } },
245
+ ])
246
+ const messageId = messageIdOf(response)
247
+ if (messageId) manager.rememberOutgoing(target, messageId)
248
+ manager.markMessageSent(context.sessionID)
249
+ return text("sent")
250
+ },
251
+ })
252
+
253
+ editor.add({
254
+ name: "send_forward",
255
+ description:
256
+ "向当前 QQ 群聊或私聊发送一条合并转发消息。nodes 为节点列表,每个节点包含 user_id、nickname 和纯文本 content。",
257
+ input: {
258
+ type: "object",
259
+ properties: {
260
+ nodes: {
261
+ type: "array",
262
+ description: "合并转发的节点列表",
263
+ items: {
264
+ type: "object",
265
+ properties: {
266
+ user_id: { type: "string", description: "发送者 QQ 号" },
267
+ nickname: { type: "string", description: "发送者昵称" },
268
+ content: { type: "string", description: "节点文本内容" },
269
+ },
270
+ required: ["content"],
271
+ additionalProperties: false,
272
+ },
273
+ },
274
+ },
275
+ required: ["nodes"],
276
+ additionalProperties: false,
277
+ },
278
+ options: { namespace: TOOL_NAMESPACE },
279
+ execute: async (raw, context) => {
280
+ const input = (raw ?? {}) as ForwardInput
281
+ const target = requireTarget(context.sessionID)
282
+ if (!target) return text("error: 当前会话没有绑定 QQ 群聊或私聊")
283
+ const nodes: {
284
+ user_id: number
285
+ nickname: string
286
+ content: { type: string; data: { text: string } }[]
287
+ }[] = []
288
+ for (const node of input.nodes ?? []) {
289
+ const userId = node.user_id === undefined ? 0 : Number(node.user_id)
290
+ if (!Number.isInteger(userId) || userId < 0) {
291
+ return text(`error: 非法 user_id:${String(node.user_id)}`)
292
+ }
293
+ nodes.push({
294
+ user_id: userId,
295
+ nickname: node.nickname?.trim() || "未知用户",
296
+ content: [{ type: "text", data: { text: node.content ?? "" } }],
297
+ })
298
+ }
299
+ if (nodes.length === 0) return text("error: nodes 为空")
300
+ const response = await sendForward(target, nodes)
301
+ const messageId = messageIdOf(response)
302
+ if (messageId) manager.rememberOutgoing(target, messageId)
303
+ manager.markMessageSent(context.sessionID)
304
+ return text("sent")
305
+ },
306
+ })
307
+
308
+ editor.add({
309
+ name: "send_poke",
310
+ description: "在当前 QQ 会话戳一下某个用户。",
311
+ input: {
312
+ type: "object",
313
+ properties: {
314
+ user_id: { type: "string", description: "被戳的 QQ 号" },
315
+ },
316
+ required: ["user_id"],
317
+ additionalProperties: false,
318
+ },
319
+ options: { namespace: TOOL_NAMESPACE },
320
+ execute: async (raw, context) => {
321
+ const input = (raw ?? {}) as UserInput
322
+ const target = requireTarget(context.sessionID)
323
+ if (!target) return text("error: 当前会话没有绑定 QQ 群聊或私聊")
324
+ if (!input.user_id) return text("error: 缺少 user_id")
325
+ await sendPoke(target, input.user_id)
326
+ manager.markMessageSent(context.sessionID)
327
+ return text("sent")
328
+ },
329
+ })
330
+
331
+ editor.add({
332
+ name: "delete_message",
333
+ description: "撤回一条机器人自己发过的消息。",
334
+ input: {
335
+ type: "object",
336
+ properties: {
337
+ message_id: { type: "string", description: "要撤回的消息 ID" },
338
+ },
339
+ required: ["message_id"],
340
+ additionalProperties: false,
341
+ },
342
+ options: { namespace: TOOL_NAMESPACE },
343
+ execute: async (raw, context) => {
344
+ const input = (raw ?? {}) as MessageIdInput
345
+ const target = requireTarget(context.sessionID)
346
+ if (!target) return text("error: 当前会话没有绑定 QQ 群聊或私聊")
347
+ if (input.message_id === undefined) return text("error: 缺少 message_id")
348
+ if (!manager.isOutgoing(target, String(input.message_id))) {
349
+ return text("error: 只能撤回机器人自己发送过的消息")
350
+ }
351
+ await client.api.deleteMessage(input.message_id)
352
+ return text("deleted")
353
+ },
354
+ })
355
+
356
+ editor.add({
357
+ name: "get_group_member_info",
358
+ description: "查询用户资料:群聊返回群名片与昵称,私聊返回 QQ 昵称。",
359
+ input: {
360
+ type: "object",
361
+ properties: {
362
+ user_id: { type: "string", description: "QQ 号" },
363
+ },
364
+ required: ["user_id"],
365
+ additionalProperties: false,
366
+ },
367
+ options: { namespace: TOOL_NAMESPACE },
368
+ execute: async (raw, context) => {
369
+ const input = (raw ?? {}) as UserInput
370
+ const target = requireTarget(context.sessionID)
371
+ if (!target) return text("error: 当前会话没有绑定 QQ 群聊或私聊")
372
+ if (!input.user_id) return text("error: 缺少 user_id")
373
+ const info =
374
+ target.kind === "group"
375
+ ? await client.api.getGroupMemberInfo(target.id, input.user_id)
376
+ : await client.api.getStrangerInfo(input.user_id)
377
+ return text(JSON.stringify(info))
378
+ },
379
+ })
380
+
381
+ editor.add({
382
+ name: "fetch_ptt_text",
383
+ description: "把一条语音消息转写成文字。",
384
+ input: {
385
+ type: "object",
386
+ properties: {
387
+ message_id: { type: "string", description: "语音消息的 ID" },
388
+ },
389
+ required: ["message_id"],
390
+ additionalProperties: false,
391
+ },
392
+ options: { namespace: TOOL_NAMESPACE },
393
+ execute: async (raw, context) => {
394
+ const input = (raw ?? {}) as MessageIdInput
395
+ const target = requireTarget(context.sessionID)
396
+ if (!target) return text("error: 当前会话没有绑定 QQ 群聊或私聊")
397
+ if (input.message_id === undefined) return text("error: 缺少 message_id")
398
+ if (!manager.isKnownMessage(target, String(input.message_id))) {
399
+ return text("error: 只能转写当前会话最近收到的消息")
400
+ }
401
+ const result = await client.api.fetchPttText(input.message_id)
402
+ return text(result?.text ?? "")
403
+ },
404
+ })
405
+
406
+ editor.add({
407
+ name: "get_login_info",
408
+ description: "获取机器人自身的 QQ 号与昵称。",
409
+ input: {
410
+ type: "object",
411
+ properties: {},
412
+ additionalProperties: false,
413
+ },
414
+ options: { namespace: TOOL_NAMESPACE },
415
+ execute: async () => {
416
+ const info = await client.api.getLoginInfo()
417
+ return text(JSON.stringify(info))
418
+ },
419
+ })
420
+ })
421
+ }