@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,628 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import { type ChatTarget, isTargetEnabled, parseTargetKey, targetKey, targetLabel } from "../chat"
3
+ import type { QQBotConfig } from "../config"
4
+ import type { Logger } from "../logger"
5
+ import { limitFiles, type PromptFile } from "../naplink/render"
6
+
7
+ /** 插件存储里保存会话与目标绑定的键。 */
8
+ const BINDINGS_KEY = "bindings"
9
+ /** 插件存储里保存目标最近活跃时间的键。 */
10
+ const ACTIVITY_KEY = "activity"
11
+ /** 单个目标最多缓存的消息条数。 */
12
+ export const MAX_BUFFER_TURNS = 100
13
+ /** 单个目标最多缓存的文本与附件字节数。 */
14
+ export const MAX_BUFFER_BYTES = 32 * 1024 * 1024
15
+ /** 每个目标最多记录的消息 ID 数量。 */
16
+ const MAX_TRACKED_MESSAGES = 200
17
+
18
+ export interface Turn {
19
+ /** 交给模型的文本。 */
20
+ text: string
21
+ /** 图片附件。 */
22
+ files: PromptFile[]
23
+ /** 本轮是否 @ 了机器人。 */
24
+ atMe: boolean
25
+ /** 是否由主动说话心跳发起。 */
26
+ proactive?: boolean
27
+ }
28
+
29
+ interface ChatState {
30
+ target: ChatTarget
31
+ sessionID?: string
32
+ /** 会话是否正在跑模型(收到消息到 idle 之间)。 */
33
+ busy: boolean
34
+ /** 进入忙碌状态的时间戳(毫秒),用于识别卡死的会话。 */
35
+ busySince: number
36
+ /** 忙碌超时看门狗。 */
37
+ busyTimer?: ReturnType<typeof setTimeout>
38
+ /** 忙碌期间攒下的消息,空闲后合并成一次 prompt。 */
39
+ buffer: Turn[]
40
+ /** 当前轮次的消息,供系统提示注入 @ 提示。 */
41
+ current?: Turn
42
+ /** 当前轮次模型是否还没调用发送工具。 */
43
+ awaitingSend: boolean
44
+ /** 连续提醒次数,防止死循环。 */
45
+ reminders: number
46
+ /** 最近一次真实用户消息的时间戳(毫秒)。 */
47
+ lastUserMessageAt: number
48
+ }
49
+
50
+ type SessionListCursor = NonNullable<Parameters<Plugin.Context["session"]["list"]>[0]>["cursor"]
51
+
52
+ export function mergeTurns(turns: readonly Turn[]): Turn {
53
+ return {
54
+ text: turns
55
+ .map((turn) => turn.text.trim())
56
+ .filter((text) => text.length > 0)
57
+ .join("\n"),
58
+ files: limitFiles(turns.flatMap((turn) => turn.files)),
59
+ atMe: turns.some((turn) => turn.atMe),
60
+ ...(turns.some((turn) => turn.proactive) ? { proactive: true } : {}),
61
+ }
62
+ }
63
+
64
+ function turnBytes(turn: Turn): number {
65
+ return (
66
+ turn.text.length * 2 +
67
+ turn.files.reduce((total, file) => total + (file.bytes ?? file.uri.length), 0)
68
+ )
69
+ }
70
+
71
+ /**
72
+ * 管理 QQ 群聊/私聊与 OpenCode 会话的映射。
73
+ *
74
+ * 一个目标固定对应一个会话,一个会话只能绑定一个目标;
75
+ * 会话忙碌时新到的消息先缓存,等会话真正空闲之后再合并成新的一轮 prompt 发进去。
76
+ */
77
+ export class SessionManager {
78
+ private readonly states = new Map<string, ChatState>()
79
+ private readonly sessions = new Map<string, ChatState>()
80
+ private readonly ensureRequests = new Map<string, Promise<string | undefined>>()
81
+ private readonly verifiedSessions = new Set<string>()
82
+ private readonly outgoing = new Map<string, Set<string>>()
83
+ private readonly incoming = new Map<string, Set<string>>()
84
+ private activity: Record<string, number> = {}
85
+ private bindingsLoaded: Promise<void> | undefined
86
+ private saveQueue: Promise<void> = Promise.resolve()
87
+
88
+ constructor(
89
+ private readonly ctx: Plugin.Context,
90
+ private readonly config: QQBotConfig,
91
+ private readonly logger: Logger,
92
+ ) {}
93
+
94
+ /** 载入上次运行时保存的目标与会话绑定。 */
95
+ async load(): Promise<void> {
96
+ await this.ensureBindings()
97
+ }
98
+
99
+ private ensureBindings(): Promise<void> {
100
+ this.bindingsLoaded ??= this.readBindings()
101
+ return this.bindingsLoaded
102
+ }
103
+
104
+ /** 从插件存储恢复绑定;读取失败只告警,不影响正常使用。 */
105
+ private async readBindings(): Promise<void> {
106
+ let stored: unknown
107
+ try {
108
+ stored = await this.ctx.storage.get(BINDINGS_KEY)
109
+ } catch (error) {
110
+ this.logger.warn("读取会话绑定失败", error)
111
+ }
112
+ if (stored && typeof stored === "object" && !Array.isArray(stored)) {
113
+ let restored = 0
114
+ for (const [key, value] of Object.entries(stored)) {
115
+ const target = parseTargetKey(key)
116
+ if (!target || typeof value !== "string" || value.length === 0) continue
117
+ // 配置里已经移除的目标不再恢复,避免旧绑定继续获得工具权限。
118
+ if (!isTargetEnabled(this.config, target)) continue
119
+ if (!this.bindSession(this.state(target), value)) continue
120
+ restored += 1
121
+ }
122
+ if (restored > 0) this.logger.debug(`已恢复 ${restored} 个目标的会话绑定`)
123
+ }
124
+
125
+ try {
126
+ const activity = await this.ctx.storage.get(ACTIVITY_KEY)
127
+ if (!activity || typeof activity !== "object" || Array.isArray(activity)) return
128
+ for (const [key, value] of Object.entries(activity)) {
129
+ const target = parseTargetKey(key)
130
+ if (!target || typeof value !== "number" || !Number.isFinite(value)) continue
131
+ if (!isTargetEnabled(this.config, target)) continue
132
+ this.activity[targetKey(target)] = value
133
+ const state = this.states.get(targetKey(target))
134
+ if (state) state.lastUserMessageAt = value
135
+ }
136
+ } catch (error) {
137
+ this.logger.warn("读取目标活跃时间失败", error)
138
+ }
139
+ }
140
+
141
+ /** 绑定或活跃时间变化后写回插件存储,串行执行避免互相覆盖。 */
142
+ private scheduleSave(): void {
143
+ this.saveQueue = this.saveQueue.then(() => this.persist())
144
+ }
145
+
146
+ private async persist(): Promise<void> {
147
+ await this.writeBindings()
148
+ await this.writeActivity()
149
+ }
150
+
151
+ private async writeBindings(): Promise<void> {
152
+ const bindings: Record<string, string> = {}
153
+ for (const state of this.states.values()) {
154
+ if (state.sessionID) bindings[targetKey(state.target)] = state.sessionID
155
+ }
156
+ try {
157
+ await this.ctx.storage.set(BINDINGS_KEY, bindings)
158
+ } catch (error) {
159
+ this.logger.error("保存会话绑定失败", error)
160
+ }
161
+ }
162
+
163
+ private async writeActivity(): Promise<void> {
164
+ try {
165
+ await this.ctx.storage.set(ACTIVITY_KEY, { ...this.activity })
166
+ } catch (error) {
167
+ this.logger.error("保存目标活跃时间失败", error)
168
+ }
169
+ }
170
+
171
+ /** 等待所有待写入的绑定落盘。 */
172
+ async flush(): Promise<void> {
173
+ await this.saveQueue
174
+ await this.persist()
175
+ }
176
+
177
+ private state(target: ChatTarget): ChatState {
178
+ const key = targetKey(target)
179
+ let state = this.states.get(key)
180
+ if (!state) {
181
+ state = {
182
+ target,
183
+ busy: false,
184
+ busySince: 0,
185
+ buffer: [],
186
+ awaitingSend: false,
187
+ reminders: 0,
188
+ lastUserMessageAt: this.activity[key] ?? Date.now(),
189
+ }
190
+ this.states.set(key, state)
191
+ }
192
+ return state
193
+ }
194
+
195
+ private beginBusy(state: ChatState): void {
196
+ state.busy = true
197
+ state.busySince = Date.now()
198
+ this.armBusyTimer(state)
199
+ }
200
+
201
+ private armBusyTimer(state: ChatState): void {
202
+ this.clearBusyTimer(state)
203
+ state.busyTimer = setTimeout(() => {
204
+ void this.recoverTarget(state.target)
205
+ }, this.config.busyTimeoutMs)
206
+ const timer = state.busyTimer as { unref?: () => void }
207
+ timer.unref?.()
208
+ }
209
+
210
+ private clearBusyTimer(state: ChatState): void {
211
+ if (state.busyTimer === undefined) return
212
+ clearTimeout(state.busyTimer)
213
+ state.busyTimer = undefined
214
+ }
215
+
216
+ private endBusy(state: ChatState): void {
217
+ state.busy = false
218
+ state.busySince = 0
219
+ this.clearBusyTimer(state)
220
+ }
221
+
222
+ /** 会话 ID 反查目标。 */
223
+ targetForSession(sessionID: string): ChatTarget | undefined {
224
+ return this.sessions.get(sessionID)?.target
225
+ }
226
+
227
+ /** 当前轮次的消息。 */
228
+ currentTurn(sessionID: string): Turn | undefined {
229
+ return this.sessions.get(sessionID)?.current
230
+ }
231
+
232
+ /** 当前是否有 QQ 消息轮次正在执行,用于阻止手动 prompt 调用 QQ 工具。 */
233
+ hasActiveTurn(sessionID: string): boolean {
234
+ return this.sessions.get(sessionID)?.current !== undefined
235
+ }
236
+
237
+ /** 标记模型已经调用过发送工具。 */
238
+ markMessageSent(sessionID: string): void {
239
+ const state = this.sessions.get(sessionID)
240
+ if (!state) return
241
+ state.awaitingSend = false
242
+ state.reminders = 0
243
+ }
244
+
245
+ /** 记录一条机器人发出的消息,撤回工具只能操作这些消息。 */
246
+ rememberOutgoing(target: ChatTarget, messageId: string): void {
247
+ const key = targetKey(target)
248
+ const ids = this.outgoing.get(key) ?? new Set<string>()
249
+ ids.add(messageId)
250
+ while (ids.size > MAX_TRACKED_MESSAGES) {
251
+ const oldest = ids.values().next().value
252
+ if (oldest === undefined) break
253
+ ids.delete(oldest)
254
+ }
255
+ this.outgoing.set(key, ids)
256
+ }
257
+
258
+ /** 判断消息是否是机器人发出的。 */
259
+ isOutgoing(target: ChatTarget, messageId: string): boolean {
260
+ return this.outgoing.get(targetKey(target))?.has(messageId) ?? false
261
+ }
262
+
263
+ /** 记录一条收到的消息,语音转写工具只能操作这些消息。 */
264
+ rememberIncoming(target: ChatTarget, messageId: string): void {
265
+ const key = targetKey(target)
266
+ const ids = this.incoming.get(key) ?? new Set<string>()
267
+ ids.add(messageId)
268
+ while (ids.size > MAX_TRACKED_MESSAGES) {
269
+ const oldest = ids.values().next().value
270
+ if (oldest === undefined) break
271
+ ids.delete(oldest)
272
+ }
273
+ this.incoming.set(key, ids)
274
+ }
275
+
276
+ /** 判断消息是否属于当前目标最近收到的消息。 */
277
+ isKnownMessage(target: ChatTarget, messageId: string): boolean {
278
+ return (
279
+ this.isOutgoing(target, messageId) ||
280
+ (this.incoming.get(targetKey(target))?.has(messageId) ?? false)
281
+ )
282
+ }
283
+
284
+ /** 记录一次真实用户消息的时间。 */
285
+ touch(target: ChatTarget): void {
286
+ const state = this.state(target)
287
+ state.lastUserMessageAt = Date.now()
288
+ this.activity[targetKey(target)] = state.lastUserMessageAt
289
+ this.scheduleSave()
290
+ }
291
+
292
+ /** 距上次用户消息的沉默时长(秒)。 */
293
+ silenceSeconds(target: ChatTarget): number {
294
+ return (Date.now() - this.state(target).lastUserMessageAt) / 1000
295
+ }
296
+
297
+ /**
298
+ * 收到一条消息:空闲则直接投进会话,忙则先缓存。
299
+ */
300
+ async enqueue(target: ChatTarget, turn: Turn): Promise<void> {
301
+ const state = this.state(target)
302
+ if (state.busy) {
303
+ this.pushBuffered(state, turn)
304
+ return
305
+ }
306
+ await this.dispatch(state, turn)
307
+ }
308
+
309
+ private pushBuffered(state: ChatState, turn: Turn): void {
310
+ state.buffer.push(turn)
311
+ let bytes = state.buffer.reduce((total, item) => total + turnBytes(item), 0)
312
+ while (
313
+ state.buffer.length > 1 &&
314
+ (state.buffer.length > MAX_BUFFER_TURNS || bytes > MAX_BUFFER_BYTES)
315
+ ) {
316
+ const dropped = state.buffer.shift()
317
+ if (!dropped) break
318
+ bytes -= turnBytes(dropped)
319
+ this.logger.warn(`${targetLabel(state.target)} 缓存已满,丢弃最早的一条消息`)
320
+ }
321
+ if (state.buffer.length === 1 && bytes > MAX_BUFFER_BYTES) {
322
+ this.logger.warn(`${targetLabel(state.target)} 单条消息超过缓存上限,仍保留该消息`)
323
+ }
324
+ this.logger.debug(
325
+ `${targetLabel(state.target)} 忙碌中,消息已缓存(累计 ${state.buffer.length} 条)`,
326
+ )
327
+ }
328
+
329
+ /** 把一轮消息投进 OpenCode 会话。 */
330
+ private async dispatch(state: ChatState, turn: Turn): Promise<void> {
331
+ if (state.busy) return
332
+ // 先同步占位,避免并发消息重复投递
333
+ this.beginBusy(state)
334
+ state.current = turn
335
+ state.awaitingSend = true
336
+ // 新的用户轮次重新给一份提醒预算,否则连续几轮不调工具就再也提醒不了。
337
+ state.reminders = 0
338
+ const label = targetLabel(state.target)
339
+ try {
340
+ const sessionID = await this.ensureSession(state.target)
341
+ if (!sessionID) {
342
+ this.endBusy(state)
343
+ state.current = undefined
344
+ state.awaitingSend = false
345
+ return
346
+ }
347
+ await this.ctx.session.prompt({
348
+ sessionID,
349
+ text: turn.text,
350
+ ...(turn.files.length > 0 ? { files: turn.files } : {}),
351
+ })
352
+ this.logger.debug(`${label} 已投递消息到会话 ${sessionID}`)
353
+ } catch (error) {
354
+ this.endBusy(state)
355
+ state.current = undefined
356
+ state.awaitingSend = false
357
+ this.logger.error(`${label} 投递消息失败`, error)
358
+ const buffered = state.buffer
359
+ state.buffer = []
360
+ if (buffered.length > 0) {
361
+ await this.dispatch(state, mergeTurns(buffered))
362
+ }
363
+ }
364
+ }
365
+
366
+ /**
367
+ * 会话即将空闲:若有缓存消息,保持忙碌等 session.idle 后再投递;
368
+ * 否则清掉忙碌标记,若模型这一轮没调用发送工具则提醒它补一次。
369
+ *
370
+ * `background` 只统计本会话自己的后台任务;子会话遗留的任务不会唤醒本会话,
371
+ * 不能因此让目标一直保持忙碌。
372
+ */
373
+ async handleIdle(
374
+ sessionID: string,
375
+ background: ReadonlyArray<{ sessionID: string }> = [],
376
+ ): Promise<void> {
377
+ const state = this.sessions.get(sessionID)
378
+ if (!state) return
379
+ const label = targetLabel(state.target)
380
+
381
+ if (background.some((task) => task.sessionID === sessionID)) {
382
+ this.logger.debug(`${label} 仍有后台任务,保持忙碌状态`)
383
+ return
384
+ }
385
+
386
+ if (state.buffer.length > 0) {
387
+ // 在 idle 钩子里投递会被并入旧的一轮;保持忙碌,等真正空闲后再开始新一轮。
388
+ this.logger.debug(`${label} 空闲,等待投递 ${state.buffer.length} 条缓存消息`)
389
+ return
390
+ }
391
+
392
+ this.endBusy(state)
393
+ state.current = undefined
394
+
395
+ if (
396
+ state.awaitingSend &&
397
+ this.config.reminder.enabled &&
398
+ state.reminders < this.config.reminder.max
399
+ ) {
400
+ state.reminders += 1
401
+ // 合成消息会唤醒会话再跑一轮,先占位避免新消息插队。
402
+ this.beginBusy(state)
403
+ state.current = { text: "[提醒]", files: [], atMe: false }
404
+ this.logger.warn(`${label} 模型未调用发送工具,提醒第 ${state.reminders} 次`)
405
+ try {
406
+ await this.ctx.session.synthetic({
407
+ sessionID,
408
+ text: `[提醒] 你这一轮没有调用任何发送工具,直接输出的文字会被丢弃。请调用 qq_send_message 工具:想回复就 reply=true 并给出 content,不想回复就 reply=false;发送图片、语音、转发或戳一戳也视为已回复。`,
409
+ })
410
+ } catch (error) {
411
+ this.endBusy(state)
412
+ state.awaitingSend = false
413
+ this.logger.error(`${label} 发送提醒失败`, error)
414
+ }
415
+ }
416
+ }
417
+
418
+ /**
419
+ * 模型执行失败或被中断:idle 钩子不会触发,必须主动释放忙碌状态并处理缓存。
420
+ */
421
+ async handleExecutionEnd(sessionID: string, reason: string): Promise<void> {
422
+ const state = this.sessions.get(sessionID)
423
+ if (!state) return
424
+ this.logger.warn(`${targetLabel(state.target)} 会话执行结束(${reason}),释放忙碌状态`)
425
+ this.endBusy(state)
426
+ state.current = undefined
427
+ state.awaitingSend = false
428
+ if (state.buffer.length > 0) {
429
+ await this.flushBuffered(sessionID)
430
+ }
431
+ }
432
+
433
+ /** 超时看门狗:会话长时间没有 idle 时强制放行缓存。 */
434
+ async recoverTarget(target: ChatTarget): Promise<void> {
435
+ const state = this.states.get(targetKey(target))
436
+ if (!state?.busy) return
437
+ this.logger.warn(`${targetLabel(target)} 会话超时未结束,强制放行缓存消息`)
438
+ const buffered = state.buffer
439
+ state.buffer = []
440
+ this.endBusy(state)
441
+ state.current = undefined
442
+ state.awaitingSend = false
443
+ if (buffered.length > 0) {
444
+ await this.dispatch(state, mergeTurns(buffered))
445
+ }
446
+ }
447
+
448
+ /** 检查所有目标是否有超过阈值的忙碌会话。 */
449
+ async recoverStale(): Promise<void> {
450
+ for (const state of this.states.values()) {
451
+ if (!state.busy) continue
452
+ if (Date.now() - state.busySince <= this.config.busyTimeoutMs) continue
453
+ await this.recoverTarget(state.target)
454
+ }
455
+ }
456
+
457
+ /** 会话真正空闲后,把忙碌期间缓存的消息合并成新的一轮投递。 */
458
+ async flushBuffered(sessionID: string): Promise<void> {
459
+ const state = this.sessions.get(sessionID)
460
+ if (!state) return
461
+ if (state.buffer.length === 0) {
462
+ // 控制类工作(例如手动压缩)不会触发 idle 钩子,但仍会发 session.idle。
463
+ // 这里兜底释放可能残留的忙碌状态。
464
+ this.endBusy(state)
465
+ state.current = undefined
466
+ return
467
+ }
468
+
469
+ const turns = state.buffer
470
+ state.buffer = []
471
+ this.endBusy(state)
472
+ state.current = undefined
473
+ this.logger.debug(`${targetLabel(state.target)} 空闲,合并 ${turns.length} 条缓存消息`)
474
+ await this.dispatch(state, mergeTurns(turns))
475
+ }
476
+
477
+ /** 取得(必要时创建)目标对应的会话;并发调用复用同一个请求。 */
478
+ ensureSession(target: ChatTarget): Promise<string | undefined> {
479
+ const key = targetKey(target)
480
+ const pending = this.ensureRequests.get(key)
481
+ if (pending) return pending
482
+ const request = this.resolveSession(target).finally(() => {
483
+ if (this.ensureRequests.get(key) === request) this.ensureRequests.delete(key)
484
+ })
485
+ this.ensureRequests.set(key, request)
486
+ return request
487
+ }
488
+
489
+ private async resolveSession(target: ChatTarget): Promise<string | undefined> {
490
+ await this.ensureBindings()
491
+ const state = this.state(target)
492
+ const label = targetLabel(target)
493
+ if (state.sessionID) {
494
+ if (await this.sessionExists(state.sessionID)) return state.sessionID
495
+ this.unbind(state)
496
+ }
497
+
498
+ if (this.config.reuseSession) {
499
+ const reused = await this.findExistingSession(target)
500
+ // 会话已经绑给别的目标时不能复用,改为新建一个会话。
501
+ if (reused && this.bindSession(state, reused)) {
502
+ this.scheduleSave()
503
+ this.logger.info(`${label} 复用已有会话 ${reused}`)
504
+ return reused
505
+ }
506
+ }
507
+
508
+ const created = await this.ctx.session.create({
509
+ title: target.kind === "group" ? `QQ 群 ${target.id}` : `QQ 私聊 ${target.id}`,
510
+ ...(this.config.agent ? { agent: this.config.agent } : {}),
511
+ ...(this.config.model ? { model: this.config.model } : {}),
512
+ metadata: {
513
+ qqTargetKind: target.kind,
514
+ qqTargetId: target.id,
515
+ },
516
+ })
517
+ if (!this.bindSession(state, created.id)) {
518
+ this.logger.error(`${label} 新建会话 ${created.id} 已被其他目标占用`)
519
+ await this.ctx.session.remove({ sessionID: created.id }).catch((error: unknown) => {
520
+ this.logger.warn(`清理冲突会话 ${created.id} 失败`, error)
521
+ })
522
+ return undefined
523
+ }
524
+ this.scheduleSave()
525
+ this.logger.info(`${label} 新建会话 ${created.id}`)
526
+ return created.id
527
+ }
528
+
529
+ /** 确认恢复的会话仍然存在,避免陈旧绑定让每条消息都失败。 */
530
+ private async sessionExists(sessionID: string): Promise<boolean> {
531
+ if (this.verifiedSessions.has(sessionID)) return true
532
+ try {
533
+ await this.ctx.session.get({ sessionID })
534
+ this.verifiedSessions.add(sessionID)
535
+ return true
536
+ } catch (error) {
537
+ this.logger.warn(`会话 ${sessionID} 已不存在,将重新建立绑定`, error)
538
+ return false
539
+ }
540
+ }
541
+
542
+ private unbind(state: ChatState): void {
543
+ if (!state.sessionID) return
544
+ this.sessions.delete(state.sessionID)
545
+ this.verifiedSessions.delete(state.sessionID)
546
+ state.sessionID = undefined
547
+ this.scheduleSave()
548
+ }
549
+
550
+ /** 绑定会话到目标;一个会话只能绑定一个目标,已有绑定不能被覆盖。 */
551
+ private bindSession(state: ChatState, sessionID: string): boolean {
552
+ if (state.sessionID && state.sessionID !== sessionID) {
553
+ this.logger.error(
554
+ `${targetLabel(state.target)} 已绑定会话 ${state.sessionID},拒绝覆盖为 ${sessionID}`,
555
+ )
556
+ return false
557
+ }
558
+ const owner = this.sessions.get(sessionID)
559
+ if (owner && targetKey(owner.target) !== targetKey(state.target)) {
560
+ this.logger.error(
561
+ `会话 ${sessionID} 已绑定 ${targetLabel(owner.target)},不能再次绑定 ${targetLabel(state.target)}`,
562
+ )
563
+ return false
564
+ }
565
+ state.sessionID = sessionID
566
+ this.sessions.set(sessionID, state)
567
+ return true
568
+ }
569
+
570
+ /** 查找带相同目标标记的历史会话,按最近优先分页查找。 */
571
+ private async findExistingSession(target: ChatTarget): Promise<string | undefined> {
572
+ let cursor: SessionListCursor | undefined
573
+ try {
574
+ for (let page = 0; page < 5; page += 1) {
575
+ const result = await this.ctx.session.list({
576
+ directory: this.ctx.location.directory,
577
+ limit: 100,
578
+ order: "desc",
579
+ ...(cursor ? { cursor } : {}),
580
+ })
581
+ const hit = result.data.find((session) => {
582
+ const metadata = session.metadata
583
+ return metadata?.["qqTargetKind"] === target.kind && metadata["qqTargetId"] === target.id
584
+ })
585
+ if (hit) return hit.id
586
+ cursor = result.cursor.next ?? undefined
587
+ if (!cursor) break
588
+ }
589
+ } catch (error) {
590
+ this.logger.warn(`查找历史会话失败 ${targetLabel(target)}`, error)
591
+ }
592
+ return undefined
593
+ }
594
+
595
+ /** 该目标是否已经建立过会话。 */
596
+ hasSession(target: ChatTarget): boolean {
597
+ return this.states.get(targetKey(target))?.sessionID !== undefined
598
+ }
599
+
600
+ /** 主动说话:向目标对应的会话投递一条合成消息。 */
601
+ async proactive(target: ChatTarget, text: string): Promise<void> {
602
+ const state = this.state(target)
603
+ if (state.busy) return
604
+ // 在 await 之前占位,避免用户消息或下一次心跳同时投递。
605
+ this.beginBusy(state)
606
+ state.current = { text, files: [], atMe: true, proactive: true }
607
+ state.awaitingSend = true
608
+ state.reminders = 0
609
+ try {
610
+ const sessionID = await this.ensureSession(target)
611
+ if (!sessionID) {
612
+ this.endBusy(state)
613
+ state.current = undefined
614
+ state.awaitingSend = false
615
+ return
616
+ }
617
+ state.lastUserMessageAt = Date.now()
618
+ this.activity[targetKey(target)] = state.lastUserMessageAt
619
+ this.scheduleSave()
620
+ await this.ctx.session.synthetic({ sessionID, text })
621
+ } catch (error) {
622
+ this.endBusy(state)
623
+ state.current = undefined
624
+ state.awaitingSend = false
625
+ this.logger.error(`${targetLabel(target)} 主动说话失败`, error)
626
+ }
627
+ }
628
+ }
@@ -0,0 +1,22 @@
1
+ import type { ProactiveConfig } from "../config"
2
+
3
+ /**
4
+ * 把“每小时目标概率”换算成单次心跳的触发概率。
5
+ *
6
+ * 直接在每个 tick 上抽 `startProbability + probabilityPerHour * hours` 会让实际频率
7
+ * 随 tickSeconds 放大;这里按 tick 占一小时的比例做指数换算。
8
+ */
9
+ export function proactiveTickProbability(
10
+ config: Pick<
11
+ ProactiveConfig,
12
+ "startProbability" | "probabilityPerHour" | "silenceThresholdSeconds" | "tickSeconds"
13
+ >,
14
+ silenceSeconds: number,
15
+ ): number {
16
+ const silenceHours = (silenceSeconds - config.silenceThresholdSeconds) / 3600
17
+ if (silenceHours <= 0) return 0
18
+ const target = Math.min(1, config.startProbability + config.probabilityPerHour * silenceHours)
19
+ if (target >= 1) return 1
20
+ const fraction = Math.min(1, config.tickSeconds / 3600)
21
+ return 1 - Math.pow(1 - target, fraction)
22
+ }
@@ -0,0 +1,41 @@
1
+ /** 发消息用的工具名(命名空间 qq 下的 send_message)。 */
2
+ export const SEND_MESSAGE_TOOL = "qq_send_message"
3
+
4
+ const GUIDELINES = `
5
+ GUIDELINES:
6
+ 你在 QQ 群聊或私聊里,你收到的消息不一定是发给你的,你需要根据上下文和语境来判断是否回复。私聊消息必须回复;群聊里只有被 @ 时才必须回复。
7
+ 对于一条消息,<quote>...</quote> 里面的内容是引用的消息,通常是你之前发过的消息或者其他人的消息,你需要根据这些引用来判断当前消息的语境。<forward>...</forward> 里面是合并转发消息的内容,每条前面的名字是转发节点的发送者。user_name : message 是用户发给你的消息, 不同的user_name代表不同的用户,你需要根据这些消息来判断每个用户的意图和情感。
8
+ 重要!!!发送消息时,**必须**调用发送类工具;任何直接输出的文字都会被**完全丢弃**,不会作为消息内容发送!!!
9
+ 文本回复用 ${SEND_MESSAGE_TOOL};图片、语音、合并转发、戳一戳等用对应的 qq_* 工具,调用成功同样算作已回复。
10
+ 每轮至少调用一个发送类工具;不想回复时把 ${SEND_MESSAGE_TOOL} 的 reply 设成 false(content 可留空),相当于已读不回。群聊里不需要每一条都回复,像人一样选择性回复就行。
11
+ 不要输出markdown、代码块、表格、列表等格式化内容,直接输出纯文本即可。
12
+ `
13
+
14
+ /** 主动发起话题时追加的模式说明。 */
15
+ export const PROACTIVE_HINT = `
16
+ [模式] 主动发起话题。不一定与记忆相关,也不一定与当前聊天的最新消息相关。
17
+ 可以是一个问题、一个建议、一个有趣的想法、一个冷知识、一个笑话等。
18
+ 注意,不要再重复之前的话题,也不要重复之前的消息内容。
19
+ 你需要调用 ${SEND_MESSAGE_TOOL} 工具把话题发到当前聊天。
20
+ `
21
+
22
+ /** 组装完整的系统提示词。 */
23
+ export function buildSystemPrompt(soul: string): string {
24
+ const base = soul.trim()
25
+ return base ? `${base}\n${GUIDELINES}` : GUIDELINES.trim()
26
+ }
27
+
28
+ const TIME_FORMAT = new Intl.DateTimeFormat("sv-SE", {
29
+ year: "numeric",
30
+ month: "2-digit",
31
+ day: "2-digit",
32
+ hour: "2-digit",
33
+ minute: "2-digit",
34
+ second: "2-digit",
35
+ hour12: false,
36
+ })
37
+
38
+ /** 当前时间文本,注入到系统提示里让模型知道现在几点。 */
39
+ export function currentTimeText(now = new Date()): string {
40
+ return TIME_FORMAT.format(now)
41
+ }