@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,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
|
+
}
|
package/src/tools/qq.ts
ADDED
|
@@ -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
|
+
}
|