Files
gori-agent/test/qq-adapter.test.ts
T
zenord 6b506b8c55 feat: send workspace images to QQ chats
Worker results may report image attachments stored inside the workspace;
the runtime validates them (containment, png/jpg magic, size) and
delivers them with the pending event. Assistants gain a send_image
action so users can ask for an image later. QQ uploads via /files with
base64 file_data and sends msg_type 7 rich media, sharing the same
msg_id/msg_seq counter as text replies; non-image adapters flatten
images to text.
2026-08-19 14:39:18 +08:00

281 lines
16 KiB
TypeScript

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<Record<string, unknown>> = [];
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<string, unknown>);
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<Record<string, unknown>> = [];
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<string, unknown>);
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<Record<string, unknown>> = [];
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<string, unknown>);
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<Record<string, unknown>> = [];
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<string, unknown>);
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; }
});