@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.
- package/LICENSE +443 -0
- package/README.md +178 -2
- package/SOUL.txt +8 -0
- package/package.json +47 -4
- package/src/chat.ts +44 -0
- package/src/config.ts +444 -0
- package/src/index.ts +245 -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 +85 -0
- package/src/tools/qq.ts +421 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
import type { GroupMessageEvent, MessageEvent } from "@naplink/naplink"
|
|
3
|
+
import { type ChatTarget, configuredTargets, isTargetEnabled, targetKey, targetLabel } from "./chat"
|
|
4
|
+
import { resolveConfig, type QQBotConfig } from "./config"
|
|
5
|
+
import { claimInstance } from "./instance"
|
|
6
|
+
import { createLogger } from "./logger"
|
|
7
|
+
import { QQClient } from "./naplink/client"
|
|
8
|
+
import { createMediaStore } from "./naplink/media"
|
|
9
|
+
import { mentionsUser, renderMessage, resolveUserName, shouldProcess } from "./naplink/render"
|
|
10
|
+
import { SessionManager } from "./session/manager"
|
|
11
|
+
import {
|
|
12
|
+
buildSystemPrompt,
|
|
13
|
+
currentTimeText,
|
|
14
|
+
loadSoul,
|
|
15
|
+
PROACTIVE_HINT,
|
|
16
|
+
resolveSoulPath,
|
|
17
|
+
packageDirectory,
|
|
18
|
+
} from "./session/prompt"
|
|
19
|
+
import { proactiveTickProbability } from "./session/proactive"
|
|
20
|
+
import { registerTools } from "./tools/qq"
|
|
21
|
+
|
|
22
|
+
const RETRY_DELAY_MS = 1000
|
|
23
|
+
|
|
24
|
+
function delay(ms: number): Promise<void> {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default Plugin.define({
|
|
29
|
+
id: "opencode-qq-bot",
|
|
30
|
+
async setup(ctx) {
|
|
31
|
+
const logger = createLogger("[qq-bot]")
|
|
32
|
+
let config: QQBotConfig
|
|
33
|
+
try {
|
|
34
|
+
config = resolveConfig(ctx.options, process.env, (message) => logger.warn(message))
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logger.error(error instanceof Error ? error.message : String(error))
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (config.groups.length === 0 && config.users.length === 0) {
|
|
41
|
+
logger.warn(
|
|
42
|
+
"未配置目标群号或私聊 QQ 号,插件不会响应任何消息(请设置 groups/users 或 QQ_TARGET_GROUPS/QQ_TARGET_USERS)",
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// OpenCode 重新加载插件时未必调用旧的清理函数,接管同一连接的单例并主动卸载旧实例。
|
|
47
|
+
const { previous, lease } = claimInstance(`${ctx.location.directory}\n${config.napcat.url}`)
|
|
48
|
+
if (previous?.dispose) {
|
|
49
|
+
try {
|
|
50
|
+
await previous.dispose()
|
|
51
|
+
} catch (error) {
|
|
52
|
+
logger.error("卸载旧插件实例失败", error)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const soulPath = resolveSoulPath(config.soulPath, packageDirectory(), process.cwd())
|
|
57
|
+
const soul = await loadSoul(soulPath)
|
|
58
|
+
if (!soul) logger.warn(`角色设定读取失败:${soulPath ?? "(未找到)"}`)
|
|
59
|
+
const systemPrompt = buildSystemPrompt(soul)
|
|
60
|
+
|
|
61
|
+
const client = new QQClient(config.napcat, logger)
|
|
62
|
+
const manager = new SessionManager(ctx, config, logger)
|
|
63
|
+
await manager.load()
|
|
64
|
+
const media = await createMediaStore(logger)
|
|
65
|
+
const mediaCleanup = setInterval(
|
|
66
|
+
() => {
|
|
67
|
+
void media.cleanup()
|
|
68
|
+
},
|
|
69
|
+
60 * 60 * 1000,
|
|
70
|
+
)
|
|
71
|
+
const mediaHandle = mediaCleanup as { unref?: () => void }
|
|
72
|
+
mediaHandle.unref?.()
|
|
73
|
+
const toolRegistration = await registerTools({ ctx, config, logger, manager, client })
|
|
74
|
+
const nameCache = new Map<string, { name: string; expires: number }>()
|
|
75
|
+
|
|
76
|
+
// 系统提示:角色设定、当前时间、本轮是否必须回复。
|
|
77
|
+
const contextRegistration = await ctx.session.hook("context", (event) => {
|
|
78
|
+
const target = manager.targetForSession(event.sessionID)
|
|
79
|
+
if (target === undefined) return
|
|
80
|
+
event.system.push({ type: "text", text: systemPrompt })
|
|
81
|
+
event.system.push({ type: "text", text: `当前时间:${currentTimeText()}` })
|
|
82
|
+
const turn = manager.currentTurn(event.sessionID)
|
|
83
|
+
const destination = target.kind === "group" ? "群里" : "私聊里"
|
|
84
|
+
event.system.push({
|
|
85
|
+
type: "text",
|
|
86
|
+
text: turn?.proactive
|
|
87
|
+
? `这是一次主动发起话题,必须调用 qq_send_message 把话题发到${destination}。`
|
|
88
|
+
: turn?.atMe
|
|
89
|
+
? target.kind === "private"
|
|
90
|
+
? "这是一条私聊消息,必须回复。"
|
|
91
|
+
: "你被 @ 了,必须回复。"
|
|
92
|
+
: "你没有被 @,可以选择不回复。",
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// 会话空闲:冲刷缓存消息,或提醒模型补一次发送。
|
|
97
|
+
const idleRegistration = await ctx.session.hook("experimental.idle", async (event) => {
|
|
98
|
+
await manager.handleIdle(event.sessionID, event.background)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// 真正空闲(含后台任务结束)后,把忙碌期间缓存的消息合并成新的一轮。
|
|
102
|
+
// 失败或中断不会触发 idle 钩子,也要在这里释放状态;订阅断掉后自动重连。
|
|
103
|
+
const idleAbort = new AbortController()
|
|
104
|
+
void (async () => {
|
|
105
|
+
while (!idleAbort.signal.aborted) {
|
|
106
|
+
try {
|
|
107
|
+
for await (const event of ctx.event.subscribe({
|
|
108
|
+
types: ["session.idle", "session.execution.failed", "session.execution.interrupted"],
|
|
109
|
+
signal: idleAbort.signal,
|
|
110
|
+
})) {
|
|
111
|
+
if (event.type === "session.idle") {
|
|
112
|
+
await manager.flushBuffered(event.data.sessionID)
|
|
113
|
+
} else {
|
|
114
|
+
await manager.handleExecutionEnd(event.data.sessionID, event.type)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (idleAbort.signal.aborted) break
|
|
119
|
+
logger.error("订阅会话事件失败,稍后重试", error)
|
|
120
|
+
}
|
|
121
|
+
if (!idleAbort.signal.aborted) await delay(RETRY_DELAY_MS)
|
|
122
|
+
}
|
|
123
|
+
})()
|
|
124
|
+
|
|
125
|
+
const isGroupEvent = (event: MessageEvent): event is GroupMessageEvent =>
|
|
126
|
+
event.message_type === "group"
|
|
127
|
+
|
|
128
|
+
const eventTarget = (event: MessageEvent): ChatTarget =>
|
|
129
|
+
isGroupEvent(event)
|
|
130
|
+
? { kind: "group", id: event.group_id }
|
|
131
|
+
: { kind: "private", id: event.user_id }
|
|
132
|
+
|
|
133
|
+
const handleMessage = async (event: MessageEvent, target: ChatTarget, selfId: string) => {
|
|
134
|
+
try {
|
|
135
|
+
if (!isTargetEnabled(config, target)) return
|
|
136
|
+
if (!shouldProcess(event.message, selfId)) return
|
|
137
|
+
|
|
138
|
+
await client.hydrate(event.message)
|
|
139
|
+
const renderCtx = {
|
|
140
|
+
api: client.api,
|
|
141
|
+
target,
|
|
142
|
+
logger,
|
|
143
|
+
media,
|
|
144
|
+
names: nameCache,
|
|
145
|
+
}
|
|
146
|
+
const rendered = await renderMessage(renderCtx, event)
|
|
147
|
+
if (!rendered.text.trim()) return
|
|
148
|
+
|
|
149
|
+
const userName = await resolveUserName(renderCtx, String(event.user_id))
|
|
150
|
+
// 私聊消息默认都是对机器人说的,群聊只有被 @ 时才必须回复。
|
|
151
|
+
const atMe = target.kind === "private" || mentionsUser(event.message, selfId)
|
|
152
|
+
|
|
153
|
+
await manager.enqueue(target, {
|
|
154
|
+
text: `${userName}: ${rendered.text}`,
|
|
155
|
+
files: rendered.files,
|
|
156
|
+
atMe,
|
|
157
|
+
})
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger.error(`处理 ${targetLabel(target)} 消息失败`, error)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 同一会话内串行处理,避免图片下载、成员查询等异步操作打乱消息顺序。
|
|
164
|
+
const processing = new Map<string, Promise<void>>()
|
|
165
|
+
const scheduleMessage = (event: MessageEvent) => {
|
|
166
|
+
const target = eventTarget(event)
|
|
167
|
+
if (!isTargetEnabled(config, target)) return
|
|
168
|
+
const selfId = String(event.self_id ?? client.selfId ?? "")
|
|
169
|
+
if (selfId && String(event.user_id) === selfId) return
|
|
170
|
+
|
|
171
|
+
manager.touch(target)
|
|
172
|
+
manager.rememberIncoming(target, String(event.message_id))
|
|
173
|
+
|
|
174
|
+
const key = targetKey(target)
|
|
175
|
+
const previous = processing.get(key) ?? Promise.resolve()
|
|
176
|
+
const task = previous
|
|
177
|
+
.then(() => handleMessage(event, target, selfId))
|
|
178
|
+
.catch((error: unknown) => logger.error(`处理 ${targetLabel(target)} 消息失败`, error))
|
|
179
|
+
processing.set(key, task)
|
|
180
|
+
void task.finally(() => {
|
|
181
|
+
if (processing.get(key) === task) processing.delete(key)
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 先注册监听再连接:NapCat 连接期间推送的消息也会被收到。
|
|
186
|
+
client.onGroupMessage(scheduleMessage)
|
|
187
|
+
client.onPrivateMessage(scheduleMessage)
|
|
188
|
+
|
|
189
|
+
await client.connectSafe()
|
|
190
|
+
|
|
191
|
+
// 主动说话心跳。
|
|
192
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
193
|
+
let ticking = false
|
|
194
|
+
if (config.proactive.enabled) {
|
|
195
|
+
const tick = async () => {
|
|
196
|
+
if (ticking) return
|
|
197
|
+
ticking = true
|
|
198
|
+
try {
|
|
199
|
+
await manager.recoverStale()
|
|
200
|
+
for (const target of configuredTargets(config)) {
|
|
201
|
+
const probability = proactiveTickProbability(
|
|
202
|
+
config.proactive,
|
|
203
|
+
manager.silenceSeconds(target),
|
|
204
|
+
)
|
|
205
|
+
if (probability <= 0 || Math.random() >= probability) continue
|
|
206
|
+
logger.info(`${targetLabel(target)} 沉默达到阈值,主动发起话题`)
|
|
207
|
+
await manager.proactive(target, PROACTIVE_HINT)
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
ticking = false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
timer = setInterval(() => {
|
|
214
|
+
void tick()
|
|
215
|
+
}, config.proactive.tickSeconds * 1000)
|
|
216
|
+
const handle = timer as { unref?: () => void }
|
|
217
|
+
handle.unref?.()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
logger.info("插件已加载", {
|
|
221
|
+
groups: config.groups,
|
|
222
|
+
users: config.users,
|
|
223
|
+
napcat: config.napcat.url,
|
|
224
|
+
agent: config.agent ?? "(默认)",
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
let disposed = false
|
|
228
|
+
const dispose = async () => {
|
|
229
|
+
if (disposed) return
|
|
230
|
+
disposed = true
|
|
231
|
+
if (timer) clearInterval(timer)
|
|
232
|
+
clearInterval(mediaCleanup)
|
|
233
|
+
idleAbort.abort()
|
|
234
|
+
await manager.flush()
|
|
235
|
+
await toolRegistration.dispose()
|
|
236
|
+
await contextRegistration.dispose()
|
|
237
|
+
await idleRegistration.dispose()
|
|
238
|
+
client.dispose()
|
|
239
|
+
lease.release()
|
|
240
|
+
logger.info("插件已卸载")
|
|
241
|
+
}
|
|
242
|
+
lease.setDispose(dispose)
|
|
243
|
+
return dispose
|
|
244
|
+
},
|
|
245
|
+
})
|
package/src/instance.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-reload singleton registry.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode may load a plugin again without invoking the previous instance's
|
|
5
|
+
* cleanup, which would leave duplicate WebSocket connections and duplicate
|
|
6
|
+
* message handlers behind. The registry lives on globalThis so it survives
|
|
7
|
+
* module reloads within the same server process.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PluginInstance {
|
|
11
|
+
dispose?: () => Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InstanceLease {
|
|
15
|
+
/** Registers the cleanup used when a newer instance takes over or the plugin unloads. */
|
|
16
|
+
setDispose(dispose: () => Promise<void>): void
|
|
17
|
+
/** Clears the slot only when this lease still owns it. */
|
|
18
|
+
release(): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Host {
|
|
22
|
+
active?: { key: string; instance: PluginInstance }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HOST_KEY = "__opencodeQQBotHost__"
|
|
26
|
+
|
|
27
|
+
function host(): Host {
|
|
28
|
+
const global = globalThis as Record<string, unknown>
|
|
29
|
+
const existing = global[HOST_KEY]
|
|
30
|
+
if (existing && typeof existing === "object") return existing as Host
|
|
31
|
+
const created: Host = {}
|
|
32
|
+
global[HOST_KEY] = created
|
|
33
|
+
return created
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Claims the singleton slot for `key` and returns any previous instance with
|
|
38
|
+
* the same key so the caller can dispose it. Instances with different keys
|
|
39
|
+
* (for example another project) are left untouched.
|
|
40
|
+
*/
|
|
41
|
+
export function claimInstance(key: string): {
|
|
42
|
+
previous: PluginInstance | undefined
|
|
43
|
+
lease: InstanceLease
|
|
44
|
+
} {
|
|
45
|
+
const state = host()
|
|
46
|
+
const previous = state.active?.key === key ? state.active.instance : undefined
|
|
47
|
+
const instance: PluginInstance = {}
|
|
48
|
+
state.active = { key, instance }
|
|
49
|
+
return {
|
|
50
|
+
previous,
|
|
51
|
+
lease: {
|
|
52
|
+
setDispose(dispose) {
|
|
53
|
+
instance.dispose = dispose
|
|
54
|
+
},
|
|
55
|
+
release() {
|
|
56
|
+
if (state.active?.instance === instance) state.active = undefined
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error"
|
|
2
|
+
|
|
3
|
+
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
|
4
|
+
|
|
5
|
+
export interface Logger {
|
|
6
|
+
debug(message: string, ...meta: unknown[]): void
|
|
7
|
+
info(message: string, ...meta: unknown[]): void
|
|
8
|
+
warn(message: string, ...meta: unknown[]): void
|
|
9
|
+
error(message: string, ...meta: unknown[]): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 创建带前缀的日志器,插件运行在 OpenCode 服务端,直接写 stdout/stderr。 */
|
|
13
|
+
export function createLogger(prefix: string, level: LogLevel = "info"): Logger {
|
|
14
|
+
const threshold = LEVEL_ORDER[level]
|
|
15
|
+
const write = (lvl: LogLevel, stream: "log" | "error", message: string, meta: unknown[]) => {
|
|
16
|
+
if (LEVEL_ORDER[lvl] < threshold) return
|
|
17
|
+
const stamp = new Date().toISOString()
|
|
18
|
+
const line = `[${stamp}] ${lvl.toUpperCase()} ${prefix} ${message}`
|
|
19
|
+
const details = meta.filter((entry) => entry !== undefined)
|
|
20
|
+
if (details.length > 0) console[stream](line, ...details)
|
|
21
|
+
else console[stream](line)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
debug: (message, ...meta) => write("debug", "log", message, meta),
|
|
26
|
+
info: (message, ...meta) => write("info", "log", message, meta),
|
|
27
|
+
warn: (message, ...meta) => write("warn", "log", message, meta),
|
|
28
|
+
error: (message, ...meta) => write("error", "error", message, meta),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NapLink,
|
|
3
|
+
type GroupMessageEvent,
|
|
4
|
+
type MessageSegment,
|
|
5
|
+
type OneBotApi,
|
|
6
|
+
type PrivateMessageEvent,
|
|
7
|
+
} from "@naplink/naplink"
|
|
8
|
+
import type { Logger } from "../logger"
|
|
9
|
+
import type { NapcatConfig } from "../config"
|
|
10
|
+
|
|
11
|
+
/** NapCat 的连接与 API 门面,屏蔽 SDK 细节。 */
|
|
12
|
+
export class QQClient {
|
|
13
|
+
private readonly link: NapLink
|
|
14
|
+
private botId: string | undefined
|
|
15
|
+
private botIdRequest: Promise<void> | undefined
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly config: NapcatConfig,
|
|
19
|
+
private readonly logger: Logger,
|
|
20
|
+
) {
|
|
21
|
+
this.link = new NapLink({
|
|
22
|
+
connection: {
|
|
23
|
+
url: config.url,
|
|
24
|
+
...(config.token ? { token: config.token } : {}),
|
|
25
|
+
timeout: 15_000,
|
|
26
|
+
},
|
|
27
|
+
reconnect: {
|
|
28
|
+
enabled: true,
|
|
29
|
+
maxAttempts: Number.POSITIVE_INFINITY,
|
|
30
|
+
backoff: { initial: 1000, max: 30_000, multiplier: 2 },
|
|
31
|
+
},
|
|
32
|
+
logging: { level: "info", logger },
|
|
33
|
+
})
|
|
34
|
+
// 每次(重)连成功都刷新机器人 QQ 号,自动重连后 selfId 依然可用。
|
|
35
|
+
this.link.on("connect", () => {
|
|
36
|
+
void this.refreshBotId().then(() => {
|
|
37
|
+
this.logger.info("NapCat 已连接", this.config.url, this.botId ? `bot=${this.botId}` : "")
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get api(): OneBotApi {
|
|
43
|
+
return this.link.api
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get connected(): boolean {
|
|
47
|
+
return this.link.isConnected()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async connect(): Promise<void> {
|
|
51
|
+
await this.link.connect()
|
|
52
|
+
await this.refreshBotId()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 读取机器人自己的 QQ 号,并发调用时复用同一个请求。 */
|
|
56
|
+
private refreshBotId(): Promise<void> {
|
|
57
|
+
this.botIdRequest ??= this.loadBotId().finally(() => {
|
|
58
|
+
this.botIdRequest = undefined
|
|
59
|
+
})
|
|
60
|
+
return this.botIdRequest
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async loadBotId(): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
const info = (await this.api.getLoginInfo()) as { user_id?: number | string } | undefined
|
|
66
|
+
this.botId = info?.user_id !== undefined ? String(info.user_id) : undefined
|
|
67
|
+
} catch (error) {
|
|
68
|
+
this.logger.warn("读取机器人 QQ 号失败", error)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 连接 NapCat,失败时只记录日志。
|
|
74
|
+
*
|
|
75
|
+
* 后续重连由 NapLink 自己负责,插件加载不应该因为 NapCat 暂时离线而失败。
|
|
76
|
+
*/
|
|
77
|
+
async connectSafe(): Promise<boolean> {
|
|
78
|
+
try {
|
|
79
|
+
await this.connect()
|
|
80
|
+
return true
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.logger.error("连接 NapCat 失败", error)
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dispose(): void {
|
|
88
|
+
try {
|
|
89
|
+
this.link.dispose()
|
|
90
|
+
} catch (error) {
|
|
91
|
+
this.logger.warn("关闭 NapCat 连接失败", error)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 订阅群消息事件。 */
|
|
96
|
+
onGroupMessage(handler: (event: GroupMessageEvent) => void): void {
|
|
97
|
+
this.link.on("message.group.normal", handler)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 订阅私聊消息事件(好友、临时会话等)。 */
|
|
101
|
+
onPrivateMessage(handler: (event: PrivateMessageEvent) => void): void {
|
|
102
|
+
this.link.on("message.private", handler)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 补全消息里媒体段的直链,便于下载图片。 */
|
|
106
|
+
async hydrate(message: MessageSegment[]): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
await this.link.hydrateMessage(message)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
this.logger.warn("补全媒体直链失败", error)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 机器人自己的 QQ 号,未连接时返回 undefined。 */
|
|
115
|
+
get selfId(): string | undefined {
|
|
116
|
+
return this.botId
|
|
117
|
+
}
|
|
118
|
+
}
|