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
+153 -3
View File
@@ -1,4 +1,7 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { ConversationRequest, ConversationRuntime } from "../src/acp/types.js";
import type { PlatformAdapter } from "../src/core/adapter.js";
@@ -6,6 +9,15 @@ import { Gateway } from "../src/core/gateway.js";
import type { Proposal } from "../src/core/proposal-store.js";
import type { IncomingMessage, OutgoingMessage } from "../src/core/types.js";
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
async function tempPng(payload: string): Promise<{ dir: string; file: string }> {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-gateway-img-"));
const file = path.join(dir, "shot.png");
await fs.promises.writeFile(file, Buffer.concat([PNG_HEADER, Buffer.from(payload)]));
return { dir, file };
}
interface PendingTurn {
request: ConversationRequest;
resolve(text: string): void;
@@ -76,12 +88,17 @@ const groupMessage = (text: string, messageId: string): IncomingMessage => ({
});
const flush = async () => { await Promise.resolve(); await Promise.resolve(); await new Promise<void>((resolve) => setImmediate(resolve)); };
const waitFor = async (condition: () => boolean) => {
for (let count = 0; count < 50 && !condition(); count++) await flush();
const deadline = Date.now() + 5_000;
while (!condition()) {
if (Date.now() > deadline) break;
await flush();
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
assert.equal(condition(), true);
};
function recordingAdapter(sent: OutgoingMessage[] = []): PlatformAdapter {
return { name: "test", async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } };
function recordingAdapter(sent: OutgoingMessage[] = [], supportsImages = false): PlatformAdapter {
return { name: "test", supportsImages, async handleWebhook() { return {}; }, async sendMessage(outgoing) { sent.push(outgoing); } };
}
// Simulates QQ passive-reply dedup: a repeated msg_id + msg_seq pair is rejected.
@@ -286,6 +303,139 @@ test("status reports the last successful event delivery for the current chat", a
assert.match(status.reply || "", /lastEventAt=\d{4}-\d{2}-\d{2}T/);
});
test("sendEvent delivers the text first, then images, all sharing the msg_seq counter", async () => {
const { runtime, gateway } = createGateway();
const { dir, file } = await tempPng("payload-v1");
try {
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "img-event"), recordingAdapter(sent, true));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "截图好了", [{ path: file, filename: "shot.png" }]);
const pieces = sent.map((outgoing) => [outgoing.text, outgoing.replySequence, outgoing.images?.length || 0]);
assert.deepEqual(pieces, [
["done", 1, 0],
["截图好了", 2, 0],
["", 3, 1]
]);
const image = sent[2]!.images![0]!;
assert.equal(image.mimeType, "image/png");
assert.equal(image.filename, "shot.png");
assert.equal(Buffer.from(image.data, "base64").toString("latin1"), Buffer.concat([PNG_HEADER, Buffer.from("payload-v1")]).toString("latin1"));
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
test("a queued event re-reads its image at redelivery time and degrades when the file is gone", async () => {
let now = 1_000_000;
const runtime = new FakeRuntime();
const gateway = new Gateway(policy, runtime, { now: () => now });
const { dir, file } = await tempPng("payload-v1");
try {
const sent: OutgoingMessage[] = [];
const adapter = recordingAdapter(sent, true);
const first = gateway.receive(groupMessage("work", "m1"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await first;
now += 6 * 60_000; // past the group window: the event is queued instead of sent
await gateway.sendEvent("qq:group:g1", "stale event", [{ path: file, filename: "shot.png" }]);
assert.equal(sent.filter((outgoing) => outgoing.text === "stale event").length, 0);
// The worker updated the image after the event was queued: redelivery must send the new content.
await fs.promises.writeFile(file, Buffer.concat([PNG_HEADER, Buffer.from("payload-v2")]));
const second = gateway.receive(groupMessage("hello", "m2"), adapter);
await waitFor(() => runtime.turns.length === 2);
runtime.turns[1].resolve("ok");
await second;
const redelivered = sent.filter((outgoing) => outgoing.replyTo === "m2");
assert.deepEqual(redelivered.map((outgoing) => [outgoing.text, outgoing.replySequence, outgoing.images?.length || 0]), [
["stale event", 1, 0],
["", 2, 1],
["ok", 3, 0]
]);
assert.equal(Buffer.from(redelivered[1]!.images![0]!.data, "base64").toString("latin1"), Buffer.concat([PNG_HEADER, Buffer.from("payload-v2")]).toString("latin1"));
// Queue another event, then delete the file: the redelivery degrades to a text note.
now += 6 * 60_000;
await gateway.sendEvent("qq:group:g1", "another stale event", [{ path: file, filename: "shot.png" }]);
await fs.promises.rm(file);
const third = gateway.receive(groupMessage("hello again", "m3"), adapter);
await waitFor(() => runtime.turns.length === 3);
runtime.turns[2].resolve("ok again");
await third;
const degraded = sent.filter((outgoing) => outgoing.replyTo === "m3");
assert.equal(degraded.filter((outgoing) => outgoing.images?.length).length, 0);
assert.match(degraded[0]!.text, /another stale event/);
assert.match(degraded[0]!.text, /图片 shot\.png 发送失败/);
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
test("adapters without image support receive images flattened into text lines", async () => {
const { runtime, gateway } = createGateway();
const { dir, file } = await tempPng("payload-v1");
try {
const sent: OutgoingMessage[] = [];
const turn = gateway.receive(message("work", "flat"), recordingAdapter(sent));
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "截图好了", [{ path: file, filename: "shot.png" }]);
const event = sent.find((outgoing) => outgoing.text.includes("截图好了"))!;
assert.equal(event.images, undefined);
assert.match(event.text, /截图好了/);
assert.match(event.text, /\[图片\] shot\.png/);
assert.equal(sent.filter((outgoing) => outgoing.images?.length).length, 0);
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
test("an image send failure degrades to a text note without blocking the event", async () => {
const { runtime, gateway } = createGateway();
const { dir, file } = await tempPng("payload-v1");
try {
const sent: OutgoingMessage[] = [];
const errors: string[] = [];
const adapter: PlatformAdapter = {
name: "test", supportsImages: true, async handleWebhook() { return {}; },
async sendMessage(outgoing) {
if (outgoing.images?.length) throw new Error("sensitive provider response");
sent.push(outgoing);
}
};
const originalError = console.error;
console.error = (...args: unknown[]) => { errors.push(args.map(String).join(" ")); };
try {
const turn = gateway.receive(message("work", "img-fail"), adapter);
await waitFor(() => runtime.turns.length === 1);
runtime.turns[0].resolve("done");
await turn;
await gateway.sendEvent("qq:chat", "截图好了", [{ path: file, filename: "shot.png" }]);
} finally {
console.error = originalError;
}
const texts = sent.map((outgoing) => [outgoing.text, outgoing.replySequence]);
// The failed image attempt consumed msg_seq 3 (never reused in case QQ actually received it).
assert.deepEqual(texts, [
["done", 1],
["截图好了", 2],
["(图片 shot.png 发送失败)", 4]
]);
assert.deepEqual(errors, ["Gateway event image send failed (platform=qq)"]);
} finally {
await fs.promises.rm(dir, { recursive: true, force: true });
}
});
test("normal replies and fresh events share one msg_seq counter per inbound message", async () => {
const runtime = new FakeRuntime();
const gateway = new Gateway(policy, runtime);