ework-qq-bridge 0.5.1 → 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/chat.ts +11 -2
- package/src/config.ts +11 -0
- package/src/index.ts +3 -0
- package/src/router.ts +6 -6
- package/tests/bridge.test.ts +132 -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/chat.ts
CHANGED
|
@@ -76,12 +76,17 @@ export function buildChatMessages(
|
|
|
76
76
|
];
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export function stripThink(text: string): string {
|
|
80
|
+
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
79
83
|
export async function chatComplete(
|
|
80
84
|
apiBase: string,
|
|
81
85
|
apiKey: string,
|
|
82
86
|
model: string,
|
|
83
87
|
messages: { role: string; name?: string; content: string }[],
|
|
84
88
|
timeoutMs: number,
|
|
89
|
+
noThink = true,
|
|
85
90
|
): Promise<string> {
|
|
86
91
|
const ctrl = new AbortController();
|
|
87
92
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
@@ -89,14 +94,18 @@ export async function chatComplete(
|
|
|
89
94
|
const res = await fetch(`${apiBase.replace(/\/+$/, "")}/chat/completions`, {
|
|
90
95
|
method: "POST",
|
|
91
96
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
92
|
-
body: JSON.stringify(
|
|
97
|
+
body: JSON.stringify(
|
|
98
|
+
noThink
|
|
99
|
+
? { model, messages, max_tokens: 1024, temperature: 0.4, chat_template_kwargs: { enable_thinking: false } }
|
|
100
|
+
: { model, messages, max_tokens: 1024, temperature: 0.4 },
|
|
101
|
+
),
|
|
93
102
|
signal: ctrl.signal,
|
|
94
103
|
});
|
|
95
104
|
if (!res.ok) {
|
|
96
105
|
throw new Error(`LLM ${res.status}: ${(await res.text()).slice(0, 120)}`);
|
|
97
106
|
}
|
|
98
107
|
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
99
|
-
const text = data.choices?.[0]?.message?.content
|
|
108
|
+
const text = stripThink(data.choices?.[0]?.message?.content ?? "");
|
|
100
109
|
if (!text) throw new Error("LLM returned empty content");
|
|
101
110
|
return text;
|
|
102
111
|
} finally {
|
package/src/config.ts
CHANGED
|
@@ -35,6 +35,14 @@ const Schema = z.object({
|
|
|
35
35
|
// Token budget (heuristic estimate) for one chat request; when history
|
|
36
36
|
// exceeds it the oldest turns are dropped ("满了就清理").
|
|
37
37
|
WORK_CHAT_MAX_CONTEXT: z.coerce.number().int().default(50000),
|
|
38
|
+
// Disable the model's thinking pass for QQ chat replies only (default on:
|
|
39
|
+
// "仅仅这个机器人关掉"); "0" restores thinking. Daemon/opencode sessions
|
|
40
|
+
// are untouched — they use their own runtime.
|
|
41
|
+
WORK_CHAT_NO_THINK: z.preprocess((v) => (v === undefined || v === "" ? "1" : v), z.string()).transform((v) => v !== "0"),
|
|
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(""),
|
|
38
46
|
|
|
39
47
|
// QQ user_ids allowed to dispatch AI work (comma-separated). Messages from
|
|
40
48
|
// other members are logged and ignored — same trust model as the GitHub
|
|
@@ -104,5 +112,8 @@ export function loadConfig(): Config {
|
|
|
104
112
|
if (!cfg.WORK_BINDINGS_FILE) {
|
|
105
113
|
cfg.WORK_BINDINGS_FILE = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/bindings.json`;
|
|
106
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
|
+
}
|
|
107
118
|
return cfg;
|
|
108
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,16 +49,14 @@ 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
|
-
const answer = await chatComplete(cfg.WORK_CHAT_API, cfg.WORK_CHAT_API_KEY, cfg.WORK_CHAT_MODEL, messages, cfg.WORK_CHAT_TIMEOUT_MS);
|
|
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) }]);
|
|
61
61
|
for (const part of splitForQQ(answer)) {
|
|
62
62
|
await deps.reply(ev.groupId, part);
|
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 },
|
|
@@ -361,3 +367,129 @@ describe("pure-API chat", () => {
|
|
|
361
367
|
}
|
|
362
368
|
});
|
|
363
369
|
});
|
|
370
|
+
|
|
371
|
+
describe("no-think", () => {
|
|
372
|
+
const { stripThink } = require("../src/chat");
|
|
373
|
+
|
|
374
|
+
test("stripThink removes inline think blocks", () => {
|
|
375
|
+
expect(stripThink("<think>internal</think>答案")).toBe("答案");
|
|
376
|
+
expect(stripThink("<think>a</think>前<think>b</think>后")).toBe("前后");
|
|
377
|
+
expect(stripThink("普通回复")).toBe("普通回复");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("chat request carries enable_thinking:false by default", async () => {
|
|
381
|
+
const origFetch = globalThis.fetch;
|
|
382
|
+
let sent: Record<string, unknown> = {};
|
|
383
|
+
globalThis.fetch = (async (_u: unknown, init?: { body?: string }) => {
|
|
384
|
+
sent = JSON.parse(String(init?.body));
|
|
385
|
+
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200 });
|
|
386
|
+
}) as typeof fetch;
|
|
387
|
+
try {
|
|
388
|
+
const { chatComplete } = require("../src/chat");
|
|
389
|
+
const out = await chatComplete("http://x/v1", "k", "m", [{ role: "user", content: "q" }], 1000);
|
|
390
|
+
expect(out).toBe("ok");
|
|
391
|
+
expect((sent.chat_template_kwargs as { enable_thinking?: boolean })?.enable_thinking).toBe(false);
|
|
392
|
+
await chatComplete("http://x/v1", "k", "m", [{ role: "user", content: "q" }], 1000, false);
|
|
393
|
+
expect(sent.chat_template_kwargs).toBeUndefined();
|
|
394
|
+
} finally {
|
|
395
|
+
globalThis.fetch = origFetch;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("thinking payload is stripped before replying", async () => {
|
|
400
|
+
const origFetch = globalThis.fetch;
|
|
401
|
+
globalThis.fetch = (async () => new Response(JSON.stringify({ choices: [{ message: { content: "<think>隐藏推理</think>可见答案" } }] }), { status: 200 })) as typeof fetch;
|
|
402
|
+
try {
|
|
403
|
+
const { createRouter } = require("../src/router");
|
|
404
|
+
const replies: string[] = [];
|
|
405
|
+
const router = createRouter({
|
|
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 },
|
|
407
|
+
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
|
|
408
|
+
chatHistory: new ChatHistoryStore(histFile()),
|
|
409
|
+
wakeList: new Set(["1"]),
|
|
410
|
+
ework: { createIssue: async () => 9, addComment: async () => {} },
|
|
411
|
+
store: { seenPost: () => false },
|
|
412
|
+
reply: async (_g: number, x: string) => { replies.push(x); },
|
|
413
|
+
});
|
|
414
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "nt1", rawMessage: "[CQ:at,qq=2661222094] 问" });
|
|
415
|
+
expect(replies).toEqual(["可见答案"]);
|
|
416
|
+
} finally {
|
|
417
|
+
globalThis.fetch = origFetch;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
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
|
+
});
|