Files
openclaw/extensions/qa-channel/src/outbound.ts
Peter Steinberger 6dc77a37d9 refactor(agents): render failover user copy from one reason-keyed module (#121717)
* refactor(agents): centralize failover user copy

* refactor(agents): route failure callers through user copy

* refactor(qa): carry typed reply failure markers

* refactor(agents): keep failover copy import-light

* refactor(agents): pass structured failure copy context

* refactor(agents): isolate copy rendering from runtime state

* refactor(agents): separate copy rendering from sanitization

* refactor(agents): keep failover copy internals private

* fix(qa-channel): type failure markers on bus sends

* style(agents): brace failover copy conditions

* test(qa-lab): avoid map spread in failure cases

* chore(plugin-sdk): refresh failover closure hashes

* fix(agents): preserve generic runner fallback

* fix(qa): preserve text-only failure markers

* test(qa): type failure delivery fixture
2026-08-10 16:43:51 -07:00

92 lines
3.2 KiB
TypeScript

// Qa Channel plugin module implements outbound behavior.
import { randomUUID } from "node:crypto";
import path from "node:path";
import {
loadOutboundMediaFromUrl,
type OutboundMediaLoadOptions,
} from "openclaw/plugin-sdk/outbound-media";
import { resolveQaChannelAccount } from "./accounts.js";
import { buildQaTarget, resolveQaTargetThread, sendQaBusMessage } from "./bus-client.js";
import type { QaBusAttachment } from "./protocol.js";
import type { CoreConfig } from "./types.js";
type QaChannelTextSendParams = {
cfg: CoreConfig;
accountId?: string | null;
to: string;
text: string;
isError?: boolean;
threadId?: string | number | null;
replyToId?: string | number | null;
attachments?: QaBusAttachment[];
};
type QaChannelMediaAccessParams = {
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
export async function sendQaChannelText(params: QaChannelTextSendParams) {
const account = resolveQaChannelAccount({ cfg: params.cfg, accountId: params.accountId });
const resolved = resolveQaTargetThread({ target: params.to, threadId: params.threadId });
const parsed = resolved.target;
const { message } = await sendQaBusMessage({
baseUrl: account.baseUrl,
accountId: account.accountId,
to: buildQaTarget({
chatType: parsed.chatType,
conversationId: parsed.conversationId,
threadId: resolved.threadId,
}),
text: params.text,
isError: params.isError,
senderId: account.botUserId,
senderName: account.botDisplayName,
threadId: resolved.threadId,
replyToId: params.replyToId == null ? undefined : String(params.replyToId),
...(params.attachments?.length ? { attachments: params.attachments } : {}),
});
return {
to: params.to,
messageId: message.id,
};
}
/** Resolve every attachment first so a failed batch cannot publish a partial reply. */
export async function sendQaChannelMediaBatch(
params: QaChannelTextSendParams & QaChannelMediaAccessParams & { mediaUrls: readonly string[] },
) {
if (params.mediaUrls.length === 0) {
throw new Error("QA channel media batch requires at least one media URL");
}
const attachments: QaBusAttachment[] = await Promise.all(
params.mediaUrls.map(async (mediaUrl) => {
const media = await loadOutboundMediaFromUrl(mediaUrl, {
mediaAccess: params.mediaAccess,
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
optimizeImages: false,
});
const kind =
media.kind === "image" || media.kind === "video" || media.kind === "audio"
? media.kind
: "file";
return {
id: randomUUID(),
kind,
mimeType: media.contentType ?? "application/octet-stream",
fileName: media.fileName ?? path.basename(mediaUrl),
contentBase64: media.buffer.toString("base64"),
};
}),
);
return await sendQaChannelText({ ...params, attachments });
}
export async function sendQaChannelMedia(
params: QaChannelTextSendParams & QaChannelMediaAccessParams & { mediaUrl: string },
) {
return await sendQaChannelMediaBatch({ ...params, mediaUrls: [params.mediaUrl] });
}