@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.
@@ -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
+ }