ework-qq-bridge 0.2.1 → 0.4.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 -12
- package/src/index.ts +3 -4
- package/src/ingest.ts +4 -4
- package/src/onebot.ts +10 -5
- package/src/router.ts +39 -39
- package/tests/bridge.test.ts +129 -61
- package/src/chat.ts +0 -72
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.
|
|
@@ -36,14 +41,6 @@ const Schema = z.object({
|
|
|
36
41
|
|
|
37
42
|
DB_PATH: z.string().default(""),
|
|
38
43
|
|
|
39
|
-
// Chat mode (instant Q&A). When WORK_CHAT_API is set, @bot messages without
|
|
40
|
-
// a recognized command are answered directly by the LLM instead of usage
|
|
41
|
-
// help. Empty = chat mode disabled.
|
|
42
|
-
WORK_CHAT_API: z.string().default(""),
|
|
43
|
-
WORK_CHAT_API_KEY: z.string().default("sk-vllm"),
|
|
44
|
-
WORK_CHAT_MODEL: z.string().default("qwen3.8-27b"),
|
|
45
|
-
WORK_CHAT_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000),
|
|
46
|
-
WORK_CHAT_MAX_HISTORY: z.coerce.number().int().positive().default(20),
|
|
47
44
|
|
|
48
45
|
VERBOSE: z.coerce.boolean().default(false),
|
|
49
46
|
|
|
@@ -58,6 +55,7 @@ export interface GroupBinding {
|
|
|
58
55
|
groupId: number;
|
|
59
56
|
owner: string;
|
|
60
57
|
repo: string;
|
|
58
|
+
issue?: number;
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
export function parseGroupMap(raw: string): GroupBinding[] {
|
|
@@ -65,11 +63,11 @@ export function parseGroupMap(raw: string): GroupBinding[] {
|
|
|
65
63
|
for (const part of raw.split(",")) {
|
|
66
64
|
const item = part.trim();
|
|
67
65
|
if (!item) continue;
|
|
68
|
-
const m = /^(\d+):([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)
|
|
66
|
+
const m = /^(\d+):([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:#(\d+))?$/.exec(item);
|
|
69
67
|
if (!m?.[1] || !m[2] || !m[3]) {
|
|
70
68
|
throw new Error(`invalid GROUP_MAP entry: ${item}`);
|
|
71
69
|
}
|
|
72
|
-
out.push({ groupId: Number(m[1]), owner: m[2], repo: m[3] });
|
|
70
|
+
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
71
|
}
|
|
74
72
|
if (out.length === 0) throw new Error("GROUP_MAP must define at least one group");
|
|
75
73
|
return out;
|
|
@@ -92,5 +90,8 @@ export function loadConfig(): Config {
|
|
|
92
90
|
if (!cfg.DB_PATH) {
|
|
93
91
|
cfg.DB_PATH = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/qq-bridge.db`;
|
|
94
92
|
}
|
|
93
|
+
if (!cfg.WORK_BINDINGS_FILE) {
|
|
94
|
+
cfg.WORK_BINDINGS_FILE = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/bindings.json`;
|
|
95
|
+
}
|
|
95
96
|
return cfg;
|
|
96
97
|
}
|
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)}`;
|
package/src/router.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
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 {
|
|
5
|
+
import type { BindingStore } from "./bindings";
|
|
6
6
|
|
|
7
7
|
const HELP_TEXT = [
|
|
8
8
|
"用法:",
|
|
9
|
-
" 任务 <标题> —— 新建 issue
|
|
9
|
+
" 任务 <标题> —— 新建 issue 并接单(本群已绑定时:新建并换绑到新 issue)",
|
|
10
10
|
" #<编号> <内容> —— 给指定 issue 追加内容",
|
|
11
|
+
" 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
|
|
12
|
+
" 解绑 —— 恢复为项目模式(接收整个项目的回复)",
|
|
11
13
|
" 查询 —— 列出最近 issue",
|
|
12
|
-
"
|
|
14
|
+
" (绑定后:@我 和普通发言等效,都进入绑定的 issue,AI 回复自动回群)",
|
|
13
15
|
].join("\n");
|
|
14
16
|
|
|
15
17
|
export interface RouterDeps {
|
|
16
18
|
cfg: Config;
|
|
17
|
-
bindings:
|
|
19
|
+
bindings: BindingStore;
|
|
18
20
|
wakeList: Set<string>;
|
|
19
21
|
ework: EworkClient;
|
|
20
22
|
store: BridgeStore;
|
|
@@ -22,7 +24,7 @@ export interface RouterDeps {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
interface ParsedCommand {
|
|
25
|
-
kind: "create" | "comment" | "help";
|
|
27
|
+
kind: "create" | "comment" | "bind" | "unbind" | "help";
|
|
26
28
|
title?: string;
|
|
27
29
|
number?: number;
|
|
28
30
|
body?: string;
|
|
@@ -32,6 +34,9 @@ export function parseCommand(raw: string): ParsedCommand | null {
|
|
|
32
34
|
const text = raw.trim();
|
|
33
35
|
const stripped = text.replace(/^\[CQ:at,qq=\d+\]\s*/, "").trim();
|
|
34
36
|
if (stripped === "帮助" || stripped === "help" || stripped === "查询") return { kind: "help" };
|
|
37
|
+
const bind = /^绑定\s*#(\d{1,6})$/.exec(stripped) ?? /^绑定\s*#(\d{1,6})$/.exec(text);
|
|
38
|
+
if (bind?.[1]) return { kind: "bind", number: Number(bind[1]) };
|
|
39
|
+
if (stripped === "解绑" || stripped === "unbind") return { kind: "unbind" };
|
|
35
40
|
const create = /^(?:任务|task|新任务)\s+(.+)$/i.exec(stripped) ?? /^(?:任务|task|新任务)\s+(.+)$/i.exec(text);
|
|
36
41
|
if (create?.[1]) return { kind: "create", title: create[1].trim() };
|
|
37
42
|
const comment = /^#(\d{1,6})\s+([\s\S]+)$/.exec(stripped) ?? /^#(\d{1,6})\s+([\s\S]+)$/.exec(text);
|
|
@@ -41,27 +46,9 @@ export function parseCommand(raw: string): ParsedCommand | null {
|
|
|
41
46
|
|
|
42
47
|
export function createRouter(deps: RouterDeps) {
|
|
43
48
|
const { cfg, bindings, wakeList, ework, store } = deps;
|
|
44
|
-
const chatHistory = new Map<number, ChatTurn[]>();
|
|
45
|
-
|
|
46
|
-
async function answerChat(ev: GroupMessageEvent, question: string): Promise<void> {
|
|
47
|
-
const turn: ChatTurn = { role: "user", name: ev.nickname, content: question };
|
|
48
|
-
const history = chatHistory.get(ev.groupId) ?? [];
|
|
49
|
-
const messages = buildChatMessages(history, turn, cfg.WORK_CHAT_MAX_HISTORY);
|
|
50
|
-
const answer = await chatComplete(cfg.WORK_CHAT_API, cfg.WORK_CHAT_API_KEY, cfg.WORK_CHAT_MODEL, messages, cfg.WORK_CHAT_TIMEOUT_MS);
|
|
51
|
-
history.push(turn, { role: "assistant", name: "bot", content: answer });
|
|
52
|
-
if (history.length > cfg.WORK_CHAT_MAX_HISTORY * 2) {
|
|
53
|
-
chatHistory.set(ev.groupId, history.slice(-cfg.WORK_CHAT_MAX_HISTORY * 2));
|
|
54
|
-
} else {
|
|
55
|
-
chatHistory.set(ev.groupId, history);
|
|
56
|
-
}
|
|
57
|
-
for (const part of splitForQQ(answer)) {
|
|
58
|
-
await deps.reply(ev.groupId, part);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
49
|
async function handleGroupMessage(ev: GroupMessageEvent): Promise<void> {
|
|
63
50
|
if (store.seenPost(ev.postId)) return;
|
|
64
|
-
const binding = bindings.
|
|
51
|
+
const binding = bindings.resolve(ev.groupId);
|
|
65
52
|
if (!binding) return;
|
|
66
53
|
|
|
67
54
|
if (!wakeList.has(String(ev.userId))) {
|
|
@@ -69,24 +56,21 @@ export function createRouter(deps: RouterDeps) {
|
|
|
69
56
|
return;
|
|
70
57
|
}
|
|
71
58
|
|
|
72
|
-
const atBot = ev.rawMessage.includes("[CQ:at,qq=") || /^\s*(任务|task
|
|
59
|
+
const atBot = ev.rawMessage.includes("[CQ:at,qq=") || /^\s*(任务|task|新任务|#|绑定|解绑|帮助|help|查询)/.test(ev.rawMessage);
|
|
73
60
|
const cmd = parseCommand(ev.rawMessage);
|
|
74
|
-
if (!cmd && !atBot) {
|
|
75
|
-
if (cfg.VERBOSE) console.log(`[qq-bridge] unrecognized message from ${ev.userId}: ${ev.rawMessage.slice(0, 80)}`);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
61
|
if (!cmd) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
await
|
|
83
|
-
} catch (err) {
|
|
84
|
-
console.error(`[qq-bridge] chat failed: ${err instanceof Error ? err.message : err}`);
|
|
85
|
-
await deps.reply(ev.groupId, "🤖 回答失败了,稍后再试一次。");
|
|
62
|
+
if (binding.issue !== undefined) {
|
|
63
|
+
const text = ev.rawMessage.replace(/\[CQ:[^\]]*\]/g, "").trim();
|
|
64
|
+
if (text) {
|
|
65
|
+
await ework.addComment(binding.owner, binding.repo, binding.issue, `> 来自 QQ 群用户 **${ev.nickname}** (${ev.userId})\n\n${text}`);
|
|
86
66
|
}
|
|
87
67
|
return;
|
|
88
68
|
}
|
|
89
|
-
|
|
69
|
+
if (atBot) {
|
|
70
|
+
await deps.reply(ev.groupId, "本群还没绑定 issue。发「绑定 #<编号>」绑定已有任务,或「任务 <标题>」新建并自动绑定。");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (cfg.VERBOSE) console.log(`[qq-bridge] ignore non-command message from ${ev.userId}`);
|
|
90
74
|
return;
|
|
91
75
|
}
|
|
92
76
|
if (cmd.kind === "help") {
|
|
@@ -97,9 +81,25 @@ export function createRouter(deps: RouterDeps) {
|
|
|
97
81
|
const attribution = `> 来自 QQ 群用户 **${ev.nickname}** (${ev.userId})`;
|
|
98
82
|
|
|
99
83
|
try {
|
|
84
|
+
if (cmd.kind === "bind" && cmd.number !== undefined) {
|
|
85
|
+
const pinned = bindings.pin(ev.groupId, cmd.number);
|
|
86
|
+
if (!pinned) {
|
|
87
|
+
await deps.reply(ev.groupId, "❌ 本群没有配置项目映射,无法绑定");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await deps.reply(ev.groupId, `📌 本群已绑定 ${pinned.owner}/${pinned.repo}#${cmd.number},之后的发言都会进这个 issue`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (cmd.kind === "unbind") {
|
|
94
|
+
const ok = bindings.unpin(ev.groupId);
|
|
95
|
+
await deps.reply(ev.groupId, ok ? "↩️ 已解绑,恢复项目模式(接收整个项目的回复)" : "本群本来就没有绑定 issue");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
100
98
|
if (cmd.kind === "create") {
|
|
101
99
|
const n = await ework.createIssue(binding.owner, binding.repo, cmd.title ?? "", `${attribution}\n\n${cmd.title ?? ""}`);
|
|
102
|
-
|
|
100
|
+
bindings.pin(ev.groupId, n);
|
|
101
|
+
const swap = binding.issue !== undefined ? `(原 #${binding.issue} 已解绑)` : "(本群已绑定,长记忆模式)";
|
|
102
|
+
await deps.reply(ev.groupId, `✅ 已创建 issue #${n},AI 已接单 ${swap}`);
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
105
|
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";
|
|
@@ -76,72 +80,52 @@ test("helps when @bot without verb", () => {
|
|
|
76
80
|
expect(parseCommand("[CQ:at,qq=2661222094] 测试2")).toBeNull();
|
|
77
81
|
});
|
|
78
82
|
|
|
79
|
-
describe("
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
test("buildChatMessages: system + capped history + new turn", () => {
|
|
83
|
-
const hist = Array.from({ length: 30 }, (_, i) => ({ role: "user" as const, name: `u${i}`, content: `m${i}` }));
|
|
84
|
-
const msgs = buildChatMessages(hist, { role: "user", name: "dog", content: "q" }, 20);
|
|
85
|
-
expect(msgs[0].role).toBe("system");
|
|
86
|
-
expect(msgs[0].content).toBe(CHAT_SYSTEM_PROMPT);
|
|
87
|
-
expect(msgs).toHaveLength(22);
|
|
88
|
-
expect(msgs[1].name).toBe("u10");
|
|
89
|
-
expect(msgs[msgs.length - 1]).toEqual({ role: "user", name: "dog", content: "q" });
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("splitForQQ keeps <=1500 chunks and prefers newline cuts", () => {
|
|
93
|
-
const short = splitForQQ("hello");
|
|
94
|
-
expect(short).toEqual(["hello"]);
|
|
95
|
-
const long = "line\n".repeat(600);
|
|
96
|
-
const parts = splitForQQ(long);
|
|
97
|
-
expect(parts.length).toBeGreaterThan(1);
|
|
98
|
-
for (const p of parts) expect(p.length).toBeLessThanOrEqual(1500);
|
|
99
|
-
expect(parts.join("\n")).toBe(long.replace(/\n$/, ""));
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("router: @bot + question hits chat when configured, help when not", async () => {
|
|
103
|
-
const { createRouter } = require("../src/router");
|
|
104
|
-
const replies: string[] = [];
|
|
105
|
-
const chatCalls: string[] = [];
|
|
106
|
-
const baseDeps = (chatApi: string) => ({
|
|
107
|
-
cfg: {
|
|
108
|
-
VERBOSE: false,
|
|
109
|
-
WORK_CHAT_API: chatApi,
|
|
110
|
-
WORK_CHAT_API_KEY: "k",
|
|
111
|
-
WORK_CHAT_MODEL: "m",
|
|
112
|
-
WORK_CHAT_TIMEOUT_MS: 1000,
|
|
113
|
-
WORK_CHAT_MAX_HISTORY: 20,
|
|
114
|
-
},
|
|
115
|
-
bindings: [{ groupId: 1, owner: "o", repo: "r" }],
|
|
116
|
-
wakeList: new Set(["1403558951"]),
|
|
117
|
-
ework: { createIssue: async () => 1, addComment: async () => {} },
|
|
118
|
-
store: { seenPost: () => false },
|
|
119
|
-
reply: async (_g: number, text: string) => { replies.push(text); },
|
|
120
|
-
});
|
|
121
|
-
// chat disabled → usage help
|
|
122
|
-
let router = createRouter(baseDeps(""));
|
|
123
|
-
await router.handleGroupMessage({ groupId: 1, userId: 1403558951, nickname: "dog", postId: "p1", rawMessage: "[CQ:at,qq=2661222094] 你好呀" });
|
|
124
|
-
expect(replies[0]).toContain("没看懂指令");
|
|
125
|
-
// chat enabled → mocked LLM answer (patch chatComplete via env endpoint failure is overkill; test buildChatMessages path instead)
|
|
126
|
-
replies.length = 0;
|
|
127
|
-
router = createRouter(baseDeps("http://invalid.test/v1"));
|
|
128
|
-
await router.handleGroupMessage({ groupId: 1, userId: 1403558951, nickname: "dog", postId: "p2", rawMessage: "[CQ:at,qq=2661222094] 你好呀" });
|
|
129
|
-
expect(replies[0]).toContain("回答失败");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("router: @bot with CQ-only payload (no text) still gets help", async () => {
|
|
83
|
+
describe("@bot unified routing", () => {
|
|
84
|
+
const mk = (bs: BindingStore) => {
|
|
133
85
|
const { createRouter } = require("../src/router");
|
|
134
86
|
const replies: string[] = [];
|
|
87
|
+
const comments: Array<[string, string, number, string]> = [];
|
|
135
88
|
const router = createRouter({
|
|
136
|
-
cfg: { VERBOSE: false
|
|
137
|
-
bindings:
|
|
89
|
+
cfg: { VERBOSE: false },
|
|
90
|
+
bindings: bs,
|
|
138
91
|
wakeList: new Set(["1"]),
|
|
139
|
-
ework: { createIssue: async () =>
|
|
92
|
+
ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
|
|
140
93
|
store: { seenPost: () => false },
|
|
141
|
-
reply: async (_g: number,
|
|
94
|
+
reply: async (_g: number, x: string) => { replies.push(x); },
|
|
142
95
|
});
|
|
143
|
-
|
|
144
|
-
|
|
96
|
+
return { router, replies, comments };
|
|
97
|
+
};
|
|
98
|
+
const ev = (postId: string, raw: string) => ({ groupId: 1, userId: 1, nickname: "u", postId, rawMessage: raw });
|
|
99
|
+
|
|
100
|
+
test("pinned: @bot question goes to bound issue like plain messages", async () => {
|
|
101
|
+
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()));
|
|
102
|
+
await router.handleGroupMessage(ev("p1", "[CQ:at,qq=2661222094] 这个报错啥意思"));
|
|
103
|
+
expect(comments.length).toBe(1);
|
|
104
|
+
expect(comments[0][2]).toBe(7);
|
|
105
|
+
expect(comments[0][3]).toContain("这个报错啥意思");
|
|
106
|
+
expect(comments[0][3]).not.toContain("CQ:at");
|
|
107
|
+
expect(replies).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("pinned: @bot CQ-only payload silently dropped", async () => {
|
|
111
|
+
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()));
|
|
112
|
+
await router.handleGroupMessage(ev("p2", "[CQ:at,qq=2661222094]"));
|
|
113
|
+
expect(comments).toEqual([]);
|
|
114
|
+
expect(replies).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("unpinned: @bot question gets bind guidance", async () => {
|
|
118
|
+
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()));
|
|
119
|
+
await router.handleGroupMessage(ev("p3", "[CQ:at,qq=2661222094] 你好呀"));
|
|
120
|
+
expect(comments).toEqual([]);
|
|
121
|
+
expect(replies[0]).toContain("绑定");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("unpinned: plain chatter stays silent", async () => {
|
|
125
|
+
const { router, replies, comments } = mk(new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()));
|
|
126
|
+
await router.handleGroupMessage(ev("p4", "今天天气不错"));
|
|
127
|
+
expect(comments).toEqual([]);
|
|
128
|
+
expect(replies).toEqual([]);
|
|
145
129
|
});
|
|
146
130
|
});
|
|
147
131
|
|
|
@@ -154,10 +138,94 @@ test("onebot close ignores non-active client (reconnect race)", () => {
|
|
|
154
138
|
srv.handlers.open(a);
|
|
155
139
|
expect(ready).toBe(1);
|
|
156
140
|
srv.handlers.open(b);
|
|
157
|
-
expect(ready).toBe(
|
|
141
|
+
expect(ready).toBe(2);
|
|
158
142
|
srv.handlers.close(a);
|
|
159
143
|
expect(srv.connected).toBe(true);
|
|
160
144
|
srv.handlers.close(b);
|
|
161
145
|
expect(srv.connected).toBe(false);
|
|
162
146
|
});
|
|
163
147
|
|
|
148
|
+
|
|
149
|
+
describe("issue pinning", () => {
|
|
150
|
+
test("parseGroupMap accepts #N suffix and bare form", () => {
|
|
151
|
+
const [pinned, bare] = parseGroupMap("111:o/r#7,222:o/r");
|
|
152
|
+
expect(pinned.issue).toBe(7);
|
|
153
|
+
expect(bare.issue).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("parseCommand: 绑定/解绑", () => {
|
|
157
|
+
expect(parseCommand("绑定 #7")).toEqual({ kind: "bind", number: 7 });
|
|
158
|
+
expect(parseCommand("解绑")).toEqual({ kind: "unbind" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("BindingStore pin/unpin persist + groupsFor filter", () => {
|
|
162
|
+
const f = pinFile();
|
|
163
|
+
const bs = new BindingStore([{ groupId: 111, owner: "o", repo: "r" }, { groupId: 222, owner: "o", repo: "r" }], f);
|
|
164
|
+
expect(bs.groupsFor("o", "r", 7)).toEqual([111, 222]);
|
|
165
|
+
bs.pin(111, 7);
|
|
166
|
+
expect(bs.groupsFor("o", "r", 7)).toEqual([111, 222]);
|
|
167
|
+
expect(bs.groupsFor("o", "r", 8)).toEqual([222]);
|
|
168
|
+
expect(bs.pin(999, 1)).toBeNull();
|
|
169
|
+
const reloaded = new BindingStore([{ groupId: 111, owner: "o", repo: "r" }], f);
|
|
170
|
+
expect(reloaded.resolve(111)?.issue).toBe(7);
|
|
171
|
+
expect(reloaded.groupsFor("o", "r", 8)).toEqual([]);
|
|
172
|
+
expect(reloaded.unpin(111)).toBe(true);
|
|
173
|
+
expect(reloaded.groupsFor("o", "r", 8)).toEqual([111]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("router: pinned plain message comments bound issue silently", async () => {
|
|
177
|
+
const { createRouter } = require("../src/router");
|
|
178
|
+
const replies: string[] = [];
|
|
179
|
+
const comments: Array<[string, string, number, string]> = [];
|
|
180
|
+
const router = createRouter({
|
|
181
|
+
cfg: { VERBOSE: false },
|
|
182
|
+
bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
|
|
183
|
+
wakeList: new Set(["1"]),
|
|
184
|
+
ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
|
|
185
|
+
store: { seenPost: () => false },
|
|
186
|
+
reply: async (_g: number, t: string) => { replies.push(t); },
|
|
187
|
+
});
|
|
188
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p1", rawMessage: "帮我看下这个报错" });
|
|
189
|
+
expect(comments.length).toBe(1);
|
|
190
|
+
expect(comments[0][2]).toBe(7);
|
|
191
|
+
expect(comments[0][3]).toContain("帮我看下这个报错");
|
|
192
|
+
expect(replies).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("router: 绑定 #5 pins group, then plain message targets #5", async () => {
|
|
196
|
+
const { createRouter } = require("../src/router");
|
|
197
|
+
const replies: string[] = [];
|
|
198
|
+
const comments: Array<number, any> = [];
|
|
199
|
+
const bs = new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile());
|
|
200
|
+
const router = createRouter({
|
|
201
|
+
cfg: { VERBOSE: false },
|
|
202
|
+
bindings: bs,
|
|
203
|
+
wakeList: new Set(["1"]),
|
|
204
|
+
ework: { createIssue: async () => 9, addComment: async (_o: any, _r: any, n: number) => { comments.push(n); } },
|
|
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: "绑定 #5" });
|
|
209
|
+
expect(replies[0]).toContain("#5");
|
|
210
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p2", rawMessage: "第二条消息" });
|
|
211
|
+
expect(comments).toEqual([5]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("router: 任务 in pinned group creates AND rebinds", async () => {
|
|
215
|
+
const { createRouter } = require("../src/router");
|
|
216
|
+
const replies: string[] = [];
|
|
217
|
+
const bs = new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 3 }], pinFile());
|
|
218
|
+
const router = createRouter({
|
|
219
|
+
cfg: { VERBOSE: false },
|
|
220
|
+
bindings: bs,
|
|
221
|
+
wakeList: new Set(["1"]),
|
|
222
|
+
ework: { createIssue: async () => 12, addComment: async () => {} },
|
|
223
|
+
store: { seenPost: () => false },
|
|
224
|
+
reply: async (_g: number, t: string) => { replies.push(t); },
|
|
225
|
+
});
|
|
226
|
+
await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p1", rawMessage: "任务 新主题" });
|
|
227
|
+
expect(replies[0]).toContain("#12");
|
|
228
|
+
expect(replies[0]).toContain("#3");
|
|
229
|
+
expect(bs.resolve(1)?.issue).toBe(12);
|
|
230
|
+
});
|
|
231
|
+
});
|
package/src/chat.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
// Chat mode: instant Q&A directly against the OpenAI-compatible endpoint.
|
|
2
|
-
// Issue mode (任务/#N) stays the path for long agent work — chat is for the
|
|
3
|
-
// conversational 90% of group traffic that never needs a session.
|
|
4
|
-
|
|
5
|
-
export interface ChatTurn {
|
|
6
|
-
role: "user" | "assistant";
|
|
7
|
-
name: string;
|
|
8
|
-
content: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export const CHAT_SYSTEM_PROMPT = [
|
|
12
|
-
"你是 QQ 群里的即时问答助手,背后是 ework 开发平台。",
|
|
13
|
-
"风格:简短直接,能用一两句话说清的就别铺开;技术问题给结论和关键理由,需要展开再展开。",
|
|
14
|
-
"群里成员通过 @你 提问。你看到的多轮对话里每条 user 消息前缀了提问者的昵称,注意区分不同人。",
|
|
15
|
-
"如果请求明显是需要长时间执行的开发任务(改代码、查仓库、提交 PR),不要假装去做——建议对方发「任务 <标题>」创建 issue,AI agent 会接单处理。",
|
|
16
|
-
"不知道就直说,不要编造。",
|
|
17
|
-
].join("\n");
|
|
18
|
-
|
|
19
|
-
export function buildChatMessages(
|
|
20
|
-
history: ChatTurn[],
|
|
21
|
-
question: ChatTurn,
|
|
22
|
-
maxHistory: number,
|
|
23
|
-
): { role: string; name?: string; content: string }[] {
|
|
24
|
-
const kept = history.slice(-maxHistory);
|
|
25
|
-
return [
|
|
26
|
-
{ role: "system", content: CHAT_SYSTEM_PROMPT },
|
|
27
|
-
...kept.map((t) => ({ role: t.role, name: t.name, content: t.content })),
|
|
28
|
-
{ role: question.role, name: question.name, content: question.content },
|
|
29
|
-
];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function chatComplete(
|
|
33
|
-
apiBase: string,
|
|
34
|
-
apiKey: string,
|
|
35
|
-
model: string,
|
|
36
|
-
messages: { role: string; name?: string; content: string }[],
|
|
37
|
-
timeoutMs: number,
|
|
38
|
-
): Promise<string> {
|
|
39
|
-
const ctrl = new AbortController();
|
|
40
|
-
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
41
|
-
try {
|
|
42
|
-
const res = await fetch(`${apiBase.replace(/\/+$/, "")}/chat/completions`, {
|
|
43
|
-
method: "POST",
|
|
44
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
45
|
-
body: JSON.stringify({ model, messages, max_tokens: 1024, temperature: 0.4 }),
|
|
46
|
-
signal: ctrl.signal,
|
|
47
|
-
});
|
|
48
|
-
if (!res.ok) {
|
|
49
|
-
throw new Error(`LLM ${res.status}: ${(await res.text()).slice(0, 120)}`);
|
|
50
|
-
}
|
|
51
|
-
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
52
|
-
const text = data.choices?.[0]?.message?.content?.trim();
|
|
53
|
-
if (!text) throw new Error("LLM returned empty content");
|
|
54
|
-
return text;
|
|
55
|
-
} finally {
|
|
56
|
-
clearTimeout(timer);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function splitForQQ(text: string, maxLen = 1500): string[] {
|
|
61
|
-
if (text.length <= maxLen) return [text];
|
|
62
|
-
const parts: string[] = [];
|
|
63
|
-
let rest = text;
|
|
64
|
-
while (rest.length > maxLen) {
|
|
65
|
-
let cut = rest.lastIndexOf("\n", maxLen);
|
|
66
|
-
if (cut < maxLen * 0.5) cut = maxLen;
|
|
67
|
-
parts.push(rest.slice(0, cut));
|
|
68
|
-
rest = rest.slice(cut).replace(/^\n+/, "");
|
|
69
|
-
}
|
|
70
|
-
if (rest) parts.push(rest);
|
|
71
|
-
return parts;
|
|
72
|
-
}
|