import assert from "node:assert/strict"; import test from "node:test"; import type { QqConfig } from "../src/config.js"; import { QqAdapter } from "../src/platforms/qq/adapter.js"; const config: QqConfig = { type: "qq", connectionMode: "webhook", appId: "id", clientSecret: "secret", botSecret: "", verifySignature: false, botNames: ["Bot"], intents: 33_554_432, shard: [0, 1] }; test("normalizes GROUP and C2C author openid while webhook ACK remains immediate", async () => { const received: any[] = []; const gateway = { receive: async (message: unknown) => { received.push(message); throw new Error("ACP failed"); } }; const adapter = new QqAdapter(config, gateway as never); const group = await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m1", group_openid: "g1", author: { user_openid: "u1", member_openid: "mem1" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never }); const c2c = await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m2", author: { user_openid: "u2", member_openid: "mem2" }, content: "hello" } }, headers: {}, query: {}, req: {} as never }); assert.deepEqual(group.body, { op: 12 }); assert.deepEqual(c2c.body, { op: 12 }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(received[0].chatId, "group:g1"); assert.equal(received[0].userId, "mem1"); assert.equal(received[1].chatId, "user:u2"); assert.equal(received[1].userId, "u2"); }); test("group messages without member_openid fall back to user_openid", async () => { const received: any[] = []; const gateway = { receive: async (message: unknown) => { received.push(message); } }; const adapter = new QqAdapter(config, gateway as never); await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m3", group_openid: "g1", author: { user_openid: "u3" }, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never }); await adapter.handleWebhook({ body: { op: 0, t: "GROUP_AT_MESSAGE_CREATE", d: { id: "m4", group_openid: "g1", member_openid: "mem4", author: {}, content: "@Bot hi" } }, headers: {}, query: {}, req: {} as never }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(received[0].userId, "u3"); assert.equal(received[1].userId, "mem4"); }); test("image attachments are downloaded to base64 while video and file attachments degrade to text links", async () => { const received: any[] = []; const gateway = { receive: async (message: unknown) => { received.push(message); } }; const adapter = new QqAdapter(config, gateway as never); const pngBytes = Buffer.from("fake-png-bytes"); const urls: string[] = []; const original = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { urls.push(String(input)); return new Response(pngBytes, { status: 200 }); }) as typeof fetch; try { await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m10", author: { user_openid: "u10" }, content: "看看这些", attachments: [ { content_type: "image/png", filename: "a.png", size: pngBytes.length, url: "//cdn.example.com/a.png" }, { content_type: "video/mp4", filename: "v.mp4", size: 1024, url: "https://cdn.example.com/v.mp4" }, { content_type: "application/pdf", filename: "f.pdf", size: 1024, url: "https://cdn.example.com/f.pdf" } ] } }, headers: {}, query: {}, req: {} as never }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(received.length, 1); const message = received[0]; assert.deepEqual(urls, ["https://cdn.example.com/a.png"]); assert.equal(message.attachments.length, 1); assert.equal(message.attachments[0].mimeType, "image/png"); assert.equal(message.attachments[0].filename, "a.png"); assert.equal(message.attachments[0].data, pngBytes.toString("base64")); assert.match(message.text, /^看看这些/); assert.match(message.text, /\[视频\] https:\/\/cdn\.example\.com\/v\.mp4/); assert.match(message.text, /\[文件\] https:\/\/cdn\.example\.com\/f\.pdf/); } finally { globalThis.fetch = original; } }); test("oversized images are skipped and images beyond the per-message cap are not downloaded", async () => { const received: any[] = []; const gateway = { receive: async (message: unknown) => { received.push(message); } }; const adapter = new QqAdapter(config, gateway as never); const urls: string[] = []; const original = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { urls.push(String(input)); return new Response(Buffer.from("x"), { status: 200 }); }) as typeof fetch; try { await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m11", author: { user_openid: "u11" }, content: "hi", attachments: [ { content_type: "image/png", size: 6 * 1024 * 1024, url: "https://cdn.example.com/big.png" }, { content_type: "image/png", size: 10, url: "https://cdn.example.com/1.png" }, { content_type: "image/png", size: 10, url: "https://cdn.example.com/2.png" }, { content_type: "image/png", size: 10, url: "https://cdn.example.com/3.png" }, { content_type: "image/png", size: 10, url: "https://cdn.example.com/4.png" } ] } }, headers: {}, query: {}, req: {} as never }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(received.length, 1); assert.deepEqual(urls, [ "https://cdn.example.com/1.png", "https://cdn.example.com/2.png", "https://cdn.example.com/3.png" ]); assert.equal(received[0].attachments.length, 3); } finally { globalThis.fetch = original; } }); test("a pure image message uses placeholder text and failed downloads degrade", async () => { const received: any[] = []; const gateway = { receive: async (message: unknown) => { received.push(message); } }; const adapter = new QqAdapter(config, gateway as never); const original = globalThis.fetch; let fail = false; globalThis.fetch = (async () => { if (fail) throw new Error("network down"); return new Response(Buffer.from("img"), { status: 200 }); }) as typeof fetch; try { await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m12", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/a.jpg" }] } }, headers: {}, query: {}, req: {} as never }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(received.length, 1); assert.equal(received[0].text, "(发来一张图片)"); assert.equal(received[0].attachments.length, 1); // Every download failing with no text keeps the message ignored. fail = true; await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m13", author: { user_openid: "u12" }, content: "", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/b.jpg" }] } }, headers: {}, query: {}, req: {} as never }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(received.length, 1); // Failed downloads with text still deliver the text without attachments. await adapter.handleWebhook({ body: { op: 0, t: "C2C_MESSAGE_CREATE", d: { id: "m14", author: { user_openid: "u12" }, content: "看图", attachments: [{ content_type: "image/jpeg", url: "https://cdn.example.com/c.jpg" }] } }, headers: {}, query: {}, req: {} as never }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(received.length, 2); assert.equal(received[1].text, "看图"); assert.equal(received[1].attachments, undefined); } finally { globalThis.fetch = original; } }); test("sendMessage uses C2C endpoint and forwards reply sequences as msg_seq", async () => { const original = globalThis.fetch; const urls: string[] = []; const bodies: Array> = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { urls.push(String(input)); if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 }); bodies.push(JSON.parse(String(init?.body)) as Record); return new Response(JSON.stringify({ id: "sent" }), { status: 200 }); }) as typeof fetch; try { const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never); const target = { platform: "qq", chatId: "user:u2", raw: { author: { user_openid: "u2" } } }; await adapter.sendMessage({ target, text: "first", replyTo: "m2", replySequence: 1 }); await adapter.sendMessage({ target, text: "later", replyTo: "m2", replySequence: 7 }); assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/messages"))); assert.deepEqual(bodies, [ { content: "first", msg_id: "m2", msg_seq: 1 }, { content: "later", msg_id: "m2", msg_seq: 7 } ]); } finally { globalThis.fetch = original; } }); test("sendMessage without replyTo omits msg_id and msg_seq from the body", async () => { const original = globalThis.fetch; const bodies: Array> = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 }); bodies.push(JSON.parse(String(init?.body)) as Record); return new Response(JSON.stringify({ id: "sent" }), { status: 200 }); }) as typeof fetch; try { const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never); const target = { platform: "qq", chatId: "group:g1", raw: { group_openid: "g1" } }; await adapter.sendMessage({ target, text: "proactive" }); assert.deepEqual(bodies, [{ content: "proactive" }]); } finally { globalThis.fetch = original; } }); test("sendMessage surfaces safe QQ error details (HTTP status and code) without the request body", async () => { const original = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { if (String(input).includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 }); return new Response(JSON.stringify({ code: 40034105, message: "proactive message not allowed" }), { status: 400 }); }) as typeof fetch; try { const adapter = new QqAdapter(config, { receive: async () => ({ ok: true }) } as never); const target = { platform: "qq", chatId: "group:g1", raw: { group_openid: "g1" } }; await assert.rejects( adapter.sendMessage({ target, text: "secret message content" }), (error: Error) => { assert.match(error.message, /HTTP 400/); assert.match(error.message, /code=40034105/); assert.match(error.message, /proactive message not allowed/); assert.doesNotMatch(error.message, /secret message content/); return true; } ); } finally { globalThis.fetch = original; } });