ework-qq-bridge 0.1.4 → 0.2.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.1.4",
3
+ "version": "0.2.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/chat.ts ADDED
@@ -0,0 +1,72 @@
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
+ }
package/src/config.ts CHANGED
@@ -36,6 +36,15 @@ const Schema = z.object({
36
36
 
37
37
  DB_PATH: z.string().default(""),
38
38
 
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
+
39
48
  VERBOSE: z.coerce.boolean().default(false),
40
49
 
41
50
  // Deployment-specific hostnames scrubbed from outbound QQ messages.
package/src/ingest.ts CHANGED
@@ -19,6 +19,23 @@ interface IngestDeps {
19
19
  send(groupId: number, text: string): Promise<void>;
20
20
  }
21
21
 
22
+ // Looping agent sessions can emit a reply every few seconds; cap forwards
23
+ // per issue so a runaway session cannot flood the group.
24
+ const FORWARD_WINDOW_MS = 60_000;
25
+ const FORWARD_MAX_PER_WINDOW = 5;
26
+ const forwardTimestamps = new Map<string, number[]>();
27
+
28
+ function forwardAllowed(key: string, now = Date.now()): boolean {
29
+ const list = (forwardTimestamps.get(key) ?? []).filter((t) => now - t < FORWARD_WINDOW_MS);
30
+ if (list.length >= FORWARD_MAX_PER_WINDOW) {
31
+ forwardTimestamps.set(key, list);
32
+ return false;
33
+ }
34
+ list.push(now);
35
+ forwardTimestamps.set(key, list);
36
+ return true;
37
+ }
38
+
22
39
  export function verifySignature(secret: string, body: string, header: string | null): boolean {
23
40
  if (!secret) return true;
24
41
  if (!header) return false;
@@ -74,6 +91,10 @@ export function createIngest(deps: IngestDeps) {
74
91
  return new Response("skipped:dup", { status: 200 });
75
92
  }
76
93
 
94
+ if (!forwardAllowed(`${owner}/${String(repo.name)}#${number}`)) {
95
+ console.warn(`[qq-bridge] forward rate-limited for ${owner}/${String(repo.name)}#${number}`);
96
+ return new Response("rate-limited", { status: 200 });
97
+ }
77
98
  const text = deps.scrub(`[#${number}] ${body}`);
78
99
  try {
79
100
  await deps.send(groupId, text);
package/src/router.ts CHANGED
@@ -2,12 +2,14 @@ import type { GroupBinding, 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
6
 
6
7
  const HELP_TEXT = [
7
8
  "用法:",
8
9
  " 任务 <标题> —— 新建 issue,AI 自动接单",
9
10
  " #<编号> <内容> —— 给指定 issue 追加内容",
10
11
  " 查询 —— 列出最近 issue",
12
+ " @我 + 任意问题 —— 即时问答(不建 issue)",
11
13
  ].join("\n");
12
14
 
13
15
  export interface RouterDeps {
@@ -39,6 +41,23 @@ export function parseCommand(raw: string): ParsedCommand | null {
39
41
 
40
42
  export function createRouter(deps: RouterDeps) {
41
43
  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
+ }
42
61
 
43
62
  async function handleGroupMessage(ev: GroupMessageEvent): Promise<void> {
44
63
  if (store.seenPost(ev.postId)) return;
@@ -57,7 +76,16 @@ export function createRouter(deps: RouterDeps) {
57
76
  return;
58
77
  }
59
78
  if (!cmd) {
60
- // Wake-word without a verb: silent-ignore hides the syntax from users.
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, "🤖 回答失败了,稍后再试一次。");
86
+ }
87
+ return;
88
+ }
61
89
  await deps.reply(ev.groupId, "没看懂指令。\n" + HELP_TEXT);
62
90
  return;
63
91
  }
@@ -75,3 +75,72 @@ describe("verifySignature", () => {
75
75
  test("helps when @bot without verb", () => {
76
76
  expect(parseCommand("[CQ:at,qq=2661222094] 测试2")).toBeNull();
77
77
  });
78
+
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 () => {
133
+ const { createRouter } = require("../src/router");
134
+ const replies: string[] = [];
135
+ 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" }],
138
+ wakeList: new Set(["1"]),
139
+ ework: { createIssue: async () => 1, addComment: async () => {} },
140
+ store: { seenPost: () => false },
141
+ reply: async (_g: number, text: string) => { replies.push(text); },
142
+ });
143
+ await router.handleGroupMessage({ groupId: 1, userId: 1, nickname: "u", postId: "p3", rawMessage: "[CQ:at,qq=2661222094]" });
144
+ expect(replies[0]).toContain("没看懂指令");
145
+ });
146
+ });