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 +1 -1
- package/src/chat-store.ts +56 -0
- package/src/config.ts +7 -0
- package/src/index.ts +3 -0
- package/src/router.ts +5 -5
- package/tests/bridge.test.ts +82 -0
package/package.json
CHANGED
|
@@ -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
|
|
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)
|
|
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) }]);
|
package/tests/bridge.test.ts
CHANGED
|
@@ -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
|
+
});
|