From 39af215c31131967f5da057eb572569c088ed3d0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Feb 2026 17:12:31 +0000 Subject: [PATCH] refactor(outbound): extract message action param helpers --- src/infra/outbound/message-action-params.ts | 375 +++++++++++++++++++ src/infra/outbound/message-action-runner.ts | 376 +------------------- 2 files changed, 386 insertions(+), 365 deletions(-) create mode 100644 src/infra/outbound/message-action-params.ts diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts new file mode 100644 index 00000000000..4fc9ddb7fb8 --- /dev/null +++ b/src/infra/outbound/message-action-params.ts @@ -0,0 +1,375 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + ChannelId, + ChannelMessageActionName, + ChannelThreadingToolContext, +} from "../../channels/plugins/types.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; +import { readStringParam } from "../../agents/tools/common.js"; +import { extensionForMime } from "../../media/mime.js"; +import { parseSlackTarget } from "../../slack/targets.js"; +import { parseTelegramTarget } from "../../telegram/targets.js"; +import { loadWebMedia } from "../../web/media.js"; + +export function readBooleanParam( + params: Record, + key: string, +): boolean | undefined { + const raw = params[key]; + if (typeof raw === "boolean") { + return raw; + } + if (typeof raw === "string") { + const trimmed = raw.trim().toLowerCase(); + if (trimmed === "true") { + return true; + } + if (trimmed === "false") { + return false; + } + } + return undefined; +} + +export function resolveSlackAutoThreadId(params: { + to: string; + toolContext?: ChannelThreadingToolContext; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + // Only mirror auto-threading when Slack would reply in the active thread for this channel. + if (context.replyToMode !== "all" && context.replyToMode !== "first") { + return undefined; + } + const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); + if (!parsedTarget || parsedTarget.kind !== "channel") { + return undefined; + } + if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) { + return undefined; + } + if (context.replyToMode === "first" && context.hasRepliedRef?.value) { + return undefined; + } + return context.currentThreadTs; +} + +/** + * Auto-inject Telegram forum topic thread ID when the message tool targets + * the same chat the session originated from. Mirrors the Slack auto-threading + * pattern so media, buttons, and other tool-sent messages land in the correct + * topic instead of the General Topic. + * + * Unlike Slack, we do not gate on `replyToMode` here: Telegram forum topics + * are persistent sub-channels (not ephemeral reply threads), so auto-injection + * should always apply when the target chat matches. + */ +export function resolveTelegramAutoThreadId(params: { + to: string; + toolContext?: ChannelThreadingToolContext; +}): string | undefined { + const context = params.toolContext; + if (!context?.currentThreadTs || !context.currentChannelId) { + return undefined; + } + // Use parseTelegramTarget to extract canonical chatId from both sides, + // mirroring how Slack uses parseSlackTarget. This handles format variations + // like `telegram:group:123:topic:456` vs `telegram:123`. + const parsedTo = parseTelegramTarget(params.to); + const parsedChannel = parseTelegramTarget(context.currentChannelId); + if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) { + return undefined; + } + return context.currentThreadTs; +} + +function resolveAttachmentMaxBytes(params: { + cfg: OpenClawConfig; + channel: ChannelId; + accountId?: string | null; +}): number | undefined { + const accountId = typeof params.accountId === "string" ? params.accountId.trim() : ""; + const channelCfg = params.cfg.channels?.[params.channel]; + const channelObj = + channelCfg && typeof channelCfg === "object" + ? (channelCfg as Record) + : undefined; + const channelMediaMax = + typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined; + const accountsObj = + channelObj?.accounts && typeof channelObj.accounts === "object" + ? (channelObj.accounts as Record) + : undefined; + const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined; + const accountMediaMax = + accountCfg && typeof accountCfg === "object" + ? (accountCfg as Record).mediaMaxMb + : undefined; + // Priority: account-specific > channel-level > global default + const limitMb = + (typeof accountMediaMax === "number" ? accountMediaMax : undefined) ?? + channelMediaMax ?? + params.cfg.agents?.defaults?.mediaMaxMb; + return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined; +} + +function inferAttachmentFilename(params: { + mediaHint?: string; + contentType?: string; +}): string | undefined { + const mediaHint = params.mediaHint?.trim(); + if (mediaHint) { + try { + if (mediaHint.startsWith("file://")) { + const filePath = fileURLToPath(mediaHint); + const base = path.basename(filePath); + if (base) { + return base; + } + } else if (/^https?:\/\//i.test(mediaHint)) { + const url = new URL(mediaHint); + const base = path.basename(url.pathname); + if (base) { + return base; + } + } else { + const base = path.basename(mediaHint); + if (base) { + return base; + } + } + } catch { + // fall through to content-type based default + } + } + const ext = params.contentType ? extensionForMime(params.contentType) : undefined; + return ext ? `attachment${ext}` : "attachment"; +} + +function normalizeBase64Payload(params: { base64?: string; contentType?: string }): { + base64?: string; + contentType?: string; +} { + if (!params.base64) { + return { base64: params.base64, contentType: params.contentType }; + } + const match = /^data:([^;]+);base64,(.*)$/i.exec(params.base64.trim()); + if (!match) { + return { base64: params.base64, contentType: params.contentType }; + } + const [, mime, payload] = match; + return { + base64: payload, + contentType: params.contentType ?? mime, + }; +} + +export async function normalizeSandboxMediaParams(params: { + args: Record; + sandboxRoot?: string; +}): Promise { + const sandboxRoot = params.sandboxRoot?.trim(); + const mediaKeys: Array<"media" | "path" | "filePath"> = ["media", "path", "filePath"]; + for (const key of mediaKeys) { + const raw = readStringParam(params.args, key, { trim: false }); + if (!raw) { + continue; + } + assertMediaNotDataUrl(raw); + if (!sandboxRoot) { + continue; + } + const normalized = await resolveSandboxedMediaSource({ media: raw, sandboxRoot }); + if (normalized !== raw) { + params.args[key] = normalized; + } + } +} + +export async function normalizeSandboxMediaList(params: { + values: string[]; + sandboxRoot?: string; +}): Promise { + const sandboxRoot = params.sandboxRoot?.trim(); + const normalized: string[] = []; + const seen = new Set(); + for (const value of params.values) { + const raw = value?.trim(); + if (!raw) { + continue; + } + assertMediaNotDataUrl(raw); + const resolved = sandboxRoot + ? await resolveSandboxedMediaSource({ media: raw, sandboxRoot }) + : raw; + if (seen.has(resolved)) { + continue; + } + seen.add(resolved); + normalized.push(resolved); + } + return normalized; +} + +export async function hydrateSetGroupIconParams(params: { + cfg: OpenClawConfig; + channel: ChannelId; + accountId?: string | null; + args: Record; + action: ChannelMessageActionName; + dryRun?: boolean; +}): Promise { + if (params.action !== "setGroupIcon") { + return; + } + + const mediaHint = readStringParam(params.args, "media", { trim: false }); + const fileHint = + readStringParam(params.args, "path", { trim: false }) ?? + readStringParam(params.args, "filePath", { trim: false }); + const contentTypeParam = + readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); + + const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); + const normalized = normalizeBase64Payload({ + base64: rawBuffer, + contentType: contentTypeParam ?? undefined, + }); + if (normalized.base64 !== rawBuffer && normalized.base64) { + params.args.buffer = normalized.base64; + if (normalized.contentType && !contentTypeParam) { + params.args.contentType = normalized.contentType; + } + } + + const filename = readStringParam(params.args, "filename"); + const mediaSource = mediaHint ?? fileHint; + + if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { + const maxBytes = resolveAttachmentMaxBytes({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + }); + // localRoots: "any" — media paths are already validated by normalizeSandboxMediaList above. + const media = await loadWebMedia(mediaSource, maxBytes, { localRoots: "any" }); + params.args.buffer = media.buffer.toString("base64"); + if (!contentTypeParam && media.contentType) { + params.args.contentType = media.contentType; + } + if (!filename) { + params.args.filename = inferAttachmentFilename({ + mediaHint: media.fileName ?? mediaSource, + contentType: media.contentType ?? contentTypeParam ?? undefined, + }); + } + } else if (!filename) { + params.args.filename = inferAttachmentFilename({ + mediaHint: mediaSource, + contentType: contentTypeParam ?? undefined, + }); + } +} + +export async function hydrateSendAttachmentParams(params: { + cfg: OpenClawConfig; + channel: ChannelId; + accountId?: string | null; + args: Record; + action: ChannelMessageActionName; + dryRun?: boolean; +}): Promise { + if (params.action !== "sendAttachment") { + return; + } + + const mediaHint = readStringParam(params.args, "media", { trim: false }); + const fileHint = + readStringParam(params.args, "path", { trim: false }) ?? + readStringParam(params.args, "filePath", { trim: false }); + const contentTypeParam = + readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); + const caption = readStringParam(params.args, "caption", { allowEmpty: true })?.trim(); + const message = readStringParam(params.args, "message", { allowEmpty: true })?.trim(); + if (!caption && message) { + params.args.caption = message; + } + + const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); + const normalized = normalizeBase64Payload({ + base64: rawBuffer, + contentType: contentTypeParam ?? undefined, + }); + if (normalized.base64 !== rawBuffer && normalized.base64) { + params.args.buffer = normalized.base64; + if (normalized.contentType && !contentTypeParam) { + params.args.contentType = normalized.contentType; + } + } + + const filename = readStringParam(params.args, "filename"); + const mediaSource = mediaHint ?? fileHint; + + if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { + const maxBytes = resolveAttachmentMaxBytes({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + }); + // localRoots: "any" — media paths are already validated by normalizeSandboxMediaList above. + const media = await loadWebMedia(mediaSource, maxBytes, { localRoots: "any" }); + params.args.buffer = media.buffer.toString("base64"); + if (!contentTypeParam && media.contentType) { + params.args.contentType = media.contentType; + } + if (!filename) { + params.args.filename = inferAttachmentFilename({ + mediaHint: media.fileName ?? mediaSource, + contentType: media.contentType ?? contentTypeParam ?? undefined, + }); + } + } else if (!filename) { + params.args.filename = inferAttachmentFilename({ + mediaHint: mediaSource, + contentType: contentTypeParam ?? undefined, + }); + } +} + +export function parseButtonsParam(params: Record): void { + const raw = params.buttons; + if (typeof raw !== "string") { + return; + } + const trimmed = raw.trim(); + if (!trimmed) { + delete params.buttons; + return; + } + try { + params.buttons = JSON.parse(trimmed) as unknown; + } catch { + throw new Error("--buttons must be valid JSON"); + } +} + +export function parseCardParam(params: Record): void { + const raw = params.card; + if (typeof raw !== "string") { + return; + } + const trimmed = raw.trim(); + if (!trimmed) { + delete params.card; + return; + } + try { + params.card = JSON.parse(trimmed) as unknown; + } catch { + throw new Error("--card must be valid JSON"); + } +} diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index a86bdc31ed6..17f24636353 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -1,6 +1,4 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import type { ChannelId, ChannelMessageActionName, @@ -10,7 +8,6 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { OutboundSendDeps } from "./deliver.js"; import type { MessagePollResult, MessageSendResult } from "./message.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; -import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; import { readNumberParam, readStringArrayParam, @@ -18,22 +15,29 @@ import { } from "../../agents/tools/common.js"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js"; -import { extensionForMime } from "../../media/mime.js"; -import { parseSlackTarget } from "../../slack/targets.js"; -import { parseTelegramTarget } from "../../telegram/targets.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, type GatewayClientMode, type GatewayClientName, } from "../../utils/message-channel.js"; -import { loadWebMedia } from "../../web/media.js"; import { throwIfAborted } from "./abort.js"; import { listConfiguredMessageChannels, resolveMessageChannelSelection, } from "./channel-selection.js"; import { applyTargetToParams } from "./channel-target.js"; +import { + hydrateSendAttachmentParams, + hydrateSetGroupIconParams, + normalizeSandboxMediaList, + normalizeSandboxMediaParams, + parseButtonsParam, + parseCardParam, + readBooleanParam, + resolveSlackAutoThreadId, + resolveTelegramAutoThreadId, +} from "./message-action-params.js"; import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js"; import { applyCrossContextDecoration, @@ -204,364 +208,6 @@ async function maybeApplyCrossContextMarker(params: { }); } -function readBooleanParam(params: Record, key: string): boolean | undefined { - const raw = params[key]; - if (typeof raw === "boolean") { - return raw; - } - if (typeof raw === "string") { - const trimmed = raw.trim().toLowerCase(); - if (trimmed === "true") { - return true; - } - if (trimmed === "false") { - return false; - } - } - return undefined; -} - -function resolveSlackAutoThreadId(params: { - to: string; - toolContext?: ChannelThreadingToolContext; -}): string | undefined { - const context = params.toolContext; - if (!context?.currentThreadTs || !context.currentChannelId) { - return undefined; - } - // Only mirror auto-threading when Slack would reply in the active thread for this channel. - if (context.replyToMode !== "all" && context.replyToMode !== "first") { - return undefined; - } - const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" }); - if (!parsedTarget || parsedTarget.kind !== "channel") { - return undefined; - } - if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) { - return undefined; - } - if (context.replyToMode === "first" && context.hasRepliedRef?.value) { - return undefined; - } - return context.currentThreadTs; -} - -/** - * Auto-inject Telegram forum topic thread ID when the message tool targets - * the same chat the session originated from. Mirrors the Slack auto-threading - * pattern so media, buttons, and other tool-sent messages land in the correct - * topic instead of the General Topic. - * - * Unlike Slack, we do not gate on `replyToMode` here: Telegram forum topics - * are persistent sub-channels (not ephemeral reply threads), so auto-injection - * should always apply when the target chat matches. - */ -function resolveTelegramAutoThreadId(params: { - to: string; - toolContext?: ChannelThreadingToolContext; -}): string | undefined { - const context = params.toolContext; - if (!context?.currentThreadTs || !context.currentChannelId) { - return undefined; - } - // Use parseTelegramTarget to extract canonical chatId from both sides, - // mirroring how Slack uses parseSlackTarget. This handles format variations - // like `telegram:group:123:topic:456` vs `telegram:123`. - const parsedTo = parseTelegramTarget(params.to); - const parsedChannel = parseTelegramTarget(context.currentChannelId); - if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) { - return undefined; - } - return context.currentThreadTs; -} - -function resolveAttachmentMaxBytes(params: { - cfg: OpenClawConfig; - channel: ChannelId; - accountId?: string | null; -}): number | undefined { - const accountId = typeof params.accountId === "string" ? params.accountId.trim() : ""; - const channelCfg = params.cfg.channels?.[params.channel]; - const channelObj = - channelCfg && typeof channelCfg === "object" - ? (channelCfg as Record) - : undefined; - const channelMediaMax = - typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined; - const accountsObj = - channelObj?.accounts && typeof channelObj.accounts === "object" - ? (channelObj.accounts as Record) - : undefined; - const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined; - const accountMediaMax = - accountCfg && typeof accountCfg === "object" - ? (accountCfg as Record).mediaMaxMb - : undefined; - // Priority: account-specific > channel-level > global default - const limitMb = - (typeof accountMediaMax === "number" ? accountMediaMax : undefined) ?? - channelMediaMax ?? - params.cfg.agents?.defaults?.mediaMaxMb; - return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined; -} - -function inferAttachmentFilename(params: { - mediaHint?: string; - contentType?: string; -}): string | undefined { - const mediaHint = params.mediaHint?.trim(); - if (mediaHint) { - try { - if (mediaHint.startsWith("file://")) { - const filePath = fileURLToPath(mediaHint); - const base = path.basename(filePath); - if (base) { - return base; - } - } else if (/^https?:\/\//i.test(mediaHint)) { - const url = new URL(mediaHint); - const base = path.basename(url.pathname); - if (base) { - return base; - } - } else { - const base = path.basename(mediaHint); - if (base) { - return base; - } - } - } catch { - // fall through to content-type based default - } - } - const ext = params.contentType ? extensionForMime(params.contentType) : undefined; - return ext ? `attachment${ext}` : "attachment"; -} - -function normalizeBase64Payload(params: { base64?: string; contentType?: string }): { - base64?: string; - contentType?: string; -} { - if (!params.base64) { - return { base64: params.base64, contentType: params.contentType }; - } - const match = /^data:([^;]+);base64,(.*)$/i.exec(params.base64.trim()); - if (!match) { - return { base64: params.base64, contentType: params.contentType }; - } - const [, mime, payload] = match; - return { - base64: payload, - contentType: params.contentType ?? mime, - }; -} - -async function normalizeSandboxMediaParams(params: { - args: Record; - sandboxRoot?: string; -}): Promise { - const sandboxRoot = params.sandboxRoot?.trim(); - const mediaKeys: Array<"media" | "path" | "filePath"> = ["media", "path", "filePath"]; - for (const key of mediaKeys) { - const raw = readStringParam(params.args, key, { trim: false }); - if (!raw) { - continue; - } - assertMediaNotDataUrl(raw); - if (!sandboxRoot) { - continue; - } - const normalized = await resolveSandboxedMediaSource({ media: raw, sandboxRoot }); - if (normalized !== raw) { - params.args[key] = normalized; - } - } -} - -async function normalizeSandboxMediaList(params: { - values: string[]; - sandboxRoot?: string; -}): Promise { - const sandboxRoot = params.sandboxRoot?.trim(); - const normalized: string[] = []; - const seen = new Set(); - for (const value of params.values) { - const raw = value?.trim(); - if (!raw) { - continue; - } - assertMediaNotDataUrl(raw); - const resolved = sandboxRoot - ? await resolveSandboxedMediaSource({ media: raw, sandboxRoot }) - : raw; - if (seen.has(resolved)) { - continue; - } - seen.add(resolved); - normalized.push(resolved); - } - return normalized; -} - -async function hydrateSetGroupIconParams(params: { - cfg: OpenClawConfig; - channel: ChannelId; - accountId?: string | null; - args: Record; - action: ChannelMessageActionName; - dryRun?: boolean; -}): Promise { - if (params.action !== "setGroupIcon") { - return; - } - - const mediaHint = readStringParam(params.args, "media", { trim: false }); - const fileHint = - readStringParam(params.args, "path", { trim: false }) ?? - readStringParam(params.args, "filePath", { trim: false }); - const contentTypeParam = - readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); - - const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); - const normalized = normalizeBase64Payload({ - base64: rawBuffer, - contentType: contentTypeParam ?? undefined, - }); - if (normalized.base64 !== rawBuffer && normalized.base64) { - params.args.buffer = normalized.base64; - if (normalized.contentType && !contentTypeParam) { - params.args.contentType = normalized.contentType; - } - } - - const filename = readStringParam(params.args, "filename"); - const mediaSource = mediaHint ?? fileHint; - - if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { - const maxBytes = resolveAttachmentMaxBytes({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - }); - // localRoots: "any" — media paths are already validated by normalizeSandboxMediaList above. - const media = await loadWebMedia(mediaSource, maxBytes, { localRoots: "any" }); - params.args.buffer = media.buffer.toString("base64"); - if (!contentTypeParam && media.contentType) { - params.args.contentType = media.contentType; - } - if (!filename) { - params.args.filename = inferAttachmentFilename({ - mediaHint: media.fileName ?? mediaSource, - contentType: media.contentType ?? contentTypeParam ?? undefined, - }); - } - } else if (!filename) { - params.args.filename = inferAttachmentFilename({ - mediaHint: mediaSource, - contentType: contentTypeParam ?? undefined, - }); - } -} - -async function hydrateSendAttachmentParams(params: { - cfg: OpenClawConfig; - channel: ChannelId; - accountId?: string | null; - args: Record; - action: ChannelMessageActionName; - dryRun?: boolean; -}): Promise { - if (params.action !== "sendAttachment") { - return; - } - - const mediaHint = readStringParam(params.args, "media", { trim: false }); - const fileHint = - readStringParam(params.args, "path", { trim: false }) ?? - readStringParam(params.args, "filePath", { trim: false }); - const contentTypeParam = - readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType"); - const caption = readStringParam(params.args, "caption", { allowEmpty: true })?.trim(); - const message = readStringParam(params.args, "message", { allowEmpty: true })?.trim(); - if (!caption && message) { - params.args.caption = message; - } - - const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); - const normalized = normalizeBase64Payload({ - base64: rawBuffer, - contentType: contentTypeParam ?? undefined, - }); - if (normalized.base64 !== rawBuffer && normalized.base64) { - params.args.buffer = normalized.base64; - if (normalized.contentType && !contentTypeParam) { - params.args.contentType = normalized.contentType; - } - } - - const filename = readStringParam(params.args, "filename"); - const mediaSource = mediaHint ?? fileHint; - - if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) { - const maxBytes = resolveAttachmentMaxBytes({ - cfg: params.cfg, - channel: params.channel, - accountId: params.accountId, - }); - // localRoots: "any" — media paths are already validated by normalizeSandboxMediaList above. - const media = await loadWebMedia(mediaSource, maxBytes, { localRoots: "any" }); - params.args.buffer = media.buffer.toString("base64"); - if (!contentTypeParam && media.contentType) { - params.args.contentType = media.contentType; - } - if (!filename) { - params.args.filename = inferAttachmentFilename({ - mediaHint: media.fileName ?? mediaSource, - contentType: media.contentType ?? contentTypeParam ?? undefined, - }); - } - } else if (!filename) { - params.args.filename = inferAttachmentFilename({ - mediaHint: mediaSource, - contentType: contentTypeParam ?? undefined, - }); - } -} - -function parseButtonsParam(params: Record): void { - const raw = params.buttons; - if (typeof raw !== "string") { - return; - } - const trimmed = raw.trim(); - if (!trimmed) { - delete params.buttons; - return; - } - try { - params.buttons = JSON.parse(trimmed) as unknown; - } catch { - throw new Error("--buttons must be valid JSON"); - } -} - -function parseCardParam(params: Record): void { - const raw = params.card; - if (typeof raw !== "string") { - return; - } - const trimmed = raw.trim(); - if (!trimmed) { - delete params.card; - return; - } - try { - params.card = JSON.parse(trimmed) as unknown; - } catch { - throw new Error("--card must be valid JSON"); - } -} - async function resolveChannel(cfg: OpenClawConfig, params: Record) { const channelHint = readStringParam(params, "channel"); const selection = await resolveMessageChannelSelection({