ework-qq-bridge 0.3.0 → 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.3.0",
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",
package/src/config.ts CHANGED
@@ -41,14 +41,6 @@ const Schema = z.object({
41
41
 
42
42
  DB_PATH: z.string().default(""),
43
43
 
44
- // Chat mode (instant Q&A). When WORK_CHAT_API is set, @bot messages without
45
- // a recognized command are answered directly by the LLM instead of usage
46
- // help. Empty = chat mode disabled.
47
- WORK_CHAT_API: z.string().default(""),
48
- WORK_CHAT_API_KEY: z.string().default("sk-vllm"),
49
- WORK_CHAT_MODEL: z.string().default("qwen3.8-27b"),
50
- WORK_CHAT_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000),
51
- WORK_CHAT_MAX_HISTORY: z.coerce.number().int().positive().default(20),
52
44
 
53
45
  VERBOSE: z.coerce.boolean().default(false),
54
46
 
package/src/router.ts CHANGED
@@ -3,7 +3,6 @@ import type { EworkClient } from "./ework";
3
3
  import type { GroupMessageEvent } from "./onebot";
4
4
  import type { BridgeStore } from "./db";
5
5
  import type { BindingStore } from "./bindings";
6
- import { buildChatMessages, chatComplete, splitForQQ, type ChatTurn } from "./chat";
7
6
 
8
7
  const HELP_TEXT = [
9
8
  "用法:",
@@ -12,7 +11,7 @@ const HELP_TEXT = [
12
11
  " 绑定 #<编号> —— 把本群绑定到该 issue(长记忆模式:此后发言都进这个 issue)",
13
12
  " 解绑 —— 恢复为项目模式(接收整个项目的回复)",
14
13
  " 查询 —— 列出最近 issue",
15
- " @我 + 任意问题 —— 即时问答(不建 issue、不留痕)",
14
+ " (绑定后:@我 和普通发言等效,都进入绑定的 issue,AI 回复自动回群)",
16
15
  ].join("\n");
17
16
 
18
17
  export interface RouterDeps {
@@ -47,24 +46,6 @@ export function parseCommand(raw: string): ParsedCommand | null {
47
46
 
48
47
  export function createRouter(deps: RouterDeps) {
49
48
  const { cfg, bindings, wakeList, ework, store } = deps;
50
- const chatHistory = new Map<number, ChatTurn[]>();
51
-
52
- async function answerChat(ev: GroupMessageEvent, question: string): Promise<void> {
53
- const turn: ChatTurn = { role: "user", name: ev.nickname, content: question };
54
- const history = chatHistory.get(ev.groupId) ?? [];
55
- const messages = buildChatMessages(history, turn, cfg.WORK_CHAT_MAX_HISTORY);
56
- const answer = await chatComplete(cfg.WORK_CHAT_API, cfg.WORK_CHAT_API_KEY, cfg.WORK_CHAT_MODEL, messages, cfg.WORK_CHAT_TIMEOUT_MS);
57
- history.push(turn, { role: "assistant", name: "bot", content: answer });
58
- if (history.length > cfg.WORK_CHAT_MAX_HISTORY * 2) {
59
- chatHistory.set(ev.groupId, history.slice(-cfg.WORK_CHAT_MAX_HISTORY * 2));
60
- } else {
61
- chatHistory.set(ev.groupId, history);
62
- }
63
- for (const part of splitForQQ(answer)) {
64
- await deps.reply(ev.groupId, part);
65
- }
66
- }
67
-
68
49
  async function handleGroupMessage(ev: GroupMessageEvent): Promise<void> {
69
50
  if (store.seenPost(ev.postId)) return;
70
51
  const binding = bindings.resolve(ev.groupId);
@@ -77,26 +58,19 @@ export function createRouter(deps: RouterDeps) {
77
58
 
78
59
  const atBot = ev.rawMessage.includes("[CQ:at,qq=") || /^\s*(任务|task|新任务|#|绑定|解绑|帮助|help|查询)/.test(ev.rawMessage);
79
60
  const cmd = parseCommand(ev.rawMessage);
80
- if (!cmd && !atBot && binding.issue === undefined) {
81
- if (cfg.VERBOSE) console.log(`[qq-bridge] unrecognized message from ${ev.userId}: ${ev.rawMessage.slice(0, 80)}`);
82
- return;
83
- }
84
61
  if (!cmd) {
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}`);
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}`);
66
+ }
88
67
  return;
89
68
  }
90
- if (cfg.WORK_CHAT_API && question) {
91
- try {
92
- await answerChat(ev, question);
93
- } catch (err) {
94
- console.error(`[qq-bridge] chat failed: ${err instanceof Error ? err.message : err}`);
95
- await deps.reply(ev.groupId, "🤖 回答失败了,稍后再试一次。");
96
- }
69
+ if (atBot) {
70
+ await deps.reply(ev.groupId, "本群还没绑定 issue。发「绑定 #<编号>」绑定已有任务,或「任务 <标题>」新建并自动绑定。");
97
71
  return;
98
72
  }
99
- await deps.reply(ev.groupId, "没看懂指令。\n" + HELP_TEXT);
73
+ if (cfg.VERBOSE) console.log(`[qq-bridge] ignore non-command message from ${ev.userId}`);
100
74
  return;
101
75
  }
102
76
  if (cmd.kind === "help") {
@@ -80,72 +80,52 @@ test("helps when @bot without verb", () => {
80
80
  expect(parseCommand("[CQ:at,qq=2661222094] 测试2")).toBeNull();
81
81
  });
82
82
 
83
- describe("chat mode", () => {
84
- const { buildChatMessages, splitForQQ, CHAT_SYSTEM_PROMPT } = require("../src/chat");
85
-
86
- test("buildChatMessages: system + capped history + new turn", () => {
87
- const hist = Array.from({ length: 30 }, (_, i) => ({ role: "user" as const, name: `u${i}`, content: `m${i}` }));
88
- const msgs = buildChatMessages(hist, { role: "user", name: "dog", content: "q" }, 20);
89
- expect(msgs[0].role).toBe("system");
90
- expect(msgs[0].content).toBe(CHAT_SYSTEM_PROMPT);
91
- expect(msgs).toHaveLength(22);
92
- expect(msgs[1].name).toBe("u10");
93
- expect(msgs[msgs.length - 1]).toEqual({ role: "user", name: "dog", content: "q" });
94
- });
95
-
96
- test("splitForQQ keeps <=1500 chunks and prefers newline cuts", () => {
97
- const short = splitForQQ("hello");
98
- expect(short).toEqual(["hello"]);
99
- const long = "line\n".repeat(600);
100
- const parts = splitForQQ(long);
101
- expect(parts.length).toBeGreaterThan(1);
102
- for (const p of parts) expect(p.length).toBeLessThanOrEqual(1500);
103
- expect(parts.join("\n")).toBe(long);
104
- });
105
-
106
- test("router: @bot + question hits chat when configured, help when not", async () => {
107
- const { createRouter } = require("../src/router");
108
- const replies: string[] = [];
109
- const chatCalls: string[] = [];
110
- const baseDeps = (chatApi: string) => ({
111
- cfg: {
112
- VERBOSE: false,
113
- WORK_CHAT_API: chatApi,
114
- WORK_CHAT_API_KEY: "k",
115
- WORK_CHAT_MODEL: "m",
116
- WORK_CHAT_TIMEOUT_MS: 1000,
117
- WORK_CHAT_MAX_HISTORY: 20,
118
- },
119
- bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
120
- wakeList: new Set(["1403558951"]),
121
- ework: { createIssue: async () => 1, addComment: async () => {} },
122
- store: { seenPost: () => false },
123
- reply: async (_g: number, text: string) => { replies.push(text); },
124
- });
125
- // chat disabled → usage help
126
- let router = createRouter(baseDeps(""));
127
- await router.handleGroupMessage({ groupId: 1, userId: 1403558951, nickname: "dog", postId: "p1", rawMessage: "[CQ:at,qq=2661222094] 你好呀" });
128
- expect(replies[0]).toContain("没看懂指令");
129
- // chat enabled → mocked LLM answer (patch chatComplete via env endpoint failure is overkill; test buildChatMessages path instead)
130
- replies.length = 0;
131
- router = createRouter(baseDeps("http://invalid.test/v1"));
132
- await router.handleGroupMessage({ groupId: 1, userId: 1403558951, nickname: "dog", postId: "p2", rawMessage: "[CQ:at,qq=2661222094] 你好呀" });
133
- expect(replies[0]).toContain("回答失败");
134
- });
135
-
136
- test("router: @bot with CQ-only payload (no text) still gets help", async () => {
83
+ describe("@bot unified routing", () => {
84
+ const mk = (bs: BindingStore) => {
137
85
  const { createRouter } = require("../src/router");
138
86
  const replies: string[] = [];
87
+ const comments: Array<[string, string, number, string]> = [];
139
88
  const router = createRouter({
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 },
141
- bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile()),
89
+ cfg: { VERBOSE: false },
90
+ bindings: bs,
142
91
  wakeList: new Set(["1"]),
143
- 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]); } },
144
93
  store: { seenPost: () => false },
145
- reply: async (_g: number, text: string) => { replies.push(text); },
94
+ reply: async (_g: number, x: string) => { replies.push(x); },
146
95
  });
147
- await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p3", rawMessage: "[CQ:at,qq=2661222094]" });
148
- 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([]);
149
129
  });
150
130
  });
151
131
 
@@ -198,7 +178,7 @@ describe("issue pinning", () => {
198
178
  const replies: string[] = [];
199
179
  const comments: Array<[string, string, number, string]> = [];
200
180
  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 },
181
+ cfg: { VERBOSE: false },
202
182
  bindings: new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 7 }], pinFile()),
203
183
  wakeList: new Set(["1"]),
204
184
  ework: { createIssue: async () => 9, addComment: async (o: string, r: string, n: number, b: string) => { comments.push([o, r, n, b]); } },
@@ -218,7 +198,7 @@ describe("issue pinning", () => {
218
198
  const comments: Array<number, any> = [];
219
199
  const bs = new BindingStore([{ groupId: 1, owner: "o", repo: "r" }], pinFile());
220
200
  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 },
201
+ cfg: { VERBOSE: false },
222
202
  bindings: bs,
223
203
  wakeList: new Set(["1"]),
224
204
  ework: { createIssue: async () => 9, addComment: async (_o: any, _r: any, n: number) => { comments.push(n); } },
@@ -236,7 +216,7 @@ describe("issue pinning", () => {
236
216
  const replies: string[] = [];
237
217
  const bs = new BindingStore([{ groupId: 1, owner: "o", repo: "r", issue: 3 }], pinFile());
238
218
  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 },
219
+ cfg: { VERBOSE: false },
240
220
  bindings: bs,
241
221
  wakeList: new Set(["1"]),
242
222
  ework: { createIssue: async () => 12, addComment: async () => {} },
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
- }