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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-qq-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "QQ group <-> ework issue bridge (OneBot 11 reverse WebSocket)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,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. Comma-separated:
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_.-]+)$/.exec(item);
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
- projectOf: (owner, repo) => groupIdOfProject.get(`${owner}/${repo}`) ?? null,
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
- projectOf(owner: string, repo: string): number | null;
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 groupId = deps.projectOf(owner, String(repo.name));
89
- if (groupId === null) return new Response("skipped:unmapped", { status: 200 });
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 rejectAll(reason: string) {
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
- if (ws) {
79
- client.close(4000, "duplicate connection");
80
- return;
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 { GroupBinding, Config } from "./config";
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 { buildChatMessages, chatComplete, splitForQQ, type ChatTurn } from "./chat";
5
+ import type { BindingStore } from "./bindings";
6
6
 
7
7
  const HELP_TEXT = [
8
8
  "用法:",
9
- " 任务 <标题> —— 新建 issue,AI 自动接单",
9
+ " 任务 <标题> —— 新建 issue 并接单(本群已绑定时:新建并换绑到新 issue)",
10
10
  " #<编号> <内容> —— 给指定 issue 追加内容",
11
+ " 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
12
+ " 解绑 —— 恢复为项目模式(接收整个项目的回复)",
11
13
  " 查询 —— 列出最近 issue",
12
- " @我 + 任意问题 —— 即时问答(不建 issue)",
14
+ " (绑定后:@我 和普通发言等效,都进入绑定的 issue,AI 回复自动回群)",
13
15
  ].join("\n");
14
16
 
15
17
  export interface RouterDeps {
16
18
  cfg: Config;
17
- bindings: GroupBinding[];
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.find((b) => b.groupId === ev.groupId);
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|新任务|#|帮助|help|查询)/.test(ev.rawMessage);
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
- const question = ev.rawMessage.replace(/\[CQ:[^\]]*\]/g, "").trim();
80
- if (cfg.WORK_CHAT_API && question) {
81
- try {
82
- await answerChat(ev, question);
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
- await deps.reply(ev.groupId, "没看懂指令。\n" + HELP_TEXT);
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
- await deps.reply(ev.groupId, `✅ 已创建 issue #${n},AI 已接单:${cmd.title ?? ""}`);
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) {
@@ -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("chat mode", () => {
80
- const { buildChatMessages, splitForQQ, CHAT_SYSTEM_PROMPT } = require("../src/chat");
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, 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" }],
89
+ cfg: { VERBOSE: false },
90
+ bindings: bs,
138
91
  wakeList: new Set(["1"]),
139
- ework: { createIssue: async () => 1, addComment: 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, text: string) => { replies.push(text); },
94
+ reply: async (_g: number, x: string) => { replies.push(x); },
142
95
  });
143
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p3", rawMessage: "[CQ:at,qq=2661222094]" });
144
- expect(replies[0]).toContain("没看懂指令");
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(1);
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
- }