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.
This commit is contained in:
zenord
2026-08-19 14:39:18 +08:00
parent 7aa610294c
commit 6b506b8c55
17 changed files with 659 additions and 62 deletions
+71
View File
@@ -171,6 +171,77 @@ test("sendMessage uses C2C endpoint and forwards reply sequences as msg_seq", as
} 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) => {