ework-qq-bridge 0.2.0 → 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 +1 -1
- package/src/bindings.ts +69 -0
- package/src/config.ts +13 -4
- package/src/index.ts +3 -4
- package/src/ingest.ts +4 -4
- package/src/onebot.ts +15 -6
- package/src/router.ts +35 -9
- package/tests/bridge.test.ts +108 -3
package/package.json
CHANGED
package/src/bindings.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type { GroupBinding } from "./config";
|
|
4
|
+
|
|
5
|
+
// Runtime issue pins: group -> issue number, persisted across restarts.
|
|
6
|
+
// owner/repo always come from the static GROUP_MAP entry; the pin only
|
|
7
|
+
// narrows which single issue the group is bound to.
|
|
8
|
+
type PinFile = Record<string, number>;
|
|
9
|
+
|
|
10
|
+
export class BindingStore {
|
|
11
|
+
private pins = new Map<number, number>();
|
|
12
|
+
|
|
13
|
+
constructor(private base: GroupBinding[], private file: string) {
|
|
14
|
+
try {
|
|
15
|
+
if (existsSync(file)) {
|
|
16
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as PinFile;
|
|
17
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
18
|
+
if (Number.isInteger(Number(k)) && Number.isInteger(v)) {
|
|
19
|
+
this.pins.set(Number(k), v);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.warn(`[qq-bridge] bindings file unreadable, starting clean: ${err instanceof Error ? err.message : err}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private persist(): void {
|
|
29
|
+
const out: PinFile = {};
|
|
30
|
+
for (const [g, n] of this.pins) out[String(g)] = n;
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
33
|
+
writeFileSync(this.file, JSON.stringify(out, null, 2) + "\n");
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(`[qq-bridge] failed to persist bindings: ${err instanceof Error ? err.message : err}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resolve(groupId: number): GroupBinding | null {
|
|
40
|
+
const base = this.base.find((b) => b.groupId === groupId);
|
|
41
|
+
if (!base) return null;
|
|
42
|
+
const pinned = this.pins.get(groupId);
|
|
43
|
+
if (pinned === undefined) return base;
|
|
44
|
+
return { ...base, issue: pinned };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
all(): GroupBinding[] {
|
|
48
|
+
return this.base.map((b) => this.resolve(b.groupId)!);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
groupsFor(owner: string, repo: string, issueNumber: number): number[] {
|
|
52
|
+
return this.all()
|
|
53
|
+
.filter((b) => b.owner === owner && b.repo === repo && (b.issue === undefined || b.issue === issueNumber))
|
|
54
|
+
.map((b) => b.groupId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pin(groupId: number, issue: number): GroupBinding | null {
|
|
58
|
+
if (!this.base.some((b) => b.groupId === groupId)) return null;
|
|
59
|
+
this.pins.set(groupId, issue);
|
|
60
|
+
this.persist();
|
|
61
|
+
return this.resolve(groupId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
unpin(groupId: number): boolean {
|
|
65
|
+
if (!this.pins.delete(groupId)) return false;
|
|
66
|
+
this.persist();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -16,10 +16,15 @@ const Schema = z.object({
|
|
|
16
16
|
// comment events here so agent replies can be pushed back to the group.
|
|
17
17
|
EWORK_WEBHOOK_SECRET: z.string().default(""),
|
|
18
18
|
|
|
19
|
-
// group_id -> owner/repo mapping
|
|
20
|
-
// "123456789:ranxianglei/billion-context,987654321:dog/test1"
|
|
19
|
+
// group_id -> owner/repo mapping, optionally pinned to one issue:
|
|
20
|
+
// "123456789:ranxianglei/billion-context#7,987654321:dog/test1"
|
|
21
|
+
// A `#N` suffix binds the group to that single issue (long-memory mode);
|
|
22
|
+
// without it the group gets all agent replies from the whole repo.
|
|
21
23
|
GROUP_MAP: z.string().min(1),
|
|
22
24
|
|
|
25
|
+
// Runtime pin overrides written by the 绑定/解绑 commands (JSON, group -> issue).
|
|
26
|
+
WORK_BINDINGS_FILE: z.string().default(""),
|
|
27
|
+
|
|
23
28
|
// QQ user_ids allowed to dispatch AI work (comma-separated). Messages from
|
|
24
29
|
// other members are logged and ignored — same trust model as the GitHub
|
|
25
30
|
// side (WORK_WAKE_LOGINS): strangers never wake the AI.
|
|
@@ -58,6 +63,7 @@ export interface GroupBinding {
|
|
|
58
63
|
groupId: number;
|
|
59
64
|
owner: string;
|
|
60
65
|
repo: string;
|
|
66
|
+
issue?: number;
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
export function parseGroupMap(raw: string): GroupBinding[] {
|
|
@@ -65,11 +71,11 @@ export function parseGroupMap(raw: string): GroupBinding[] {
|
|
|
65
71
|
for (const part of raw.split(",")) {
|
|
66
72
|
const item = part.trim();
|
|
67
73
|
if (!item) continue;
|
|
68
|
-
const m = /^(\d+):([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)
|
|
74
|
+
const m = /^(\d+):([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:#(\d+))?$/.exec(item);
|
|
69
75
|
if (!m?.[1] || !m[2] || !m[3]) {
|
|
70
76
|
throw new Error(`invalid GROUP_MAP entry: ${item}`);
|
|
71
77
|
}
|
|
72
|
-
out.push({ groupId: Number(m[1]), owner: m[2], repo: m[3] });
|
|
78
|
+
out.push(m[4] ? { groupId: Number(m[1]), owner: m[2], repo: m[3], issue: Number(m[4]) } : { groupId: Number(m[1]), owner: m[2], repo: m[3] });
|
|
73
79
|
}
|
|
74
80
|
if (out.length === 0) throw new Error("GROUP_MAP must define at least one group");
|
|
75
81
|
return out;
|
|
@@ -92,5 +98,8 @@ export function loadConfig(): Config {
|
|
|
92
98
|
if (!cfg.DB_PATH) {
|
|
93
99
|
cfg.DB_PATH = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/qq-bridge.db`;
|
|
94
100
|
}
|
|
101
|
+
if (!cfg.WORK_BINDINGS_FILE) {
|
|
102
|
+
cfg.WORK_BINDINGS_FILE = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/bindings.json`;
|
|
103
|
+
}
|
|
95
104
|
return cfg;
|
|
96
105
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadConfig, parseGroupMap, parseList } from "./config";
|
|
2
2
|
import { BridgeStore } from "./db";
|
|
3
|
+
import { BindingStore } from "./bindings";
|
|
3
4
|
import { createOneBotServer, type OneBotApi, type GroupMessageEvent } from "./onebot";
|
|
4
5
|
import { createRouter } from "./router";
|
|
5
6
|
import { createIngest } from "./ingest";
|
|
@@ -14,9 +15,7 @@ async function main() {
|
|
|
14
15
|
}
|
|
15
16
|
const store = new BridgeStore(cfg.DB_PATH || "/tmp/ework-qq-bridge.db");
|
|
16
17
|
|
|
17
|
-
const bindings = parseGroupMap(cfg.GROUP_MAP);
|
|
18
|
-
const groupIdOfProject = new Map<string, number>();
|
|
19
|
-
for (const b of bindings) groupIdOfProject.set(`${b.owner}/${b.repo}`, b.groupId);
|
|
18
|
+
const bindings = new BindingStore(parseGroupMap(cfg.GROUP_MAP), cfg.WORK_BINDINGS_FILE);
|
|
20
19
|
|
|
21
20
|
let api: OneBotApi | null = null;
|
|
22
21
|
const send = async (groupId: number, text: string) => {
|
|
@@ -40,7 +39,7 @@ const store = new BridgeStore(cfg.DB_PATH || "/tmp/ework-qq-bridge.db");
|
|
|
40
39
|
bridgeLogin: cfg.BRIDGE_LOGIN,
|
|
41
40
|
agentLogins: new Set(parseList(cfg.AGENT_LOGINS)),
|
|
42
41
|
scrub,
|
|
43
|
-
|
|
42
|
+
groupsFor: (owner, repo, number) => bindings.groupsFor(owner, repo, number),
|
|
44
43
|
commentForwarded: (id) => store.commentForwarded(id),
|
|
45
44
|
send,
|
|
46
45
|
});
|
package/src/ingest.ts
CHANGED
|
@@ -14,7 +14,7 @@ interface IngestDeps {
|
|
|
14
14
|
bridgeLogin: string;
|
|
15
15
|
agentLogins: Set<string>;
|
|
16
16
|
scrub: (text: string) => string;
|
|
17
|
-
|
|
17
|
+
groupsFor(owner: string, repo: string, number: number): number[];
|
|
18
18
|
commentForwarded(commentId: number): boolean;
|
|
19
19
|
send(groupId: number, text: string): Promise<void>;
|
|
20
20
|
}
|
|
@@ -85,8 +85,8 @@ export function createIngest(deps: IngestDeps) {
|
|
|
85
85
|
if (body.startsWith("[system]") || body.startsWith("[SYSTEM ")) return new Response("skipped:system", { status: 200 });
|
|
86
86
|
if (!deps.agentLogins.has(author)) return new Response("skipped:non-agent", { status: 200 });
|
|
87
87
|
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
88
|
+
const groups = deps.groupsFor(owner, String(repo.name), number);
|
|
89
|
+
if (groups.length === 0) return new Response("skipped:unmapped", { status: 200 });
|
|
90
90
|
if (!Number.isInteger(commentId) || deps.commentForwarded(commentId)) {
|
|
91
91
|
return new Response("skipped:dup", { status: 200 });
|
|
92
92
|
}
|
|
@@ -97,7 +97,7 @@ export function createIngest(deps: IngestDeps) {
|
|
|
97
97
|
}
|
|
98
98
|
const text = deps.scrub(`[#${number}] ${body}`);
|
|
99
99
|
try {
|
|
100
|
-
await deps.send(groupId, text);
|
|
100
|
+
for (const groupId of groups) await deps.send(groupId, text);
|
|
101
101
|
} catch (err) {
|
|
102
102
|
console.error(`[qq-bridge] send_group_msg failed: ${err instanceof Error ? err.message : err}`);
|
|
103
103
|
return new Response("send failed", { status: 502 });
|
package/src/onebot.ts
CHANGED
|
@@ -59,11 +59,15 @@ export function createOneBotServer(opts: OneBotServerOptions) {
|
|
|
59
59
|
const pending = new Map<string, PendingEntry>();
|
|
60
60
|
let ws: ServerWebSocket<unknown> | null = null;
|
|
61
61
|
|
|
62
|
-
function
|
|
62
|
+
function drainPending(reason: string) {
|
|
63
63
|
for (const [, entry] of pending) {
|
|
64
64
|
entry.reject(new Error(reason));
|
|
65
65
|
}
|
|
66
66
|
pending.clear();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function rejectAll(reason: string) {
|
|
70
|
+
drainPending(reason);
|
|
67
71
|
ws = null;
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -75,11 +79,12 @@ export function createOneBotServer(opts: OneBotServerOptions) {
|
|
|
75
79
|
// Bun handlers: wire these into Bun.serve({websocket:{...}})
|
|
76
80
|
handlers: {
|
|
77
81
|
open(client: ServerWebSocket<unknown>) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
// A fresh connect means the peer restarted: the old socket is dead or
|
|
83
|
+
// dying (TCP may not have noticed yet). Supersede it, never reject
|
|
84
|
+
// the newcomer, or reconnects stall behind zombie sockets.
|
|
85
|
+
if (ws) ws.close(4001, "superseded by new connection");
|
|
82
86
|
ws = client;
|
|
87
|
+
drainPending("superseded by new connection");
|
|
83
88
|
const api: OneBotApi = {
|
|
84
89
|
call(action, params) {
|
|
85
90
|
const echo = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -123,7 +128,11 @@ export function createOneBotServer(opts: OneBotServerOptions) {
|
|
|
123
128
|
void opts.onEvent(ev);
|
|
124
129
|
}
|
|
125
130
|
},
|
|
126
|
-
close() {
|
|
131
|
+
close(client: ServerWebSocket<unknown>) {
|
|
132
|
+
// A reconnect race can fire the OLD socket's close after the NEW
|
|
133
|
+
// socket's open; only tear down when the active client is the one
|
|
134
|
+
// that closed, else the live connection is orphaned.
|
|
135
|
+
if (ws !== client) return;
|
|
127
136
|
rejectAll("connection closed");
|
|
128
137
|
},
|
|
129
138
|
},
|
package/src/router.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Config } from "./config";
|
|
2
2
|
import type { EworkClient } from "./ework";
|
|
3
3
|
import type { GroupMessageEvent } from "./onebot";
|
|
4
4
|
import type { BridgeStore } from "./db";
|
|
5
|
+
import type { BindingStore } from "./bindings";
|
|
5
6
|
import { buildChatMessages, chatComplete, splitForQQ, type ChatTurn } from "./chat";
|
|
6
7
|
|
|
7
8
|
const HELP_TEXT = [
|
|
8
9
|
"用法:",
|
|
9
|
-
" 任务 <标题> —— 新建 issue
|
|
10
|
+
" 任务 <标题> —— 新建 issue 并接单(本群已绑定时:新建并换绑到新 issue)",
|
|
10
11
|
" #<编号> <内容> —— 给指定 issue 追加内容",
|
|
12
|
+
" 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
|
|
13
|
+
" 解绑 —— 恢复为项目模式(接收整个项目的回复)",
|
|
11
14
|
" 查询 —— 列出最近 issue",
|
|
12
|
-
" @我 + 任意问题 —— 即时问答(不建 issue
|
|
15
|
+
" @我 + 任意问题 —— 即时问答(不建 issue、不留痕)",
|
|
13
16
|
].join("\n");
|
|
14
17
|
|
|
15
18
|
export interface RouterDeps {
|
|
16
19
|
cfg: Config;
|
|
17
|
-
bindings:
|
|
20
|
+
bindings: BindingStore;
|
|
18
21
|
wakeList: Set<string>;
|
|
19
22
|
ework: EworkClient;
|
|
20
23
|
store: BridgeStore;
|
|
@@ -22,7 +25,7 @@ export interface RouterDeps {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
interface ParsedCommand {
|
|
25
|
-
kind: "create" | "comment" | "help";
|
|
28
|
+
kind: "create" | "comment" | "bind" | "unbind" | "help";
|
|
26
29
|
title?: string;
|
|
27
30
|
number?: number;
|
|
28
31
|
body?: string;
|
|
@@ -32,6 +35,9 @@ export function parseCommand(raw: string): ParsedCommand | null {
|
|
|
32
35
|
const text = raw.trim();
|
|
33
36
|
const stripped = text.replace(/^\[CQ:at,qq=\d+\]\s*/, "").trim();
|
|
34
37
|
if (stripped === "帮助" || stripped === "help" || stripped === "查询") return { kind: "help" };
|
|
38
|
+
const bind = /^绑定\s*#(\d{1,6})$/.exec(stripped) ?? /^绑定\s*#(\d{1,6})$/.exec(text);
|
|
39
|
+
if (bind?.[1]) return { kind: "bind", number: Number(bind[1]) };
|
|
40
|
+
if (stripped === "解绑" || stripped === "unbind") return { kind: "unbind" };
|
|
35
41
|
const create = /^(?:任务|task|新任务)\s+(.+)$/i.exec(stripped) ?? /^(?:任务|task|新任务)\s+(.+)$/i.exec(text);
|
|
36
42
|
if (create?.[1]) return { kind: "create", title: create[1].trim() };
|
|
37
43
|
const comment = /^#(\d{1,6})\s+([\s\S]+)$/.exec(stripped) ?? /^#(\d{1,6})\s+([\s\S]+)$/.exec(text);
|
|
@@ -61,7 +67,7 @@ export function createRouter(deps: RouterDeps) {
|
|
|
61
67
|
|
|
62
68
|
async function handleGroupMessage(ev: GroupMessageEvent): Promise<void> {
|
|
63
69
|
if (store.seenPost(ev.postId)) return;
|
|
64
|
-
const binding = bindings.
|
|
70
|
+
const binding = bindings.resolve(ev.groupId);
|
|
65
71
|
if (!binding) return;
|
|
66
72
|
|
|
67
73
|
if (!wakeList.has(String(ev.userId))) {
|
|
@@ -69,14 +75,18 @@ export function createRouter(deps: RouterDeps) {
|
|
|
69
75
|
return;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
const atBot = ev.rawMessage.includes("[CQ:at,qq=") || /^\s*(任务|task
|
|
78
|
+
const atBot = ev.rawMessage.includes("[CQ:at,qq=") || /^\s*(任务|task|新任务|#|绑定|解绑|帮助|help|查询)/.test(ev.rawMessage);
|
|
73
79
|
const cmd = parseCommand(ev.rawMessage);
|
|
74
|
-
if (!cmd && !atBot) {
|
|
80
|
+
if (!cmd && !atBot && binding.issue === undefined) {
|
|
75
81
|
if (cfg.VERBOSE) console.log(`[qq-bridge] unrecognized message from ${ev.userId}: ${ev.rawMessage.slice(0, 80)}`);
|
|
76
82
|
return;
|
|
77
83
|
}
|
|
78
84
|
if (!cmd) {
|
|
79
85
|
const question = ev.rawMessage.replace(/\[CQ:[^\]]*\]/g, "").trim();
|
|
86
|
+
if (binding.issue !== undefined && !atBot) {
|
|
87
|
+
await ework.addComment(binding.owner, binding.repo, binding.issue, `> 来自 QQ 群用户 **${ev.nickname}** (${ev.userId})\n\n${question}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
80
90
|
if (cfg.WORK_CHAT_API && question) {
|
|
81
91
|
try {
|
|
82
92
|
await answerChat(ev, question);
|
|
@@ -97,9 +107,25 @@ export function createRouter(deps: RouterDeps) {
|
|
|
97
107
|
const attribution = `> 来自 QQ 群用户 **${ev.nickname}** (${ev.userId})`;
|
|
98
108
|
|
|
99
109
|
try {
|
|
110
|
+
if (cmd.kind === "bind" && cmd.number !== undefined) {
|
|
111
|
+
const pinned = bindings.pin(ev.groupId, cmd.number);
|
|
112
|
+
if (!pinned) {
|
|
113
|
+
await deps.reply(ev.groupId, "❌ 本群没有配置项目映射,无法绑定");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await deps.reply(ev.groupId, `📌 本群已绑定 ${pinned.owner}/${pinned.repo}#${cmd.number},之后的发言都会进这个 issue`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (cmd.kind === "unbind") {
|
|
120
|
+
const ok = bindings.unpin(ev.groupId);
|
|
121
|
+
await deps.reply(ev.groupId, ok ? "↩️ 已解绑,恢复项目模式(接收整个项目的回复)" : "本群本来就没有绑定 issue");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
100
124
|
if (cmd.kind === "create") {
|
|
101
125
|
const n = await ework.createIssue(binding.owner, binding.repo, cmd.title ?? "", `${attribution}\n\n${cmd.title ?? ""}`);
|
|
102
|
-
|
|
126
|
+
bindings.pin(ev.groupId, n);
|
|
127
|
+
const swap = binding.issue !== undefined ? `(原 #${binding.issue} 已解绑)` : "(本群已绑定,长记忆模式)";
|
|
128
|
+
await deps.reply(ev.groupId, `✅ 已创建 issue #${n},AI 已接单 ${swap}`);
|
|
103
129
|
return;
|
|
104
130
|
}
|
|
105
131
|
if (cmd.kind === "comment" && cmd.number !== undefined) {
|
package/tests/bridge.test.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
2
|
import { parseCommand } from "../src/router";
|
|
3
|
+
import { BindingStore } from "../src/bindings";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
const pinFile = () => `${tmpdir()}/qqb-test-${randomUUID()}.json`;
|
|
3
7
|
import { parseGroupMap, parseList } from "../src/config";
|
|
4
8
|
import { buildScrubber } from "../src/scrub";
|
|
5
9
|
import { verifySignature } from "../src/ingest";
|
|
@@ -96,7 +100,7 @@ describe("chat mode", () => {
|
|
|
96
100
|
const parts = splitForQQ(long);
|
|
97
101
|
expect(parts.length).toBeGreaterThan(1);
|
|
98
102
|
for (const p of parts) expect(p.length).toBeLessThanOrEqual(1500);
|
|
99
|
-
expect(parts.join("\n")).toBe(long
|
|
103
|
+
expect(parts.join("\n")).toBe(long);
|
|
100
104
|
});
|
|
101
105
|
|
|
102
106
|
test("router: @bot + question hits chat when configured, help when not", async () => {
|
|
@@ -112,7 +116,7 @@ describe("chat mode", () => {
|
|
|
112
116
|
WORK_CHAT_TIMEOUT_MS: 1000,
|
|
113
117
|
WORK_CHAT_MAX_HISTORY: 20,
|
|
114
118
|
},
|
|
115
|
-
bindings: [{ groupId: 1, owner: "o", repo: "r" }],
|
|
119
|
+
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
|
|
116
120
|
wakeList: new Set(["1403558951"]),
|
|
117
121
|
ework: { createIssue: async () => 1, addComment: async () => {} },
|
|
118
122
|
store: { seenPost: () => false },
|
|
@@ -134,7 +138,7 @@ describe("chat mode", () => {
|
|
|
134
138
|
const replies: string[] = [];
|
|
135
139
|
const router = createRouter({
|
|
136
140
|
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 },
|
|
137
|
-
bindings: [{ groupId: 1, owner: "o", repo: "r" }],
|
|
141
|
+
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
|
|
138
142
|
wakeList: new Set(["1"]),
|
|
139
143
|
ework: { createIssue: async () => 1, addComment: async () => {} },
|
|
140
144
|
store: { seenPost: () => false },
|
|
@@ -144,3 +148,104 @@ describe("chat mode", () => {
|
|
|
144
148
|
expect(replies[0]).toContain("没看懂指令");
|
|
145
149
|
});
|
|
146
150
|
});
|
|
151
|
+
|
|
152
|
+
test("onebot close ignores non-active client (reconnect race)", () => {
|
|
153
|
+
const { createOneBotServer } = require("../src/onebot");
|
|
154
|
+
let ready = 0;
|
|
155
|
+
const srv = createOneBotServer({ path: "/ws", accessToken: "t", onEvent: () => {}, onReady: () => { ready++; } });
|
|
156
|
+
const fake = (id: string) => ({ id, send: () => {}, close: () => {} });
|
|
157
|
+
const a = fake("a"), b = fake("b");
|
|
158
|
+
srv.handlers.open(a);
|
|
159
|
+
expect(ready).toBe(1);
|
|
160
|
+
srv.handlers.open(b);
|
|
161
|
+
expect(ready).toBe(2);
|
|
162
|
+
srv.handlers.close(a);
|
|
163
|
+
expect(srv.connected).toBe(true);
|
|
164
|
+
srv.handlers.close(b);
|
|
165
|
+
expect(srv.connected).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
describe("issue pinning", () => {
|
|
170
|
+
test("parseGroupMap accepts #N suffix and bare form", () => {
|
|
171
|
+
const [pinned, bare] = parseGroupMap("111:o/r#7,222:o/r");
|
|
172
|
+
expect(pinned.issue).toBe(7);
|
|
173
|
+
expect(bare.issue).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("parseCommand: 绑定/解绑", () => {
|
|
177
|
+
expect(parseCommand("绑定 #7")).toEqual({ kind: "bind", number: 7 });
|
|
178
|
+
expect(parseCommand("解绑")).toEqual({ kind: "unbind" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("BindingStore pin/unpin persist + groupsFor filter", () => {
|
|
182
|
+
const f = pinFile();
|
|
183
|
+
const bs = new BindingStore([{ groupId: 111, owner: "o", repo: "r" }, { groupId: 222, owner: "o", repo: "r" }], f);
|
|
184
|
+
expect(bs.groupsFor("o", "r", 7)).toEqual([111, 222]);
|
|
185
|
+
bs.pin(111, 7);
|
|
186
|
+
expect(bs.groupsFor("o", "r", 7)).toEqual([111, 222]);
|
|
187
|
+
expect(bs.groupsFor("o", "r", 8)).toEqual([222]);
|
|
188
|
+
expect(bs.pin(999, 1)).toBeNull();
|
|
189
|
+
const reloaded = new BindingStore([{ groupId: 111, owner: "o", repo: "r" }], f);
|
|
190
|
+
expect(reloaded.resolve(111)?.issue).toBe(7);
|
|
191
|
+
expect(reloaded.groupsFor("o", "r", 8)).toEqual([]);
|
|
192
|
+
expect(reloaded.unpin(111)).toBe(true);
|
|
193
|
+
expect(reloaded.groupsFor("o", "r", 8)).toEqual([111]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("router: pinned plain message comments bound issue silently", async () => {
|
|
197
|
+
const { createRouter } = require("../src/router");
|
|
198
|
+
const replies: string[] = [];
|
|
199
|
+
const comments: Array<[string, string, number, string]> = [];
|
|
200
|
+
const router = createRouter({
|
|
201
|
+
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 },
|
|
202
|
+
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
|
|
203
|
+
wakeList: new Set(["1"]),
|
|
204
|
+
ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
|
|
205
|
+
store: { seenPost: () => false },
|
|
206
|
+
reply: async (_g: number, t: string) => { replies.push(t); },
|
|
207
|
+
});
|
|
208
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p1", rawMessage: "帮我看下这个报错" });
|
|
209
|
+
expect(comments.length).toBe(1);
|
|
210
|
+
expect(comments[0][2]).toBe(7);
|
|
211
|
+
expect(comments[0][3]).toContain("帮我看下这个报错");
|
|
212
|
+
expect(replies).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("router: 绑定 #5 pins group, then plain message targets #5", async () => {
|
|
216
|
+
const { createRouter } = require("../src/router");
|
|
217
|
+
const replies: string[] = [];
|
|
218
|
+
const comments: Array<number, any> = [];
|
|
219
|
+
const bs = new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile());
|
|
220
|
+
const router = createRouter({
|
|
221
|
+
cfg: { VERBOSE: false, WORK_CHAT_API: "", WORK_CHAT_API_KEY: "k", WORK_CHAT_MODEL: "m", WORK_CHAT_TIMEOUT_MS: 1000, WORK_CHAT_MAX_HISTORY: 20 },
|
|
222
|
+
bindings: bs,
|
|
223
|
+
wakeList: new Set(["1"]),
|
|
224
|
+
ework: { createIssue: async () => 9, addComment: async (_o: any, _r: any, n: number) => { comments.push(n); } },
|
|
225
|
+
store: { seenPost: () => false },
|
|
226
|
+
reply: async (_g: number, t: string) => { replies.push(t); },
|
|
227
|
+
});
|
|
228
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p1", rawMessage: "绑定 #5" });
|
|
229
|
+
expect(replies[0]).toContain("#5");
|
|
230
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p2", rawMessage: "第二条消息" });
|
|
231
|
+
expect(comments).toEqual([5]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("router: 任务 in pinned group creates AND rebinds", async () => {
|
|
235
|
+
const { createRouter } = require("../src/router");
|
|
236
|
+
const replies: string[] = [];
|
|
237
|
+
const bs = new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 3 }], pinFile());
|
|
238
|
+
const router = createRouter({
|
|
239
|
+
cfg: { VERBOSE: false, WORK_CHAT_API: "", WORK_CHAT_API_KEY: "k", WORK_CHAT_MODEL: "m", WORK_CHAT_TIMEOUT_MS: 1000, WORK_CHAT_MAX_HISTORY: 20 },
|
|
240
|
+
bindings: bs,
|
|
241
|
+
wakeList: new Set(["1"]),
|
|
242
|
+
ework: { createIssue: async () => 12, addComment: async () => {} },
|
|
243
|
+
store: { seenPost: () => false },
|
|
244
|
+
reply: async (_g: number, t: string) => { replies.push(t); },
|
|
245
|
+
});
|
|
246
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p1", rawMessage: "任务 新主题" });
|
|
247
|
+
expect(replies[0]).toContain("#12");
|
|
248
|
+
expect(replies[0]).toContain("#3");
|
|
249
|
+
expect(bs.resolve(1)?.issue).toBe(12);
|
|
250
|
+
});
|
|
251
|
+
});
|