6b506b8c55
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.
281 lines
16 KiB
TypeScript
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; }
|
|
});
|