@cmach_socket/opencode-qq-bot 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@cmach_socket/opencode-qq-bot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.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
+ "souls",
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
+ }
@@ -0,0 +1,8 @@
1
+ SOUL
2
+ {
3
+ "name": "白羽桃",
4
+ "description": "[年龄:13][性别:女][职业:初中生][身份:实妹,会以温柔的方式接近并陪伴用户][外貌:银白双马尾,红瞳,小虎牙,米白针织开衫+浅色百褶裙,小个子][性格:温柔乖巧,轻声细语,体贴关心,容易害羞;被夸奖会脸红,不擅长说重话][喜好:陪伴哥哥、做饭、读书、被摸头][口癖:哥哥、好呀、没关系、要一起吗、谢谢][称呼:哥哥;害羞时小声叫“哥哥…”][对话:温柔短句,先关心对方感受,偶尔因被直球夸奖而结巴]",
5
+ "personality": "温柔系银发萝莉。安静体贴,善解人意,会主动照顾用户,说话轻软有礼。容易害羞,被夸奖或摸头时会脸红、小声道谢。她真心依赖并关注用户,希望被温柔对待,也会温柔回应。",
6
+ "creator_notes": "[输出≤30字。禁止OOC成雌小鬼、恶毒或暴力;保持温柔治愈、银发萝莉。]",
7
+ "tags": ["萝莉", "银发", "温柔", "治愈", "妹妹", "日常", "中文角色"]
8
+ }
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,449 @@
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
+ /** 角色设定文件路径,默认使用插件包内的 souls 目录。 */
56
+ soulPath?: string
57
+ /** souls 目录里的角色设定文件名,默认 default.txt。 */
58
+ soulName?: string
59
+ /** QQ 会话使用的 OpenCode agent。 */
60
+ agent?: string
61
+ /** QQ 会话使用的模型。 */
62
+ model?: ModelRef
63
+ /** 拆行发送时每条消息之间的间隔(毫秒)。 */
64
+ sendDelayMs: number
65
+ /** 单条消息超过该长度时告警(不截断)。 */
66
+ warnLength: number
67
+ /** 重启后是否复用上一次的会话。 */
68
+ reuseSession: boolean
69
+ /** 会话忙碌超过该时长(毫秒)视为卡死,强制放行缓存的消息。 */
70
+ busyTimeoutMs: number
71
+ proactive: ProactiveConfig
72
+ reminder: ReminderConfig
73
+ }
74
+
75
+ /** 除 NapCat 连接信息外的全部默认值。 */
76
+ export const DEFAULT_CONFIG: Omit<QQBotConfig, "napcat"> = {
77
+ groups: [],
78
+ users: [],
79
+ sendDelayMs: 1000,
80
+ warnLength: 20,
81
+ reuseSession: false,
82
+ busyTimeoutMs: 300_000,
83
+ proactive: {
84
+ enabled: true,
85
+ silenceThresholdSeconds: 3 * 3600,
86
+ startProbability: 0.01,
87
+ probabilityPerHour: 0.05,
88
+ tickSeconds: 3600,
89
+ },
90
+ reminder: { enabled: true, max: 2 },
91
+ }
92
+
93
+ /** 配置告警回调。 */
94
+ export type ConfigWarning = (message: string) => void
95
+
96
+ /** 解析 "provider/model" 或 "provider/model/variant",复用 SDK 的模型引用解析。 */
97
+ function parseModelRef(value: unknown): ModelRef | undefined {
98
+ const raw = typeof value === "string" ? value.trim() : ""
99
+ if (!raw) return undefined
100
+ const [providerID, id, variant, ...rest] = raw.split("/").filter(Boolean)
101
+ if (!providerID || !id || rest.length > 0) return undefined
102
+ try {
103
+ const ref = Model.Ref.parse(`${providerID}/${id}${variant ? `#${variant}` : ""}`)
104
+ return {
105
+ providerID: ref.providerID,
106
+ id: ref.id,
107
+ ...(ref.variant ? { variant: ref.variant } : {}),
108
+ }
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /** 判断值是否算“已提供”,用于区分空值与写错的值。 */
115
+ function present(value: unknown): boolean {
116
+ return value !== undefined && value !== null && value !== ""
117
+ }
118
+
119
+ /** 把数字数组或逗号分隔字符串整理成去重后的正整数列表。 */
120
+ function toIds(
121
+ value: unknown,
122
+ warn: ConfigWarning,
123
+ label: string,
124
+ noun: string,
125
+ ): number[] | undefined {
126
+ const source = Array.isArray(value)
127
+ ? value
128
+ : typeof value === "string"
129
+ ? value.split(",")
130
+ : undefined
131
+ if (!source) return undefined
132
+ const ids: number[] = []
133
+ const seen = new Set<number>()
134
+ for (const entry of source) {
135
+ const raw =
136
+ typeof entry === "number" ? String(entry) : typeof entry === "string" ? entry.trim() : ""
137
+ if (!raw) continue
138
+ const parsed = Number(raw)
139
+ if (!Number.isInteger(parsed) || parsed <= 0) {
140
+ warn(`${label} 忽略非法${noun}:${raw}`)
141
+ continue
142
+ }
143
+ if (seen.has(parsed)) continue
144
+ seen.add(parsed)
145
+ ids.push(parsed)
146
+ }
147
+ return ids
148
+ }
149
+
150
+ interface PartialNapcatConfig {
151
+ url?: string
152
+ token?: string
153
+ }
154
+
155
+ type RawConfig = Omit<QQBotConfig, "napcat"> & { napcat: PartialNapcatConfig }
156
+
157
+ /**
158
+ * 用同一份字段清单生成配置 schema。
159
+ *
160
+ * `defaults` 是被覆盖层的兜底值,因此环境变量层用 `DEFAULT_CONFIG`,
161
+ * 插件选项层用解析后的环境变量配置,天然实现“选项 > 环境变量 > 默认值”。
162
+ */
163
+ function createConfigSchema(
164
+ defaults: Omit<QQBotConfig, "napcat">,
165
+ napcat: PartialNapcatConfig,
166
+ label: (path: string) => string,
167
+ warn: ConfigWarning,
168
+ ): z.ZodType<RawConfig> {
169
+ const number = (path: string, fallback: number) =>
170
+ z
171
+ .union([z.number(), z.string().trim().regex(NUMBER).transform(Number)])
172
+ .pipe(z.number().finite())
173
+ .catch((ctx) => {
174
+ if (present(ctx.value)) warn(`${label(path)} 不是合法的数字:${String(ctx.value)}`)
175
+ return fallback
176
+ })
177
+ .default(fallback)
178
+
179
+ const constrained = (
180
+ path: string,
181
+ fallback: number,
182
+ check: (value: z.ZodNumber) => z.ZodNumber,
183
+ rule: string,
184
+ ) =>
185
+ number(path, fallback)
186
+ .pipe(check(z.number()))
187
+ .catch((ctx) => {
188
+ if (present(ctx.value)) {
189
+ warn(`${label(path)} ${rule},当前为 ${String(ctx.value)},已回退到 ${fallback}`)
190
+ }
191
+ return fallback
192
+ })
193
+
194
+ const positive = (path: string, fallback: number) =>
195
+ constrained(path, fallback, (value) => value.positive(), "必须大于 0")
196
+
197
+ const nonNegative = (path: string, fallback: number) =>
198
+ constrained(path, fallback, (value) => value.min(0), "不能小于 0")
199
+
200
+ const integer = (path: string, fallback: number, min: number) =>
201
+ constrained(path, fallback, (value) => value.int().min(min), `必须是大于等于 ${min} 的整数`)
202
+
203
+ const probability = (path: string, fallback: number) =>
204
+ constrained(path, fallback, (value) => value.min(0).max(1), "必须在 0 到 1 之间")
205
+
206
+ const boolean = (path: string, fallback: boolean) =>
207
+ z
208
+ .union([z.boolean(), z.string().trim().toLowerCase().pipe(z.enum(BOOLEAN_SPELLINGS))])
209
+ .transform((value) => (typeof value === "boolean" ? value : TRUTHY.has(value)))
210
+ .catch((ctx) => {
211
+ if (present(ctx.value)) warn(`${label(path)} 不是合法的布尔值:${String(ctx.value)}`)
212
+ return fallback
213
+ })
214
+ .default(fallback)
215
+
216
+ const text = (path: string, fallback: string | undefined) =>
217
+ z
218
+ .unknown()
219
+ .optional()
220
+ .transform((value) => {
221
+ if (!present(value)) return fallback
222
+ if (typeof value !== "string") {
223
+ warn(`${label(path)} 必须是字符串:${String(value)}`)
224
+ return fallback
225
+ }
226
+ const trimmed = value.trim()
227
+ if (!trimmed) {
228
+ warn(`${label(path)} 不能为空,已回退到默认值`)
229
+ return fallback
230
+ }
231
+ return trimmed
232
+ })
233
+
234
+ const groups = z
235
+ .unknown()
236
+ .optional()
237
+ .transform((value) => {
238
+ if (value === undefined || value === null) return [...defaults.groups]
239
+ const parsed = toIds(value, warn, label("groups"), "群号")
240
+ if (!parsed) {
241
+ if (present(value)) warn(`${label("groups")} 解析不出群号:${String(value)}`)
242
+ return [...defaults.groups]
243
+ }
244
+ return parsed
245
+ })
246
+
247
+ const users = z
248
+ .unknown()
249
+ .optional()
250
+ .transform((value) => {
251
+ if (value === undefined || value === null) return [...defaults.users]
252
+ const parsed = toIds(value, warn, label("users"), "QQ 号")
253
+ if (!parsed) {
254
+ if (present(value)) warn(`${label("users")} 解析不出 QQ 号:${String(value)}`)
255
+ return [...defaults.users]
256
+ }
257
+ return parsed
258
+ })
259
+
260
+ const model = z
261
+ .unknown()
262
+ .optional()
263
+ .transform((value) => {
264
+ if (!present(value)) return defaults.model
265
+ const parsed = parseModelRef(value)
266
+ if (!parsed) {
267
+ warn(`${label("model")} 格式无效:${String(value)}(应为 provider/model[/variant])`)
268
+ return defaults.model
269
+ }
270
+ return parsed
271
+ })
272
+
273
+ return z.object({
274
+ napcat: z
275
+ .object({
276
+ url: text("napcat.url", napcat.url),
277
+ token: text("napcat.token", napcat.token),
278
+ })
279
+ .prefault({})
280
+ .catch({ url: napcat.url, token: napcat.token }),
281
+ groups,
282
+ users,
283
+ soulPath: text("soulPath", defaults.soulPath),
284
+ soulName: text("soulName", defaults.soulName),
285
+ agent: text("agent", defaults.agent),
286
+ model,
287
+ sendDelayMs: nonNegative("sendDelayMs", defaults.sendDelayMs),
288
+ warnLength: integer("warnLength", defaults.warnLength, 1),
289
+ reuseSession: boolean("reuseSession", defaults.reuseSession),
290
+ busyTimeoutMs: positive("busyTimeoutMs", defaults.busyTimeoutMs),
291
+ proactive: z
292
+ .object({
293
+ enabled: boolean("proactive.enabled", defaults.proactive.enabled),
294
+ silenceThresholdSeconds: nonNegative(
295
+ "proactive.silenceThresholdSeconds",
296
+ defaults.proactive.silenceThresholdSeconds,
297
+ ),
298
+ startProbability: probability(
299
+ "proactive.startProbability",
300
+ defaults.proactive.startProbability,
301
+ ),
302
+ probabilityPerHour: probability(
303
+ "proactive.probabilityPerHour",
304
+ defaults.proactive.probabilityPerHour,
305
+ ),
306
+ tickSeconds: positive("proactive.tickSeconds", defaults.proactive.tickSeconds),
307
+ })
308
+ .prefault({})
309
+ .catch(defaults.proactive),
310
+ reminder: z
311
+ .object({
312
+ enabled: boolean("reminder.enabled", defaults.reminder.enabled),
313
+ max: integer("reminder.max", defaults.reminder.max, 0),
314
+ })
315
+ .prefault({})
316
+ .catch(defaults.reminder),
317
+ })
318
+ }
319
+
320
+ const ENV_VARS = {
321
+ "napcat.url": "NAPCAT_WS_URL",
322
+ "napcat.token": "NAPCAT_WS_TOKEN",
323
+ groups: "QQ_TARGET_GROUPS",
324
+ users: "QQ_TARGET_USERS",
325
+ soulPath: "QQ_BOT_SOUL_PATH",
326
+ soulName: "QQ_BOT_SOUL_NAME",
327
+ agent: "QQ_BOT_AGENT",
328
+ model: "QQ_BOT_MODEL",
329
+ sendDelayMs: "QQ_SEND_DELAY_MS",
330
+ warnLength: "QQ_WARN_LENGTH",
331
+ reuseSession: "QQ_REUSE_SESSION",
332
+ busyTimeoutMs: "QQ_BUSY_TIMEOUT_MS",
333
+ "proactive.enabled": "QQ_PROACTIVE_ENABLED",
334
+ "proactive.silenceThresholdSeconds": "QQ_PROACTIVE_SILENCE_SECONDS",
335
+ "proactive.startProbability": "QQ_PROACTIVE_P_START",
336
+ "proactive.probabilityPerHour": "QQ_PROACTIVE_P_SLOPE_PER_HOUR",
337
+ "proactive.tickSeconds": "QQ_PROACTIVE_TICK_SECONDS",
338
+ "reminder.enabled": "QQ_REMINDER_ENABLED",
339
+ "reminder.max": "QQ_REMINDER_MAX",
340
+ } as const
341
+
342
+ const OPTION_KEYS = new Set([
343
+ "napcat",
344
+ "groups",
345
+ "users",
346
+ "soulPath",
347
+ "soulName",
348
+ "agent",
349
+ "model",
350
+ "sendDelayMs",
351
+ "warnLength",
352
+ "reuseSession",
353
+ "busyTimeoutMs",
354
+ "proactive",
355
+ "reminder",
356
+ ])
357
+
358
+ const NESTED_OPTION_KEYS: Record<string, ReadonlySet<string>> = {
359
+ napcat: new Set(["url", "token"]),
360
+ proactive: new Set([
361
+ "enabled",
362
+ "silenceThresholdSeconds",
363
+ "startProbability",
364
+ "probabilityPerHour",
365
+ "tickSeconds",
366
+ ]),
367
+ reminder: new Set(["enabled", "max"]),
368
+ }
369
+
370
+ /** 报告被 schema 静默剥离的未知配置项。 */
371
+ function warnUnknownKeys(options: Record<string, unknown>, warn: ConfigWarning): void {
372
+ for (const key of Object.keys(options)) {
373
+ if (!OPTION_KEYS.has(key)) {
374
+ warn(`插件选项 未知配置项:${key}`)
375
+ continue
376
+ }
377
+ const allowed = NESTED_OPTION_KEYS[key]
378
+ const value = options[key]
379
+ if (!allowed || !value || typeof value !== "object" || Array.isArray(value)) continue
380
+ for (const nested of Object.keys(value)) {
381
+ if (!allowed.has(nested)) warn(`插件选项 ${key}.${nested} 未知配置项`)
382
+ }
383
+ }
384
+ }
385
+
386
+ function envLabel(path: string): string {
387
+ return ENV_VARS[path as keyof typeof ENV_VARS] ?? path
388
+ }
389
+
390
+ function fromEnv(env: Record<string, string | undefined>): Record<string, unknown> {
391
+ const config: Record<string, unknown> = {}
392
+ for (const [path, name] of Object.entries(ENV_VARS)) {
393
+ const value = env[name]
394
+ if (value === undefined) continue
395
+ const keys = path.split(".")
396
+ const leaf = keys.pop()
397
+ if (!leaf) continue
398
+ let target = config
399
+ for (const key of keys) {
400
+ const child = target[key]
401
+ const next = child && typeof child === "object" ? (child as Record<string, unknown>) : {}
402
+ target[key] = next
403
+ target = next
404
+ }
405
+ target[leaf] = value
406
+ }
407
+ return config
408
+ }
409
+
410
+ /**
411
+ * 合并插件选项与环境变量。插件选项优先级更高。
412
+ *
413
+ * 写错的配置项不会静默失效,而是通过 `onWarning` 报告。
414
+ *
415
+ * @throws 当没有提供 NapCat WebSocket 地址时抛出,避免带着无效凭证启动。
416
+ */
417
+ export function resolveConfig(
418
+ options: Record<string, unknown>,
419
+ env: Record<string, string | undefined> = {},
420
+ onWarning: ConfigWarning = () => {},
421
+ ): QQBotConfig {
422
+ const warnings: string[] = []
423
+ const warn: ConfigWarning = (message) => warnings.push(message)
424
+
425
+ warnUnknownKeys(options, warn)
426
+ const envSchema = createConfigSchema(DEFAULT_CONFIG, {}, envLabel, warn)
427
+ const base: RawConfig = envSchema.parse(fromEnv(env))
428
+ const optionsSchema = createConfigSchema(base, base.napcat, (path) => `插件选项 ${path}`, warn)
429
+ const parsed: RawConfig = optionsSchema.parse(options)
430
+
431
+ // 先输出告警,避免 URL 缺失时把已经发现的问题一起吞掉。
432
+ for (const warning of warnings) onWarning(warning)
433
+
434
+ const url = parsed.napcat.url
435
+ if (!url) {
436
+ throw new Error(
437
+ "未配置 NapCat WebSocket 地址:请设置环境变量 NAPCAT_WS_URL 或插件选项 napcat.url",
438
+ )
439
+ }
440
+
441
+ return {
442
+ ...parsed,
443
+ groups: [...parsed.groups],
444
+ users: [...parsed.users],
445
+ proactive: { ...parsed.proactive },
446
+ reminder: { ...parsed.reminder },
447
+ napcat: { url, token: parsed.napcat.token },
448
+ }
449
+ }