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 uploads images via /files and sends msg_type 7 media with the shared msg_seq", async () => { const original = globalThis.fetch; const urls: string[] = []; const bodies: Array> = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); urls.push(url); if (url.includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 }); bodies.push(JSON.parse(String(init?.body)) as Record); if (url.endsWith("/files")) return new Response(JSON.stringify({ file_info: "FILEINFO", ttl: 60 }), { status: 200 }); 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" } }; const image = { mimeType: "image/png", data: Buffer.from("fake-png").toString("base64"), filename: "shot.png" }; await adapter.sendMessage({ target, text: "", images: [image], replyTo: "m1", replySequence: 3 }); const apiCalls = urls.filter((url) => !url.includes("getAppAccessToken")).map((url) => url.replace("https://api.sgroup.qq.com", "")); assert.deepEqual(apiCalls, ["/v2/groups/g1/files", "/v2/groups/g1/messages"]); assert.deepEqual(bodies, [ { file_type: 1, file_data: image.data, srv_send_msg: false }, { msg_type: 7, media: { file_info: "FILEINFO" }, content: "", msg_id: "m1", msg_seq: 3 } ]); } finally { globalThis.fetch = original; } }); test("sendMessage delivers text and images to C2C with independent upload per target", async () => { const original = globalThis.fetch; const urls: string[] = []; const bodies: Array> = []; globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); urls.push(url); if (url.includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 }); bodies.push(JSON.parse(String(init?.body)) as Record); if (url.endsWith("/files")) return new Response(JSON.stringify({ file_info: "FILEINFO" }), { status: 200 }); 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" } } }; const image = { mimeType: "image/jpeg", data: Buffer.from("fake-jpg").toString("base64") }; await adapter.sendMessage({ target, text: "看图", images: [image], replyTo: "m2", replySequence: 5 }); assert.ok(urls.some((url) => url.endsWith("/v2/users/u2/files"))); assert.equal(urls.filter((url) => url.endsWith("/v2/users/u2/messages")).length, 2); assert.deepEqual(bodies[0], { file_type: 1, file_data: image.data, srv_send_msg: false }); assert.deepEqual(bodies[1], { msg_type: 7, media: { file_info: "FILEINFO" }, content: "", msg_id: "m2", msg_seq: 5 }); assert.deepEqual(bodies[2], { content: "看图", msg_id: "m2", msg_seq: 5 }); } finally { globalThis.fetch = original; } }); test("image upload failures surface safe errors without the base64 payload", async () => { const original = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { const url = String(input); if (url.includes("getAppAccessToken")) return new Response(JSON.stringify({ access_token: "token", expires_in: 7200 }), { status: 200 }); return new Response(JSON.stringify({ code: 40034001, message: "invalid file_data" }), { 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" } }; const image = { mimeType: "image/png", data: Buffer.from("secret-image-bytes").toString("base64") }; await assert.rejects( adapter.sendMessage({ target, text: "", images: [image], replyTo: "m1", replySequence: 1 }), (error: Error) => { assert.match(error.message, /QQ image upload failed: HTTP 400/); assert.match(error.message, /code=40034001/); assert.doesNotMatch(error.message, /secret-image-bytes/); assert.doesNotMatch(error.message, /base64/); return true; } ); } 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; } });