refactor: deduplicate reply payload handling

This commit is contained in:
Peter Steinberger
2026-03-18 18:14:36 +00:00
parent 152d179302
commit 62edfdffbd
58 changed files with 704 additions and 450 deletions

View File

@@ -16,6 +16,7 @@ import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runt
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import {
@@ -610,7 +611,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}
if (draftStream && isFinal) {
await flushDraft();
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
const finalText = payload.text;
const previewFinalText = resolvePreviewFinalText(finalText);
const previewMessageId = draftStream.messageId();

View File

@@ -26,7 +26,7 @@ import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import {
resolveOutboundMediaUrls,
resolveSendableOutboundReplyParts,
resolveTextChunksWithFallback,
} from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
@@ -236,13 +236,7 @@ function isDiscordUnknownInteraction(error: unknown): boolean {
}
function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
if ((payload.text ?? "").trim()) {
return true;
}
if ((payload.mediaUrl ?? "").trim()) {
return true;
}
if (payload.mediaUrls?.some((entry) => entry.trim())) {
if (resolveSendableOutboundReplyParts(payload).hasContent) {
return true;
}
const discordData = payload.channelData?.discord as
@@ -891,8 +885,7 @@ async function deliverDiscordInteractionReply(params: {
chunkMode: "length" | "newline";
}) {
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
const mediaList = resolveOutboundMediaUrls(payload);
const text = payload.text ?? "";
const reply = resolveSendableOutboundReplyParts(payload);
const discordData = payload.channelData?.discord as
| { components?: TopLevelComponents[] }
| undefined;
@@ -937,9 +930,9 @@ async function deliverDiscordInteractionReply(params: {
});
};
if (mediaList.length > 0) {
if (reply.hasMedia) {
const media = await Promise.all(
mediaList.map(async (url) => {
reply.mediaUrls.map(async (url) => {
const loaded = await loadWebMedia(url, {
localRoots: params.mediaLocalRoots,
});
@@ -950,8 +943,8 @@ async function deliverDiscordInteractionReply(params: {
}),
);
const chunks = resolveTextChunksWithFallback(
text,
chunkDiscordTextWithMode(text, {
reply.text,
chunkDiscordTextWithMode(reply.text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
@@ -968,14 +961,14 @@ async function deliverDiscordInteractionReply(params: {
return;
}
if (!text.trim() && !firstMessageComponents) {
if (!reply.hasText && !firstMessageComponents) {
return;
}
const chunks =
text || firstMessageComponents
reply.text || firstMessageComponents
? resolveTextChunksWithFallback(
text,
chunkDiscordTextWithMode(text, {
reply.text,
chunkDiscordTextWithMode(reply.text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,

View File

@@ -9,7 +9,7 @@ import {
type RetryConfig,
} from "openclaw/plugin-sdk/infra-runtime";
import {
resolveOutboundMediaUrls,
resolveSendableOutboundReplyParts,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
@@ -268,18 +268,18 @@ export async function deliverDiscordReply(params: {
: undefined;
let deliveredAny = false;
for (const payload of params.replies) {
const mediaList = resolveOutboundMediaUrls(payload);
const rawText = payload.text ?? "";
const tableMode = params.tableMode ?? "code";
const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) {
const reply = resolveSendableOutboundReplyParts(payload, {
text: convertMarkdownTables(payload.text ?? "", tableMode),
});
if (!reply.hasContent) {
continue;
}
if (mediaList.length === 0) {
if (!reply.hasMedia) {
const mode = params.chunkMode ?? "length";
const chunks = resolveTextChunksWithFallback(
text,
chunkDiscordTextWithMode(text, {
reply.text,
chunkDiscordTextWithMode(reply.text, {
maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage,
chunkMode: mode,
@@ -312,7 +312,7 @@ export async function deliverDiscordReply(params: {
continue;
}
const firstMedia = mediaList[0];
const firstMedia = reply.mediaUrls[0];
if (!firstMedia) {
continue;
}
@@ -331,7 +331,7 @@ export async function deliverDiscordReply(params: {
await sendDiscordChunkWithFallback({
cfg: params.cfg,
target: params.target,
text,
text: reply.text,
token: params.token,
rest: params.rest,
accountId: params.accountId,
@@ -347,7 +347,7 @@ export async function deliverDiscordReply(params: {
});
// Additional media items are sent as regular attachments (voice is single-file only).
await sendMediaWithLeadingCaption({
mediaUrls: mediaList.slice(1),
mediaUrls: reply.mediaUrls.slice(1),
caption: "",
send: async ({ mediaUrl }) => {
const replyTo = resolveReplyTo();
@@ -370,8 +370,8 @@ export async function deliverDiscordReply(params: {
}
await sendMediaWithLeadingCaption({
mediaUrls: mediaList,
caption: text,
mediaUrls: reply.mediaUrls,
caption: reply.text,
send: async ({ mediaUrl, caption }) => {
const replyTo = resolveReplyTo();
await sendWithRetry(

View File

@@ -1,3 +1,8 @@
import {
resolveSendableOutboundReplyParts,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import {
createReplyPrefixContext,
createTypingCallbacks,
@@ -13,12 +18,7 @@ import { sendMediaFeishu } from "./media.js";
import type { MentionTarget } from "./mention.js";
import { buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import {
sendMarkdownCardFeishu,
sendMessageFeishu,
sendStructuredCardFeishu,
type CardHeaderConfig,
} from "./send.js";
import { sendMessageFeishu, sendStructuredCardFeishu, type CardHeaderConfig } from "./send.js";
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
import { resolveReceiveIdType } from "./targets.js";
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
@@ -300,37 +300,43 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
text: string;
useCard: boolean;
infoKind?: string;
sendChunk: (params: { chunk: string; isFirst: boolean }) => Promise<void>;
}) => {
let first = true;
const chunkSource = params.useCard
? params.text
: core.channel.text.convertMarkdownTables(params.text, tableMode);
for (const chunk of core.channel.text.chunkTextWithMode(
const chunks = resolveTextChunksWithFallback(
chunkSource,
textChunkLimit,
chunkMode,
)) {
const message = {
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: first ? mentionTargets : undefined,
accountId,
};
if (params.useCard) {
await sendMarkdownCardFeishu(message);
} else {
await sendMessageFeishu(message);
}
first = false;
core.channel.text.chunkTextWithMode(chunkSource, textChunkLimit, chunkMode),
);
for (const [index, chunk] of chunks.entries()) {
await params.sendChunk({
chunk,
isFirst: index === 0,
});
}
if (params.infoKind === "final") {
deliveredFinalTexts.add(params.text);
}
};
const sendMediaReplies = async (payload: ReplyPayload) => {
await sendMediaWithLeadingCaption({
mediaUrls: resolveSendableOutboundReplyParts(payload).mediaUrls,
caption: "",
send: async ({ mediaUrl }) => {
await sendMediaFeishu({
cfg,
to: chatId,
mediaUrl,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
accountId,
});
},
});
};
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
@@ -344,15 +350,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
void typingCallbacks.onReplyStart?.();
},
deliver: async (payload: ReplyPayload, info) => {
const text = payload.text ?? "";
const mediaList =
payload.mediaUrls && payload.mediaUrls.length > 0
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const hasText = Boolean(text.trim());
const hasMedia = mediaList.length > 0;
const reply = resolveSendableOutboundReplyParts(payload);
const text = reply.text;
const hasText = reply.hasText;
const hasMedia = reply.hasMedia;
const skipTextForDuplicateFinal =
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
@@ -363,7 +364,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (shouldDeliverText) {
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
let first = true;
if (info?.kind === "block") {
// Drop internal block chunks unless we can safely consume them as
@@ -397,16 +397,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
// Send media even when streaming handled the text
if (hasMedia) {
for (const mediaUrl of mediaList) {
await sendMediaFeishu({
cfg,
to: chatId,
mediaUrl,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
accountId,
});
}
await sendMediaReplies(payload);
}
return;
}
@@ -414,43 +405,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (useCard) {
const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
for (const chunk of core.channel.text.chunkTextWithMode(
await sendChunkedTextReply({
text,
textChunkLimit,
chunkMode,
)) {
await sendStructuredCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: first ? mentionTargets : undefined,
accountId,
header: cardHeader,
note: cardNote,
});
first = false;
}
if (info?.kind === "final") {
deliveredFinalTexts.add(text);
}
useCard: true,
infoKind: info?.kind,
sendChunk: async ({ chunk, isFirst }) => {
await sendStructuredCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: isFirst ? mentionTargets : undefined,
accountId,
header: cardHeader,
note: cardNote,
});
},
});
} else {
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
await sendChunkedTextReply({
text,
useCard: false,
infoKind: info?.kind,
sendChunk: async ({ chunk, isFirst }) => {
await sendMessageFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: isFirst ? mentionTargets : undefined,
accountId,
});
},
});
}
}
if (hasMedia) {
for (const mediaUrl of mediaList) {
await sendMediaFeishu({
cfg,
to: chatId,
mediaUrl,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
accountId,
});
}
await sendMediaReplies(payload);
}
},
onError: async (error, info) => {

View File

@@ -1,5 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig } from "../runtime-api.js";
import {
createWebhookInFlightLimiter,
@@ -376,8 +379,10 @@ async function deliverGoogleChatReply(params: {
}): Promise<void> {
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } =
params;
const hasMedia = Boolean(payload.mediaUrls?.length) || Boolean(payload.mediaUrl);
const text = payload.text ?? "";
const reply = resolveSendableOutboundReplyParts(payload);
const mediaCount = reply.mediaCount;
const hasMedia = reply.hasMedia;
const text = reply.text;
let firstTextChunk = true;
let suppressCaption = false;
@@ -390,8 +395,7 @@ async function deliverGoogleChatReply(params: {
});
} catch (err) {
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
const fallbackText = text.trim()
const fallbackText = reply.hasText
? text
: mediaCount > 1
? "Sent attachments."
@@ -414,7 +418,7 @@ async function deliverGoogleChatReply(params: {
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
await deliverTextOrMediaReply({
payload,
text: suppressCaption ? "" : text,
text: suppressCaption ? "" : reply.text,
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
sendText: async (chunk) => {
try {

View File

@@ -1,6 +1,9 @@
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@@ -32,14 +35,15 @@ export async function deliverReplies(params: {
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
for (const payload of replies) {
const rawText = sanitizeOutboundText(payload.text ?? "");
const text = convertMarkdownTables(rawText, tableMode);
const hasMedia = Boolean(payload.mediaUrls?.length ?? payload.mediaUrl);
if (!hasMedia && text) {
sentMessageCache?.remember(scope, { text });
const reply = resolveSendableOutboundReplyParts(payload, {
text: convertMarkdownTables(rawText, tableMode),
});
if (!reply.hasMedia && reply.hasText) {
sentMessageCache?.remember(scope, { text: reply.text });
}
const delivered = await deliverTextOrMediaReply({
payload,
text,
text: reply.text,
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
sendText: async (chunk) => {
const sent = await sendMessageIMessage(target, chunk, {

View File

@@ -1,5 +1,8 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js";
import { getMatrixRuntime } from "../../runtime.js";
import { sendMessageMatrix } from "../send.js";
@@ -33,8 +36,10 @@ export async function deliverMatrixReplies(params: {
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
let hasReplied = false;
for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
if (!reply?.text && !hasMedia) {
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const replyContent = resolveSendableOutboundReplyParts(reply, { text });
if (!replyContent.hasContent) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
@@ -49,13 +54,6 @@ export async function deliverMatrixReplies(params: {
}
const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
? [reply.mediaUrl]
: [];
const shouldIncludeReply = (id?: string) =>
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
@@ -63,7 +61,7 @@ export async function deliverMatrixReplies(params: {
const delivered = await deliverTextOrMediaReply({
payload: reply,
text,
text: replyContent.text,
chunkText: (value) =>
core.channel.text
.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode)

View File

@@ -1,4 +1,7 @@
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js";
import { getAgentScopedMediaLocalRoots } from "../runtime-api.js";
@@ -27,10 +30,12 @@ export async function deliverMattermostReplyPayload(params: {
tableMode: MarkdownTableMode;
sendMessage: SendMattermostMessage;
}): Promise<void> {
const text = params.core.channel.text.convertMarkdownTables(
params.payload.text ?? "",
params.tableMode,
);
const reply = resolveSendableOutboundReplyParts(params.payload, {
text: params.core.channel.text.convertMarkdownTables(
params.payload.text ?? "",
params.tableMode,
),
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
const chunkMode = params.core.channel.text.resolveChunkMode(
params.cfg,
@@ -39,7 +44,7 @@ export async function deliverMattermostReplyPayload(params: {
);
await deliverTextOrMediaReply({
payload: params.payload,
text,
text: reply.text,
chunkText: (value) =>
params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode),
sendText: async (chunk) => {

View File

@@ -5,7 +5,7 @@ import {
type MarkdownTableMode,
type MSTeamsReplyStyle,
type ReplyPayload,
resolveOutboundMediaUrls,
resolveSendableOutboundReplyParts,
SILENT_REPLY_TOKEN,
sleep,
} from "../runtime-api.js";
@@ -217,41 +217,39 @@ export function renderReplyPayloadsToMessages(
});
for (const payload of replies) {
const mediaList = resolveOutboundMediaUrls(payload);
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
payload.text ?? "",
tableMode,
);
const reply = resolveSendableOutboundReplyParts(payload, {
text: getMSTeamsRuntime().channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
});
if (!text && mediaList.length === 0) {
if (!reply.hasContent) {
continue;
}
if (mediaList.length === 0) {
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
if (!reply.hasMedia) {
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
continue;
}
if (mediaMode === "inline") {
// For inline mode, combine text with first media as attachment
const firstMedia = mediaList[0];
const firstMedia = reply.mediaUrls[0];
if (firstMedia) {
out.push({ text: text || undefined, mediaUrl: firstMedia });
out.push({ text: reply.text || undefined, mediaUrl: firstMedia });
// Additional media URLs as separate messages
for (let i = 1; i < mediaList.length; i++) {
if (mediaList[i]) {
out.push({ mediaUrl: mediaList[i] });
for (let i = 1; i < reply.mediaUrls.length; i++) {
if (reply.mediaUrls[i]) {
out.push({ mediaUrl: reply.mediaUrls[i] });
}
}
} else {
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
}
continue;
}
// mediaMode === "split"
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
for (const mediaUrl of mediaList) {
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
for (const mediaUrl of reply.mediaUrls) {
if (!mediaUrl) {
continue;
}

View File

@@ -9,7 +9,10 @@ import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import {
chunkTextWithMode,
resolveChunkMode,
@@ -297,9 +300,10 @@ async function deliverReplies(params: {
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
params;
for (const payload of replies) {
const reply = resolveSendableOutboundReplyParts(payload);
const delivered = await deliverTextOrMediaReply({
payload,
text: payload.text ?? "",
text: reply.text,
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
sendText: async (chunk) => {
await sendMessageSignal(target, chunk, {

View File

@@ -5,6 +5,7 @@ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime";
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
@@ -33,7 +34,7 @@ import {
import type { PreparedSlackMessage } from "./types.js";
function hasMedia(payload: ReplyPayload): boolean {
return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
return resolveSendableOutboundReplyParts(payload).hasMedia;
}
export function isSlackStreamingEnabled(params: {
@@ -250,17 +251,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
};
const deliverWithStreaming = async (payload: ReplyPayload): Promise<void> => {
if (
streamFailed ||
hasMedia(payload) ||
readSlackReplyBlocks(payload)?.length ||
!payload.text?.trim()
) {
const reply = resolveSendableOutboundReplyParts(payload);
if (streamFailed || reply.hasMedia || readSlackReplyBlocks(payload)?.length || !reply.hasText) {
await deliverNormally(payload, streamSession?.threadTs);
return;
}
const text = payload.text.trim();
const text = reply.trimmedText;
let plannedThreadTs: string | undefined;
try {
if (!streamSession) {
@@ -311,16 +308,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
return;
}
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
const reply = resolveSendableOutboundReplyParts(payload);
const slackBlocks = readSlackReplyBlocks(payload);
const draftMessageId = draftStream?.messageId();
const draftChannelId = draftStream?.channelId();
const finalText = payload.text ?? "";
const trimmedFinalText = finalText.trim();
const finalText = reply.text;
const trimmedFinalText = reply.trimmedText;
const canFinalizeViaPreviewEdit =
previewStreamingEnabled &&
streamMode !== "status_final" &&
mediaCount === 0 &&
!reply.hasMedia &&
!payload.isError &&
(trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) &&
typeof draftMessageId === "string" &&
@@ -361,7 +358,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
} catch (err) {
logVerbose(`slack: status_final completion update failed (${String(err)})`);
}
} else if (mediaCount > 0) {
} else if (reply.hasMedia) {
await draftStream?.clear();
hasStreamedMessage = false;
}

View File

@@ -1,5 +1,8 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
@@ -38,15 +41,14 @@ export async function deliverReplies(params: {
// must not force threading.
const inlineReplyToId = params.replyToMode === "off" ? undefined : payload.replyToId;
const threadTs = inlineReplyToId ?? params.replyThreadTs;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
const reply = resolveSendableOutboundReplyParts(payload);
const slackBlocks = readSlackReplyBlocks(payload);
if (!text && mediaList.length === 0 && !slackBlocks?.length) {
if (!reply.hasContent && !slackBlocks?.length) {
continue;
}
if (mediaList.length === 0 && slackBlocks?.length) {
const trimmed = text.trim();
if (!reply.hasMedia && slackBlocks?.length) {
const trimmed = reply.trimmedText;
if (!trimmed && !slackBlocks?.length) {
continue;
}
@@ -66,17 +68,16 @@ export async function deliverReplies(params: {
const delivered = await deliverTextOrMediaReply({
payload,
text,
chunkText:
mediaList.length === 0
? (value) => {
const trimmed = value.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
return [];
}
return [trimmed];
text: reply.text,
chunkText: !reply.hasMedia
? (value) => {
const trimmed = value.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
return [];
}
: undefined,
return [trimmed];
}
: undefined,
sendText: async (trimmed) => {
await sendMessageSlack(params.target, trimmed, {
token: params.token,
@@ -189,12 +190,12 @@ export async function deliverSlackSlashReplies(params: {
const messages: string[] = [];
const chunkLimit = Math.min(params.textLimit, 4000);
for (const payload of params.replies) {
const textRaw = payload.text?.trim() ?? "";
const text = textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN) ? textRaw : undefined;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const combined = [text ?? "", ...mediaList.map((url) => url.trim()).filter(Boolean)]
.filter(Boolean)
.join("\n");
const reply = resolveSendableOutboundReplyParts(payload);
const text =
reply.hasText && !isSilentReplyText(reply.trimmedText, SILENT_REPLY_TOKEN)
? reply.trimmedText
: undefined;
const combined = [text ?? "", ...reply.mediaUrls].filter(Boolean).join("\n");
if (!combined) {
continue;
}

View File

@@ -22,6 +22,7 @@ import type {
TelegramAccountConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
@@ -567,7 +568,8 @@ export const dispatchTelegramMessage = async ({
)?.buttons;
const split = splitTextIntoLaneSegments(payload.text);
const segments = split.segments;
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
const flushBufferedFinalAnswer = async () => {
const buffered = reasoningStepState.takeBufferedFinalAnswer();
@@ -631,7 +633,7 @@ export const dispatchTelegramMessage = async ({
return;
}
if (split.suppressedReasoningOnly) {
if (hasMedia) {
if (reply.hasMedia) {
const payloadWithoutSuppressedReasoning =
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
await sendPayload(payloadWithoutSuppressedReasoning);
@@ -647,8 +649,7 @@ export const dispatchTelegramMessage = async ({
await reasoningLane.stream?.stop();
reasoningStepState.resetForNextStep();
}
const canSendAsIs =
hasMedia || (typeof payload.text === "string" && payload.text.length > 0);
const canSendAsIs = reply.hasMedia || reply.text.length > 0;
if (!canSendAsIs) {
if (info.kind === "final") {
await flushBufferedFinalAnswer();

View File

@@ -1,3 +1,4 @@
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js";
import type { TelegramDraftStream } from "./draft-stream.js";
@@ -459,7 +460,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
allowPreviewUpdateForNonFinal = false,
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
const lane = params.lanes[laneName];
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const reply = resolveSendableOutboundReplyParts(payload, { text });
const hasMedia = reply.hasMedia;
const canEditViaPreview =
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;

View File

@@ -9,6 +9,10 @@ import {
} from "openclaw/plugin-sdk/config-runtime";
import { emitHeartbeatEvent, resolveIndicatorType } from "openclaw/plugin-sdk/infra-runtime";
import { resolveHeartbeatVisibility } from "openclaw/plugin-sdk/infra-runtime";
import {
hasOutboundReplyContent,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { resolveHeartbeatReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
@@ -178,10 +182,7 @@ export async function runWebHeartbeatOnce(opts: {
);
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
if (
!replyPayload ||
(!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length)
) {
if (!replyPayload || !hasOutboundReplyContent(replyPayload)) {
heartbeatLogger.info(
{
to: redactedTo,
@@ -201,7 +202,8 @@ export async function runWebHeartbeatOnce(opts: {
return;
}
const hasMedia = Boolean(replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0);
const reply = resolveSendableOutboundReplyParts(replyPayload);
const hasMedia = reply.hasMedia;
const ackMaxChars = Math.max(
0,
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
@@ -250,7 +252,7 @@ export async function runWebHeartbeatOnce(opts: {
);
}
const finalText = stripped.text || replyPayload.text || "";
const finalText = stripped.text || reply.text;
// Check if alerts are disabled for WhatsApp
if (!visibility.showAlerts) {

View File

@@ -6,6 +6,7 @@ import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime";
import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime";
@@ -429,10 +430,11 @@ export async function processMessage(params: {
});
const fromDisplay =
params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown");
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
const reply = resolveSendableOutboundReplyParts(payload);
const hasMedia = reply.hasMedia;
whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`);
if (shouldLogVerbose()) {
const preview = payload.text != null ? elide(payload.text, 400) : "<media>";
const preview = payload.text != null ? elide(reply.text, 400) : "<media>";
whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`);
}
},

View File

@@ -5,6 +5,7 @@ import {
createAttachedChannelResultAdapter,
createEmptyChannelResult,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveWhatsAppOutboundTarget } from "./runtime-api.js";
@@ -24,7 +25,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
sendPayload: async (ctx) => {
const text = trimLeadingWhitespace(ctx.payload.text);
const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0;
const hasMedia = resolveSendableOutboundReplyParts(ctx.payload).hasMedia;
if (!text && !hasMedia) {
return createEmptyChannelResult("whatsapp");
}

View File

@@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
@@ -579,11 +580,13 @@ async function deliverZaloReply(params: {
}): Promise<void> {
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const reply = resolveSendableOutboundReplyParts(payload, {
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
});
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
await deliverTextOrMediaReply({
payload,
text,
text: reply.text,
chunkText: (value) =>
core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode),
sendText: async (chunk) => {

View File

@@ -28,6 +28,7 @@ import {
mergeAllowlist,
resolveMentionGatingWithBypass,
resolveOpenProviderRuntimeGroupPolicy,
resolveSendableOutboundReplyParts,
resolveDefaultGroupPolicy,
resolveSenderCommandAuthorization,
resolveSenderScopedGroupPolicy,
@@ -706,14 +707,16 @@ async function deliverZalouserReply(params: {
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const reply = resolveSendableOutboundReplyParts(payload, {
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
});
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
fallbackLimit: ZALOUSER_TEXT_LIMIT,
});
await deliverTextOrMediaReply({
payload,
text,
text: reply.text,
sendText: async (chunk) => {
try {
await sendMessageZalouser(chatId, chunk, {