@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/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
+ })
@@ -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
+ }