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
+89 -5
View File
@@ -22,7 +22,7 @@ interface Harness {
store: DurableSessionStore;
proposals: ProposalStore;
manager: AssistantManager;
events: { chatKey: string; text: string }[];
events: { chatKey: string; text: string; images?: { path: string; mimeType?: string; filename?: string }[] }[];
logFile: string;
}
@@ -41,12 +41,12 @@ async function createHarness(options: { initialize?: boolean; agentEnv?: Record<
const proposals = new ProposalStore(path.join(home, "state", "proposals.json"), identity);
await store.open();
await proposals.open();
const events: { chatKey: string; text: string }[] = [];
const events: { chatKey: string; text: string; images?: { path: string; mimeType?: string; filename?: string }[] }[] = [];
const manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, store, proposals, {
assistantWorkspaceHome: home,
allowUnverifiedAssistantAgent: true
});
manager.setEventSink(async (chatKey, text) => { events.push({ chatKey, text }); });
manager.setEventSink(async (chatKey, text, images) => { events.push({ chatKey, text, images }); });
if (options.initialize !== false) await manager.initialize();
return { home, workspace, store, proposals, manager, events, logFile };
}
@@ -70,7 +70,7 @@ async function reopenManager(harness: Harness): Promise<void> {
assistantWorkspaceHome: harness.home,
allowUnverifiedAssistantAgent: true
});
harness.manager.setEventSink(async (chatKey, text) => { harness.events.push({ chatKey, text }); });
harness.manager.setEventSink(async (chatKey, text, images) => { harness.events.push({ chatKey, text, images }); });
await harness.manager.initialize();
}
@@ -485,10 +485,21 @@ test("envelope parsers accept valid tails and reject invalid ones", () => {
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V1>{"reply":"hi","actions":[]}</GORI_ASSISTANT_ACTION_V1>`), undefined);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","question":"q","workspaceDirty":true}</GORI_WORKER_RESULT_V2>`),
{ status: "PENDING", summary: "s", question: "q", workspaceDirty: true }
{ status: "PENDING", summary: "s", question: "q", workspaceDirty: true, attachments: undefined }
);
assert.deepEqual(
parseWorkerResult(`text\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","attachments":[{"path":".gori-outbox/a.png","mimeType":"image/png"}]}</GORI_WORKER_RESULT_V2>`),
{ status: "PENDING", summary: "s", question: undefined, workspaceDirty: undefined, attachments: [{ path: ".gori-outbox/a.png", mimeType: "image/png" }] }
);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","attachments":[{"path":"a.png"},{"path":"b.png"},{"path":"c.png"},{"path":"d.png"}]}</GORI_WORKER_RESULT_V2>`), undefined);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V2>{"status":"PENDING","summary":"s","attachments":[{"mimeType":"image/png"}]}</GORI_WORKER_RESULT_V2>`), undefined);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V2>{"status":"SUCCESS","summary":"s"}</GORI_WORKER_RESULT_V2>`), undefined);
assert.equal(parseWorkerResult(`x\n<GORI_WORKER_RESULT_V1>{"status":"PENDING","summary":"s"}</GORI_WORKER_RESULT_V1>`), undefined);
assert.deepEqual(
parseAssistantActions(`text\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"send_image","path":".gori-outbox/a.png"}]}</GORI_ASSISTANT_ACTION_V2>`),
{ reply: "hi", actions: [{ type: "send_image", path: ".gori-outbox/a.png" }] }
);
assert.equal(parseAssistantActions(`x\n<GORI_ASSISTANT_ACTION_V2>{"reply":"hi","actions":[{"type":"send_image"}]}</GORI_ASSISTANT_ACTION_V2>`), undefined);
});
test("an owner's own pending proposal blocks their start_next with an actionable correction", async () => {
@@ -678,6 +689,79 @@ test("image attachments degrade to a text note when the agent has no image capab
}
});
test("worker attachments are validated against the workspace and ride along with the owner event", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: attach"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const pending = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pending.attachments?.length, 1);
const stored = pending.attachments![0]!;
assert.equal(stored.mimeType, "image/png");
const workspaceRoot = fs.realpathSync(harness.workspace);
assert.ok(stored.path.startsWith(`${workspaceRoot}${path.sep}`), stored.path);
assert.ok(stored.path.endsWith(path.join(".gori-outbox", "shot.png")), stored.path);
assert.equal(pending.droppedAttachments, undefined);
await waitFor(() => harness.events.length > 0);
assert.equal(harness.events[0]!.images?.length, 1);
assert.equal(harness.events[0]!.images![0]!.path, stored.path);
assert.equal(harness.events[0]!.images![0]!.filename, "shot.png");
} finally {
await closeHarness(harness);
}
});
test("worker attachments outside the workspace or not png/jpg are dropped without failing the result", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: attachbad"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const pending = harness.proposals.get(proposal.id)!.pending!;
assert.equal(pending.summary, "made a pic");
assert.equal(pending.attachments, undefined);
assert.deepEqual(pending.droppedAttachments, ["/tmp/evil.png", "not-an-image.txt"]);
await waitFor(() => harness.events.length > 0);
assert.equal(harness.events[0]!.images, undefined);
} finally {
await closeHarness(harness);
}
});
test("send_image validates the path and attaches the image to the reply", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("create proposal: attach"));
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const sent = await harness.manager.prompt(request("send image: .gori-outbox/shot.png"));
assert.equal(sent.images?.length, 1);
assert.equal(sent.images![0]!.filename, "shot.png");
assert.equal(sent.images![0]!.mimeType, "image/png");
assert.ok(sent.images![0]!.path.startsWith(fs.realpathSync(harness.workspace)));
assert.doesNotMatch(sent.text, /发不出去/);
const outside = await harness.manager.prompt(request("send image: /tmp/evil.png"));
assert.equal(outside.images, undefined);
assert.match(outside.text, /发不出去/);
const notImage = await harness.manager.prompt(request("send image: not-an-image.txt"));
assert.equal(notImage.images, undefined);
assert.match(notImage.text, /发不出去/);
} finally {
await closeHarness(harness);
}
});
function processGroupExists(pgid: number): boolean {
try { process.kill(-pgid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }