ework-qq-bridge 0.5.2 → 0.5.3

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,6 @@
1
1
  {
2
2
  "name": "ework-qq-bridge",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "QQ group <-> ework issue bridge (OneBot 11 reverse WebSocket)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,56 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { ChatTurn } from "./chat";
4
+
5
+ // Persistent @bot chat history: group -> turns, survives restarts.
6
+ // No trimming here — set() callers must pass already-trimmed arrays
7
+ // (same trimStored policy as before), keeping the file bounded.
8
+ type HistoryFile = Record<string, ChatTurn[]>;
9
+
10
+ function validTurn(v: unknown): v is ChatTurn {
11
+ if (typeof v !== "object" || v === null) return false;
12
+ const t = v as { role?: unknown; content?: unknown };
13
+ return (t.role === "user" || t.role === "assistant") && typeof t.content === "string";
14
+ }
15
+
16
+ export class ChatHistoryStore {
17
+ private history = new Map<number, ChatTurn[]>();
18
+
19
+ constructor(private file: string) {
20
+ try {
21
+ if (existsSync(file)) {
22
+ const raw = JSON.parse(readFileSync(file, "utf8")) as HistoryFile;
23
+ for (const [k, v] of Object.entries(raw)) {
24
+ if (Number.isInteger(Number(k)) && Array.isArray(v)) {
25
+ const turns = v.filter(validTurn);
26
+ if (turns.length > 0) this.history.set(Number(k), turns);
27
+ }
28
+ }
29
+ }
30
+ } catch (err) {
31
+ console.warn(`[qq-bridge] chat history file unreadable, starting clean: ${err instanceof Error ? err.message : err}`);
32
+ }
33
+ }
34
+
35
+ private persist(): void {
36
+ const out: HistoryFile = {};
37
+ for (const [g, turns] of this.history) out[String(g)] = turns;
38
+ try {
39
+ mkdirSync(dirname(this.file), { recursive: true });
40
+ const tmp = `${this.file}.tmp`;
41
+ writeFileSync(tmp, JSON.stringify(out));
42
+ renameSync(tmp, this.file);
43
+ } catch (err) {
44
+ console.error(`[qq-bridge] failed to persist chat history: ${err instanceof Error ? err.message : err}`);
45
+ }
46
+ }
47
+
48
+ get(groupId: number): ChatTurn[] {
49
+ return this.history.get(groupId) ?? [];
50
+ }
51
+
52
+ set(groupId: number, turns: ChatTurn[]): void {
53
+ this.history.set(groupId, turns);
54
+ this.persist();
55
+ }
56
+ }
package/src/config.ts CHANGED
@@ -40,6 +40,10 @@ const Schema = z.object({
40
40
  // are untouched — they use their own runtime.
41
41
  WORK_CHAT_NO_THINK: z.preprocess((v) => (v === undefined || v === "" ? "1" : v), z.string()).transform((v) => v !== "0"),
42
42
 
43
+ // Where @bot chat history is persisted (JSON, group -> turns). Restarting
44
+ // the bridge no longer clears conversations.
45
+ WORK_CHAT_HISTORY_FILE: z.string().default(""),
46
+
43
47
  // QQ user_ids allowed to dispatch AI work (comma-separated). Messages from
44
48
  // other members are logged and ignored — same trust model as the GitHub
45
49
  // side (WORK_WAKE_LOGINS): strangers never wake the AI.
@@ -108,5 +112,8 @@ export function loadConfig(): Config {
108
112
  if (!cfg.WORK_BINDINGS_FILE) {
109
113
  cfg.WORK_BINDINGS_FILE = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/bindings.json`;
110
114
  }
115
+ if (!cfg.WORK_CHAT_HISTORY_FILE) {
116
+ cfg.WORK_CHAT_HISTORY_FILE = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/chat-history.json`;
117
+ }
111
118
  return cfg;
112
119
  }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { loadConfig, parseGroupMap, parseList } from "./config";
2
2
  import { BridgeStore } from "./db";
3
3
  import { BindingStore } from "./bindings";
4
+ import { ChatHistoryStore } from "./chat-store";
4
5
  import { createOneBotServer, type OneBotApi, type GroupMessageEvent } from "./onebot";
5
6
  import { createRouter } from "./router";
6
7
  import { createIngest } from "./ingest";
@@ -16,6 +17,7 @@ async function main() {
16
17
  const store = new BridgeStore(cfg.DB_PATH || "/tmp/ework-qq-bridge.db");
17
18
 
18
19
  const bindings = new BindingStore(parseGroupMap(cfg.GROUP_MAP), cfg.WORK_BINDINGS_FILE);
20
+ const chatHistory = new ChatHistoryStore(cfg.WORK_CHAT_HISTORY_FILE);
19
21
 
20
22
  let api: OneBotApi | null = null;
21
23
  const send = async (groupId: number, text: string) => {
@@ -28,6 +30,7 @@ const store = new BridgeStore(cfg.DB_PATH || "/tmp/ework-qq-bridge.db");
28
30
  const router = createRouter({
29
31
  cfg,
30
32
  bindings,
33
+ chatHistory,
31
34
  wakeList: new Set(parseList(cfg.QQ_WAKE_LIST)),
32
35
  ework,
33
36
  store,
package/src/router.ts CHANGED
@@ -3,6 +3,7 @@ import type { EworkClient } from "./ework";
3
3
  import type { GroupMessageEvent } from "./onebot";
4
4
  import type { BridgeStore } from "./db";
5
5
  import type { BindingStore } from "./bindings";
6
+ import type { ChatHistoryStore } from "./chat-store";
6
7
  import { buildChatMessages, capContent, chatComplete, splitForQQ, trimStored, type ChatTurn } from "./chat";
7
8
 
8
9
  const HELP_TEXT = [
@@ -12,13 +13,14 @@ const HELP_TEXT = [
12
13
  " 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
13
14
  " 解绑 —— 恢复为项目模式(接收整个项目的回复)",
14
15
  " 查询 —— 列出最近 issue",
15
- " @我 <问题> —— 即时问答(纯 API,不留 issue,上下文满自动清理)",
16
+ " @我 <问题> —— 即时问答(纯 API 直连 bili 压缩,历史落盘,重启不清)",
16
17
  " (绑定后:普通发言进绑定的 issue,AI 回复自动回群)",
17
18
  ].join("\n");
18
19
 
19
20
  export interface RouterDeps {
20
21
  cfg: Config;
21
22
  bindings: BindingStore;
23
+ chatHistory: ChatHistoryStore;
22
24
  wakeList: Set<string>;
23
25
  ework: EworkClient;
24
26
  store: BridgeStore;
@@ -47,14 +49,12 @@ export function parseCommand(raw: string): ParsedCommand | null {
47
49
  }
48
50
 
49
51
  export function createRouter(deps: RouterDeps) {
50
- const { cfg, bindings, wakeList, ework, store } = deps;
51
- const chatHistory = new Map<number, ChatTurn[]>();
52
+ const { cfg, bindings, chatHistory, wakeList, ework, store } = deps;
52
53
 
53
54
  async function answerChat(ev: GroupMessageEvent, question: string): Promise<void> {
54
55
  try {
55
56
  const turn: ChatTurn = { role: "user", name: ev.nickname, content: capContent(question) };
56
- const stored = trimStored(chatHistory.get(ev.groupId) ?? [], cfg.WORK_CHAT_MAX_HISTORY, cfg.WORK_CHAT_MAX_CONTEXT);
57
- chatHistory.set(ev.groupId, stored);
57
+ const stored = trimStored(chatHistory.get(ev.groupId), cfg.WORK_CHAT_MAX_HISTORY, cfg.WORK_CHAT_MAX_CONTEXT);
58
58
  const messages = buildChatMessages(stored, turn);
59
59
  const answer = await chatComplete(cfg.WORK_CHAT_API, cfg.WORK_CHAT_API_KEY, cfg.WORK_CHAT_MODEL, messages, cfg.WORK_CHAT_TIMEOUT_MS, cfg.WORK_CHAT_NO_THINK);
60
60
  chatHistory.set(ev.groupId, [...stored, turn, { role: "assistant", name: "bot", content: capContent(answer) }]);
@@ -1,9 +1,11 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { parseCommand } from "../src/router";
3
3
  import { BindingStore } from "../src/bindings";
4
+ import { ChatHistoryStore } from "../src/chat-store";
4
5
  import { tmpdir } from "node:os";
5
6
  import { randomUUID } from "node:crypto";
6
7
  const pinFile = () => `${tmpdir()}/qqb-test-${randomUUID()}.json`;
8
+ const histFile = () => `${tmpdir()}/qqb-hist-${randomUUID()}.json`;
7
9
  import { parseGroupMap, parseList } from "../src/config";
8
10
  import { buildScrubber } from "../src/scrub";
9
11
  import { verifySignature } from "../src/ingest";
@@ -88,6 +90,7 @@ describe("@bot unified routing", () => {
88
90
  const router = createRouter({
89
91
  cfg: { VERBOSE: false },
90
92
  bindings: bs,
93
+ chatHistory: new ChatHistoryStore(histFile()),
91
94
  wakeList: new Set(["1"]),
92
95
  ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
93
96
  store: { seenPost: () => false },
@@ -180,6 +183,7 @@ describe("issue pinning", () => {
180
183
  const router = createRouter({
181
184
  cfg: { VERBOSE: false },
182
185
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
186
+ chatHistory: new ChatHistoryStore(histFile()),
183
187
  wakeList: new Set(["1"]),
184
188
  ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
185
189
  store: { seenPost: () => false },
@@ -324,6 +328,7 @@ describe("pure-API chat", () => {
324
328
  const router = createRouter({
325
329
  cfg: { VERBOSE: false, WORK_CHAT_API: "http://x/v1", WORK_CHAT_API_KEY: "k", WORK_CHAT_MODEL: "m", WORK_CHAT_TIMEOUT_MS: 1000, WORK_CHAT_MAX_HISTORY: 20, WORK_CHAT_MAX_CONTEXT: 50000 },
326
330
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
331
+ chatHistory: new ChatHistoryStore(histFile()),
327
332
  wakeList: new Set(["1"]),
328
333
  ework: { createIssue: async () => 9, addComment: async (...a: unknown[]) => { comments.push(a); } },
329
334
  store: { seenPost: () => false },
@@ -349,6 +354,7 @@ describe("pure-API chat", () => {
349
354
  const router = createRouter({
350
355
  cfg: { VERBOSE: false, WORK_CHAT_API: "http://x/v1", WORK_CHAT_API_KEY: "k", WORK_CHAT_MODEL: "m", WORK_CHAT_TIMEOUT_MS: 1000, WORK_CHAT_MAX_HISTORY: 20, WORK_CHAT_MAX_CONTEXT: 50000 },
351
356
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
357
+ chatHistory: new ChatHistoryStore(histFile()),
352
358
  wakeList: new Set(["1"]),
353
359
  ework: { createIssue: async () => 9, addComment: async () => {} },
354
360
  store: { seenPost: () => false },
@@ -399,6 +405,7 @@ describe("no-think", () => {
399
405
  const router = createRouter({
400
406
  cfg: { VERBOSE: false, WORK_CHAT_API: "http://x/v1", WORK_CHAT_API_KEY: "k", WORK_CHAT_MODEL: "m", WORK_CHAT_TIMEOUT_MS: 1000, WORK_CHAT_MAX_HISTORY: 20, WORK_CHAT_MAX_CONTEXT: 50000, WORK_CHAT_NO_THINK: true },
401
407
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
408
+ chatHistory: new ChatHistoryStore(histFile()),
402
409
  wakeList: new Set(["1"]),
403
410
  ework: { createIssue: async () => 9, addComment: async () => {} },
404
411
  store: { seenPost: () => false },
@@ -411,3 +418,78 @@ describe("no-think", () => {
411
418
  }
412
419
  });
413
420
  });
421
+
422
+ describe("chat history persistence", () => {
423
+ const { ChatHistoryStore } = require("../src/chat-store");
424
+ const { trimStored } = require("../src/chat");
425
+ const { writeFileSync } = require("node:fs");
426
+
427
+ test("set then reload returns same turns", () => {
428
+ const f = histFile();
429
+ const a = new ChatHistoryStore(f);
430
+ a.set(1, [{ role: "user", content: "早" }, { role: "assistant", content: "早呀" }]);
431
+ a.set(2, [{ role: "user", content: "群二的问题" }]);
432
+ const b = new ChatHistoryStore(f);
433
+ expect(b.get(1)).toEqual([{ role: "user", content: "早" }, { role: "assistant", content: "早呀" }]);
434
+ expect(b.get(2)).toEqual([{ role: "user", content: "群二的问题" }]);
435
+ expect(b.get(3)).toEqual([]);
436
+ });
437
+
438
+ test("invalid file starts clean", () => {
439
+ const f = histFile();
440
+ writeFileSync(f, "{not json");
441
+ const s = new ChatHistoryStore(f);
442
+ expect(s.get(1)).toEqual([]);
443
+ s.set(1, [{ role: "user", content: "x" }]);
444
+ expect(new ChatHistoryStore(f).get(1)).toEqual([{ role: "user", content: "x" }]);
445
+ });
446
+
447
+ test("invalid turns filtered on load, groups isolated", () => {
448
+ const f = histFile();
449
+ writeFileSync(f, JSON.stringify({ "1": [{ role: "system", content: "bad" }, { role: "user", content: "ok" }], "2": "nope" }));
450
+ const s = new ChatHistoryStore(f);
451
+ expect(s.get(1)).toEqual([{ role: "user", content: "ok" }]);
452
+ expect(s.get(2)).toEqual([]);
453
+ });
454
+
455
+ test("evicted turns stay evicted across reload", () => {
456
+ const f = histFile();
457
+ const turns = Array.from({ length: 50 }, (_, i) => ({ role: "user" as const, content: `msg${i}` }));
458
+ const trimmed = trimStored(turns, 10, 500000);
459
+ expect(trimmed.length).toBeLessThan(50);
460
+ new ChatHistoryStore(f).set(7, trimmed);
461
+ const re = new ChatHistoryStore(f);
462
+ expect(re.get(7)).toEqual(trimmed);
463
+ const again = trimStored(re.get(7), 10, 500000);
464
+ expect(again.length).toBe(trimmed.length);
465
+ });
466
+
467
+ test("router chat survives simulated restart", async () => {
468
+ const origFetch = globalThis.fetch;
469
+ globalThis.fetch = (async () => new Response(JSON.stringify({ choices: [{ message: { content: "第一答" } }] }), { status: 200 })) as typeof fetch;
470
+ try {
471
+ const { createRouter } = require("../src/router");
472
+ const f = histFile();
473
+ const mk2 = () => {
474
+ const replies: string[] = [];
475
+ const router = createRouter({
476
+ cfg: { VERBOSE: false, WORK_CHAT_API: "http://x/v1", WORK_CHAT_API_KEY: "k", WORK_CHAT_MODEL: "m", WORK_CHAT_TIMEOUT_MS: 1000, WORK_CHAT_MAX_HISTORY: 20, WORK_CHAT_MAX_CONTEXT: 50000 },
477
+ bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
478
+ chatHistory: new ChatHistoryStore(f),
479
+ wakeList: new Set(["1"]),
480
+ ework: { createIssue: async () => 9, addComment: async () => {} },
481
+ store: { seenPost: () => false },
482
+ reply: async (_g: number, x: string) => { replies.push(x); },
483
+ });
484
+ return { router, replies };
485
+ };
486
+ const first = mk2();
487
+ await first.router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "r1", rawMessage: "[CQ:at,qq=2661222094] 记住暗号是西瓜" });
488
+ const second = mk2();
489
+ await second.router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "r2", rawMessage: "[CQ:at,qq=2661222094] 我刚才说了什么" });
490
+ expect(new ChatHistoryStore(f).get(1).map((t: { content: string }) => t.content)).toContain("记住暗号是西瓜");
491
+ } finally {
492
+ globalThis.fetch = origFetch;
493
+ }
494
+ });
495
+ });