@cmach_socket/opencode-qq-bot 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -67,7 +67,6 @@ bun install
67
67
  | `sendDelayMs` | `QQ_SEND_DELAY_MS` | `1000` | 超长消息分段发送时每段之间的间隔 |
68
68
  | `warnLength` | `QQ_WARN_LENGTH` | `20` | 一轮回复总长度超过该值时告警 |
69
69
  | `reuseSession` | `QQ_REUSE_SESSION` | `false` | 没有保存的绑定时,按会话标记找回上一次的会话 |
70
- | `busyTimeoutMs` | `QQ_BUSY_TIMEOUT_MS` | `300000` | 会话超过该时长未结束视为卡死,强制放行缓存消息 |
71
70
  | `proactive.enabled` | `QQ_PROACTIVE_ENABLED` | `true` | 是否启用主动说话 |
72
71
  | `proactive.silenceThresholdSeconds` | `QQ_PROACTIVE_SILENCE_SECONDS` | `10800` | 沉默多久后才开始计算主动说话概率 |
73
72
  | `proactive.startProbability` | `QQ_PROACTIVE_P_START` | `0.01` | 刚跨过阈值时的概率 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmach_socket/opencode-qq-bot",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "OpenCode V2 plugin bridging a QQ group chat (NapCat via NapLink) to an OpenCode session.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -35,6 +35,7 @@
35
35
  "@naplink/naplink": "1.2.0",
36
36
  "@opencode/plugin": "2.2.5",
37
37
  "@opencode/schema": "2.2.5",
38
+ "async-mutex": "^0.5.0",
38
39
  "zod": "4.6.7"
39
40
  },
40
41
  "devDependencies": {
package/src/config.ts CHANGED
@@ -66,8 +66,6 @@ export interface QQBotConfig {
66
66
  warnLength: number
67
67
  /** 重启后是否复用上一次的会话。 */
68
68
  reuseSession: boolean
69
- /** 会话忙碌超过该时长(毫秒)视为卡死,强制放行缓存的消息。 */
70
- busyTimeoutMs: number
71
69
  proactive: ProactiveConfig
72
70
  reminder: ReminderConfig
73
71
  }
@@ -79,7 +77,6 @@ export const DEFAULT_CONFIG: Omit<QQBotConfig, "napcat"> = {
79
77
  sendDelayMs: 1000,
80
78
  warnLength: 20,
81
79
  reuseSession: false,
82
- busyTimeoutMs: 300_000,
83
80
  proactive: {
84
81
  enabled: true,
85
82
  silenceThresholdSeconds: 3 * 3600,
@@ -287,7 +284,6 @@ function createConfigSchema(
287
284
  sendDelayMs: nonNegative("sendDelayMs", defaults.sendDelayMs),
288
285
  warnLength: integer("warnLength", defaults.warnLength, 1),
289
286
  reuseSession: boolean("reuseSession", defaults.reuseSession),
290
- busyTimeoutMs: positive("busyTimeoutMs", defaults.busyTimeoutMs),
291
287
  proactive: z
292
288
  .object({
293
289
  enabled: boolean("proactive.enabled", defaults.proactive.enabled),
@@ -329,7 +325,6 @@ const ENV_VARS = {
329
325
  sendDelayMs: "QQ_SEND_DELAY_MS",
330
326
  warnLength: "QQ_WARN_LENGTH",
331
327
  reuseSession: "QQ_REUSE_SESSION",
332
- busyTimeoutMs: "QQ_BUSY_TIMEOUT_MS",
333
328
  "proactive.enabled": "QQ_PROACTIVE_ENABLED",
334
329
  "proactive.silenceThresholdSeconds": "QQ_PROACTIVE_SILENCE_SECONDS",
335
330
  "proactive.startProbability": "QQ_PROACTIVE_P_START",
@@ -350,7 +345,6 @@ const OPTION_KEYS = new Set([
350
345
  "sendDelayMs",
351
346
  "warnLength",
352
347
  "reuseSession",
353
- "busyTimeoutMs",
354
348
  "proactive",
355
349
  "reminder",
356
350
  ])
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
2
  import type { GroupMessageEvent, MessageEvent } from "@naplink/naplink"
3
+ import { Mutex } from "async-mutex"
3
4
  import { type ChatTarget, configuredTargets, isTargetEnabled, targetKey, targetLabel } from "./chat"
4
5
  import { resolveConfig, type QQBotConfig } from "./config"
5
6
  import { claimInstance } from "./instance"
@@ -102,17 +103,28 @@ export default Plugin.define({
102
103
  await manager.handleIdle(event.sessionID, event.background)
103
104
  })
104
105
 
105
- // 真正空闲(含后台任务结束)后,把忙碌期间缓存的消息合并成新的一轮。
106
- // 失败或中断不会触发 idle 钩子,也要在这里释放状态;订阅断掉后自动重连。
106
+ // 会话空闲后,把队列里缓存的消息合并成新的一轮。
107
+ // `session.status` 是官方推荐的状态信号;2.2.5 实际只在执行结束时发
108
+ // `session.execution.succeeded`,所以两者都要监听。失败或中断单独结束轮次。
109
+ // 订阅断掉后自动重连。
107
110
  const idleAbort = new AbortController()
108
111
  void (async () => {
109
112
  while (!idleAbort.signal.aborted) {
110
113
  try {
111
114
  for await (const event of ctx.event.subscribe({
112
- types: ["session.idle", "session.execution.failed", "session.execution.interrupted"],
115
+ types: [
116
+ "session.status",
117
+ "session.execution.succeeded",
118
+ "session.execution.failed",
119
+ "session.execution.interrupted",
120
+ ],
113
121
  signal: idleAbort.signal,
114
122
  })) {
115
- if (event.type === "session.idle") {
123
+ if (event.type === "session.status") {
124
+ if (event.data.status.type === "idle") {
125
+ await manager.flushBuffered(event.data.sessionID)
126
+ }
127
+ } else if (event.type === "session.execution.succeeded") {
116
128
  await manager.flushBuffered(event.data.sessionID)
117
129
  } else {
118
130
  await manager.handleExecutionEnd(event.data.sessionID, event.type)
@@ -165,25 +177,30 @@ export default Plugin.define({
165
177
  }
166
178
 
167
179
  // 同一会话内串行处理,避免图片下载、成员查询等异步操作打乱消息顺序。
168
- const processing = new Map<string, Promise<void>>()
180
+ const processing = new Map<string, Mutex>()
181
+ const processingMutex = (key: string): Mutex => {
182
+ let mutex = processing.get(key)
183
+ if (!mutex) {
184
+ mutex = new Mutex()
185
+ processing.set(key, mutex)
186
+ }
187
+ return mutex
188
+ }
169
189
  const scheduleMessage = (event: MessageEvent) => {
170
190
  const target = eventTarget(event)
171
191
  if (!isTargetEnabled(config, target)) return
172
192
  const selfId = String(event.self_id ?? client.selfId ?? "")
173
193
  if (selfId && String(event.user_id) === selfId) return
174
194
 
195
+ if (!manager.rememberIncoming(target, String(event.message_id))) {
196
+ logger.debug(`忽略重复消息 ${event.message_id}`)
197
+ return
198
+ }
175
199
  manager.touch(target)
176
- manager.rememberIncoming(target, String(event.message_id))
177
200
 
178
- const key = targetKey(target)
179
- const previous = processing.get(key) ?? Promise.resolve()
180
- const task = previous
181
- .then(() => handleMessage(event, target, selfId))
201
+ void processingMutex(targetKey(target))
202
+ .runExclusive(() => handleMessage(event, target, selfId))
182
203
  .catch((error: unknown) => logger.error(`处理 ${targetLabel(target)} 消息失败`, error))
183
- processing.set(key, task)
184
- void task.finally(() => {
185
- if (processing.get(key) === task) processing.delete(key)
186
- })
187
204
  }
188
205
 
189
206
  // 先注册监听再连接:NapCat 连接期间推送的消息也会被收到。
@@ -194,13 +211,11 @@ export default Plugin.define({
194
211
 
195
212
  // 主动说话心跳。
196
213
  let timer: ReturnType<typeof setInterval> | undefined
197
- let ticking = false
198
214
  if (config.proactive.enabled) {
215
+ const tickMutex = new Mutex()
199
216
  const tick = async () => {
200
- if (ticking) return
201
- ticking = true
202
- try {
203
- await manager.recoverStale()
217
+ if (tickMutex.isLocked()) return
218
+ await tickMutex.runExclusive(async () => {
204
219
  for (const target of configuredTargets(config)) {
205
220
  const probability = proactiveTickProbability(
206
221
  config.proactive,
@@ -210,12 +225,10 @@ export default Plugin.define({
210
225
  logger.info(`${targetLabel(target)} 沉默达到阈值,主动发起话题`)
211
226
  await manager.proactive(target, PROACTIVE_HINT)
212
227
  }
213
- } finally {
214
- ticking = false
215
- }
228
+ })
216
229
  }
217
230
  timer = setInterval(() => {
218
- void tick()
231
+ void tick().catch((error: unknown) => logger.error("主动说话心跳失败", error))
219
232
  }, config.proactive.tickSeconds * 1000)
220
233
  const handle = timer as { unref?: () => void }
221
234
  handle.unref?.()
@@ -8,6 +8,83 @@ import {
8
8
  import type { Logger } from "../logger"
9
9
  import type { NapcatConfig } from "../config"
10
10
 
11
+ const MEDIA_SEGMENT_TYPES = ["image", "video", "record", "audio", "file"] as const
12
+
13
+ type MediaSegmentType = (typeof MEDIA_SEGMENT_TYPES)[number]
14
+
15
+ interface MediaSegmentData {
16
+ file?: string
17
+ file_id?: string
18
+ url?: string
19
+ }
20
+
21
+ interface MediaFileLike {
22
+ file?: unknown
23
+ url?: unknown
24
+ }
25
+
26
+ function isMediaSegmentType(type: string): type is MediaSegmentType {
27
+ return (MEDIA_SEGMENT_TYPES as readonly string[]).includes(type)
28
+ }
29
+
30
+ /** 取第一个可下载的 http(s) 直链,忽略本地路径与 base64。 */
31
+ export function pickRemoteUrl(...values: unknown[]): string | undefined {
32
+ for (const value of values) {
33
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) return value
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ /**
39
+ * 补全单个媒体段的远程直链。
40
+ *
41
+ * NapCat 同时返回本地路径(`file`)和 CDN 直链(`url`),SDK 自带的补全逻辑
42
+ * 会优先选用本地路径,导致插件无法下载。这里只写入 `url`,且优先使用 http(s) 地址。
43
+ */
44
+ export async function hydrateSegment(
45
+ api: OneBotApi,
46
+ segment: MessageSegment,
47
+ logger: Logger,
48
+ ): Promise<void> {
49
+ if (!isMediaSegmentType(segment.type)) return
50
+ const data = segment.data as MediaSegmentData
51
+ // 已有可用的远程直链时不要覆盖。
52
+ if (pickRemoteUrl(data.url)) return
53
+ const fileId = data.file ?? data.file_id
54
+ if (typeof fileId !== "string" || fileId.length === 0) return
55
+ if (/^(file|base64|data):/i.test(fileId)) return
56
+
57
+ try {
58
+ const file = (await api.getFile(fileId)) as MediaFileLike | undefined
59
+ const direct = pickRemoteUrl(file?.url, file?.file)
60
+ if (direct) {
61
+ data.url = direct
62
+ return
63
+ }
64
+ if (segment.type === "image") {
65
+ const image = (await api.getImage(fileId)) as MediaFileLike | undefined
66
+ const imageUrl = pickRemoteUrl(image?.url, image?.file)
67
+ if (imageUrl) data.url = imageUrl
68
+ } else if (segment.type === "record" || segment.type === "audio") {
69
+ const record = (await api.getRecord(fileId, "mp3")) as MediaFileLike | undefined
70
+ const recordUrl = pickRemoteUrl(record?.url, record?.file)
71
+ if (recordUrl) data.url = recordUrl
72
+ }
73
+ } catch (error) {
74
+ logger.debug(`补全媒体直链失败 type=${segment.type} file=${fileId}`, error)
75
+ }
76
+ }
77
+
78
+ /** 补全一条消息里所有媒体段的远程直链。 */
79
+ export async function hydrateMedia(
80
+ api: OneBotApi,
81
+ message: MessageSegment[],
82
+ logger: Logger,
83
+ ): Promise<void> {
84
+ if (!Array.isArray(message)) return
85
+ await Promise.all(message.map((segment) => hydrateSegment(api, segment, logger)))
86
+ }
87
+
11
88
  /** NapCat 的连接与 API 门面,屏蔽 SDK 细节。 */
12
89
  export class QQClient {
13
90
  private readonly link: NapLink
@@ -105,7 +182,7 @@ export class QQClient {
105
182
  /** 补全消息里媒体段的直链,便于下载图片。 */
106
183
  async hydrate(message: MessageSegment[]): Promise<void> {
107
184
  try {
108
- await this.link.hydrateMessage(message)
185
+ await hydrateMedia(this.api, message, this.logger)
109
186
  } catch (error) {
110
187
  this.logger.warn("补全媒体直链失败", error)
111
188
  }
@@ -83,6 +83,11 @@ function truncateCodePoints(text: string, max: number): string {
83
83
  return Array.from(text).slice(0, max).join("")
84
84
  }
85
85
 
86
+ /** 截断过长的地址,避免日志被超长 URL 淹没。 */
87
+ function truncateForLog(value: string, max = 120): string {
88
+ return value.length > max ? `${value.slice(0, max)}…` : value
89
+ }
90
+
86
91
  /** 判断主机名是否指向本机或内网,避免下载请求打内网服务。 */
87
92
  export function isPrivateHost(hostname: string): boolean {
88
93
  let host = hostname.trim().toLowerCase()
@@ -199,7 +204,10 @@ export class MediaStore {
199
204
  return { bytes, mime }
200
205
  }
201
206
  if (url.startsWith("data:")) return parseDataUrl(url)
202
- if (!url.startsWith("http://") && !url.startsWith("https://")) return undefined
207
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
208
+ this.logger.warn(`不支持的图片地址,跳过下载 url=${truncateForLog(url)}`)
209
+ return undefined
210
+ }
203
211
 
204
212
  let parsed: URL
205
213
  try {
@@ -2,6 +2,7 @@ import type { MessageEvent, MessageSegment, OneBotApi } from "@naplink/naplink"
2
2
  import type { ChatTarget } from "../chat"
3
3
  import { targetKey } from "../chat"
4
4
  import type { Logger } from "../logger"
5
+ import { hydrateSegment } from "./client"
5
6
  import {
6
7
  MAX_IMAGE_BYTES,
7
8
  inlineImageBytes,
@@ -174,8 +175,10 @@ async function renderForwardSegments(
174
175
  break
175
176
  case "image": {
176
177
  parts.push("[图片]")
178
+ if (budget.images <= 0) break
179
+ await hydrateSegment(ctx.api, segment, ctx.logger)
177
180
  const url = segment.data.url || segment.data.file
178
- if (!url || budget.images <= 0) break
181
+ if (!url) break
179
182
  const stored = await ctx.media.storeImage(url)
180
183
  if (!stored || !takeImage(budget, stored)) break
181
184
  images.push(stored)
@@ -363,8 +366,10 @@ export async function renderMessage(
363
366
  break
364
367
  case "image": {
365
368
  textBuffer.push("[图片] ")
369
+ if (budget.images <= 0) break
370
+ await hydrateSegment(ctx.api, segment, ctx.logger)
366
371
  const url = segment.data.url || segment.data.file
367
- if (!url || budget.images <= 0) break
372
+ if (!url) break
368
373
  const stored = await ctx.media.storeImage(url)
369
374
  if (!stored || !takeImage(budget, stored)) break
370
375
  imageIndex += 1
@@ -1,4 +1,6 @@
1
+ import { randomUUID } from "node:crypto"
1
2
  import type { Plugin } from "@opencode/plugin"
3
+ import { Mutex } from "async-mutex"
2
4
  import { type ChatTarget, isTargetEnabled, parseTargetKey, targetKey, targetLabel } from "../chat"
3
5
  import type { QQBotConfig } from "../config"
4
6
  import type { Logger } from "../logger"
@@ -14,8 +16,26 @@ export const MAX_BUFFER_TURNS = 100
14
16
  export const MAX_BUFFER_BYTES = 32 * 1024 * 1024
15
17
  /** 每个目标最多记录的消息 ID 数量。 */
16
18
  const MAX_TRACKED_MESSAGES = 200
19
+ /** 刚投递的一轮还没在上游体现出忙碌状态,先跳过对账避免重复投递。 */
20
+ export const SUBMIT_GRACE_MS = 5_000
21
+ function toMillis(value: unknown): number | undefined {
22
+ if (value instanceof Date) return value.getTime()
23
+ if (typeof value === "number") return value
24
+ return undefined
25
+ }
17
26
 
27
+ /** 上游会话是否仍在执行;依据 `time.busy` 是否晚于 `time.idle`。 */
28
+ function upstreamRunning(time: unknown): boolean {
29
+ if (!time || typeof time !== "object") return false
30
+ const { busy, idle } = time as { busy?: unknown; idle?: unknown }
31
+ const busyAt = toMillis(busy)
32
+ if (busyAt === undefined) return false
33
+ const idleAt = toMillis(idle)
34
+ return idleAt === undefined || busyAt > idleAt
35
+ }
18
36
  export interface Turn {
37
+ /** 投递幂等键;重试时保持不变,避免上游重复执行同一轮。 */
38
+ id?: string
19
39
  /** 交给模型的文本。 */
20
40
  text: string
21
41
  /** 图片附件。 */
@@ -29,14 +49,12 @@ export interface Turn {
29
49
  interface ChatState {
30
50
  target: ChatTarget
31
51
  sessionID?: string
32
- /** 会话是否正在跑模型(收到消息到 idle 之间)。 */
33
- busy: boolean
34
- /** 进入忙碌状态的时间戳(毫秒),用于识别卡死的会话。 */
35
- busySince: number
36
- /** 忙碌超时看门狗。 */
37
- busyTimer?: ReturnType<typeof setTimeout>
38
- /** 忙碌期间攒下的消息,空闲后合并成一次 prompt。 */
52
+ /** 等上游空闲后要投递的消息。 */
39
53
  buffer: Turn[]
54
+ /** 最近一次投递的时间戳(毫秒)。 */
55
+ lastDispatchAt: number
56
+ /** 最近一次上游确认空闲的时间戳(毫秒)。 */
57
+ idleAt: number
40
58
  /** 当前轮次的消息,供系统提示注入 @ 提示。 */
41
59
  current?: Turn
42
60
  /** 当前轮次模型是否还没调用发送工具。 */
@@ -51,6 +69,8 @@ type SessionListCursor = NonNullable<Parameters<Plugin.Context["session"]["list"
51
69
 
52
70
  export function mergeTurns(turns: readonly Turn[]): Turn {
53
71
  return {
72
+ // 合并后的批次沿用第一条消息的幂等键,重试时保持一致。
73
+ ...(turns[0]?.id ? { id: turns[0].id } : {}),
54
74
  text: turns
55
75
  .map((turn) => turn.text.trim())
56
76
  .filter((text) => text.length > 0)
@@ -77,13 +97,14 @@ function turnBytes(turn: Turn): number {
77
97
  export class SessionManager {
78
98
  private readonly states = new Map<string, ChatState>()
79
99
  private readonly sessions = new Map<string, ChatState>()
80
- private readonly ensureRequests = new Map<string, Promise<string | undefined>>()
100
+ private readonly targetMutexes = new Map<string, Mutex>()
101
+ private readonly ensureMutexes = new Map<string, Mutex>()
81
102
  private readonly verifiedSessions = new Set<string>()
82
103
  private readonly outgoing = new Map<string, Set<string>>()
83
104
  private readonly incoming = new Map<string, Set<string>>()
105
+ private readonly saveMutex = new Mutex()
84
106
  private activity: Record<string, number> = {}
85
107
  private bindingsLoaded: Promise<void> | undefined
86
- private saveQueue: Promise<void> = Promise.resolve()
87
108
 
88
109
  constructor(
89
110
  private readonly ctx: Plugin.Context,
@@ -140,7 +161,9 @@ export class SessionManager {
140
161
 
141
162
  /** 绑定或活跃时间变化后写回插件存储,串行执行避免互相覆盖。 */
142
163
  private scheduleSave(): void {
143
- this.saveQueue = this.saveQueue.then(() => this.persist())
164
+ void this.saveMutex
165
+ .runExclusive(() => this.persist())
166
+ .catch((error: unknown) => this.logger.error("保存会话状态失败", error))
144
167
  }
145
168
 
146
169
  private async persist(): Promise<void> {
@@ -170,8 +193,28 @@ export class SessionManager {
170
193
 
171
194
  /** 等待所有待写入的绑定落盘。 */
172
195
  async flush(): Promise<void> {
173
- await this.saveQueue
174
- await this.persist()
196
+ await this.saveMutex.runExclusive(() => this.persist())
197
+ }
198
+
199
+ /** 取目标对应的互斥锁,保证同一目标的状态变更与投递串行执行。 */
200
+ private targetMutex(target: ChatTarget): Mutex {
201
+ const key = targetKey(target)
202
+ let mutex = this.targetMutexes.get(key)
203
+ if (!mutex) {
204
+ mutex = new Mutex()
205
+ this.targetMutexes.set(key, mutex)
206
+ }
207
+ return mutex
208
+ }
209
+
210
+ private ensureMutex(target: ChatTarget): Mutex {
211
+ const key = targetKey(target)
212
+ let mutex = this.ensureMutexes.get(key)
213
+ if (!mutex) {
214
+ mutex = new Mutex()
215
+ this.ensureMutexes.set(key, mutex)
216
+ }
217
+ return mutex
175
218
  }
176
219
 
177
220
  private state(target: ChatTarget): ChatState {
@@ -180,9 +223,9 @@ export class SessionManager {
180
223
  if (!state) {
181
224
  state = {
182
225
  target,
183
- busy: false,
184
- busySince: 0,
185
226
  buffer: [],
227
+ lastDispatchAt: 0,
228
+ idleAt: 0,
186
229
  awaitingSend: false,
187
230
  reminders: 0,
188
231
  lastUserMessageAt: this.activity[key] ?? Date.now(),
@@ -192,33 +235,6 @@ export class SessionManager {
192
235
  return state
193
236
  }
194
237
 
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
238
  /** 会话 ID 反查目标。 */
223
239
  targetForSession(sessionID: string): ChatTarget | undefined {
224
240
  return this.sessions.get(sessionID)?.target
@@ -260,10 +276,15 @@ export class SessionManager {
260
276
  return this.outgoing.get(targetKey(target))?.has(messageId) ?? false
261
277
  }
262
278
 
263
- /** 记录一条收到的消息,语音转写工具只能操作这些消息。 */
264
- rememberIncoming(target: ChatTarget, messageId: string): void {
279
+ /**
280
+ * 记录一条收到的消息,语音转写工具只能操作这些消息。
281
+ *
282
+ * 返回该 ID 是否首次出现,用于忽略 NapCat 重连后重放的同一条消息。
283
+ */
284
+ rememberIncoming(target: ChatTarget, messageId: string): boolean {
265
285
  const key = targetKey(target)
266
286
  const ids = this.incoming.get(key) ?? new Set<string>()
287
+ const isNew = !ids.has(messageId)
267
288
  ids.add(messageId)
268
289
  while (ids.size > MAX_TRACKED_MESSAGES) {
269
290
  const oldest = ids.values().next().value
@@ -271,6 +292,7 @@ export class SessionManager {
271
292
  ids.delete(oldest)
272
293
  }
273
294
  this.incoming.set(key, ids)
295
+ return isNew
274
296
  }
275
297
 
276
298
  /** 判断消息是否属于当前目标最近收到的消息。 */
@@ -294,16 +316,14 @@ export class SessionManager {
294
316
  return (Date.now() - this.state(target).lastUserMessageAt) / 1000
295
317
  }
296
318
 
297
- /**
298
- * 收到一条消息:空闲则直接投进会话,忙则先缓存。
299
- */
319
+ /** 收到一条消息:先入队,再尝试投递;上游忙时留在队列里。 */
300
320
  async enqueue(target: ChatTarget, turn: Turn): Promise<void> {
301
321
  const state = this.state(target)
302
- if (state.busy) {
303
- this.pushBuffered(state, turn)
304
- return
305
- }
306
- await this.dispatch(state, turn)
322
+ const queued = turn.id ? turn : { ...turn, id: randomUUID() }
323
+ await this.targetMutex(target).runExclusive(async () => {
324
+ this.pushBuffered(state, queued)
325
+ await this.drain(state)
326
+ })
307
327
  }
308
328
 
309
329
  private pushBuffered(state: ChatState, turn: Turn): void {
@@ -321,16 +341,66 @@ export class SessionManager {
321
341
  if (state.buffer.length === 1 && bytes > MAX_BUFFER_BYTES) {
322
342
  this.logger.warn(`${targetLabel(state.target)} 单条消息超过缓存上限,仍保留该消息`)
323
343
  }
324
- this.logger.debug(
325
- `${targetLabel(state.target)} 忙碌中,消息已缓存(累计 ${state.buffer.length} 条)`,
326
- )
344
+ this.logger.debug(`${targetLabel(state.target)} 消息已入队(累计 ${state.buffer.length} 条)`)
327
345
  }
328
346
 
329
- /** 把一轮消息投进 OpenCode 会话。 */
330
- private async dispatch(state: ChatState, turn: Turn): Promise<void> {
331
- if (state.busy) return
332
- // 先同步占位,避免并发消息重复投递
333
- this.beginBusy(state)
347
+ /** 上游会话是否仍在执行。 */
348
+ private async sessionRunning(sessionID: string): Promise<boolean> {
349
+ try {
350
+ const info = await this.ctx.session.get({ sessionID })
351
+ return upstreamRunning((info as { time?: unknown }).time)
352
+ } catch (error) {
353
+ this.logger.debug(`查询会话 ${sessionID} 状态失败`, error)
354
+ return false
355
+ }
356
+ }
357
+
358
+ /**
359
+ * 结束信号是否属于更早的轮次。
360
+ *
361
+ * 上游 `time.idle` 只在执行结束时更新;它早于本次投递时间,说明这是上一轮
362
+ * 迟到的 idle/结束事件,不能用来清理当前轮次的状态。
363
+ */
364
+ private async idleIsStale(state: ChatState): Promise<boolean> {
365
+ if (!state.sessionID || state.lastDispatchAt === 0) return false
366
+ try {
367
+ const info = await this.ctx.session.get({ sessionID: state.sessionID })
368
+ const time = (info as { time?: { idle?: unknown } }).time
369
+ const idleAt = toMillis(time?.idle)
370
+ return idleAt !== undefined && idleAt < state.lastDispatchAt
371
+ } catch (error) {
372
+ this.logger.debug(`${targetLabel(state.target)} 查询会话结束时间失败`, error)
373
+ return false
374
+ }
375
+ }
376
+
377
+ /**
378
+ * 每次投递前都以上游状态为准:上游仍在跑就留在队列里,空闲才合并投递。
379
+ * `force` 仅用于上游已经明确发出结束事件的场景。
380
+ */
381
+ private async drain(state: ChatState, force = false): Promise<void> {
382
+ if (state.buffer.length === 0) return
383
+ if (!force && state.sessionID && this.verifiedSessions.has(state.sessionID)) {
384
+ if (await this.sessionRunning(state.sessionID)) {
385
+ this.logger.debug(
386
+ `${targetLabel(state.target)} 上游仍在执行,保留 ${state.buffer.length} 条消息`,
387
+ )
388
+ return
389
+ }
390
+ // 刚投递的轮次可能还没在上游留下忙碌时间,先给它一点时间。
391
+ const idleConfirmed = state.idleAt > 0 && state.idleAt >= state.lastDispatchAt
392
+ if (!idleConfirmed && Date.now() - state.lastDispatchAt < SUBMIT_GRACE_MS) return
393
+ }
394
+
395
+ const turns = state.buffer
396
+ state.buffer = []
397
+ if (!(await this.dispatch(state, mergeTurns(turns)))) {
398
+ state.buffer = [...turns, ...state.buffer]
399
+ }
400
+ }
401
+
402
+ /** 把一轮消息投进 OpenCode 会话;失败时由调用方放回队列。 */
403
+ private async dispatch(state: ChatState, turn: Turn): Promise<boolean> {
334
404
  state.current = turn
335
405
  state.awaitingSend = true
336
406
  // 新的用户轮次重新给一份提醒预算,否则连续几轮不调工具就再也提醒不了。
@@ -339,36 +409,31 @@ export class SessionManager {
339
409
  try {
340
410
  const sessionID = await this.ensureSession(state.target)
341
411
  if (!sessionID) {
342
- this.endBusy(state)
343
412
  state.current = undefined
344
413
  state.awaitingSend = false
345
- return
414
+ return false
346
415
  }
347
416
  await this.ctx.session.prompt({
348
417
  sessionID,
418
+ ...(turn.id ? { id: turn.id } : {}),
349
419
  text: turn.text,
350
420
  ...(turn.files.length > 0 ? { files: turn.files } : {}),
351
421
  })
422
+ state.lastDispatchAt = Date.now()
352
423
  this.logger.debug(`${label} 已投递消息到会话 ${sessionID}`)
424
+ return true
353
425
  } catch (error) {
354
- this.endBusy(state)
355
426
  state.current = undefined
356
427
  state.awaitingSend = false
357
428
  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
- }
429
+ return false
363
430
  }
364
431
  }
365
432
 
366
433
  /**
367
- * 会话即将空闲:若有缓存消息,保持忙碌等 session.idle 后再投递;
368
- * 否则清掉忙碌标记,若模型这一轮没调用发送工具则提醒它补一次。
434
+ * 会话即将空闲:有缓存就等结束事件再投递;否则按需提醒模型补一次发送。
369
435
  *
370
- * `background` 只统计本会话自己的后台任务;子会话遗留的任务不会唤醒本会话,
371
- * 不能因此让目标一直保持忙碌。
436
+ * `background` 只统计本会话自己的后台任务;子会话遗留的任务不会唤醒本会话。
372
437
  */
373
438
  async handleIdle(
374
439
  sessionID: string,
@@ -376,30 +441,39 @@ export class SessionManager {
376
441
  ): Promise<void> {
377
442
  const state = this.sessions.get(sessionID)
378
443
  if (!state) return
379
- const label = targetLabel(state.target)
444
+ await this.targetMutex(state.target).runExclusive(async () => {
445
+ const label = targetLabel(state.target)
380
446
 
381
- if (background.some((task) => task.sessionID === sessionID)) {
382
- this.logger.debug(`${label} 仍有后台任务,保持忙碌状态`)
383
- return
384
- }
447
+ if (background.some((task) => task.sessionID === sessionID)) {
448
+ this.logger.debug(`${label} 仍有后台任务,暂不投递缓存`)
449
+ return
450
+ }
385
451
 
386
- if (state.buffer.length > 0) {
387
- // 在 idle 钩子里投递会被并入旧的一轮;保持忙碌,等真正空闲后再开始新一轮。
388
- this.logger.debug(`${label} 空闲,等待投递 ${state.buffer.length} 条缓存消息`)
389
- return
390
- }
452
+ if (await this.idleIsStale(state)) {
453
+ this.logger.debug(`${label} 忽略上一轮迟到的 idle 信号`)
454
+ return
455
+ }
391
456
 
392
- this.endBusy(state)
393
- state.current = undefined
457
+ if (state.buffer.length > 0) {
458
+ // 在 idle 钩子里投递会被并入旧的一轮;等结束事件后再作为新的一轮投递。
459
+ this.logger.debug(`${label} 空闲,等待投递 ${state.buffer.length} 条缓存消息`)
460
+ return
461
+ }
462
+
463
+ state.current = undefined
464
+
465
+ if (
466
+ !state.awaitingSend ||
467
+ !this.config.reminder.enabled ||
468
+ state.reminders >= this.config.reminder.max
469
+ ) {
470
+ state.idleAt = Date.now()
471
+ return
472
+ }
394
473
 
395
- if (
396
- state.awaitingSend &&
397
- this.config.reminder.enabled &&
398
- state.reminders < this.config.reminder.max
399
- ) {
400
474
  state.reminders += 1
401
- // 合成消息会唤醒会话再跑一轮,先占位避免新消息插队。
402
- this.beginBusy(state)
475
+ state.idleAt = 0
476
+ state.lastDispatchAt = Date.now()
403
477
  state.current = { text: "[提醒]", files: [], atMe: false }
404
478
  this.logger.warn(`${label} 模型未调用发送工具,提醒第 ${state.reminders} 次`)
405
479
  try {
@@ -408,82 +482,64 @@ export class SessionManager {
408
482
  text: `[提醒] 你这一轮没有调用任何发送工具,直接输出的文字会被丢弃。请调用 qq_send_message 工具:想回复就 reply=true 并给出 content,不想回复就 reply=false;发送图片、语音、转发或戳一戳也视为已回复。`,
409
483
  })
410
484
  } catch (error) {
411
- this.endBusy(state)
412
485
  state.awaitingSend = false
413
486
  this.logger.error(`${label} 发送提醒失败`, error)
414
487
  }
415
- }
488
+ })
416
489
  }
417
490
 
418
- /**
419
- * 模型执行失败或被中断:idle 钩子不会触发,必须主动释放忙碌状态并处理缓存。
420
- */
491
+ /** 模型执行失败或被中断:结束事件之后直接放行缓存。 */
421
492
  async handleExecutionEnd(sessionID: string, reason: string): Promise<void> {
422
493
  const state = this.sessions.get(sessionID)
423
494
  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
- }
495
+ await this.targetMutex(state.target).runExclusive(async () => {
496
+ if (await this.idleIsStale(state)) {
497
+ this.logger.debug(`${targetLabel(state.target)} 忽略上一轮迟到的结束事件(${reason})`)
498
+ return
499
+ }
500
+ this.logger.warn(`${targetLabel(state.target)} 会话执行结束(${reason})`)
501
+ state.current = undefined
502
+ state.awaitingSend = false
503
+ state.idleAt = Date.now()
504
+ await this.drain(state, true)
505
+ })
446
506
  }
447
507
 
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)
508
+ /**
509
+ * 执行结束事件丢失时,用上游会话状态兜底放行缓存。
510
+ * 只在上游确认空闲且本轮确实结束后投递,不会打断仍在运行的长轮次。
511
+ */
512
+ async resumeBuffered(): Promise<void> {
513
+ for (const state of this.sessions.values()) {
514
+ if (state.buffer.length === 0) continue
515
+ await this.targetMutex(state.target).runExclusive(() => this.drain(state))
454
516
  }
455
517
  }
456
518
 
457
- /** 会话真正空闲后,把忙碌期间缓存的消息合并成新的一轮投递。 */
519
+ /** 会话状态变为 idle 后,把缓存的消息合并成新的一轮投递。 */
458
520
  async flushBuffered(sessionID: string): Promise<void> {
459
521
  const state = this.sessions.get(sessionID)
460
522
  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))
523
+ await this.targetMutex(state.target).runExclusive(async () => {
524
+ if (await this.idleIsStale(state)) {
525
+ this.logger.debug(`${targetLabel(state.target)} 忽略上一轮迟到的结束信号`)
526
+ return
527
+ }
528
+ if (state.buffer.length === 0) {
529
+ // 只有没有新轮次时,idle 时间才代表当前状态;投递缓存时若也写入,
530
+ // 会与紧接着的 dispatch 落在同一毫秒,误判为新一轮已经结束。
531
+ state.idleAt = Date.now()
532
+ state.current = undefined
533
+ return
534
+ }
535
+ this.logger.debug(`${targetLabel(state.target)} 空闲,投递 ${state.buffer.length} 条缓存消息`)
536
+ await this.drain(state, true)
537
+ })
475
538
  }
476
539
 
477
- /** 取得(必要时创建)目标对应的会话;并发调用复用同一个请求。 */
540
+ /** 取得(必要时创建)目标对应的会话;并发调用串行执行,避免重复建会话。 */
478
541
  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
542
+ return this.ensureMutex(target).runExclusive(() => this.resolveSession(target))
487
543
  }
488
544
 
489
545
  private async resolveSession(target: ChatTarget): Promise<string | undefined> {
@@ -499,6 +555,7 @@ export class SessionManager {
499
555
  const reused = await this.findExistingSession(target)
500
556
  // 会话已经绑给别的目标时不能复用,改为新建一个会话。
501
557
  if (reused && this.bindSession(state, reused)) {
558
+ this.verifiedSessions.add(reused)
502
559
  this.scheduleSave()
503
560
  this.logger.info(`${label} 复用已有会话 ${reused}`)
504
561
  return reused
@@ -521,6 +578,7 @@ export class SessionManager {
521
578
  })
522
579
  return undefined
523
580
  }
581
+ this.verifiedSessions.add(created.id)
524
582
  this.scheduleSave()
525
583
  this.logger.info(`${label} 新建会话 ${created.id}`)
526
584
  return created.id
@@ -600,29 +658,35 @@ export class SessionManager {
600
658
  /** 主动说话:向目标对应的会话投递一条合成消息。 */
601
659
  async proactive(target: ChatTarget, text: string): Promise<void> {
602
660
  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
661
+ await this.targetMutex(target).runExclusive(async () => {
662
+ if (state.buffer.length > 0 || state.current) return
663
+ // 先占位,避免用户消息或下一次心跳同时投递。
664
+ state.idleAt = 0
665
+ state.lastDispatchAt = Date.now()
666
+ state.current = { text, files: [], atMe: true, proactive: true }
667
+ state.awaitingSend = true
668
+ state.reminders = 0
669
+ try {
670
+ if (state.sessionID && (await this.sessionRunning(state.sessionID))) {
671
+ state.current = undefined
672
+ state.awaitingSend = false
673
+ return
674
+ }
675
+ const sessionID = await this.ensureSession(target)
676
+ if (!sessionID) {
677
+ state.current = undefined
678
+ state.awaitingSend = false
679
+ return
680
+ }
681
+ state.lastUserMessageAt = Date.now()
682
+ this.activity[targetKey(target)] = state.lastUserMessageAt
683
+ this.scheduleSave()
684
+ await this.ctx.session.synthetic({ sessionID, text })
685
+ } catch (error) {
614
686
  state.awaitingSend = false
615
- return
687
+ state.current = undefined
688
+ this.logger.error(`${targetLabel(target)} 主动说话失败`, error)
616
689
  }
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
- }
690
+ })
627
691
  }
628
692
  }
package/src/tools/qq.ts CHANGED
@@ -193,7 +193,7 @@ export async function registerTools(deps: ToolDeps): Promise<{ dispose(): Promis
193
193
  }
194
194
  manager.markMessageSent(context.sessionID)
195
195
  logger.info(`${label} 已发送 ${content.length} 字`)
196
- return text("sent")
196
+ return text("你已成功发送消息,可以选择结束对话")
197
197
  },
198
198
  })
199
199
 
@@ -220,7 +220,7 @@ export async function registerTools(deps: ToolDeps): Promise<{ dispose(): Promis
220
220
  const messageId = messageIdOf(response)
221
221
  if (messageId) manager.rememberOutgoing(target, messageId)
222
222
  manager.markMessageSent(context.sessionID)
223
- return text("sent")
223
+ return text("你已成功发送图片,可以选择结束对话")
224
224
  },
225
225
  })
226
226
 
@@ -246,7 +246,7 @@ export async function registerTools(deps: ToolDeps): Promise<{ dispose(): Promis
246
246
  const messageId = messageIdOf(response)
247
247
  if (messageId) manager.rememberOutgoing(target, messageId)
248
248
  manager.markMessageSent(context.sessionID)
249
- return text("sent")
249
+ return text("你已成功发送语音,可以选择结束对话")
250
250
  },
251
251
  })
252
252
 
@@ -301,7 +301,7 @@ export async function registerTools(deps: ToolDeps): Promise<{ dispose(): Promis
301
301
  const messageId = messageIdOf(response)
302
302
  if (messageId) manager.rememberOutgoing(target, messageId)
303
303
  manager.markMessageSent(context.sessionID)
304
- return text("sent")
304
+ return text("你已成功发送合并转发消息,可以选择结束对话")
305
305
  },
306
306
  })
307
307
 
@@ -324,7 +324,7 @@ export async function registerTools(deps: ToolDeps): Promise<{ dispose(): Promis
324
324
  if (!input.user_id) return text("error: 缺少 user_id")
325
325
  await sendPoke(target, input.user_id)
326
326
  manager.markMessageSent(context.sessionID)
327
- return text("sent")
327
+ return text("你已成功戳一下用户,可以选择结束对话")
328
328
  },
329
329
  })
330
330