@cmach_socket/opencode-qq-bot 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +443 -0
- package/README.md +178 -2
- package/SOUL.txt +8 -0
- package/package.json +47 -4
- package/src/chat.ts +44 -0
- package/src/config.ts +444 -0
- package/src/index.ts +245 -0
- package/src/instance.ts +60 -0
- package/src/logger.ts +30 -0
- package/src/naplink/client.ts +118 -0
- package/src/naplink/media.ts +311 -0
- package/src/naplink/render.ts +440 -0
- package/src/session/manager.ts +628 -0
- package/src/session/proactive.ts +22 -0
- package/src/session/prompt.ts +85 -0
- package/src/tools/qq.ts +421 -0
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cmach_socket/opencode-qq-bot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenCode V2 plugin bridging a QQ group chat (NapCat via NapLink) to an OpenCode session.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"SOUL.txt",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lint": "eslint .",
|
|
22
|
+
"format": "prettier --write .",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"opencode",
|
|
27
|
+
"opencode-plugin",
|
|
28
|
+
"napcat",
|
|
29
|
+
"naplink",
|
|
30
|
+
"onebot",
|
|
31
|
+
"qq"
|
|
32
|
+
],
|
|
33
|
+
"license": "CC-BY-NC-SA-4.0",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@naplink/naplink": "1.2.0",
|
|
36
|
+
"@opencode/plugin": "2.2.5",
|
|
37
|
+
"@opencode/schema": "2.2.5",
|
|
38
|
+
"zod": "4.6.7"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/bun": "1.4.4",
|
|
42
|
+
"@vitest/coverage-v8": "5.1.0",
|
|
43
|
+
"eslint": "10.14.0",
|
|
44
|
+
"prettier": "3.9.10",
|
|
45
|
+
"typescript": "6.0.3",
|
|
46
|
+
"typescript-eslint": "8.72.1",
|
|
47
|
+
"vitest": "5.1.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/chat.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** QQ 会话目标:群聊或私聊。 */
|
|
2
|
+
export type ChatTarget = { kind: "group"; id: number } | { kind: "private"; id: number }
|
|
3
|
+
|
|
4
|
+
export interface TargetConfig {
|
|
5
|
+
groups: readonly number[]
|
|
6
|
+
users: readonly number[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 目标在存储与 Map 中的唯一键,例如 `group:100`、`private:200`。 */
|
|
10
|
+
export function targetKey(target: ChatTarget): string {
|
|
11
|
+
return `${target.kind}:${target.id}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 日志与提示里使用的可读名称。 */
|
|
15
|
+
export function targetLabel(target: ChatTarget): string {
|
|
16
|
+
return target.kind === "group" ? `群 ${target.id}` : `私聊 ${target.id}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 目标是否仍在配置中启用。 */
|
|
20
|
+
export function isTargetEnabled(config: TargetConfig, target: ChatTarget): boolean {
|
|
21
|
+
return target.kind === "group"
|
|
22
|
+
? config.groups.includes(target.id)
|
|
23
|
+
: config.users.includes(target.id)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 配置里声明的全部目标。 */
|
|
27
|
+
export function configuredTargets(config: TargetConfig): ChatTarget[] {
|
|
28
|
+
return [
|
|
29
|
+
...config.groups.map((id): ChatTarget => ({ kind: "group", id })),
|
|
30
|
+
...config.users.map((id): ChatTarget => ({ kind: "private", id })),
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 解析持久化键,仅接受 `group:<id>` 与 `private:<id>`。 */
|
|
35
|
+
export function parseTargetKey(key: string): ChatTarget | undefined {
|
|
36
|
+
const separator = key.indexOf(":")
|
|
37
|
+
if (separator <= 0) return undefined
|
|
38
|
+
const kind = key.slice(0, separator)
|
|
39
|
+
const id = Number(key.slice(separator + 1))
|
|
40
|
+
if (!Number.isInteger(id) || id <= 0) return undefined
|
|
41
|
+
if (kind === "group") return { kind: "group", id }
|
|
42
|
+
if (kind === "private") return { kind: "private", id }
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { Model } from "@opencode/schema"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plugin configuration: merges OpenCode plugin options, environment variables
|
|
6
|
+
* and built-in defaults into a single validated object.
|
|
7
|
+
*
|
|
8
|
+
* Credentials are never stored in the repository: the NapCat endpoint and token
|
|
9
|
+
* must be supplied through the environment or through plugin options.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const NUMBER = /^-?(?:\d+\.?\d*|\.\d+)$/
|
|
13
|
+
const TRUTHY = new Set(["1", "true", "yes", "on"])
|
|
14
|
+
const BOOLEAN_SPELLINGS = ["1", "0", "true", "false", "yes", "no", "on", "off"] as const
|
|
15
|
+
|
|
16
|
+
export interface NapcatConfig {
|
|
17
|
+
/** NapCat 正向 WebSocket 地址,必填。 */
|
|
18
|
+
url: string
|
|
19
|
+
/** NapCat 访问令牌,可选。 */
|
|
20
|
+
token?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProactiveConfig {
|
|
24
|
+
/** 是否启用主动说话心跳。 */
|
|
25
|
+
enabled: boolean
|
|
26
|
+
/** 群沉默多少秒后才开始参与主动说话的概率计算。 */
|
|
27
|
+
silenceThresholdSeconds: number
|
|
28
|
+
/** 刚跨过阈值时的初始概率。 */
|
|
29
|
+
startProbability: number
|
|
30
|
+
/** 每多沉默一小时概率的增量。 */
|
|
31
|
+
probabilityPerHour: number
|
|
32
|
+
/** 心跳间隔(秒)。 */
|
|
33
|
+
tickSeconds: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ReminderConfig {
|
|
37
|
+
/** 模型一轮结束时没有调用 qq_send_message 时,是否提醒它补一次。 */
|
|
38
|
+
enabled: boolean
|
|
39
|
+
/** 连续提醒次数上限,避免死循环。 */
|
|
40
|
+
max: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ModelRef {
|
|
44
|
+
providerID: string
|
|
45
|
+
id: string
|
|
46
|
+
variant?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface QQBotConfig {
|
|
50
|
+
napcat: NapcatConfig
|
|
51
|
+
/** 参与 AI 对话的群号。 */
|
|
52
|
+
groups: number[]
|
|
53
|
+
/** 参与 AI 对话的私聊 QQ 号。 */
|
|
54
|
+
users: number[]
|
|
55
|
+
/** 角色设定文件路径,默认使用插件包内的 SOUL.txt。 */
|
|
56
|
+
soulPath?: string
|
|
57
|
+
/** QQ 会话使用的 OpenCode agent。 */
|
|
58
|
+
agent?: string
|
|
59
|
+
/** QQ 会话使用的模型。 */
|
|
60
|
+
model?: ModelRef
|
|
61
|
+
/** 拆行发送时每条消息之间的间隔(毫秒)。 */
|
|
62
|
+
sendDelayMs: number
|
|
63
|
+
/** 单条消息超过该长度时告警(不截断)。 */
|
|
64
|
+
warnLength: number
|
|
65
|
+
/** 重启后是否复用上一次的会话。 */
|
|
66
|
+
reuseSession: boolean
|
|
67
|
+
/** 会话忙碌超过该时长(毫秒)视为卡死,强制放行缓存的消息。 */
|
|
68
|
+
busyTimeoutMs: number
|
|
69
|
+
proactive: ProactiveConfig
|
|
70
|
+
reminder: ReminderConfig
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 除 NapCat 连接信息外的全部默认值。 */
|
|
74
|
+
export const DEFAULT_CONFIG: Omit<QQBotConfig, "napcat"> = {
|
|
75
|
+
groups: [],
|
|
76
|
+
users: [],
|
|
77
|
+
sendDelayMs: 1000,
|
|
78
|
+
warnLength: 20,
|
|
79
|
+
reuseSession: false,
|
|
80
|
+
busyTimeoutMs: 300_000,
|
|
81
|
+
proactive: {
|
|
82
|
+
enabled: true,
|
|
83
|
+
silenceThresholdSeconds: 3 * 3600,
|
|
84
|
+
startProbability: 0.01,
|
|
85
|
+
probabilityPerHour: 0.05,
|
|
86
|
+
tickSeconds: 3600,
|
|
87
|
+
},
|
|
88
|
+
reminder: { enabled: true, max: 2 },
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 配置告警回调。 */
|
|
92
|
+
export type ConfigWarning = (message: string) => void
|
|
93
|
+
|
|
94
|
+
/** 解析 "provider/model" 或 "provider/model/variant",复用 SDK 的模型引用解析。 */
|
|
95
|
+
function parseModelRef(value: unknown): ModelRef | undefined {
|
|
96
|
+
const raw = typeof value === "string" ? value.trim() : ""
|
|
97
|
+
if (!raw) return undefined
|
|
98
|
+
const [providerID, id, variant, ...rest] = raw.split("/").filter(Boolean)
|
|
99
|
+
if (!providerID || !id || rest.length > 0) return undefined
|
|
100
|
+
try {
|
|
101
|
+
const ref = Model.Ref.parse(`${providerID}/${id}${variant ? `#${variant}` : ""}`)
|
|
102
|
+
return {
|
|
103
|
+
providerID: ref.providerID,
|
|
104
|
+
id: ref.id,
|
|
105
|
+
...(ref.variant ? { variant: ref.variant } : {}),
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 判断值是否算“已提供”,用于区分空值与写错的值。 */
|
|
113
|
+
function present(value: unknown): boolean {
|
|
114
|
+
return value !== undefined && value !== null && value !== ""
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 把数字数组或逗号分隔字符串整理成去重后的正整数列表。 */
|
|
118
|
+
function toIds(
|
|
119
|
+
value: unknown,
|
|
120
|
+
warn: ConfigWarning,
|
|
121
|
+
label: string,
|
|
122
|
+
noun: string,
|
|
123
|
+
): number[] | undefined {
|
|
124
|
+
const source = Array.isArray(value)
|
|
125
|
+
? value
|
|
126
|
+
: typeof value === "string"
|
|
127
|
+
? value.split(",")
|
|
128
|
+
: undefined
|
|
129
|
+
if (!source) return undefined
|
|
130
|
+
const ids: number[] = []
|
|
131
|
+
const seen = new Set<number>()
|
|
132
|
+
for (const entry of source) {
|
|
133
|
+
const raw =
|
|
134
|
+
typeof entry === "number" ? String(entry) : typeof entry === "string" ? entry.trim() : ""
|
|
135
|
+
if (!raw) continue
|
|
136
|
+
const parsed = Number(raw)
|
|
137
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
138
|
+
warn(`${label} 忽略非法${noun}:${raw}`)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (seen.has(parsed)) continue
|
|
142
|
+
seen.add(parsed)
|
|
143
|
+
ids.push(parsed)
|
|
144
|
+
}
|
|
145
|
+
return ids
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface PartialNapcatConfig {
|
|
149
|
+
url?: string
|
|
150
|
+
token?: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type RawConfig = Omit<QQBotConfig, "napcat"> & { napcat: PartialNapcatConfig }
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 用同一份字段清单生成配置 schema。
|
|
157
|
+
*
|
|
158
|
+
* `defaults` 是被覆盖层的兜底值,因此环境变量层用 `DEFAULT_CONFIG`,
|
|
159
|
+
* 插件选项层用解析后的环境变量配置,天然实现“选项 > 环境变量 > 默认值”。
|
|
160
|
+
*/
|
|
161
|
+
function createConfigSchema(
|
|
162
|
+
defaults: Omit<QQBotConfig, "napcat">,
|
|
163
|
+
napcat: PartialNapcatConfig,
|
|
164
|
+
label: (path: string) => string,
|
|
165
|
+
warn: ConfigWarning,
|
|
166
|
+
): z.ZodType<RawConfig> {
|
|
167
|
+
const number = (path: string, fallback: number) =>
|
|
168
|
+
z
|
|
169
|
+
.union([z.number(), z.string().trim().regex(NUMBER).transform(Number)])
|
|
170
|
+
.pipe(z.number().finite())
|
|
171
|
+
.catch((ctx) => {
|
|
172
|
+
if (present(ctx.value)) warn(`${label(path)} 不是合法的数字:${String(ctx.value)}`)
|
|
173
|
+
return fallback
|
|
174
|
+
})
|
|
175
|
+
.default(fallback)
|
|
176
|
+
|
|
177
|
+
const constrained = (
|
|
178
|
+
path: string,
|
|
179
|
+
fallback: number,
|
|
180
|
+
check: (value: z.ZodNumber) => z.ZodNumber,
|
|
181
|
+
rule: string,
|
|
182
|
+
) =>
|
|
183
|
+
number(path, fallback)
|
|
184
|
+
.pipe(check(z.number()))
|
|
185
|
+
.catch((ctx) => {
|
|
186
|
+
if (present(ctx.value)) {
|
|
187
|
+
warn(`${label(path)} ${rule},当前为 ${String(ctx.value)},已回退到 ${fallback}`)
|
|
188
|
+
}
|
|
189
|
+
return fallback
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const positive = (path: string, fallback: number) =>
|
|
193
|
+
constrained(path, fallback, (value) => value.positive(), "必须大于 0")
|
|
194
|
+
|
|
195
|
+
const nonNegative = (path: string, fallback: number) =>
|
|
196
|
+
constrained(path, fallback, (value) => value.min(0), "不能小于 0")
|
|
197
|
+
|
|
198
|
+
const integer = (path: string, fallback: number, min: number) =>
|
|
199
|
+
constrained(path, fallback, (value) => value.int().min(min), `必须是大于等于 ${min} 的整数`)
|
|
200
|
+
|
|
201
|
+
const probability = (path: string, fallback: number) =>
|
|
202
|
+
constrained(path, fallback, (value) => value.min(0).max(1), "必须在 0 到 1 之间")
|
|
203
|
+
|
|
204
|
+
const boolean = (path: string, fallback: boolean) =>
|
|
205
|
+
z
|
|
206
|
+
.union([z.boolean(), z.string().trim().toLowerCase().pipe(z.enum(BOOLEAN_SPELLINGS))])
|
|
207
|
+
.transform((value) => (typeof value === "boolean" ? value : TRUTHY.has(value)))
|
|
208
|
+
.catch((ctx) => {
|
|
209
|
+
if (present(ctx.value)) warn(`${label(path)} 不是合法的布尔值:${String(ctx.value)}`)
|
|
210
|
+
return fallback
|
|
211
|
+
})
|
|
212
|
+
.default(fallback)
|
|
213
|
+
|
|
214
|
+
const text = (path: string, fallback: string | undefined) =>
|
|
215
|
+
z
|
|
216
|
+
.unknown()
|
|
217
|
+
.optional()
|
|
218
|
+
.transform((value) => {
|
|
219
|
+
if (!present(value)) return fallback
|
|
220
|
+
if (typeof value !== "string") {
|
|
221
|
+
warn(`${label(path)} 必须是字符串:${String(value)}`)
|
|
222
|
+
return fallback
|
|
223
|
+
}
|
|
224
|
+
const trimmed = value.trim()
|
|
225
|
+
if (!trimmed) {
|
|
226
|
+
warn(`${label(path)} 不能为空,已回退到默认值`)
|
|
227
|
+
return fallback
|
|
228
|
+
}
|
|
229
|
+
return trimmed
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const groups = z
|
|
233
|
+
.unknown()
|
|
234
|
+
.optional()
|
|
235
|
+
.transform((value) => {
|
|
236
|
+
if (value === undefined || value === null) return [...defaults.groups]
|
|
237
|
+
const parsed = toIds(value, warn, label("groups"), "群号")
|
|
238
|
+
if (!parsed) {
|
|
239
|
+
if (present(value)) warn(`${label("groups")} 解析不出群号:${String(value)}`)
|
|
240
|
+
return [...defaults.groups]
|
|
241
|
+
}
|
|
242
|
+
return parsed
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const users = z
|
|
246
|
+
.unknown()
|
|
247
|
+
.optional()
|
|
248
|
+
.transform((value) => {
|
|
249
|
+
if (value === undefined || value === null) return [...defaults.users]
|
|
250
|
+
const parsed = toIds(value, warn, label("users"), "QQ 号")
|
|
251
|
+
if (!parsed) {
|
|
252
|
+
if (present(value)) warn(`${label("users")} 解析不出 QQ 号:${String(value)}`)
|
|
253
|
+
return [...defaults.users]
|
|
254
|
+
}
|
|
255
|
+
return parsed
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const model = z
|
|
259
|
+
.unknown()
|
|
260
|
+
.optional()
|
|
261
|
+
.transform((value) => {
|
|
262
|
+
if (!present(value)) return defaults.model
|
|
263
|
+
const parsed = parseModelRef(value)
|
|
264
|
+
if (!parsed) {
|
|
265
|
+
warn(`${label("model")} 格式无效:${String(value)}(应为 provider/model[/variant])`)
|
|
266
|
+
return defaults.model
|
|
267
|
+
}
|
|
268
|
+
return parsed
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
return z.object({
|
|
272
|
+
napcat: z
|
|
273
|
+
.object({
|
|
274
|
+
url: text("napcat.url", napcat.url),
|
|
275
|
+
token: text("napcat.token", napcat.token),
|
|
276
|
+
})
|
|
277
|
+
.prefault({})
|
|
278
|
+
.catch({ url: napcat.url, token: napcat.token }),
|
|
279
|
+
groups,
|
|
280
|
+
users,
|
|
281
|
+
soulPath: text("soulPath", defaults.soulPath),
|
|
282
|
+
agent: text("agent", defaults.agent),
|
|
283
|
+
model,
|
|
284
|
+
sendDelayMs: nonNegative("sendDelayMs", defaults.sendDelayMs),
|
|
285
|
+
warnLength: integer("warnLength", defaults.warnLength, 1),
|
|
286
|
+
reuseSession: boolean("reuseSession", defaults.reuseSession),
|
|
287
|
+
busyTimeoutMs: positive("busyTimeoutMs", defaults.busyTimeoutMs),
|
|
288
|
+
proactive: z
|
|
289
|
+
.object({
|
|
290
|
+
enabled: boolean("proactive.enabled", defaults.proactive.enabled),
|
|
291
|
+
silenceThresholdSeconds: nonNegative(
|
|
292
|
+
"proactive.silenceThresholdSeconds",
|
|
293
|
+
defaults.proactive.silenceThresholdSeconds,
|
|
294
|
+
),
|
|
295
|
+
startProbability: probability(
|
|
296
|
+
"proactive.startProbability",
|
|
297
|
+
defaults.proactive.startProbability,
|
|
298
|
+
),
|
|
299
|
+
probabilityPerHour: probability(
|
|
300
|
+
"proactive.probabilityPerHour",
|
|
301
|
+
defaults.proactive.probabilityPerHour,
|
|
302
|
+
),
|
|
303
|
+
tickSeconds: positive("proactive.tickSeconds", defaults.proactive.tickSeconds),
|
|
304
|
+
})
|
|
305
|
+
.prefault({})
|
|
306
|
+
.catch(defaults.proactive),
|
|
307
|
+
reminder: z
|
|
308
|
+
.object({
|
|
309
|
+
enabled: boolean("reminder.enabled", defaults.reminder.enabled),
|
|
310
|
+
max: integer("reminder.max", defaults.reminder.max, 0),
|
|
311
|
+
})
|
|
312
|
+
.prefault({})
|
|
313
|
+
.catch(defaults.reminder),
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const ENV_VARS = {
|
|
318
|
+
"napcat.url": "NAPCAT_WS_URL",
|
|
319
|
+
"napcat.token": "NAPCAT_WS_TOKEN",
|
|
320
|
+
groups: "QQ_TARGET_GROUPS",
|
|
321
|
+
users: "QQ_TARGET_USERS",
|
|
322
|
+
soulPath: "QQ_BOT_SOUL_PATH",
|
|
323
|
+
agent: "QQ_BOT_AGENT",
|
|
324
|
+
model: "QQ_BOT_MODEL",
|
|
325
|
+
sendDelayMs: "QQ_SEND_DELAY_MS",
|
|
326
|
+
warnLength: "QQ_WARN_LENGTH",
|
|
327
|
+
reuseSession: "QQ_REUSE_SESSION",
|
|
328
|
+
busyTimeoutMs: "QQ_BUSY_TIMEOUT_MS",
|
|
329
|
+
"proactive.enabled": "QQ_PROACTIVE_ENABLED",
|
|
330
|
+
"proactive.silenceThresholdSeconds": "QQ_PROACTIVE_SILENCE_SECONDS",
|
|
331
|
+
"proactive.startProbability": "QQ_PROACTIVE_P_START",
|
|
332
|
+
"proactive.probabilityPerHour": "QQ_PROACTIVE_P_SLOPE_PER_HOUR",
|
|
333
|
+
"proactive.tickSeconds": "QQ_PROACTIVE_TICK_SECONDS",
|
|
334
|
+
"reminder.enabled": "QQ_REMINDER_ENABLED",
|
|
335
|
+
"reminder.max": "QQ_REMINDER_MAX",
|
|
336
|
+
} as const
|
|
337
|
+
|
|
338
|
+
const OPTION_KEYS = new Set([
|
|
339
|
+
"napcat",
|
|
340
|
+
"groups",
|
|
341
|
+
"users",
|
|
342
|
+
"soulPath",
|
|
343
|
+
"agent",
|
|
344
|
+
"model",
|
|
345
|
+
"sendDelayMs",
|
|
346
|
+
"warnLength",
|
|
347
|
+
"reuseSession",
|
|
348
|
+
"busyTimeoutMs",
|
|
349
|
+
"proactive",
|
|
350
|
+
"reminder",
|
|
351
|
+
])
|
|
352
|
+
|
|
353
|
+
const NESTED_OPTION_KEYS: Record<string, ReadonlySet<string>> = {
|
|
354
|
+
napcat: new Set(["url", "token"]),
|
|
355
|
+
proactive: new Set([
|
|
356
|
+
"enabled",
|
|
357
|
+
"silenceThresholdSeconds",
|
|
358
|
+
"startProbability",
|
|
359
|
+
"probabilityPerHour",
|
|
360
|
+
"tickSeconds",
|
|
361
|
+
]),
|
|
362
|
+
reminder: new Set(["enabled", "max"]),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 报告被 schema 静默剥离的未知配置项。 */
|
|
366
|
+
function warnUnknownKeys(options: Record<string, unknown>, warn: ConfigWarning): void {
|
|
367
|
+
for (const key of Object.keys(options)) {
|
|
368
|
+
if (!OPTION_KEYS.has(key)) {
|
|
369
|
+
warn(`插件选项 未知配置项:${key}`)
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
const allowed = NESTED_OPTION_KEYS[key]
|
|
373
|
+
const value = options[key]
|
|
374
|
+
if (!allowed || !value || typeof value !== "object" || Array.isArray(value)) continue
|
|
375
|
+
for (const nested of Object.keys(value)) {
|
|
376
|
+
if (!allowed.has(nested)) warn(`插件选项 ${key}.${nested} 未知配置项`)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function envLabel(path: string): string {
|
|
382
|
+
return ENV_VARS[path as keyof typeof ENV_VARS] ?? path
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function fromEnv(env: Record<string, string | undefined>): Record<string, unknown> {
|
|
386
|
+
const config: Record<string, unknown> = {}
|
|
387
|
+
for (const [path, name] of Object.entries(ENV_VARS)) {
|
|
388
|
+
const value = env[name]
|
|
389
|
+
if (value === undefined) continue
|
|
390
|
+
const keys = path.split(".")
|
|
391
|
+
const leaf = keys.pop()
|
|
392
|
+
if (!leaf) continue
|
|
393
|
+
let target = config
|
|
394
|
+
for (const key of keys) {
|
|
395
|
+
const child = target[key]
|
|
396
|
+
const next = child && typeof child === "object" ? (child as Record<string, unknown>) : {}
|
|
397
|
+
target[key] = next
|
|
398
|
+
target = next
|
|
399
|
+
}
|
|
400
|
+
target[leaf] = value
|
|
401
|
+
}
|
|
402
|
+
return config
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 合并插件选项与环境变量。插件选项优先级更高。
|
|
407
|
+
*
|
|
408
|
+
* 写错的配置项不会静默失效,而是通过 `onWarning` 报告。
|
|
409
|
+
*
|
|
410
|
+
* @throws 当没有提供 NapCat WebSocket 地址时抛出,避免带着无效凭证启动。
|
|
411
|
+
*/
|
|
412
|
+
export function resolveConfig(
|
|
413
|
+
options: Record<string, unknown>,
|
|
414
|
+
env: Record<string, string | undefined> = {},
|
|
415
|
+
onWarning: ConfigWarning = () => {},
|
|
416
|
+
): QQBotConfig {
|
|
417
|
+
const warnings: string[] = []
|
|
418
|
+
const warn: ConfigWarning = (message) => warnings.push(message)
|
|
419
|
+
|
|
420
|
+
warnUnknownKeys(options, warn)
|
|
421
|
+
const envSchema = createConfigSchema(DEFAULT_CONFIG, {}, envLabel, warn)
|
|
422
|
+
const base: RawConfig = envSchema.parse(fromEnv(env))
|
|
423
|
+
const optionsSchema = createConfigSchema(base, base.napcat, (path) => `插件选项 ${path}`, warn)
|
|
424
|
+
const parsed: RawConfig = optionsSchema.parse(options)
|
|
425
|
+
|
|
426
|
+
// 先输出告警,避免 URL 缺失时把已经发现的问题一起吞掉。
|
|
427
|
+
for (const warning of warnings) onWarning(warning)
|
|
428
|
+
|
|
429
|
+
const url = parsed.napcat.url
|
|
430
|
+
if (!url) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
"未配置 NapCat WebSocket 地址:请设置环境变量 NAPCAT_WS_URL 或插件选项 napcat.url",
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
...parsed,
|
|
438
|
+
groups: [...parsed.groups],
|
|
439
|
+
users: [...parsed.users],
|
|
440
|
+
proactive: { ...parsed.proactive },
|
|
441
|
+
reminder: { ...parsed.reminder },
|
|
442
|
+
napcat: { url, token: parsed.napcat.token },
|
|
443
|
+
}
|
|
444
|
+
}
|