feat(qa-channel): forward inbound media attachments

This commit is contained in:
Peter Steinberger
2026-04-12 19:40:35 -07:00
parent 1a47660518
commit f682413f57
2 changed files with 115 additions and 1 deletions

View File

@@ -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<string, unknown>) => void;
}): PluginRuntime {
const sessionUpdatedAt = new Map<string, number>();
return {
channel: {
@@ -57,6 +59,7 @@ function createMockQaRuntime(): PluginRuntime {
ctx: { BodyForAgent?: string; Body?: string };
dispatcherOptions: { deliver: (payload: { text: string }) => Promise<void> };
}) {
params?.onDispatch?.(ctx as Record<string, unknown>);
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<string, unknown> | 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 });

View File

@@ -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({