From f682413f570d9b074028225d8065f5c08a704cd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Apr 2026 19:40:35 -0700 Subject: [PATCH] feat(qa-channel): forward inbound media attachments --- extensions/qa-channel/src/channel.test.ts | 75 ++++++++++++++++++++++- extensions/qa-channel/src/inbound.ts | 41 +++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 48be4e6d1ba..8f127f9b6ff 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -6,7 +6,9 @@ import { createQaBusState, startQaBusServer } from "../../qa-lab/api.js"; import { qaChannelPlugin } from "../api.js"; import { setQaChannelRuntime } from "../api.js"; -function createMockQaRuntime(): PluginRuntime { +function createMockQaRuntime(params?: { + onDispatch?: (ctx: Record) => void; +}): PluginRuntime { const sessionUpdatedAt = new Map(); return { channel: { @@ -57,6 +59,7 @@ function createMockQaRuntime(): PluginRuntime { ctx: { BodyForAgent?: string; Body?: string }; dispatcherOptions: { deliver: (payload: { text: string }) => Promise }; }) { + params?.onDispatch?.(ctx as Record); await dispatcherOptions.deliver({ text: `qa-echo: ${ctx.BodyForAgent ?? ctx.Body ?? ""}`, }); @@ -116,6 +119,76 @@ describe("qa-channel plugin", () => { } }); + it("stages inbound image attachments into agent media payload", { timeout: 20_000 }, async () => { + const state = createQaBusState(); + const bus = await startQaBusServer({ state }); + let dispatchedCtx: Record | null = null; + setQaChannelRuntime( + createMockQaRuntime({ + onDispatch: (ctx) => { + dispatchedCtx = ctx; + }, + }), + ); + + const cfg = { + channels: { + "qa-channel": { + baseUrl: bus.baseUrl, + botUserId: "openclaw", + botDisplayName: "OpenClaw QA", + allowFrom: ["*"], + }, + }, + }; + const account = qaChannelPlugin.config.resolveAccount(cfg, "default"); + const abort = new AbortController(); + const startAccount = qaChannelPlugin.gateway?.startAccount; + expect(startAccount).toBeDefined(); + const task = startAccount!( + createStartAccountContext({ + account, + cfg, + abortSignal: abort.signal, + }), + ); + + try { + state.addInboundMessage({ + conversation: { id: "alice", kind: "direct" }, + senderId: "alice", + senderName: "Alice", + text: "describe this image", + attachments: [ + { + id: "image-1", + kind: "image", + mimeType: "image/png", + fileName: "red-top-blue-bottom.png", + contentBase64: + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVR4nGP4z8Dwn4GBgYGJAQoAHxcCAr7cGDwAAAAASUVORK5CYII=", + }, + ], + }); + + await state.waitFor({ + kind: "message-text", + textIncludes: "qa-echo: describe this image", + direction: "outbound", + timeoutMs: 15_000, + }); + + expect(dispatchedCtx?.MediaPath).toEqual(expect.stringContaining("red-top-blue-bottom")); + expect(dispatchedCtx?.MediaType).toBe("image/png"); + expect(dispatchedCtx?.MediaPaths).toEqual([dispatchedCtx?.MediaPath]); + expect(dispatchedCtx?.MediaTypes).toEqual(["image/png"]); + } finally { + abort.abort(); + await task; + await bus.stop(); + } + }); + it("exposes thread and message actions against the qa bus", async () => { const state = createQaBusState(); const bus = await startQaBusServer({ state }); diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index c42b6df6462..deb7b2fe43e 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -1,9 +1,48 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch"; +import { + buildAgentMediaPayload, + saveMediaBuffer, + saveMediaSource, +} from "openclaw/plugin-sdk/media-runtime"; import { buildQaTarget, sendQaBusMessage, type QaBusMessage } from "./bus-client.js"; import { getQaChannelRuntime } from "./runtime.js"; import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js"; +async function resolveQaInboundMediaPayload(attachments: QaBusMessage["attachments"]) { + if (!Array.isArray(attachments) || attachments.length === 0) { + return {}; + } + const mediaList: Array<{ path: string; contentType?: string | null }> = []; + for (const attachment of attachments) { + if (!attachment?.mimeType) { + continue; + } + if (typeof attachment.contentBase64 === "string" && attachment.contentBase64.trim()) { + const saved = await saveMediaBuffer( + Buffer.from(attachment.contentBase64, "base64"), + attachment.mimeType, + "inbound", + undefined, + attachment.fileName, + ); + mediaList.push({ + path: saved.path, + contentType: saved.contentType, + }); + continue; + } + if (typeof attachment.url === "string" && attachment.url.trim()) { + const saved = await saveMediaSource(attachment.url, undefined, "inbound"); + mediaList.push({ + path: saved.path, + contentType: saved.contentType, + }); + } + } + return mediaList.length > 0 ? buildAgentMediaPayload(mediaList) : {}; +} + export async function handleQaInbound(params: { channelId: string; channelLabel: string; @@ -42,6 +81,7 @@ export async function handleQaInbound(params: { envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig), body: inbound.text, }); + const mediaPayload = await resolveQaInboundMediaPayload(inbound.attachments); const ctxPayload = runtime.channel.reply.finalizeInboundContext({ Body: body, @@ -81,6 +121,7 @@ export async function handleQaInbound(params: { OriginatingChannel: params.channelId, OriginatingTo: target, CommandAuthorized: true, + ...mediaPayload, }); await dispatchInboundReplyWithBase({