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:
+153
-3
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user