@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 +0 -1
- package/package.json +2 -1
- package/src/config.ts +0 -6
- package/src/index.ts +36 -23
- package/src/naplink/client.ts +78 -1
- package/src/naplink/media.ts +9 -1
- package/src/naplink/render.ts +7 -2
- package/src/session/manager.ts +233 -169
- package/src/tools/qq.ts +5 -5
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.
|
|
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
|
-
//
|
|
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: [
|
|
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.
|
|
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,
|
|
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
|
-
|
|
179
|
-
|
|
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 (
|
|
201
|
-
|
|
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
|
-
}
|
|
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?.()
|
package/src/naplink/client.ts
CHANGED
|
@@ -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.
|
|
185
|
+
await hydrateMedia(this.api, message, this.logger)
|
|
109
186
|
} catch (error) {
|
|
110
187
|
this.logger.warn("补全媒体直链失败", error)
|
|
111
188
|
}
|
package/src/naplink/media.ts
CHANGED
|
@@ -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://"))
|
|
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 {
|
package/src/naplink/render.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/session/manager.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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.
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
/**
|
|
330
|
-
private async
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
444
|
+
await this.targetMutex(state.target).runExclusive(async () => {
|
|
445
|
+
const label = targetLabel(state.target)
|
|
380
446
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
447
|
+
if (background.some((task) => task.sessionID === sessionID)) {
|
|
448
|
+
this.logger.debug(`${label} 仍有后台任务,暂不投递缓存`)
|
|
449
|
+
return
|
|
450
|
+
}
|
|
385
451
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
452
|
+
if (await this.idleIsStale(state)) {
|
|
453
|
+
this.logger.debug(`${label} 忽略上一轮迟到的 idle 信号`)
|
|
454
|
+
return
|
|
455
|
+
}
|
|
391
456
|
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
this.
|
|
613
|
-
|
|
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
|
-
|
|
687
|
+
state.current = undefined
|
|
688
|
+
this.logger.error(`${targetLabel(target)} 主动说话失败`, error)
|
|
616
689
|
}
|
|
617
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
327
|
+
return text("你已成功戳一下用户,可以选择结束对话")
|
|
328
328
|
},
|
|
329
329
|
})
|
|
330
330
|
|