ework-qq-bridge 0.1.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/bin/ework-qq-bridge.js +5 -0
- package/bun.lock +29 -0
- package/package.json +25 -0
- package/src/config.ts +87 -0
- package/src/db.ts +46 -0
- package/src/ework.ts +37 -0
- package/src/index.ts +82 -0
- package/src/ingest.ts +83 -0
- package/src/onebot.ts +131 -0
- package/src/router.ts +82 -0
- package/src/scrub.ts +26 -0
- package/tests/bridge.test.ts +73 -0
- package/tsconfig.json +23 -0
package/bun.lock
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "ework-qq-bridge",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"zod": "^3.23.8",
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/bun": "latest",
|
|
12
|
+
"typescript": "^5.5.0",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
"packages": {
|
|
17
|
+
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
|
18
|
+
|
|
19
|
+
"@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="],
|
|
20
|
+
|
|
21
|
+
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
|
22
|
+
|
|
23
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
24
|
+
|
|
25
|
+
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
|
26
|
+
|
|
27
|
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
28
|
+
}
|
|
29
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ework-qq-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "QQ group <-> ework issue bridge (OneBot 11 reverse WebSocket)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"check": "tsc --noEmit",
|
|
9
|
+
"dev": "bun --watch src/index.ts",
|
|
10
|
+
"start": "bun src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"zod": "^3.23.8"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "latest",
|
|
17
|
+
"typescript": "^5.5.0"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"bun": ">=1.1.0"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"ework-qq-bridge": "./bin/ework-qq-bridge.js"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const Schema = z.object({
|
|
4
|
+
// OneBot 11 reverse-WS server: NapCat connects OUT to us here.
|
|
5
|
+
PORT: z.coerce.number().int().positive().default(8080),
|
|
6
|
+
HOST: z.string().default("127.0.0.1"),
|
|
7
|
+
ONEBOT_WS_PATH: z.string().default("/onebot/v11/ws"),
|
|
8
|
+
// Shared secret NapCat sends as `Authorization: Bearer <token>` on connect.
|
|
9
|
+
ONEBOT_ACCESS_TOKEN: z.string().default(""),
|
|
10
|
+
|
|
11
|
+
// ework web (Gitea-compatible shim) — used to create issues / post comments.
|
|
12
|
+
EWORK_URL: z.string().url(),
|
|
13
|
+
EWORK_TOKEN: z.string().min(1),
|
|
14
|
+
|
|
15
|
+
// ework webhook ingest (same contract as ework-mirror): web fans issue
|
|
16
|
+
// comment events here so agent replies can be pushed back to the group.
|
|
17
|
+
EWORK_WEBHOOK_SECRET: z.string().default(""),
|
|
18
|
+
|
|
19
|
+
// group_id -> owner/repo mapping. Comma-separated:
|
|
20
|
+
// "123456789:ranxianglei/billion-context,987654321:dog/test1"
|
|
21
|
+
GROUP_MAP: z.string().min(1),
|
|
22
|
+
|
|
23
|
+
// QQ user_ids allowed to dispatch AI work (comma-separated). Messages from
|
|
24
|
+
// other members are logged and ignored — same trust model as the GitHub
|
|
25
|
+
// side (WORK_WAKE_LOGINS): strangers never wake the AI.
|
|
26
|
+
QQ_WAKE_LIST: z.string().default(""),
|
|
27
|
+
|
|
28
|
+
// ework logins whose comments are agent replies worth forwarding to QQ.
|
|
29
|
+
// The bridge's own login is always skipped (echo guard).
|
|
30
|
+
AGENT_LOGINS: z.string().default("ework-daemon"),
|
|
31
|
+
|
|
32
|
+
// The ework login this bridge posts as. Must NOT be the daemon's own login
|
|
33
|
+
// (daemon ignores its own comments) and should be added to the daemon's
|
|
34
|
+
// WORK_WAKE_LOGINS so curated bridge comments dispatch the AI.
|
|
35
|
+
BRIDGE_LOGIN: z.string().default("qq-bridge"),
|
|
36
|
+
|
|
37
|
+
DB_PATH: z.string().default(""),
|
|
38
|
+
|
|
39
|
+
VERBOSE: z.coerce.boolean().default(false),
|
|
40
|
+
|
|
41
|
+
// Deployment-specific hostnames scrubbed from outbound QQ messages.
|
|
42
|
+
// Env-driven so the published package never reveals real infrastructure.
|
|
43
|
+
WORK_SCRUB_HOSTS: z.string().default(""),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export type Config = z.infer<typeof Schema>;
|
|
47
|
+
|
|
48
|
+
export interface GroupBinding {
|
|
49
|
+
groupId: number;
|
|
50
|
+
owner: string;
|
|
51
|
+
repo: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseGroupMap(raw: string): GroupBinding[] {
|
|
55
|
+
const out: GroupBinding[] = [];
|
|
56
|
+
for (const part of raw.split(",")) {
|
|
57
|
+
const item = part.trim();
|
|
58
|
+
if (!item) continue;
|
|
59
|
+
const m = /^(\d+):([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(item);
|
|
60
|
+
if (!m?.[1] || !m[2] || !m[3]) {
|
|
61
|
+
throw new Error(`invalid GROUP_MAP entry: ${item}`);
|
|
62
|
+
}
|
|
63
|
+
out.push({ groupId: Number(m[1]), owner: m[2], repo: m[3] });
|
|
64
|
+
}
|
|
65
|
+
if (out.length === 0) throw new Error("GROUP_MAP must define at least one group");
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseList(raw: string): string[] {
|
|
70
|
+
return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loadConfig(): Config {
|
|
74
|
+
const parsed = Schema.safeParse(process.env);
|
|
75
|
+
if (!parsed.success) {
|
|
76
|
+
console.error("Invalid config:");
|
|
77
|
+
for (const issue of parsed.error.issues) {
|
|
78
|
+
console.error(` ${issue.path.join(".")}: ${issue.message}`);
|
|
79
|
+
}
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
const cfg = parsed.data;
|
|
83
|
+
if (!cfg.DB_PATH) {
|
|
84
|
+
cfg.DB_PATH = `${process.env.HOME ?? "/tmp"}/.ework-qq-bridge/qq-bridge.db`;
|
|
85
|
+
}
|
|
86
|
+
return cfg;
|
|
87
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function initDB(path: string): void {
|
|
6
|
+
if (!path) return;
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class BridgeStore {
|
|
11
|
+
private db: Database;
|
|
12
|
+
|
|
13
|
+
constructor(dbPath: string) {
|
|
14
|
+
this.db = new Database(dbPath, { create: true });
|
|
15
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
16
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS seen_posts (
|
|
17
|
+
post_id TEXT PRIMARY KEY,
|
|
18
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
19
|
+
)`);
|
|
20
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS forwarded_comments (
|
|
21
|
+
comment_id INTEGER PRIMARY KEY,
|
|
22
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
23
|
+
)`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// QQ dedup: NapCat may redeliver events after reconnect; each post is
|
|
27
|
+
// handled exactly once.
|
|
28
|
+
seenPost(postId: string): boolean {
|
|
29
|
+
const row = this.db.query("SELECT 1 FROM seen_posts WHERE post_id = ?").get(postId);
|
|
30
|
+
if (row) return true;
|
|
31
|
+
this.db.query("INSERT OR IGNORE INTO seen_posts (post_id) VALUES (?)").run(postId);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Webhook dedup: web retries deliveries; mark comment ids already pushed.
|
|
36
|
+
commentForwarded(commentId: number): boolean {
|
|
37
|
+
const row = this.db.query("SELECT 1 FROM forwarded_comments WHERE comment_id = ?").get(commentId);
|
|
38
|
+
if (row) return true;
|
|
39
|
+
this.db.query("INSERT OR IGNORE INTO forwarded_comments (comment_id) VALUES (?)").run(commentId);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
close(): void {
|
|
44
|
+
this.db.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/ework.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface EworkClient {
|
|
2
|
+
createIssue(owner: string, repo: string, title: string, body: string): Promise<number>;
|
|
3
|
+
addComment(owner: string, repo: string, number: number, body: string): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function createEworkClient(baseUrl: string, token: string): EworkClient {
|
|
7
|
+
const root = baseUrl.replace(/\/+$/, "");
|
|
8
|
+
const headers = {
|
|
9
|
+
"Content-Type": "application/json",
|
|
10
|
+
Authorization: `Bearer ${token}`,
|
|
11
|
+
};
|
|
12
|
+
async function request(path: string, body: unknown): Promise<Response> {
|
|
13
|
+
const res = await fetch(`${root}${path}`, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers,
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
signal: AbortSignal.timeout(15_000),
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const text = await res.text().catch(() => "");
|
|
21
|
+
throw new Error(`ework ${path} -> ${res.status}: ${text.slice(0, 200)}`);
|
|
22
|
+
}
|
|
23
|
+
return res;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async createIssue(owner, repo, title, body) {
|
|
27
|
+
const res = await request(`/repos/${owner}/${repo}/issues`, { title, body });
|
|
28
|
+
const data = (await res.json()) as { number?: unknown };
|
|
29
|
+
const n = Number(data.number);
|
|
30
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`ework createIssue returned no number: ${JSON.stringify(data).slice(0, 200)}`);
|
|
31
|
+
return n;
|
|
32
|
+
},
|
|
33
|
+
async addComment(owner, repo, number, body) {
|
|
34
|
+
await request(`/repos/${owner}/${repo}/issues/${number}/comments`, { body });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { loadConfig, parseGroupMap, parseList } from "./config";
|
|
2
|
+
import { BridgeStore } from "./db";
|
|
3
|
+
import { createOneBotServer, type OneBotApi, type GroupMessageEvent } from "./onebot";
|
|
4
|
+
import { createRouter } from "./router";
|
|
5
|
+
import { createIngest } from "./ingest";
|
|
6
|
+
import { createEworkClient } from "./ework";
|
|
7
|
+
import { buildScrubber } from "./scrub";
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
const cfg = loadConfig();
|
|
11
|
+
if (cfg.DB_PATH) {
|
|
12
|
+
const dir = cfg.DB_PATH.slice(0, cfg.DB_PATH.lastIndexOf("/"));
|
|
13
|
+
if (dir) await Bun.write(`${dir}/.keep`, "");
|
|
14
|
+
}
|
|
15
|
+
const store = new BridgeStore(cfg.DB_PATH || "/tmp/ework-qq-bridge.db");
|
|
16
|
+
|
|
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);
|
|
20
|
+
|
|
21
|
+
let api: OneBotApi | null = null;
|
|
22
|
+
const send = async (groupId: number, text: string) => {
|
|
23
|
+
if (!api) throw new Error("OneBot client not connected");
|
|
24
|
+
await api.call("send_group_msg", { group_id: groupId, message: [{ type: "text", data: { text } }] });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ework = createEworkClient(cfg.EWORK_URL, cfg.EWORK_TOKEN);
|
|
28
|
+
const scrub = buildScrubber(parseList(cfg.WORK_SCRUB_HOSTS));
|
|
29
|
+
const router = createRouter({
|
|
30
|
+
cfg,
|
|
31
|
+
bindings,
|
|
32
|
+
wakeList: new Set(parseList(cfg.QQ_WAKE_LIST)),
|
|
33
|
+
ework,
|
|
34
|
+
store,
|
|
35
|
+
reply: send,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const ingest = createIngest({
|
|
39
|
+
secret: cfg.EWORK_WEBHOOK_SECRET,
|
|
40
|
+
bridgeLogin: cfg.BRIDGE_LOGIN,
|
|
41
|
+
agentLogins: new Set(parseList(cfg.AGENT_LOGINS)),
|
|
42
|
+
scrub,
|
|
43
|
+
projectOf: (owner, repo) => groupIdOfProject.get(`${owner}/${repo}`) ?? null,
|
|
44
|
+
commentForwarded: (id) => store.commentForwarded(id),
|
|
45
|
+
send,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const server = createOneBotServer({
|
|
49
|
+
path: cfg.ONEBOT_WS_PATH,
|
|
50
|
+
accessToken: cfg.ONEBOT_ACCESS_TOKEN,
|
|
51
|
+
onEvent: async (ev: GroupMessageEvent) => {
|
|
52
|
+
await router.handleGroupMessage(ev);
|
|
53
|
+
},
|
|
54
|
+
onReady: (a: OneBotApi) => {
|
|
55
|
+
api = a;
|
|
56
|
+
console.log("[qq-bridge] OneBot client connected");
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
Bun.serve({
|
|
61
|
+
port: cfg.PORT,
|
|
62
|
+
websocket: server.handlers,
|
|
63
|
+
fetch(req, srv) {
|
|
64
|
+
const url = new URL(req.url);
|
|
65
|
+
if (url.pathname === "/healthz") return new Response("ok");
|
|
66
|
+
if (url.pathname === cfg.ONEBOT_WS_PATH) {
|
|
67
|
+
if (cfg.ONEBOT_ACCESS_TOKEN) {
|
|
68
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
69
|
+
if (auth !== `Bearer ${cfg.ONEBOT_ACCESS_TOKEN}`) return new Response("unauthorized", { status: 401 });
|
|
70
|
+
}
|
|
71
|
+
if (srv.upgrade(req, { data: undefined })) return;
|
|
72
|
+
return new Response("upgrade failed", { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
if (url.pathname === "/ingest/ework") return ingest(req);
|
|
75
|
+
return new Response("not found", { status: 404 });
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
console.log(`[qq-bridge] listening on :${cfg.PORT} (ws ${cfg.ONEBOT_WS_PATH}, ingest /ingest/ework)`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
main();
|
package/src/ingest.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface WebhookComment {
|
|
4
|
+
commentId: number;
|
|
5
|
+
owner: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
number: number;
|
|
8
|
+
author: string;
|
|
9
|
+
body: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface IngestDeps {
|
|
13
|
+
secret: string;
|
|
14
|
+
bridgeLogin: string;
|
|
15
|
+
agentLogins: Set<string>;
|
|
16
|
+
scrub: (text: string) => string;
|
|
17
|
+
projectOf(owner: string, repo: string): number | null;
|
|
18
|
+
commentForwarded(commentId: number): boolean;
|
|
19
|
+
send(groupId: number, text: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function verifySignature(secret: string, body: string, header: string | null): boolean {
|
|
23
|
+
if (!secret) return true;
|
|
24
|
+
if (!header) return false;
|
|
25
|
+
const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
26
|
+
const a = Buffer.from(expected);
|
|
27
|
+
const b = Buffer.from(header);
|
|
28
|
+
if (a.length !== b.length) return false;
|
|
29
|
+
return timingSafeEqual(a, b);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createIngest(deps: IngestDeps) {
|
|
33
|
+
return async function ingest(req: Request): Promise<Response> {
|
|
34
|
+
const event = req.headers.get("x-gitea-event") ?? "";
|
|
35
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
36
|
+
const raw = await req.text();
|
|
37
|
+
if (!verifySignature(deps.secret, raw, req.headers.get("x-gitea-signature"))) {
|
|
38
|
+
console.warn(`[qq-bridge] rejecting webhook: bad signature (event=${event})`);
|
|
39
|
+
return new Response("bad signature", { status: 401 });
|
|
40
|
+
}
|
|
41
|
+
if (event !== "issue_comment") return new Response("ignored", { status: 200 });
|
|
42
|
+
|
|
43
|
+
let parsed: Record<string, unknown>;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
46
|
+
} catch {
|
|
47
|
+
return new Response("bad json", { status: 400 });
|
|
48
|
+
}
|
|
49
|
+
const action = String(parsed.action ?? "");
|
|
50
|
+
if (action !== "created") return new Response("ignored", { status: 200 });
|
|
51
|
+
|
|
52
|
+
const repo = (parsed.repository ?? {}) as Record<string, unknown>;
|
|
53
|
+
const owner = ((repo.owner ?? {}) as Record<string, unknown>).login;
|
|
54
|
+
const issue = (parsed.issue ?? {}) as Record<string, unknown>;
|
|
55
|
+
const comment = (parsed.comment ?? {}) as Record<string, unknown>;
|
|
56
|
+
if (typeof owner !== "string" || typeof repo.name !== "string") return new Response("ignored", { status: 200 });
|
|
57
|
+
|
|
58
|
+
const author = String(((comment.user ?? {}) as Record<string, unknown>).login ?? "");
|
|
59
|
+
const body = String(comment.body ?? "");
|
|
60
|
+
const number = Number(issue.number);
|
|
61
|
+
const commentId = Number(comment.id);
|
|
62
|
+
|
|
63
|
+
// Echo guard: our own posts and plumbing notices must never loop back.
|
|
64
|
+
if (author === deps.bridgeLogin) return new Response("skipped:self", { status: 200 });
|
|
65
|
+
if (body.startsWith("[system]") || body.startsWith("[SYSTEM ")) return new Response("skipped:system", { status: 200 });
|
|
66
|
+
if (!deps.agentLogins.has(author)) return new Response("skipped:non-agent", { status: 200 });
|
|
67
|
+
|
|
68
|
+
const groupId = deps.projectOf(owner, String(repo.name));
|
|
69
|
+
if (groupId === null) return new Response("skipped:unmapped", { status: 200 });
|
|
70
|
+
if (!Number.isInteger(commentId) || deps.commentForwarded(commentId)) {
|
|
71
|
+
return new Response("skipped:dup", { status: 200 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const text = deps.scrub(`[#${number}] ${body}`);
|
|
75
|
+
try {
|
|
76
|
+
await deps.send(groupId, text);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(`[qq-bridge] send_group_msg failed: ${err instanceof Error ? err.message : err}`);
|
|
79
|
+
return new Response("send failed", { status: 502 });
|
|
80
|
+
}
|
|
81
|
+
return new Response("forwarded", { status: 200 });
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/onebot.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
|
|
3
|
+
type HeadersInitLike = Record<string, string>;
|
|
4
|
+
|
|
5
|
+
export interface GroupMessageEvent {
|
|
6
|
+
postId: string;
|
|
7
|
+
groupId: number;
|
|
8
|
+
userId: number;
|
|
9
|
+
nickname: string;
|
|
10
|
+
rawMessage: string;
|
|
11
|
+
post_type: string;
|
|
12
|
+
message_type: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface OneBotEvent {
|
|
16
|
+
post_id?: number | string;
|
|
17
|
+
post_type?: string;
|
|
18
|
+
message_type?: string;
|
|
19
|
+
group_id?: number;
|
|
20
|
+
user_id?: number;
|
|
21
|
+
sender?: { card?: string; nickname?: string };
|
|
22
|
+
raw_message?: string;
|
|
23
|
+
time?: number;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ApiRequest {
|
|
28
|
+
action: string;
|
|
29
|
+
params: Record<string, unknown>;
|
|
30
|
+
echo?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ApiResult {
|
|
34
|
+
status: string;
|
|
35
|
+
retcode: number;
|
|
36
|
+
data?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Public API handle: call() sends OneBot actions and waits for the echo-matched
|
|
40
|
+
// result; connection lifecycle is owned by the server.
|
|
41
|
+
export interface OneBotApi {
|
|
42
|
+
call(action: string, params: Record<string, unknown>): Promise<ApiResult>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OneBotServerOptions {
|
|
46
|
+
path: string;
|
|
47
|
+
accessToken: string;
|
|
48
|
+
onEvent(ev: GroupMessageEvent): void | Promise<void>;
|
|
49
|
+
onReady(api: OneBotApi): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PendingEntry {
|
|
53
|
+
resolve: (r: ApiResult) => void;
|
|
54
|
+
reject: (e: Error) => void;
|
|
55
|
+
timer: ReturnType<typeof setTimeout>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createOneBotServer(opts: OneBotServerOptions) {
|
|
59
|
+
const pending = new Map<string, PendingEntry>();
|
|
60
|
+
let ws: ServerWebSocket<unknown> | null = null;
|
|
61
|
+
|
|
62
|
+
function rejectAll(reason: string) {
|
|
63
|
+
for (const [, entry] of pending) {
|
|
64
|
+
entry.reject(new Error(reason));
|
|
65
|
+
}
|
|
66
|
+
pending.clear();
|
|
67
|
+
ws = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
get connected() {
|
|
72
|
+
return ws !== null;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Bun handlers: wire these into Bun.serve({websocket:{...}})
|
|
76
|
+
handlers: {
|
|
77
|
+
open(client: ServerWebSocket<unknown>) {
|
|
78
|
+
if (ws) {
|
|
79
|
+
client.close(4000, "duplicate connection");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
ws = client;
|
|
83
|
+
const api: OneBotApi = {
|
|
84
|
+
call(action, params) {
|
|
85
|
+
const echo = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
86
|
+
return new Promise<ApiResult>((resolve, reject) => {
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
pending.delete(echo);
|
|
89
|
+
reject(new Error(`OneBot call ${action} timed out`));
|
|
90
|
+
}, 10_000);
|
|
91
|
+
pending.set(echo, { resolve, reject, timer });
|
|
92
|
+
const req: ApiRequest = { action, params, echo };
|
|
93
|
+
client.send(JSON.stringify(req));
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
opts.onReady(api);
|
|
98
|
+
},
|
|
99
|
+
message(_client: ServerWebSocket<unknown>, data: string | Buffer) {
|
|
100
|
+
let msg: OneBotEvent & { echo?: string; status?: string; retcode?: number; data?: unknown };
|
|
101
|
+
try {
|
|
102
|
+
msg = JSON.parse(String(data)) as typeof msg;
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (typeof msg.echo === "string" && pending.has(msg.echo)) {
|
|
107
|
+
const entry = pending.get(msg.echo)!;
|
|
108
|
+
pending.delete(msg.echo);
|
|
109
|
+
clearTimeout(entry.timer);
|
|
110
|
+
entry.resolve({ status: msg.status ?? "unknown", retcode: msg.retcode ?? -1, data: msg.data });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (msg.post_type === "message" && msg.message_type === "group") {
|
|
114
|
+
const ev: GroupMessageEvent = {
|
|
115
|
+
postId: String(msg.post_id ?? `${msg.time ?? 0}-${msg.user_id ?? 0}`),
|
|
116
|
+
groupId: Number(msg.group_id ?? 0),
|
|
117
|
+
userId: Number(msg.user_id ?? 0),
|
|
118
|
+
nickname: String(msg.sender?.card || msg.sender?.nickname || String(msg.user_id ?? "")),
|
|
119
|
+
rawMessage: String(msg.raw_message ?? ""),
|
|
120
|
+
post_type: "message",
|
|
121
|
+
message_type: "group",
|
|
122
|
+
};
|
|
123
|
+
void opts.onEvent(ev);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
close() {
|
|
127
|
+
rejectAll("connection closed");
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { GroupBinding, Config } from "./config";
|
|
2
|
+
import type { EworkClient } from "./ework";
|
|
3
|
+
import type { GroupMessageEvent } from "./onebot";
|
|
4
|
+
import type { BridgeStore } from "./db";
|
|
5
|
+
|
|
6
|
+
const HELP_TEXT = [
|
|
7
|
+
"用法:",
|
|
8
|
+
" 任务 <标题> —— 新建 issue,AI 自动接单",
|
|
9
|
+
" #<编号> <内容> —— 给指定 issue 追加内容",
|
|
10
|
+
" 查询 —— 列出最近 issue",
|
|
11
|
+
].join("\n");
|
|
12
|
+
|
|
13
|
+
export interface RouterDeps {
|
|
14
|
+
cfg: Config;
|
|
15
|
+
bindings: GroupBinding[];
|
|
16
|
+
wakeList: Set<string>;
|
|
17
|
+
ework: EworkClient;
|
|
18
|
+
store: BridgeStore;
|
|
19
|
+
reply(groupId: number, text: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ParsedCommand {
|
|
23
|
+
kind: "create" | "comment" | "help";
|
|
24
|
+
title?: string;
|
|
25
|
+
number?: number;
|
|
26
|
+
body?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseCommand(raw: string): ParsedCommand | null {
|
|
30
|
+
const text = raw.trim();
|
|
31
|
+
if (text === "帮助" || text === "help" || text === "查询") return { kind: "help" };
|
|
32
|
+
const create = /^(?:任务|task|新任务)\s+(.+)$/i.exec(text);
|
|
33
|
+
if (create?.[1]) return { kind: "create", title: create[1].trim() };
|
|
34
|
+
const comment = /^#(\d{1,6})\s+([\s\S]+)$/.exec(text);
|
|
35
|
+
if (comment?.[1] && comment[2]) return { kind: "comment", number: Number(comment[1]), body: comment[2].trim() };
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createRouter(deps: RouterDeps) {
|
|
40
|
+
const { cfg, bindings, wakeList, ework, store } = deps;
|
|
41
|
+
|
|
42
|
+
async function handleGroupMessage(ev: GroupMessageEvent): Promise<void> {
|
|
43
|
+
if (store.seenPost(ev.postId)) return;
|
|
44
|
+
const binding = bindings.find((b) => b.groupId === ev.groupId);
|
|
45
|
+
if (!binding) return;
|
|
46
|
+
|
|
47
|
+
if (!wakeList.has(String(ev.userId))) {
|
|
48
|
+
if (cfg.VERBOSE) console.log(`[qq-bridge] ignore non-whitelisted ${ev.userId} in ${ev.groupId}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cmd = parseCommand(ev.rawMessage);
|
|
53
|
+
if (!cmd) {
|
|
54
|
+
if (cfg.VERBOSE) console.log(`[qq-bridge] unrecognized message from ${ev.userId}: ${ev.rawMessage.slice(0, 80)}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (cmd.kind === "help") {
|
|
58
|
+
await deps.reply(ev.groupId, HELP_TEXT);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const attribution = `> 来自 QQ 群用户 **${ev.nickname}** (${ev.userId})`;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (cmd.kind === "create") {
|
|
66
|
+
const n = await ework.createIssue(binding.owner, binding.repo, cmd.title ?? "", `${attribution}\n\n${cmd.title ?? ""}`);
|
|
67
|
+
await deps.reply(ev.groupId, `✅ 已创建 issue #${n},AI 已接单:${cmd.title ?? ""}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (cmd.kind === "comment" && cmd.number !== undefined) {
|
|
71
|
+
await ework.addComment(binding.owner, binding.repo, cmd.number, `${attribution}\n\n${cmd.body ?? ""}`);
|
|
72
|
+
await deps.reply(ev.groupId, `✅ 已追加到 #${cmd.number}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(`[qq-bridge] ework call failed: ${err instanceof Error ? err.message : err}`);
|
|
77
|
+
await deps.reply(ev.groupId, `❌ 处理失败:${err instanceof Error ? err.message : "unknown"}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { handleGroupMessage };
|
|
82
|
+
}
|
package/src/scrub.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Outbound text hygiene: never leak internal hostnames/IPs into QQ groups.
|
|
2
|
+
// IP ranges are always scrubbed (RFC1918); hostnames come from env so the
|
|
3
|
+
// published package carries no real infrastructure names (same policy as
|
|
4
|
+
// ework-mirror).
|
|
5
|
+
|
|
6
|
+
const IP_PATTERNS = [
|
|
7
|
+
/192\.168\.\d{1,3}\.\d{1,3}/g,
|
|
8
|
+
/10\.\d{1,3}\.\d{1,3}\.\d{1,3}/g,
|
|
9
|
+
/172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}/g,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function buildScrubber(hosts: string[]): (text: string) => string {
|
|
13
|
+
const hostPatterns = hosts
|
|
14
|
+
.map((h) => h.trim())
|
|
15
|
+
.filter((h) => h.length > 0)
|
|
16
|
+
.map((h) => ({ re: new RegExp(h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), h }));
|
|
17
|
+
return (text: string): string => {
|
|
18
|
+
let out = text;
|
|
19
|
+
for (const p of IP_PATTERNS) out = out.replace(p, "[内网IP]");
|
|
20
|
+
for (const { re, h } of hostPatterns) {
|
|
21
|
+
if (h.includes(out)) continue;
|
|
22
|
+
out = out.replace(re, "[内部主机]");
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { parseCommand } from "../src/router";
|
|
3
|
+
import { parseGroupMap, parseList } from "../src/config";
|
|
4
|
+
import { buildScrubber } from "../src/scrub";
|
|
5
|
+
import { verifySignature } from "../src/ingest";
|
|
6
|
+
|
|
7
|
+
describe("parseCommand", () => {
|
|
8
|
+
test("create via 任务/task", () => {
|
|
9
|
+
expect(parseCommand("任务 修复登录超时")).toEqual({ kind: "create", title: "修复登录超时" });
|
|
10
|
+
expect(parseCommand("task add dark mode")).toEqual({ kind: "create", title: "add dark mode" });
|
|
11
|
+
expect(parseCommand("新任务 优化缓存")).toEqual({ kind: "create", title: "优化缓存" });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("comment via #N", () => {
|
|
15
|
+
expect(parseCommand("#42 这个问题还在")).toEqual({ kind: "comment", number: 42, body: "这个问题还在" });
|
|
16
|
+
expect(parseCommand("#123\n多行\n内容")).toEqual({ kind: "comment", number: 123, body: "多行\n内容" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("help keywords", () => {
|
|
20
|
+
for (const k of ["帮助", "help", "查询"]) expect(parseCommand(k)?.kind).toBe("help");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("unrecognized returns null", () => {
|
|
24
|
+
expect(parseCommand("今天天气不错")).toBeNull();
|
|
25
|
+
expect(parseCommand("")).toBeNull();
|
|
26
|
+
expect(parseCommand("#999")).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("parseGroupMap", () => {
|
|
31
|
+
test("valid mapping", () => {
|
|
32
|
+
expect(parseGroupMap("123456:ranxianglei/billion-context, 987654:dog/test1")).toEqual([
|
|
33
|
+
{ groupId: 123456, owner: "ranxianglei", repo: "billion-context" },
|
|
34
|
+
{ groupId: 987654, owner: "dog", repo: "test1" },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("invalid entries throw", () => {
|
|
39
|
+
expect(() => parseGroupMap("123456:billion-context")).toThrow();
|
|
40
|
+
expect(() => parseGroupMap("abc:x/y")).toThrow();
|
|
41
|
+
expect(() => parseGroupMap(",,")).toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("buildScrubber", () => {
|
|
46
|
+
test("scrubs RFC1918 IPs always", () => {
|
|
47
|
+
const scrub = buildScrubber([]);
|
|
48
|
+
expect(scrub("server at 192.168.1.5 and 10.0.0.2")).not.toContain("192.168.1.5");
|
|
49
|
+
expect(scrub("server at 192.168.1.5 and 10.0.0.2")).not.toContain("10.0.0.2");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("scrubs configured hosts but keeps others", () => {
|
|
53
|
+
const scrub = buildScrubber(["internal.example", "box-one"]);
|
|
54
|
+
const out = scrub("see internal.example and box-one but example.org is fine");
|
|
55
|
+
expect(out).toContain("[内部主机]");
|
|
56
|
+
expect(out).toContain("example.org");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("verifySignature", () => {
|
|
61
|
+
test("valid HMAC passes", () => {
|
|
62
|
+
const body = JSON.stringify({ a: 1 });
|
|
63
|
+
const crypto = require("node:crypto");
|
|
64
|
+
const mac = `sha256=${crypto.createHmac("sha256", "secret").update(body).digest("hex")}`;
|
|
65
|
+
expect(verifySignature("secret", body, mac)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("wrong signature and missing header fail; empty secret allows", () => {
|
|
69
|
+
expect(verifySignature("secret", "x", "sha256=deadbeef")).toBe(false);
|
|
70
|
+
expect(verifySignature("secret", "x", null)).toBe(false);
|
|
71
|
+
expect(verifySignature("", "x", null)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ESNext"],
|
|
7
|
+
"types": ["bun-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noImplicitOverride": true,
|
|
11
|
+
"noFallthroughCasesInSwitch": true,
|
|
12
|
+
"exactOptionalPropertyTypes": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"allowSyntheticDefaultImports": true,
|
|
17
|
+
"resolveJsonModule": true,
|
|
18
|
+
"noEmit": true,
|
|
19
|
+
"forceConsistentCasingInFileNames": true
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*.ts", "bin/**/*.js"],
|
|
22
|
+
"exclude": ["node_modules"]
|
|
23
|
+
}
|