openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
428 lines (427 loc) • 14.9 kB
JavaScript
import { c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js";
import "./read-response-with-limit-MDCSJrlg.js";
import { t as tempWorkspace } from "./private-temp-workspace-MCwLg_M9.js";
import { n as normalizeAccountId } from "./account-id-Df9e41E6.js";
import { a as shouldLogVerbose, r as logVerbose } from "./globals-GTrXU4s9.js";
import { i as getFileExtension, u as normalizeMimeType } from "./mime-C8mVE2Bw.js";
import "./store-C9M_0A1p.js";
import { x as sendTextMediaPayload } from "./reply-payload-D-VMfpYI.js";
import "./media-services-DE3J1qM-.js";
import "./fetch-2npQWHQh.js";
import "./local-roots-BqdtViGS.js";
import "./local-media-access-Bw3v5URj.js";
import { a as chunkText } from "./chunk-Cpb8JO1x.js";
import { t as sanitizeForPlainText } from "./sanitize-text-BOGhD8MR.js";
import "./defaults-BK9rbdLc.js";
import "./image-runtime-CmS6zMcE.js";
import "./defaults.constants-A49pIMjc.js";
import "./outbound-attachment-DeL7GCBY.js";
import { x as isAudioAttachment } from "./runner.entries-B23jIAju.js";
import { n as loadQrCodeRuntime, r as normalizeQrText } from "./qr-terminal-zFHurnm4.js";
import "./agent-media-payload-BXUnfV7N.js";
import { a as runCapability, i as resolveMediaAttachmentLocalRoots, o as createMediaAttachmentCache, s as normalizeMediaAttachments, t as buildProviderRegistry } from "./runner-CswjdYkd.js";
import { n as sendTranscriptEcho } from "./echo-transcript-B1g_NN9U.js";
import path from "node:path";
import fs from "node:fs/promises";
import { deflateSync } from "node:zlib";
//#region src/media/audio.ts
/** File extensions accepted by channel voice-message upload paths. */
const VOICE_MESSAGE_AUDIO_EXTENSIONS = new Set([
".oga",
".ogg",
".opus",
".mp3",
".m4a"
]);
/** MIME types compatible with voice-message upload paths. */
const VOICE_MESSAGE_MIME_TYPES = new Set([
"audio/ogg",
"audio/opus",
"audio/mpeg",
"audio/mp3",
"audio/mp4",
"audio/x-m4a",
"audio/m4a"
]);
/** Checks whether MIME type or filename is compatible with voice-message delivery. */
function isVoiceMessageCompatibleAudio(opts) {
const mime = normalizeMimeType(opts.contentType);
if (mime && VOICE_MESSAGE_MIME_TYPES.has(mime)) return true;
const fileName = normalizeOptionalString(opts.fileName);
if (!fileName) return false;
const ext = getFileExtension(fileName);
if (!ext) return false;
return VOICE_MESSAGE_AUDIO_EXTENSIONS.has(ext);
}
/**
* Backward-compatible alias for voice-message audio compatibility checks.
*
* @deprecated Use isVoiceMessageCompatibleAudio.
*/
function isVoiceCompatibleAudio(opts) {
return isVoiceMessageCompatibleAudio(opts);
}
//#endregion
//#region src/media/png-encode.ts
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
/** Compute CRC32 checksum for a buffer (used in PNG chunk encoding). */
function crc32(buf) {
let crc = 4294967295;
for (const byte of buf) crc = CRC_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
return (crc ^ 4294967295) >>> 0;
}
/** Create a PNG chunk with type, data, and CRC. */
function pngChunk(type, data) {
const typeBuf = Buffer.from(type, "ascii");
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const crc = crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc, 0);
return Buffer.concat([
len,
typeBuf,
data,
crcBuf
]);
}
/**
* Writes one RGBA pixel into a width-strided buffer.
* Out-of-bounds coordinates are ignored so fixture drawing code can clip shapes cheaply.
*/
function fillPixel(buf, x, y, width, r, g, b, a = 255) {
if (x < 0 || y < 0 || x >= width) return;
const idx = (y * width + x) * 4;
if (idx < 0 || idx + 3 >= buf.length) return;
buf[idx] = r;
buf[idx + 1] = g;
buf[idx + 2] = b;
buf[idx + 3] = a;
}
function encodePng(buffer, width, height, channels) {
const stride = width * channels;
const raw = Buffer.alloc((stride + 1) * height);
for (let row = 0; row < height; row += 1) {
const rawOffset = row * (stride + 1);
raw[rawOffset] = 0;
buffer.copy(raw, rawOffset + 1, row * stride, row * stride + stride);
}
const compressed = deflateSync(raw);
const signature = Buffer.from([
137,
80,
78,
71,
13,
10,
26,
10
]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = channels === 4 ? 6 : 2;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
return Buffer.concat([
signature,
pngChunk("IHDR", ihdr),
pngChunk("IDAT", compressed),
pngChunk("IEND", Buffer.alloc(0))
]);
}
/** Encodes tightly packed RGB bytes (`width * height * 3`) as a PNG image. */
function encodePngRgb(buffer, width, height) {
return encodePng(buffer, width, height, 3);
}
/** Encodes tightly packed RGBA bytes (`width * height * 4`) as a PNG image. */
function encodePngRgba(buffer, width, height) {
return encodePng(buffer, width, height, 4);
}
//#endregion
//#region src/media/qr-image.ts
const DEFAULT_QR_PNG_SCALE = 6;
const DEFAULT_QR_PNG_MARGIN_MODULES = 4;
const MIN_QR_PNG_SCALE = 1;
const MAX_QR_PNG_SCALE = 12;
const MIN_QR_PNG_MARGIN_MODULES = 0;
const MAX_QR_PNG_MARGIN_MODULES = 16;
const QR_PNG_DATA_URL_PREFIX = "data:image/png;base64,";
function resolveQrPngIntegerOption(params) {
if (params.value === void 0) return params.defaultValue;
if (!Number.isFinite(params.value)) throw new RangeError(`${params.name} must be a finite number.`);
const value = Math.floor(params.value);
if (value < params.min || value > params.max) throw new RangeError(`${params.name} must be between ${params.min} and ${params.max}.`);
return value;
}
function resolveQrTempPathSegment(name, value) {
if (!value || value === "." || value === ".." || path.basename(value) !== value) throw new RangeError(`${name} must be a non-empty filename segment.`);
return value;
}
/** Renders QR text as raw PNG base64 after validating bounded renderer options. */
async function renderQrPngBase64(input, opts = {}) {
const scale = resolveQrPngIntegerOption({
name: "scale",
value: opts.scale,
defaultValue: DEFAULT_QR_PNG_SCALE,
min: MIN_QR_PNG_SCALE,
max: MAX_QR_PNG_SCALE
});
const marginModules = resolveQrPngIntegerOption({
name: "marginModules",
value: opts.marginModules,
defaultValue: DEFAULT_QR_PNG_MARGIN_MODULES,
min: MIN_QR_PNG_MARGIN_MODULES,
max: MAX_QR_PNG_MARGIN_MODULES
});
const dataUrl = await (await loadQrCodeRuntime()).toDataURL(normalizeQrText(input), {
margin: marginModules,
scale,
type: "image/png"
});
if (!dataUrl.startsWith(QR_PNG_DATA_URL_PREFIX)) throw new Error("Expected qrcode to return a PNG data URL.");
return dataUrl.slice(22);
}
/** Wraps PNG base64 in the exact data URL prefix expected by chat/media callers. */
function formatQrPngDataUrl(base64) {
return `${QR_PNG_DATA_URL_PREFIX}${base64}`;
}
/** Renders QR text as a PNG data URL. */
async function renderQrPngDataUrl(input, opts = {}) {
return formatQrPngDataUrl(await renderQrPngBase64(input, opts));
}
/** Writes QR PNG output into a scoped temp directory and returns that directory as a media root. */
async function writeQrPngTempFile(input, opts) {
const dirPrefix = resolveQrTempPathSegment("dirPrefix", opts.dirPrefix);
const fileName = resolveQrTempPathSegment("fileName", opts.fileName ?? "qr.png");
const pngBase64 = await renderQrPngBase64(input, opts);
const workspace = await tempWorkspace({
rootDir: opts.tmpRoot,
prefix: dirPrefix
});
const dirPath = workspace.dir;
try {
return {
filePath: await workspace.write(fileName, Buffer.from(pngBase64, "base64")),
dirPath,
mediaLocalRoots: [dirPath]
};
} catch (err) {
await workspace.cleanup();
throw err;
}
}
//#endregion
//#region src/media/temp-files.ts
/** Best-effort temp-file cleanup helper for optional paths from media conversion flows. */
async function unlinkIfExists(filePath) {
if (!filePath) return;
try {
await fs.unlink(filePath);
} catch {}
}
//#endregion
//#region src/channels/plugins/media-limits.ts
const MB = 1024 * 1024;
/** Resolves channel media limit bytes from account-specific config or agent defaults. */
function resolveChannelMediaMaxBytes(params) {
const accountId = normalizeAccountId(params.accountId);
const channelLimit = params.resolveChannelLimitMb({
cfg: params.cfg,
accountId
});
if (channelLimit) return channelLimit * MB;
if (params.cfg.agents?.defaults?.mediaMaxMb) return params.cfg.agents.defaults.mediaMaxMb * MB;
}
//#endregion
//#region src/media-understanding/audio-transcription-runner.ts
/** Runs the configured audio-understanding pipeline and returns the first transcript output. */
async function runAudioTranscription(params) {
const attachments = params.attachments ?? normalizeMediaAttachments(params.ctx);
if (attachments.length === 0) return {
transcript: void 0,
attachments
};
const providerRegistry = buildProviderRegistry(params.providers, params.cfg);
const cache = createMediaAttachmentCache(attachments, {
...params.localPathRoots ? { localPathRoots: params.localPathRoots } : {},
ssrfPolicy: params.cfg.tools?.web?.fetch?.ssrfPolicy
});
try {
return {
transcript: (await runCapability({
capability: "audio",
cfg: params.cfg,
ctx: params.ctx,
attachments: cache,
media: attachments,
agentDir: params.agentDir,
providerRegistry,
config: params.cfg.tools?.media?.audio,
activeModel: params.activeModel
})).outputs.find((entry) => entry.kind === "audio.transcription")?.text?.trim() || void 0,
attachments
};
} finally {
await cache.cleanup();
}
}
//#endregion
//#region src/media-understanding/audio-preflight.ts
/**
* Transcribes the first audio attachment BEFORE mention checking.
* This allows voice notes to be processed in group chats with requireMention: true.
* Returns the transcript or undefined if transcription fails or no audio is found.
*/
async function transcribeFirstAudio(params) {
const { ctx, cfg } = params;
const audioConfig = cfg.tools?.media?.audio;
if (audioConfig?.enabled === false) return;
const attachments = normalizeMediaAttachments(ctx);
if (!attachments || attachments.length === 0) return;
const firstAudio = attachments.find((att) => att && isAudioAttachment(att) && !att.alreadyTranscribed);
if (!firstAudio) return;
if (shouldLogVerbose()) logVerbose(`audio-preflight: transcribing attachment ${firstAudio.index} for mention check`);
try {
const { transcript } = await runAudioTranscription({
ctx,
cfg,
attachments,
agentDir: params.agentDir,
providers: params.providers,
activeModel: params.activeModel,
localPathRoots: resolveMediaAttachmentLocalRoots({
cfg,
ctx
})
});
if (!transcript) return;
if (audioConfig?.echoTranscript) await sendTranscriptEcho({
ctx,
cfg,
transcript,
format: audioConfig.echoFormat ?? "📝 \"{transcript}\""
});
firstAudio.alreadyTranscribed = true;
if (shouldLogVerbose()) logVerbose(`audio-preflight: transcribed ${transcript.length} chars from attachment ${firstAudio.index}`);
return transcript;
} catch (err) {
if (shouldLogVerbose()) logVerbose(`audio-preflight: transcription failed: ${String(err)}`);
return;
}
}
//#endregion
//#region src/channels/plugins/outbound/direct-text-media.ts
/**
* Direct text/media outbound adapter helpers.
*
* Builds lightweight SDK-backed send adapters with chunking, sanitization, and media limits.
*/
function asRecord(value) {
return value != null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
}
function readNumberField(record, key) {
const value = record?.[key];
return typeof value === "number" ? value : void 0;
}
/**
* Resolves an account-scoped channel media byte limit.
*/
function resolveScopedChannelMediaMaxBytes(params) {
return resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: params.resolveChannelLimitMb,
accountId: params.accountId
});
}
/**
* Builds a media byte-limit resolver for channels with `mediaMaxMb` config.
*/
function createScopedChannelMediaMaxBytesResolver(channel) {
return (params) => resolveScopedChannelMediaMaxBytes({
cfg: params.cfg,
accountId: params.accountId,
resolveChannelLimitMb: ({ cfg, accountId }) => {
const channelConfig = asRecord(cfg.channels?.[channel]);
return readNumberField(asRecord(asRecord(channelConfig?.accounts)?.[accountId]), "mediaMaxMb") ?? readNumberField(channelConfig, "mediaMaxMb");
}
});
}
/**
* Creates a channel outbound adapter backed by direct text/media send functions.
*/
function createDirectTextMediaOutbound(params) {
const sendDirect = async (sendParams) => {
const send = params.resolveSender(sendParams.deps);
const maxBytes = params.resolveMaxBytes({
cfg: sendParams.cfg,
accountId: sendParams.accountId
});
const result = await send(sendParams.to, sendParams.text, sendParams.buildOptions({
cfg: sendParams.cfg,
mediaUrl: sendParams.mediaUrl,
mediaAccess: sendParams.mediaAccess,
mediaLocalRoots: sendParams.mediaAccess?.localRoots,
mediaReadFile: sendParams.mediaAccess?.readFile,
accountId: sendParams.accountId,
replyToId: sendParams.replyToId,
maxBytes
}));
return {
channel: params.channel,
...result
};
};
const outbound = {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4e3,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
sendPayload: async (ctx) => await sendTextMediaPayload({
channel: params.channel,
ctx,
adapter: outbound
}),
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
return await sendDirect({
cfg,
to,
text,
accountId,
deps,
replyToId,
buildOptions: params.buildTextOptions
});
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile, accountId, deps, replyToId }) => {
return await sendDirect({
cfg,
to,
text,
mediaUrl,
mediaAccess: mediaAccess ?? (mediaLocalRoots || mediaReadFile ? {
...mediaLocalRoots?.length ? { localRoots: mediaLocalRoots } : {},
...mediaReadFile ? { readFile: mediaReadFile } : {}
} : void 0),
accountId,
deps,
replyToId,
buildOptions: params.buildMediaOptions
});
}
};
return outbound;
}
//#endregion
export { isVoiceMessageCompatibleAudio as _, resolveChannelMediaMaxBytes as a, renderQrPngBase64 as c, encodePngRgb as d, encodePngRgba as f, isVoiceCompatibleAudio as g, VOICE_MESSAGE_MIME_TYPES as h, transcribeFirstAudio as i, renderQrPngDataUrl as l, VOICE_MESSAGE_AUDIO_EXTENSIONS as m, createScopedChannelMediaMaxBytesResolver as n, unlinkIfExists as o, fillPixel as p, resolveScopedChannelMediaMaxBytes as r, formatQrPngDataUrl as s, createDirectTextMediaOutbound as t, writeQrPngTempFile as u };