ework-qq-bridge 0.2.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-qq-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.3.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.
@@ -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_.-]+)$/.exec(item);
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
- 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,23 @@
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 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,AI 自动接单",
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: GroupBinding[];
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.find((b) => b.groupId === ev.groupId);
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|新任务|#|帮助|help|查询)/.test(ev.rawMessage);
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
- await deps.reply(ev.groupId, `✅ 已创建 issue #${n},AI 已接单:${cmd.title ?? ""}`);
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) {
@@ -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.replace(/\n$/, ""));
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 },
@@ -154,10 +158,94 @@ test("onebot close ignores non-active client (reconnect race)", () => {
154
158
  srv.handlers.open(a);
155
159
  expect(ready).toBe(1);
156
160
  srv.handlers.open(b);
157
- expect(ready).toBe(1);
161
+ expect(ready).toBe(2);
158
162
  srv.handlers.close(a);
159
163
  expect(srv.connected).toBe(true);
160
164
  srv.handlers.close(b);
161
165
  expect(srv.connected).toBe(false);
162
166
  });
163
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
+ });