refactor: deduplicate reply payload handling
This commit is contained in:
parent
152d179302
commit
62edfdffbd
@ -16,6 +16,7 @@ import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runt
|
|||||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-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 { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
|
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import {
|
import {
|
||||||
@ -610,7 +611,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
}
|
}
|
||||||
if (draftStream && isFinal) {
|
if (draftStream && isFinal) {
|
||||||
await flushDraft();
|
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 finalText = payload.text;
|
||||||
const previewFinalText = resolvePreviewFinalText(finalText);
|
const previewFinalText = resolvePreviewFinalText(finalText);
|
||||||
const previewMessageId = draftStream.messageId();
|
const previewMessageId = draftStream.messageId();
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
|||||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
|
import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
|
||||||
import {
|
import {
|
||||||
resolveOutboundMediaUrls,
|
resolveSendableOutboundReplyParts,
|
||||||
resolveTextChunksWithFallback,
|
resolveTextChunksWithFallback,
|
||||||
} from "openclaw/plugin-sdk/reply-payload";
|
} from "openclaw/plugin-sdk/reply-payload";
|
||||||
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
@ -236,13 +236,7 @@ function isDiscordUnknownInteraction(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
|
function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
|
||||||
if ((payload.text ?? "").trim()) {
|
if (resolveSendableOutboundReplyParts(payload).hasContent) {
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if ((payload.mediaUrl ?? "").trim()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (payload.mediaUrls?.some((entry) => entry.trim())) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const discordData = payload.channelData?.discord as
|
const discordData = payload.channelData?.discord as
|
||||||
@ -891,8 +885,7 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
chunkMode: "length" | "newline";
|
chunkMode: "length" | "newline";
|
||||||
}) {
|
}) {
|
||||||
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
|
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
|
||||||
const mediaList = resolveOutboundMediaUrls(payload);
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const text = payload.text ?? "";
|
|
||||||
const discordData = payload.channelData?.discord as
|
const discordData = payload.channelData?.discord as
|
||||||
| { components?: TopLevelComponents[] }
|
| { components?: TopLevelComponents[] }
|
||||||
| undefined;
|
| undefined;
|
||||||
@ -937,9 +930,9 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mediaList.length > 0) {
|
if (reply.hasMedia) {
|
||||||
const media = await Promise.all(
|
const media = await Promise.all(
|
||||||
mediaList.map(async (url) => {
|
reply.mediaUrls.map(async (url) => {
|
||||||
const loaded = await loadWebMedia(url, {
|
const loaded = await loadWebMedia(url, {
|
||||||
localRoots: params.mediaLocalRoots,
|
localRoots: params.mediaLocalRoots,
|
||||||
});
|
});
|
||||||
@ -950,8 +943,8 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const chunks = resolveTextChunksWithFallback(
|
const chunks = resolveTextChunksWithFallback(
|
||||||
text,
|
reply.text,
|
||||||
chunkDiscordTextWithMode(text, {
|
chunkDiscordTextWithMode(reply.text, {
|
||||||
maxChars: textLimit,
|
maxChars: textLimit,
|
||||||
maxLines: maxLinesPerMessage,
|
maxLines: maxLinesPerMessage,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
@ -968,14 +961,14 @@ async function deliverDiscordInteractionReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!text.trim() && !firstMessageComponents) {
|
if (!reply.hasText && !firstMessageComponents) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const chunks =
|
const chunks =
|
||||||
text || firstMessageComponents
|
reply.text || firstMessageComponents
|
||||||
? resolveTextChunksWithFallback(
|
? resolveTextChunksWithFallback(
|
||||||
text,
|
reply.text,
|
||||||
chunkDiscordTextWithMode(text, {
|
chunkDiscordTextWithMode(reply.text, {
|
||||||
maxChars: textLimit,
|
maxChars: textLimit,
|
||||||
maxLines: maxLinesPerMessage,
|
maxLines: maxLinesPerMessage,
|
||||||
chunkMode,
|
chunkMode,
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
type RetryConfig,
|
type RetryConfig,
|
||||||
} from "openclaw/plugin-sdk/infra-runtime";
|
} from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import {
|
import {
|
||||||
resolveOutboundMediaUrls,
|
resolveSendableOutboundReplyParts,
|
||||||
resolveTextChunksWithFallback,
|
resolveTextChunksWithFallback,
|
||||||
sendMediaWithLeadingCaption,
|
sendMediaWithLeadingCaption,
|
||||||
} from "openclaw/plugin-sdk/reply-payload";
|
} from "openclaw/plugin-sdk/reply-payload";
|
||||||
@ -268,18 +268,18 @@ export async function deliverDiscordReply(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
let deliveredAny = false;
|
let deliveredAny = false;
|
||||||
for (const payload of params.replies) {
|
for (const payload of params.replies) {
|
||||||
const mediaList = resolveOutboundMediaUrls(payload);
|
|
||||||
const rawText = payload.text ?? "";
|
|
||||||
const tableMode = params.tableMode ?? "code";
|
const tableMode = params.tableMode ?? "code";
|
||||||
const text = convertMarkdownTables(rawText, tableMode);
|
const reply = resolveSendableOutboundReplyParts(payload, {
|
||||||
if (!text && mediaList.length === 0) {
|
text: convertMarkdownTables(payload.text ?? "", tableMode),
|
||||||
|
});
|
||||||
|
if (!reply.hasContent) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (mediaList.length === 0) {
|
if (!reply.hasMedia) {
|
||||||
const mode = params.chunkMode ?? "length";
|
const mode = params.chunkMode ?? "length";
|
||||||
const chunks = resolveTextChunksWithFallback(
|
const chunks = resolveTextChunksWithFallback(
|
||||||
text,
|
reply.text,
|
||||||
chunkDiscordTextWithMode(text, {
|
chunkDiscordTextWithMode(reply.text, {
|
||||||
maxChars: chunkLimit,
|
maxChars: chunkLimit,
|
||||||
maxLines: params.maxLinesPerMessage,
|
maxLines: params.maxLinesPerMessage,
|
||||||
chunkMode: mode,
|
chunkMode: mode,
|
||||||
@ -312,7 +312,7 @@ export async function deliverDiscordReply(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstMedia = mediaList[0];
|
const firstMedia = reply.mediaUrls[0];
|
||||||
if (!firstMedia) {
|
if (!firstMedia) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -331,7 +331,7 @@ export async function deliverDiscordReply(params: {
|
|||||||
await sendDiscordChunkWithFallback({
|
await sendDiscordChunkWithFallback({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
target: params.target,
|
target: params.target,
|
||||||
text,
|
text: reply.text,
|
||||||
token: params.token,
|
token: params.token,
|
||||||
rest: params.rest,
|
rest: params.rest,
|
||||||
accountId: params.accountId,
|
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).
|
// Additional media items are sent as regular attachments (voice is single-file only).
|
||||||
await sendMediaWithLeadingCaption({
|
await sendMediaWithLeadingCaption({
|
||||||
mediaUrls: mediaList.slice(1),
|
mediaUrls: reply.mediaUrls.slice(1),
|
||||||
caption: "",
|
caption: "",
|
||||||
send: async ({ mediaUrl }) => {
|
send: async ({ mediaUrl }) => {
|
||||||
const replyTo = resolveReplyTo();
|
const replyTo = resolveReplyTo();
|
||||||
@ -370,8 +370,8 @@ export async function deliverDiscordReply(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await sendMediaWithLeadingCaption({
|
await sendMediaWithLeadingCaption({
|
||||||
mediaUrls: mediaList,
|
mediaUrls: reply.mediaUrls,
|
||||||
caption: text,
|
caption: reply.text,
|
||||||
send: async ({ mediaUrl, caption }) => {
|
send: async ({ mediaUrl, caption }) => {
|
||||||
const replyTo = resolveReplyTo();
|
const replyTo = resolveReplyTo();
|
||||||
await sendWithRetry(
|
await sendWithRetry(
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
resolveSendableOutboundReplyParts,
|
||||||
|
resolveTextChunksWithFallback,
|
||||||
|
sendMediaWithLeadingCaption,
|
||||||
|
} from "openclaw/plugin-sdk/reply-payload";
|
||||||
import {
|
import {
|
||||||
createReplyPrefixContext,
|
createReplyPrefixContext,
|
||||||
createTypingCallbacks,
|
createTypingCallbacks,
|
||||||
@ -13,12 +18,7 @@ import { sendMediaFeishu } from "./media.js";
|
|||||||
import type { MentionTarget } from "./mention.js";
|
import type { MentionTarget } from "./mention.js";
|
||||||
import { buildMentionedCardContent } from "./mention.js";
|
import { buildMentionedCardContent } from "./mention.js";
|
||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
import {
|
import { sendMessageFeishu, sendStructuredCardFeishu, type CardHeaderConfig } from "./send.js";
|
||||||
sendMarkdownCardFeishu,
|
|
||||||
sendMessageFeishu,
|
|
||||||
sendStructuredCardFeishu,
|
|
||||||
type CardHeaderConfig,
|
|
||||||
} from "./send.js";
|
|
||||||
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
|
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
|
||||||
import { resolveReceiveIdType } from "./targets.js";
|
import { resolveReceiveIdType } from "./targets.js";
|
||||||
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
||||||
@ -300,37 +300,43 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||||||
text: string;
|
text: string;
|
||||||
useCard: boolean;
|
useCard: boolean;
|
||||||
infoKind?: string;
|
infoKind?: string;
|
||||||
|
sendChunk: (params: { chunk: string; isFirst: boolean }) => Promise<void>;
|
||||||
}) => {
|
}) => {
|
||||||
let first = true;
|
|
||||||
const chunkSource = params.useCard
|
const chunkSource = params.useCard
|
||||||
? params.text
|
? params.text
|
||||||
: core.channel.text.convertMarkdownTables(params.text, tableMode);
|
: core.channel.text.convertMarkdownTables(params.text, tableMode);
|
||||||
for (const chunk of core.channel.text.chunkTextWithMode(
|
const chunks = resolveTextChunksWithFallback(
|
||||||
chunkSource,
|
chunkSource,
|
||||||
textChunkLimit,
|
core.channel.text.chunkTextWithMode(chunkSource, textChunkLimit, chunkMode),
|
||||||
chunkMode,
|
);
|
||||||
)) {
|
for (const [index, chunk] of chunks.entries()) {
|
||||||
const message = {
|
await params.sendChunk({
|
||||||
cfg,
|
chunk,
|
||||||
to: chatId,
|
isFirst: index === 0,
|
||||||
text: chunk,
|
});
|
||||||
replyToMessageId: sendReplyToMessageId,
|
|
||||||
replyInThread: effectiveReplyInThread,
|
|
||||||
mentions: first ? mentionTargets : undefined,
|
|
||||||
accountId,
|
|
||||||
};
|
|
||||||
if (params.useCard) {
|
|
||||||
await sendMarkdownCardFeishu(message);
|
|
||||||
} else {
|
|
||||||
await sendMessageFeishu(message);
|
|
||||||
}
|
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
if (params.infoKind === "final") {
|
if (params.infoKind === "final") {
|
||||||
deliveredFinalTexts.add(params.text);
|
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 } =
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
responsePrefix: prefixContext.responsePrefix,
|
||||||
@ -344,15 +350,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||||||
void typingCallbacks.onReplyStart?.();
|
void typingCallbacks.onReplyStart?.();
|
||||||
},
|
},
|
||||||
deliver: async (payload: ReplyPayload, info) => {
|
deliver: async (payload: ReplyPayload, info) => {
|
||||||
const text = payload.text ?? "";
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const mediaList =
|
const text = reply.text;
|
||||||
payload.mediaUrls && payload.mediaUrls.length > 0
|
const hasText = reply.hasText;
|
||||||
? payload.mediaUrls
|
const hasMedia = reply.hasMedia;
|
||||||
: payload.mediaUrl
|
|
||||||
? [payload.mediaUrl]
|
|
||||||
: [];
|
|
||||||
const hasText = Boolean(text.trim());
|
|
||||||
const hasMedia = mediaList.length > 0;
|
|
||||||
const skipTextForDuplicateFinal =
|
const skipTextForDuplicateFinal =
|
||||||
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
||||||
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
|
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
|
||||||
@ -363,7 +364,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||||||
|
|
||||||
if (shouldDeliverText) {
|
if (shouldDeliverText) {
|
||||||
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||||
let first = true;
|
|
||||||
|
|
||||||
if (info?.kind === "block") {
|
if (info?.kind === "block") {
|
||||||
// Drop internal block chunks unless we can safely consume them as
|
// 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
|
// Send media even when streaming handled the text
|
||||||
if (hasMedia) {
|
if (hasMedia) {
|
||||||
for (const mediaUrl of mediaList) {
|
await sendMediaReplies(payload);
|
||||||
await sendMediaFeishu({
|
|
||||||
cfg,
|
|
||||||
to: chatId,
|
|
||||||
mediaUrl,
|
|
||||||
replyToMessageId: sendReplyToMessageId,
|
|
||||||
replyInThread: effectiveReplyInThread,
|
|
||||||
accountId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -414,43 +405,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||||||
if (useCard) {
|
if (useCard) {
|
||||||
const cardHeader = resolveCardHeader(agentId, identity);
|
const cardHeader = resolveCardHeader(agentId, identity);
|
||||||
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
|
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
|
||||||
for (const chunk of core.channel.text.chunkTextWithMode(
|
await sendChunkedTextReply({
|
||||||
text,
|
text,
|
||||||
textChunkLimit,
|
useCard: true,
|
||||||
chunkMode,
|
infoKind: info?.kind,
|
||||||
)) {
|
sendChunk: async ({ chunk, isFirst }) => {
|
||||||
await sendStructuredCardFeishu({
|
await sendStructuredCardFeishu({
|
||||||
cfg,
|
cfg,
|
||||||
to: chatId,
|
to: chatId,
|
||||||
text: chunk,
|
text: chunk,
|
||||||
replyToMessageId: sendReplyToMessageId,
|
replyToMessageId: sendReplyToMessageId,
|
||||||
replyInThread: effectiveReplyInThread,
|
replyInThread: effectiveReplyInThread,
|
||||||
mentions: first ? mentionTargets : undefined,
|
mentions: isFirst ? mentionTargets : undefined,
|
||||||
accountId,
|
accountId,
|
||||||
header: cardHeader,
|
header: cardHeader,
|
||||||
note: cardNote,
|
note: cardNote,
|
||||||
});
|
});
|
||||||
first = false;
|
},
|
||||||
}
|
});
|
||||||
if (info?.kind === "final") {
|
|
||||||
deliveredFinalTexts.add(text);
|
|
||||||
}
|
|
||||||
} else {
|
} 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) {
|
if (hasMedia) {
|
||||||
for (const mediaUrl of mediaList) {
|
await sendMediaReplies(payload);
|
||||||
await sendMediaFeishu({
|
|
||||||
cfg,
|
|
||||||
to: chatId,
|
|
||||||
mediaUrl,
|
|
||||||
replyToMessageId: sendReplyToMessageId,
|
|
||||||
replyInThread: effectiveReplyInThread,
|
|
||||||
accountId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: async (error, info) => {
|
onError: async (error, info) => {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
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 type { OpenClawConfig } from "../runtime-api.js";
|
||||||
import {
|
import {
|
||||||
createWebhookInFlightLimiter,
|
createWebhookInFlightLimiter,
|
||||||
@ -376,8 +379,10 @@ async function deliverGoogleChatReply(params: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } =
|
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } =
|
||||||
params;
|
params;
|
||||||
const hasMedia = Boolean(payload.mediaUrls?.length) || Boolean(payload.mediaUrl);
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const text = payload.text ?? "";
|
const mediaCount = reply.mediaCount;
|
||||||
|
const hasMedia = reply.hasMedia;
|
||||||
|
const text = reply.text;
|
||||||
let firstTextChunk = true;
|
let firstTextChunk = true;
|
||||||
let suppressCaption = false;
|
let suppressCaption = false;
|
||||||
|
|
||||||
@ -390,8 +395,7 @@ async function deliverGoogleChatReply(params: {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
|
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
|
||||||
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
|
const fallbackText = reply.hasText
|
||||||
const fallbackText = text.trim()
|
|
||||||
? text
|
? text
|
||||||
: mediaCount > 1
|
: mediaCount > 1
|
||||||
? "Sent attachments."
|
? "Sent attachments."
|
||||||
@ -414,7 +418,7 @@ async function deliverGoogleChatReply(params: {
|
|||||||
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
|
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
|
||||||
await deliverTextOrMediaReply({
|
await deliverTextOrMediaReply({
|
||||||
payload,
|
payload,
|
||||||
text: suppressCaption ? "" : text,
|
text: suppressCaption ? "" : reply.text,
|
||||||
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
|
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
|
||||||
sendText: async (chunk) => {
|
sendText: async (chunk) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { resolveMarkdownTableMode } 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 { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||||
@ -32,14 +35,15 @@ export async function deliverReplies(params: {
|
|||||||
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
|
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
|
||||||
for (const payload of replies) {
|
for (const payload of replies) {
|
||||||
const rawText = sanitizeOutboundText(payload.text ?? "");
|
const rawText = sanitizeOutboundText(payload.text ?? "");
|
||||||
const text = convertMarkdownTables(rawText, tableMode);
|
const reply = resolveSendableOutboundReplyParts(payload, {
|
||||||
const hasMedia = Boolean(payload.mediaUrls?.length ?? payload.mediaUrl);
|
text: convertMarkdownTables(rawText, tableMode),
|
||||||
if (!hasMedia && text) {
|
});
|
||||||
sentMessageCache?.remember(scope, { text });
|
if (!reply.hasMedia && reply.hasText) {
|
||||||
|
sentMessageCache?.remember(scope, { text: reply.text });
|
||||||
}
|
}
|
||||||
const delivered = await deliverTextOrMediaReply({
|
const delivered = await deliverTextOrMediaReply({
|
||||||
payload,
|
payload,
|
||||||
text,
|
text: reply.text,
|
||||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||||
sendText: async (chunk) => {
|
sendText: async (chunk) => {
|
||||||
const sent = await sendMessageIMessage(target, chunk, {
|
const sent = await sendMessageIMessage(target, chunk, {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
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 type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import { sendMessageMatrix } from "../send.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);
|
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
|
||||||
let hasReplied = false;
|
let hasReplied = false;
|
||||||
for (const reply of params.replies) {
|
for (const reply of params.replies) {
|
||||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
const rawText = reply.text ?? "";
|
||||||
if (!reply?.text && !hasMedia) {
|
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
|
||||||
|
const replyContent = resolveSendableOutboundReplyParts(reply, { text });
|
||||||
|
if (!replyContent.hasContent) {
|
||||||
if (reply?.audioAsVoice) {
|
if (reply?.audioAsVoice) {
|
||||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||||
continue;
|
continue;
|
||||||
@ -49,13 +54,6 @@ export async function deliverMatrixReplies(params: {
|
|||||||
}
|
}
|
||||||
const replyToIdRaw = reply.replyToId?.trim();
|
const replyToIdRaw = reply.replyToId?.trim();
|
||||||
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
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) =>
|
const shouldIncludeReply = (id?: string) =>
|
||||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||||
@ -63,7 +61,7 @@ export async function deliverMatrixReplies(params: {
|
|||||||
|
|
||||||
const delivered = await deliverTextOrMediaReply({
|
const delivered = await deliverTextOrMediaReply({
|
||||||
payload: reply,
|
payload: reply,
|
||||||
text,
|
text: replyContent.text,
|
||||||
chunkText: (value) =>
|
chunkText: (value) =>
|
||||||
core.channel.text
|
core.channel.text
|
||||||
.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode)
|
.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode)
|
||||||
|
|||||||
@ -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 type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js";
|
||||||
import { getAgentScopedMediaLocalRoots } from "../runtime-api.js";
|
import { getAgentScopedMediaLocalRoots } from "../runtime-api.js";
|
||||||
|
|
||||||
@ -27,10 +30,12 @@ export async function deliverMattermostReplyPayload(params: {
|
|||||||
tableMode: MarkdownTableMode;
|
tableMode: MarkdownTableMode;
|
||||||
sendMessage: SendMattermostMessage;
|
sendMessage: SendMattermostMessage;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const text = params.core.channel.text.convertMarkdownTables(
|
const reply = resolveSendableOutboundReplyParts(params.payload, {
|
||||||
params.payload.text ?? "",
|
text: params.core.channel.text.convertMarkdownTables(
|
||||||
params.tableMode,
|
params.payload.text ?? "",
|
||||||
);
|
params.tableMode,
|
||||||
|
),
|
||||||
|
});
|
||||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
|
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
|
||||||
const chunkMode = params.core.channel.text.resolveChunkMode(
|
const chunkMode = params.core.channel.text.resolveChunkMode(
|
||||||
params.cfg,
|
params.cfg,
|
||||||
@ -39,7 +44,7 @@ export async function deliverMattermostReplyPayload(params: {
|
|||||||
);
|
);
|
||||||
await deliverTextOrMediaReply({
|
await deliverTextOrMediaReply({
|
||||||
payload: params.payload,
|
payload: params.payload,
|
||||||
text,
|
text: reply.text,
|
||||||
chunkText: (value) =>
|
chunkText: (value) =>
|
||||||
params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode),
|
params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode),
|
||||||
sendText: async (chunk) => {
|
sendText: async (chunk) => {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
type MarkdownTableMode,
|
type MarkdownTableMode,
|
||||||
type MSTeamsReplyStyle,
|
type MSTeamsReplyStyle,
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
resolveOutboundMediaUrls,
|
resolveSendableOutboundReplyParts,
|
||||||
SILENT_REPLY_TOKEN,
|
SILENT_REPLY_TOKEN,
|
||||||
sleep,
|
sleep,
|
||||||
} from "../runtime-api.js";
|
} from "../runtime-api.js";
|
||||||
@ -217,41 +217,39 @@ export function renderReplyPayloadsToMessages(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const payload of replies) {
|
for (const payload of replies) {
|
||||||
const mediaList = resolveOutboundMediaUrls(payload);
|
const reply = resolveSendableOutboundReplyParts(payload, {
|
||||||
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
text: getMSTeamsRuntime().channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
|
||||||
payload.text ?? "",
|
});
|
||||||
tableMode,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!text && mediaList.length === 0) {
|
if (!reply.hasContent) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (!reply.hasMedia) {
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaMode === "inline") {
|
if (mediaMode === "inline") {
|
||||||
// For inline mode, combine text with first media as attachment
|
// For inline mode, combine text with first media as attachment
|
||||||
const firstMedia = mediaList[0];
|
const firstMedia = reply.mediaUrls[0];
|
||||||
if (firstMedia) {
|
if (firstMedia) {
|
||||||
out.push({ text: text || undefined, mediaUrl: firstMedia });
|
out.push({ text: reply.text || undefined, mediaUrl: firstMedia });
|
||||||
// Additional media URLs as separate messages
|
// Additional media URLs as separate messages
|
||||||
for (let i = 1; i < mediaList.length; i++) {
|
for (let i = 1; i < reply.mediaUrls.length; i++) {
|
||||||
if (mediaList[i]) {
|
if (reply.mediaUrls[i]) {
|
||||||
out.push({ mediaUrl: mediaList[i] });
|
out.push({ mediaUrl: reply.mediaUrls[i] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaMode === "split"
|
// mediaMode === "split"
|
||||||
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
|
pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode });
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of reply.mediaUrls) {
|
||||||
if (!mediaUrl) {
|
if (!mediaUrl) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,10 @@ import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-
|
|||||||
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
|
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
|
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-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 {
|
import {
|
||||||
chunkTextWithMode,
|
chunkTextWithMode,
|
||||||
resolveChunkMode,
|
resolveChunkMode,
|
||||||
@ -297,9 +300,10 @@ async function deliverReplies(params: {
|
|||||||
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
|
||||||
params;
|
params;
|
||||||
for (const payload of replies) {
|
for (const payload of replies) {
|
||||||
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const delivered = await deliverTextOrMediaReply({
|
const delivered = await deliverTextOrMediaReply({
|
||||||
payload,
|
payload,
|
||||||
text: payload.text ?? "",
|
text: reply.text,
|
||||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||||
sendText: async (chunk) => {
|
sendText: async (chunk) => {
|
||||||
await sendMessageSignal(target, chunk, {
|
await sendMessageSignal(target, chunk, {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime";
|
|||||||
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
|
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime";
|
import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-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 { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
|
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { createReplyDispatcherWithTyping } 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";
|
import type { PreparedSlackMessage } from "./types.js";
|
||||||
|
|
||||||
function hasMedia(payload: ReplyPayload): boolean {
|
function hasMedia(payload: ReplyPayload): boolean {
|
||||||
return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
return resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSlackStreamingEnabled(params: {
|
export function isSlackStreamingEnabled(params: {
|
||||||
@ -250,17 +251,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deliverWithStreaming = async (payload: ReplyPayload): Promise<void> => {
|
const deliverWithStreaming = async (payload: ReplyPayload): Promise<void> => {
|
||||||
if (
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
streamFailed ||
|
if (streamFailed || reply.hasMedia || readSlackReplyBlocks(payload)?.length || !reply.hasText) {
|
||||||
hasMedia(payload) ||
|
|
||||||
readSlackReplyBlocks(payload)?.length ||
|
|
||||||
!payload.text?.trim()
|
|
||||||
) {
|
|
||||||
await deliverNormally(payload, streamSession?.threadTs);
|
await deliverNormally(payload, streamSession?.threadTs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = payload.text.trim();
|
const text = reply.trimmedText;
|
||||||
let plannedThreadTs: string | undefined;
|
let plannedThreadTs: string | undefined;
|
||||||
try {
|
try {
|
||||||
if (!streamSession) {
|
if (!streamSession) {
|
||||||
@ -311,16 +308,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const slackBlocks = readSlackReplyBlocks(payload);
|
const slackBlocks = readSlackReplyBlocks(payload);
|
||||||
const draftMessageId = draftStream?.messageId();
|
const draftMessageId = draftStream?.messageId();
|
||||||
const draftChannelId = draftStream?.channelId();
|
const draftChannelId = draftStream?.channelId();
|
||||||
const finalText = payload.text ?? "";
|
const finalText = reply.text;
|
||||||
const trimmedFinalText = finalText.trim();
|
const trimmedFinalText = reply.trimmedText;
|
||||||
const canFinalizeViaPreviewEdit =
|
const canFinalizeViaPreviewEdit =
|
||||||
previewStreamingEnabled &&
|
previewStreamingEnabled &&
|
||||||
streamMode !== "status_final" &&
|
streamMode !== "status_final" &&
|
||||||
mediaCount === 0 &&
|
!reply.hasMedia &&
|
||||||
!payload.isError &&
|
!payload.isError &&
|
||||||
(trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) &&
|
(trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) &&
|
||||||
typeof draftMessageId === "string" &&
|
typeof draftMessageId === "string" &&
|
||||||
@ -361,7 +358,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`slack: status_final completion update failed (${String(err)})`);
|
logVerbose(`slack: status_final completion update failed (${String(err)})`);
|
||||||
}
|
}
|
||||||
} else if (mediaCount > 0) {
|
} else if (reply.hasMedia) {
|
||||||
await draftStream?.clear();
|
await draftStream?.clear();
|
||||||
hasStreamedMessage = false;
|
hasStreamedMessage = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
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 type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime";
|
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { createReplyReferencePlanner } 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.
|
// must not force threading.
|
||||||
const inlineReplyToId = params.replyToMode === "off" ? undefined : payload.replyToId;
|
const inlineReplyToId = params.replyToMode === "off" ? undefined : payload.replyToId;
|
||||||
const threadTs = inlineReplyToId ?? params.replyThreadTs;
|
const threadTs = inlineReplyToId ?? params.replyThreadTs;
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const text = payload.text ?? "";
|
|
||||||
const slackBlocks = readSlackReplyBlocks(payload);
|
const slackBlocks = readSlackReplyBlocks(payload);
|
||||||
if (!text && mediaList.length === 0 && !slackBlocks?.length) {
|
if (!reply.hasContent && !slackBlocks?.length) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaList.length === 0 && slackBlocks?.length) {
|
if (!reply.hasMedia && slackBlocks?.length) {
|
||||||
const trimmed = text.trim();
|
const trimmed = reply.trimmedText;
|
||||||
if (!trimmed && !slackBlocks?.length) {
|
if (!trimmed && !slackBlocks?.length) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -66,17 +68,16 @@ export async function deliverReplies(params: {
|
|||||||
|
|
||||||
const delivered = await deliverTextOrMediaReply({
|
const delivered = await deliverTextOrMediaReply({
|
||||||
payload,
|
payload,
|
||||||
text,
|
text: reply.text,
|
||||||
chunkText:
|
chunkText: !reply.hasMedia
|
||||||
mediaList.length === 0
|
? (value) => {
|
||||||
? (value) => {
|
const trimmed = value.trim();
|
||||||
const trimmed = value.trim();
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
|
return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [trimmed];
|
|
||||||
}
|
}
|
||||||
: undefined,
|
return [trimmed];
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
sendText: async (trimmed) => {
|
sendText: async (trimmed) => {
|
||||||
await sendMessageSlack(params.target, trimmed, {
|
await sendMessageSlack(params.target, trimmed, {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
@ -189,12 +190,12 @@ export async function deliverSlackSlashReplies(params: {
|
|||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||||
for (const payload of params.replies) {
|
for (const payload of params.replies) {
|
||||||
const textRaw = payload.text?.trim() ?? "";
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const text = textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN) ? textRaw : undefined;
|
const text =
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
reply.hasText && !isSilentReplyText(reply.trimmedText, SILENT_REPLY_TOKEN)
|
||||||
const combined = [text ?? "", ...mediaList.map((url) => url.trim()).filter(Boolean)]
|
? reply.trimmedText
|
||||||
.filter(Boolean)
|
: undefined;
|
||||||
.join("\n");
|
const combined = [text ?? "", ...reply.mediaUrls].filter(Boolean).join("\n");
|
||||||
if (!combined) {
|
if (!combined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import type {
|
|||||||
TelegramAccountConfig,
|
TelegramAccountConfig,
|
||||||
} from "openclaw/plugin-sdk/config-runtime";
|
} from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-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 { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
|
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
@ -567,7 +568,8 @@ export const dispatchTelegramMessage = async ({
|
|||||||
)?.buttons;
|
)?.buttons;
|
||||||
const split = splitTextIntoLaneSegments(payload.text);
|
const split = splitTextIntoLaneSegments(payload.text);
|
||||||
const segments = split.segments;
|
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 flushBufferedFinalAnswer = async () => {
|
||||||
const buffered = reasoningStepState.takeBufferedFinalAnswer();
|
const buffered = reasoningStepState.takeBufferedFinalAnswer();
|
||||||
@ -631,7 +633,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (split.suppressedReasoningOnly) {
|
if (split.suppressedReasoningOnly) {
|
||||||
if (hasMedia) {
|
if (reply.hasMedia) {
|
||||||
const payloadWithoutSuppressedReasoning =
|
const payloadWithoutSuppressedReasoning =
|
||||||
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
|
typeof payload.text === "string" ? { ...payload, text: "" } : payload;
|
||||||
await sendPayload(payloadWithoutSuppressedReasoning);
|
await sendPayload(payloadWithoutSuppressedReasoning);
|
||||||
@ -647,8 +649,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
await reasoningLane.stream?.stop();
|
await reasoningLane.stream?.stop();
|
||||||
reasoningStepState.resetForNextStep();
|
reasoningStepState.resetForNextStep();
|
||||||
}
|
}
|
||||||
const canSendAsIs =
|
const canSendAsIs = reply.hasMedia || reply.text.length > 0;
|
||||||
hasMedia || (typeof payload.text === "string" && payload.text.length > 0);
|
|
||||||
if (!canSendAsIs) {
|
if (!canSendAsIs) {
|
||||||
if (info.kind === "final") {
|
if (info.kind === "final") {
|
||||||
await flushBufferedFinalAnswer();
|
await flushBufferedFinalAnswer();
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import type { TelegramInlineButtons } from "./button-types.js";
|
import type { TelegramInlineButtons } from "./button-types.js";
|
||||||
import type { TelegramDraftStream } from "./draft-stream.js";
|
import type { TelegramDraftStream } from "./draft-stream.js";
|
||||||
@ -459,7 +460,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
|||||||
allowPreviewUpdateForNonFinal = false,
|
allowPreviewUpdateForNonFinal = false,
|
||||||
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
|
}: DeliverLaneTextParams): Promise<LaneDeliveryResult> => {
|
||||||
const lane = params.lanes[laneName];
|
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 =
|
const canEditViaPreview =
|
||||||
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
|
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import {
|
|||||||
} from "openclaw/plugin-sdk/config-runtime";
|
} from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { emitHeartbeatEvent, resolveIndicatorType } from "openclaw/plugin-sdk/infra-runtime";
|
import { emitHeartbeatEvent, resolveIndicatorType } from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import { resolveHeartbeatVisibility } 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 { resolveHeartbeatReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
@ -178,10 +182,7 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
);
|
);
|
||||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||||
|
|
||||||
if (
|
if (!replyPayload || !hasOutboundReplyContent(replyPayload)) {
|
||||||
!replyPayload ||
|
|
||||||
(!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length)
|
|
||||||
) {
|
|
||||||
heartbeatLogger.info(
|
heartbeatLogger.info(
|
||||||
{
|
{
|
||||||
to: redactedTo,
|
to: redactedTo,
|
||||||
@ -201,7 +202,8 @@ export async function runWebHeartbeatOnce(opts: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMedia = Boolean(replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0);
|
const reply = resolveSendableOutboundReplyParts(replyPayload);
|
||||||
|
const hasMedia = reply.hasMedia;
|
||||||
const ackMaxChars = Math.max(
|
const ackMaxChars = Math.max(
|
||||||
0,
|
0,
|
||||||
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
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
|
// Check if alerts are disabled for WhatsApp
|
||||||
if (!visibility.showAlerts) {
|
if (!visibility.showAlerts) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
|||||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime";
|
import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-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 { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime";
|
import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { formatInboundEnvelope } 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 =
|
const fromDisplay =
|
||||||
params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown");
|
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)" : ""}`);
|
whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`);
|
||||||
if (shouldLogVerbose()) {
|
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)" : ""}`);
|
whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
createAttachedChannelResultAdapter,
|
createAttachedChannelResultAdapter,
|
||||||
createEmptyChannelResult,
|
createEmptyChannelResult,
|
||||||
} from "openclaw/plugin-sdk/channel-send-result";
|
} from "openclaw/plugin-sdk/channel-send-result";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||||
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
|
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { resolveWhatsAppOutboundTarget } from "./runtime-api.js";
|
import { resolveWhatsAppOutboundTarget } from "./runtime-api.js";
|
||||||
@ -24,7 +25,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
|
|||||||
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
|
||||||
sendPayload: async (ctx) => {
|
sendPayload: async (ctx) => {
|
||||||
const text = trimLeadingWhitespace(ctx.payload.text);
|
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) {
|
if (!text && !hasMedia) {
|
||||||
return createEmptyChannelResult("whatsapp");
|
return createEmptyChannelResult("whatsapp");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
ZaloApiError,
|
ZaloApiError,
|
||||||
@ -579,11 +580,13 @@ async function deliverZaloReply(params: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
||||||
const tableMode = params.tableMode ?? "code";
|
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);
|
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
|
||||||
await deliverTextOrMediaReply({
|
await deliverTextOrMediaReply({
|
||||||
payload,
|
payload,
|
||||||
text,
|
text: reply.text,
|
||||||
chunkText: (value) =>
|
chunkText: (value) =>
|
||||||
core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode),
|
core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode),
|
||||||
sendText: async (chunk) => {
|
sendText: async (chunk) => {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
resolveMentionGatingWithBypass,
|
resolveMentionGatingWithBypass,
|
||||||
resolveOpenProviderRuntimeGroupPolicy,
|
resolveOpenProviderRuntimeGroupPolicy,
|
||||||
|
resolveSendableOutboundReplyParts,
|
||||||
resolveDefaultGroupPolicy,
|
resolveDefaultGroupPolicy,
|
||||||
resolveSenderCommandAuthorization,
|
resolveSenderCommandAuthorization,
|
||||||
resolveSenderScopedGroupPolicy,
|
resolveSenderScopedGroupPolicy,
|
||||||
@ -706,14 +707,16 @@ async function deliverZalouserReply(params: {
|
|||||||
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
|
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
|
||||||
params;
|
params;
|
||||||
const tableMode = params.tableMode ?? "code";
|
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 chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
|
||||||
const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
|
const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
|
||||||
fallbackLimit: ZALOUSER_TEXT_LIMIT,
|
fallbackLimit: ZALOUSER_TEXT_LIMIT,
|
||||||
});
|
});
|
||||||
await deliverTextOrMediaReply({
|
await deliverTextOrMediaReply({
|
||||||
payload,
|
payload,
|
||||||
text,
|
text: reply.text,
|
||||||
sendText: async (chunk) => {
|
sendText: async (chunk) => {
|
||||||
try {
|
try {
|
||||||
await sendMessageZalouser(chatId, chunk, {
|
await sendMessageZalouser(chatId, chunk, {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { ReasoningLevel, VerboseLevel } from "../../../auto-reply/thinking.
|
|||||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
|
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
|
||||||
import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
|
import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
|
||||||
import type { OpenClawConfig } from "../../../config/config.js";
|
import type { OpenClawConfig } from "../../../config/config.js";
|
||||||
|
import { hasOutboundReplyContent } from "../../../plugin-sdk/reply-payload.js";
|
||||||
import {
|
import {
|
||||||
BILLING_ERROR_USER_MESSAGE,
|
BILLING_ERROR_USER_MESSAGE,
|
||||||
formatAssistantErrorText,
|
formatAssistantErrorText,
|
||||||
@ -336,7 +337,7 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
|
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
|
||||||
}))
|
}))
|
||||||
.filter((p) => {
|
.filter((p) => {
|
||||||
if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) {
|
if (!hasOutboundReplyContent(p)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) {
|
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) {
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveSilentReplyFallbackText } from "./pi-embedded-subscribe.handlers.messages.js";
|
import {
|
||||||
|
buildAssistantStreamData,
|
||||||
|
hasAssistantVisibleReply,
|
||||||
|
resolveSilentReplyFallbackText,
|
||||||
|
} from "./pi-embedded-subscribe.handlers.messages.js";
|
||||||
|
|
||||||
describe("resolveSilentReplyFallbackText", () => {
|
describe("resolveSilentReplyFallbackText", () => {
|
||||||
it("replaces NO_REPLY with latest messaging tool text when available", () => {
|
it("replaces NO_REPLY with latest messaging tool text when available", () => {
|
||||||
@ -29,3 +33,31 @@ describe("resolveSilentReplyFallbackText", () => {
|
|||||||
).toBe("NO_REPLY");
|
).toBe("NO_REPLY");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hasAssistantVisibleReply", () => {
|
||||||
|
it("treats audio-only payloads as visible", () => {
|
||||||
|
expect(hasAssistantVisibleReply({ audioAsVoice: true })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects text or media visibility", () => {
|
||||||
|
expect(hasAssistantVisibleReply({ text: "hello" })).toBe(true);
|
||||||
|
expect(hasAssistantVisibleReply({ mediaUrls: ["https://example.com/a.png"] })).toBe(true);
|
||||||
|
expect(hasAssistantVisibleReply({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildAssistantStreamData", () => {
|
||||||
|
it("normalizes media payloads for assistant stream events", () => {
|
||||||
|
expect(
|
||||||
|
buildAssistantStreamData({
|
||||||
|
text: "hello",
|
||||||
|
delta: "he",
|
||||||
|
mediaUrl: "https://example.com/a.png",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
text: "hello",
|
||||||
|
delta: "he",
|
||||||
|
mediaUrls: ["https://example.com/a.png"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
|
|||||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { createInlineCodeState } from "../markdown/code-spans.js";
|
import { createInlineCodeState } from "../markdown/code-spans.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js";
|
||||||
import {
|
import {
|
||||||
isMessagingToolDuplicateNormalized,
|
isMessagingToolDuplicateNormalized,
|
||||||
normalizeTextForComparison,
|
normalizeTextForComparison,
|
||||||
@ -56,6 +57,29 @@ export function resolveSilentReplyFallbackText(params: {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasAssistantVisibleReply(params: {
|
||||||
|
text?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
mediaUrl?: string;
|
||||||
|
audioAsVoice?: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return resolveSendableOutboundReplyParts(params).hasContent || Boolean(params.audioAsVoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssistantStreamData(params: {
|
||||||
|
text?: string;
|
||||||
|
delta?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
mediaUrl?: string;
|
||||||
|
}): { text: string; delta: string; mediaUrls?: string[] } {
|
||||||
|
const mediaUrls = resolveSendableOutboundReplyParts(params).mediaUrls;
|
||||||
|
return {
|
||||||
|
text: params.text ?? "",
|
||||||
|
delta: params.delta ?? "",
|
||||||
|
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function handleMessageStart(
|
export function handleMessageStart(
|
||||||
ctx: EmbeddedPiSubscribeContext,
|
ctx: EmbeddedPiSubscribeContext,
|
||||||
evt: AgentEvent & { message: AgentMessage },
|
evt: AgentEvent & { message: AgentMessage },
|
||||||
@ -196,14 +220,13 @@ export function handleMessageUpdate(
|
|||||||
const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null;
|
const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null;
|
||||||
const parsedFull = parseReplyDirectives(stripTrailingDirective(next));
|
const parsedFull = parseReplyDirectives(stripTrailingDirective(next));
|
||||||
const cleanedText = parsedFull.text;
|
const cleanedText = parsedFull.text;
|
||||||
const mediaUrls = parsedDelta?.mediaUrls;
|
const { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedDelta ?? {});
|
||||||
const hasMedia = Boolean(mediaUrls && mediaUrls.length > 0);
|
|
||||||
const hasAudio = Boolean(parsedDelta?.audioAsVoice);
|
const hasAudio = Boolean(parsedDelta?.audioAsVoice);
|
||||||
const previousCleaned = ctx.state.lastStreamedAssistantCleaned ?? "";
|
const previousCleaned = ctx.state.lastStreamedAssistantCleaned ?? "";
|
||||||
|
|
||||||
let shouldEmit = false;
|
let shouldEmit = false;
|
||||||
let deltaText = "";
|
let deltaText = "";
|
||||||
if (!cleanedText && !hasMedia && !hasAudio) {
|
if (!hasAssistantVisibleReply({ text: cleanedText, mediaUrls, audioAsVoice: hasAudio })) {
|
||||||
shouldEmit = false;
|
shouldEmit = false;
|
||||||
} else if (previousCleaned && !cleanedText.startsWith(previousCleaned)) {
|
} else if (previousCleaned && !cleanedText.startsWith(previousCleaned)) {
|
||||||
shouldEmit = false;
|
shouldEmit = false;
|
||||||
@ -216,29 +239,23 @@ export function handleMessageUpdate(
|
|||||||
ctx.state.lastStreamedAssistantCleaned = cleanedText;
|
ctx.state.lastStreamedAssistantCleaned = cleanedText;
|
||||||
|
|
||||||
if (shouldEmit) {
|
if (shouldEmit) {
|
||||||
|
const data = buildAssistantStreamData({
|
||||||
|
text: cleanedText,
|
||||||
|
delta: deltaText,
|
||||||
|
mediaUrls,
|
||||||
|
});
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: ctx.params.runId,
|
runId: ctx.params.runId,
|
||||||
stream: "assistant",
|
stream: "assistant",
|
||||||
data: {
|
data,
|
||||||
text: cleanedText,
|
|
||||||
delta: deltaText,
|
|
||||||
mediaUrls: hasMedia ? mediaUrls : undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
void ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "assistant",
|
stream: "assistant",
|
||||||
data: {
|
data,
|
||||||
text: cleanedText,
|
|
||||||
delta: deltaText,
|
|
||||||
mediaUrls: hasMedia ? mediaUrls : undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
ctx.state.emittedAssistantUpdate = true;
|
ctx.state.emittedAssistantUpdate = true;
|
||||||
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
|
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
|
||||||
void ctx.params.onPartialReply({
|
void ctx.params.onPartialReply(data);
|
||||||
text: cleanedText,
|
|
||||||
mediaUrls: hasMedia ? mediaUrls : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -291,8 +308,7 @@ export function handleMessageEnd(
|
|||||||
const trimmedText = text.trim();
|
const trimmedText = text.trim();
|
||||||
const parsedText = trimmedText ? parseReplyDirectives(stripTrailingDirective(trimmedText)) : null;
|
const parsedText = trimmedText ? parseReplyDirectives(stripTrailingDirective(trimmedText)) : null;
|
||||||
let cleanedText = parsedText?.text ?? "";
|
let cleanedText = parsedText?.text ?? "";
|
||||||
let mediaUrls = parsedText?.mediaUrls;
|
let { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedText ?? {});
|
||||||
let hasMedia = Boolean(mediaUrls && mediaUrls.length > 0);
|
|
||||||
|
|
||||||
if (!cleanedText && !hasMedia && !ctx.params.enforceFinalTag) {
|
if (!cleanedText && !hasMedia && !ctx.params.enforceFinalTag) {
|
||||||
const rawTrimmed = rawText.trim();
|
const rawTrimmed = rawText.trim();
|
||||||
@ -301,28 +317,24 @@ export function handleMessageEnd(
|
|||||||
if (rawCandidate) {
|
if (rawCandidate) {
|
||||||
const parsedFallback = parseReplyDirectives(stripTrailingDirective(rawCandidate));
|
const parsedFallback = parseReplyDirectives(stripTrailingDirective(rawCandidate));
|
||||||
cleanedText = parsedFallback.text ?? rawCandidate;
|
cleanedText = parsedFallback.text ?? rawCandidate;
|
||||||
mediaUrls = parsedFallback.mediaUrls;
|
({ mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedFallback));
|
||||||
hasMedia = Boolean(mediaUrls && mediaUrls.length > 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.state.emittedAssistantUpdate && (cleanedText || hasMedia)) {
|
if (!ctx.state.emittedAssistantUpdate && (cleanedText || hasMedia)) {
|
||||||
|
const data = buildAssistantStreamData({
|
||||||
|
text: cleanedText,
|
||||||
|
delta: cleanedText,
|
||||||
|
mediaUrls,
|
||||||
|
});
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: ctx.params.runId,
|
runId: ctx.params.runId,
|
||||||
stream: "assistant",
|
stream: "assistant",
|
||||||
data: {
|
data,
|
||||||
text: cleanedText,
|
|
||||||
delta: cleanedText,
|
|
||||||
mediaUrls: hasMedia ? mediaUrls : undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
void ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "assistant",
|
stream: "assistant",
|
||||||
data: {
|
data,
|
||||||
text: cleanedText,
|
|
||||||
delta: cleanedText,
|
|
||||||
mediaUrls: hasMedia ? mediaUrls : undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
ctx.state.emittedAssistantUpdate = true;
|
ctx.state.emittedAssistantUpdate = true;
|
||||||
}
|
}
|
||||||
@ -377,7 +389,7 @@ export function handleMessageEnd(
|
|||||||
replyToCurrent,
|
replyToCurrent,
|
||||||
} = splitResult;
|
} = splitResult;
|
||||||
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
|
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
|
||||||
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
|
if (hasAssistantVisibleReply({ text: cleanedText, mediaUrls, audioAsVoice })) {
|
||||||
emitBlockReplySafely({
|
emitBlockReplySafely({
|
||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { hasOutboundReplyContent } from "../plugin-sdk/reply-payload.js";
|
||||||
import type { ReplyPayload } from "./types.js";
|
import type { ReplyPayload } from "./types.js";
|
||||||
|
|
||||||
export function resolveHeartbeatReplyPayload(
|
export function resolveHeartbeatReplyPayload(
|
||||||
@ -14,7 +15,7 @@ export function resolveHeartbeatReplyPayload(
|
|||||||
if (!payload) {
|
if (!payload) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0)) {
|
if (hasOutboundReplyContent(payload)) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
|
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
isMarkdownCapableMessageChannel,
|
isMarkdownCapableMessageChannel,
|
||||||
@ -148,6 +149,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
try {
|
try {
|
||||||
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
|
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
|
||||||
let text = payload.text;
|
let text = payload.text;
|
||||||
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||||
const stripped = stripHeartbeatToken(text, {
|
const stripped = stripHeartbeatToken(text, {
|
||||||
mode: "message",
|
mode: "message",
|
||||||
@ -156,7 +158,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
didLogHeartbeatStrip = true;
|
didLogHeartbeatStrip = true;
|
||||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||||
}
|
}
|
||||||
if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) {
|
if (stripped.shouldSkip && !reply.hasMedia) {
|
||||||
return { skip: true };
|
return { skip: true };
|
||||||
}
|
}
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
@ -172,7 +174,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
}
|
}
|
||||||
if (!text) {
|
if (!text) {
|
||||||
// Allow media-only payloads (e.g. tool result screenshots) through.
|
// Allow media-only payloads (e.g. tool result screenshots) through.
|
||||||
if ((payload.mediaUrls?.length ?? 0) > 0) {
|
if (reply.hasMedia) {
|
||||||
return { text: undefined, skip: false };
|
return { text: undefined, skip: false };
|
||||||
}
|
}
|
||||||
return { skip: true };
|
return { skip: true };
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { loadSessionStore } from "../../config/sessions.js";
|
import { loadSessionStore } from "../../config/sessions.js";
|
||||||
import { isAudioFileName } from "../../media/mime.js";
|
import { isAudioFileName } from "../../media/mime.js";
|
||||||
|
import {
|
||||||
|
hasOutboundReplyContent,
|
||||||
|
resolveSendableOutboundReplyParts,
|
||||||
|
} from "../../plugin-sdk/reply-payload.js";
|
||||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import { scheduleFollowupDrain } from "./queue.js";
|
import { scheduleFollowupDrain } from "./queue.js";
|
||||||
@ -9,7 +13,7 @@ const hasAudioMedia = (urls?: string[]): boolean =>
|
|||||||
Boolean(urls?.some((url) => isAudioFileName(url)));
|
Boolean(urls?.some((url) => isAudioFileName(url)));
|
||||||
|
|
||||||
export const isAudioPayload = (payload: ReplyPayload): boolean =>
|
export const isAudioPayload = (payload: ReplyPayload): boolean =>
|
||||||
hasAudioMedia(payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined));
|
hasAudioMedia(resolveSendableOutboundReplyParts(payload).mediaUrls);
|
||||||
|
|
||||||
type VerboseGateParams = {
|
type VerboseGateParams = {
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@ -63,19 +67,9 @@ export const signalTypingIfNeeded = async (
|
|||||||
payloads: ReplyPayload[],
|
payloads: ReplyPayload[],
|
||||||
typingSignals: TypingSignaler,
|
typingSignals: TypingSignaler,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const shouldSignalTyping = payloads.some((payload) => {
|
const shouldSignalTyping = payloads.some((payload) =>
|
||||||
const trimmed = payload.text?.trim();
|
hasOutboundReplyContent(payload, { trimText: true }),
|
||||||
if (trimmed) {
|
);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (payload.mediaUrl) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (shouldSignalTyping) {
|
if (shouldSignalTyping) {
|
||||||
await typingSignals.signalRunStart();
|
await typingSignals.signalRunStart();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ReplyToMode } from "../../config/types.js";
|
import type { ReplyToMode } from "../../config/types.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||||
import type { OriginatingChannelType } from "../templating.js";
|
import type { OriginatingChannelType } from "../templating.js";
|
||||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
@ -20,15 +21,11 @@ import {
|
|||||||
shouldSuppressMessagingToolReplies,
|
shouldSuppressMessagingToolReplies,
|
||||||
} from "./reply-payloads.js";
|
} from "./reply-payloads.js";
|
||||||
|
|
||||||
function hasPayloadMedia(payload: ReplyPayload): boolean {
|
|
||||||
return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function normalizeReplyPayloadMedia(params: {
|
async function normalizeReplyPayloadMedia(params: {
|
||||||
payload: ReplyPayload;
|
payload: ReplyPayload;
|
||||||
normalizeMediaPaths?: (payload: ReplyPayload) => Promise<ReplyPayload>;
|
normalizeMediaPaths?: (payload: ReplyPayload) => Promise<ReplyPayload>;
|
||||||
}): Promise<ReplyPayload> {
|
}): Promise<ReplyPayload> {
|
||||||
if (!params.normalizeMediaPaths || !hasPayloadMedia(params.payload)) {
|
if (!params.normalizeMediaPaths || !resolveSendableOutboundReplyParts(params.payload).hasMedia) {
|
||||||
return params.payload;
|
return params.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,11 +66,7 @@ async function normalizeSentMediaUrlsForDedupe(params: {
|
|||||||
mediaUrl: trimmed,
|
mediaUrl: trimmed,
|
||||||
mediaUrls: [trimmed],
|
mediaUrls: [trimmed],
|
||||||
});
|
});
|
||||||
const normalizedMediaUrls = normalized.mediaUrls?.length
|
const normalizedMediaUrls = resolveSendableOutboundReplyParts(normalized).mediaUrls;
|
||||||
? normalized.mediaUrls
|
|
||||||
: normalized.mediaUrl
|
|
||||||
? [normalized.mediaUrl]
|
|
||||||
: [];
|
|
||||||
for (const mediaUrl of normalizedMediaUrls) {
|
for (const mediaUrl of normalizedMediaUrls) {
|
||||||
const candidate = mediaUrl.trim();
|
const candidate = mediaUrl.trim();
|
||||||
if (!candidate || seen.has(candidate)) {
|
if (!candidate || seen.has(candidate)) {
|
||||||
@ -130,7 +123,7 @@ export async function buildReplyPayloads(params: {
|
|||||||
didLogHeartbeatStrip = true;
|
didLogHeartbeatStrip = true;
|
||||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||||
}
|
}
|
||||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||||
if (stripped.shouldSkip && !hasMedia) {
|
if (stripped.shouldSkip && !hasMedia) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import type { BlockStreamingCoalescing } from "./block-streaming.js";
|
import type { BlockStreamingCoalescing } from "./block-streaming.js";
|
||||||
|
|
||||||
@ -75,9 +76,10 @@ export function createBlockReplyCoalescer(params: {
|
|||||||
if (shouldAbort()) {
|
if (shouldAbort()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const text = payload.text ?? "";
|
const hasMedia = reply.hasMedia;
|
||||||
const hasText = text.trim().length > 0;
|
const text = reply.text;
|
||||||
|
const hasText = reply.hasText;
|
||||||
if (hasMedia) {
|
if (hasMedia) {
|
||||||
void flush({ force: true });
|
void flush({ force: true });
|
||||||
void onFlush(payload);
|
void onFlush(payload);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||||
import type { BlockStreamingCoalescing } from "./block-streaming.js";
|
import type { BlockStreamingCoalescing } from "./block-streaming.js";
|
||||||
@ -35,30 +36,20 @@ export function createAudioAsVoiceBuffer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBlockReplyPayloadKey(payload: ReplyPayload): string {
|
export function createBlockReplyPayloadKey(payload: ReplyPayload): string {
|
||||||
const text = payload.text?.trim() ?? "";
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const mediaList = payload.mediaUrls?.length
|
|
||||||
? payload.mediaUrls
|
|
||||||
: payload.mediaUrl
|
|
||||||
? [payload.mediaUrl]
|
|
||||||
: [];
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
text,
|
text: reply.trimmedText,
|
||||||
mediaList,
|
mediaList: reply.mediaUrls,
|
||||||
replyToId: payload.replyToId ?? null,
|
replyToId: payload.replyToId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlockReplyContentKey(payload: ReplyPayload): string {
|
export function createBlockReplyContentKey(payload: ReplyPayload): string {
|
||||||
const text = payload.text?.trim() ?? "";
|
const reply = resolveSendableOutboundReplyParts(payload);
|
||||||
const mediaList = payload.mediaUrls?.length
|
|
||||||
? payload.mediaUrls
|
|
||||||
: payload.mediaUrl
|
|
||||||
? [payload.mediaUrl]
|
|
||||||
: [];
|
|
||||||
// Content-only key used for final-payload suppression after block streaming.
|
// Content-only key used for final-payload suppression after block streaming.
|
||||||
// This intentionally ignores replyToId so a streamed threaded payload and the
|
// This intentionally ignores replyToId so a streamed threaded payload and the
|
||||||
// later final payload still collapse when they carry the same content.
|
// later final payload still collapse when they carry the same content.
|
||||||
return JSON.stringify({ text, mediaList });
|
return JSON.stringify({ text: reply.trimmedText, mediaList: reply.mediaUrls });
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTimeout = async <T>(
|
const withTimeout = async <T>(
|
||||||
@ -217,7 +208,7 @@ export function createBlockReplyPipeline(params: {
|
|||||||
if (bufferPayload(payload)) {
|
if (bufferPayload(payload)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||||
if (hasMedia) {
|
if (hasMedia) {
|
||||||
void coalescer?.flush({ force: true });
|
void coalescer?.flush({ force: true });
|
||||||
sendPayload(payload, /* bypassSeenCheck */ false);
|
sendPayload(payload, /* bypassSeenCheck */ false);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js";
|
|||||||
import type { TtsAutoMode } from "../../config/types.tts.js";
|
import type { TtsAutoMode } from "../../config/types.tts.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
|
import { runMessageAction } from "../../infra/outbound/message-action-runner.js";
|
||||||
|
import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js";
|
||||||
import { maybeApplyTtsToPayload } from "../../tts/tts.js";
|
import { maybeApplyTtsToPayload } from "../../tts/tts.js";
|
||||||
import type { FinalizedMsgContext } from "../templating.js";
|
import type { FinalizedMsgContext } from "../templating.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
@ -127,7 +128,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
|||||||
state.blockCount += 1;
|
state.blockCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((payload.text?.trim() ?? "").length > 0 || payload.mediaUrl || payload.mediaUrls?.length) {
|
if (hasOutboundReplyContent(payload, { trimText: true })) {
|
||||||
await startReplyLifecycleOnce();
|
await startReplyLifecycleOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
logMessageQueued,
|
logMessageQueued,
|
||||||
logSessionStateChange,
|
logSessionStateChange,
|
||||||
} from "../../logging/diagnostic.js";
|
} from "../../logging/diagnostic.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import {
|
import {
|
||||||
buildPluginBindingDeclinedText,
|
buildPluginBindingDeclinedText,
|
||||||
buildPluginBindingErrorText,
|
buildPluginBindingErrorText,
|
||||||
@ -532,7 +533,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
}
|
}
|
||||||
// Group/native flows intentionally suppress tool summary text, but media-only
|
// Group/native flows intentionally suppress tool summary text, but media-only
|
||||||
// tool results (for example TTS audio) must still be delivered.
|
// tool results (for example TTS audio) must still be delivered.
|
||||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||||
if (!hasMedia) {
|
if (!hasMedia) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import type { SessionEntry } from "../../config/sessions.js";
|
|||||||
import type { TypingMode } from "../../config/types.js";
|
import type { TypingMode } from "../../config/types.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
|
import {
|
||||||
|
hasOutboundReplyContent,
|
||||||
|
resolveSendableOutboundReplyParts,
|
||||||
|
} from "../../plugin-sdk/reply-payload.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||||
@ -81,13 +85,12 @@ export function createFollowupRunner(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const payload of payloads) {
|
for (const payload of payloads) {
|
||||||
if (!payload?.text && !payload?.mediaUrl && !payload?.mediaUrls?.length) {
|
if (!payload || !hasOutboundReplyContent(payload)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) &&
|
isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) &&
|
||||||
!payload.mediaUrl &&
|
!resolveSendableOutboundReplyParts(payload).hasMedia
|
||||||
!payload.mediaUrls?.length
|
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -289,7 +292,7 @@ export function createFollowupRunner(params: {
|
|||||||
return [payload];
|
return [payload];
|
||||||
}
|
}
|
||||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||||
if (stripped.shouldSkip && !hasMedia) {
|
if (stripped.shouldSkip && !hasMedia) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js";
|
import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js";
|
||||||
import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js";
|
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||||
import {
|
import {
|
||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
@ -32,17 +32,18 @@ export function normalizeReplyPayload(
|
|||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
opts: NormalizeReplyOptions = {},
|
opts: NormalizeReplyOptions = {},
|
||||||
): ReplyPayload | null {
|
): ReplyPayload | null {
|
||||||
const hasChannelData = hasReplyChannelData(payload.channelData);
|
const hasContent = (text: string | undefined) =>
|
||||||
|
hasReplyPayloadContent(
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trimText: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
const trimmed = payload.text?.trim() ?? "";
|
const trimmed = payload.text?.trim() ?? "";
|
||||||
if (
|
if (!hasContent(trimmed)) {
|
||||||
!hasReplyContent({
|
|
||||||
text: trimmed,
|
|
||||||
mediaUrl: payload.mediaUrl,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
interactive: payload.interactive,
|
|
||||||
hasChannelData,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
opts.onSkip?.("empty");
|
opts.onSkip?.("empty");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -50,14 +51,7 @@ export function normalizeReplyPayload(
|
|||||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||||
let text = payload.text ?? undefined;
|
let text = payload.text ?? undefined;
|
||||||
if (text && isSilentReplyText(text, silentToken)) {
|
if (text && isSilentReplyText(text, silentToken)) {
|
||||||
if (
|
if (!hasContent("")) {
|
||||||
!hasReplyContent({
|
|
||||||
mediaUrl: payload.mediaUrl,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
interactive: payload.interactive,
|
|
||||||
hasChannelData,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
opts.onSkip?.("silent");
|
opts.onSkip?.("silent");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -68,15 +62,7 @@ export function normalizeReplyPayload(
|
|||||||
// silent just like the exact-match path above. (#30916, #30955)
|
// silent just like the exact-match path above. (#30916, #30955)
|
||||||
if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) {
|
if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) {
|
||||||
text = stripSilentToken(text, silentToken);
|
text = stripSilentToken(text, silentToken);
|
||||||
if (
|
if (!hasContent(text)) {
|
||||||
!hasReplyContent({
|
|
||||||
text,
|
|
||||||
mediaUrl: payload.mediaUrl,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
interactive: payload.interactive,
|
|
||||||
hasChannelData,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
opts.onSkip?.("silent");
|
opts.onSkip?.("silent");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -92,16 +78,7 @@ export function normalizeReplyPayload(
|
|||||||
if (stripped.didStrip) {
|
if (stripped.didStrip) {
|
||||||
opts.onHeartbeatStrip?.();
|
opts.onHeartbeatStrip?.();
|
||||||
}
|
}
|
||||||
if (
|
if (stripped.shouldSkip && !hasContent(stripped.text)) {
|
||||||
stripped.shouldSkip &&
|
|
||||||
!hasReplyContent({
|
|
||||||
text: stripped.text,
|
|
||||||
mediaUrl: payload.mediaUrl,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
interactive: payload.interactive,
|
|
||||||
hasChannelData,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
opts.onSkip?.("heartbeat");
|
opts.onSkip?.("heartbeat");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -111,15 +88,7 @@ export function normalizeReplyPayload(
|
|||||||
if (text) {
|
if (text) {
|
||||||
text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) });
|
text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) });
|
||||||
}
|
}
|
||||||
if (
|
if (!hasContent(text)) {
|
||||||
!hasReplyContent({
|
|
||||||
text,
|
|
||||||
mediaUrl: payload.mediaUrl,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
interactive: payload.interactive,
|
|
||||||
hasChannelData,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
opts.onSkip?.("empty");
|
opts.onSkip?.("empty");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { BlockReplyContext, ReplyPayload } from "../types.js";
|
import type { BlockReplyContext, ReplyPayload } from "../types.js";
|
||||||
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||||
@ -57,9 +58,6 @@ export function normalizeReplyPayloadDirectives(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRenderableMedia = (payload: ReplyPayload): boolean =>
|
|
||||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
export function createBlockReplyDeliveryHandler(params: {
|
export function createBlockReplyDeliveryHandler(params: {
|
||||||
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => Promise<void> | void;
|
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => Promise<void> | void;
|
||||||
currentMessageId?: string;
|
currentMessageId?: string;
|
||||||
@ -73,7 +71,7 @@ export function createBlockReplyDeliveryHandler(params: {
|
|||||||
}): (payload: ReplyPayload) => Promise<void> {
|
}): (payload: ReplyPayload) => Promise<void> {
|
||||||
return async (payload) => {
|
return async (payload) => {
|
||||||
const { text, skip } = params.normalizeStreamingText(payload);
|
const { text, skip } = params.normalizeStreamingText(payload);
|
||||||
if (skip && !hasRenderableMedia(payload)) {
|
if (skip && !resolveSendableOutboundReplyParts(payload).hasMedia) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +104,7 @@ export function createBlockReplyDeliveryHandler(params: {
|
|||||||
? await params.normalizeMediaPaths(normalized.payload)
|
? await params.normalizeMediaPaths(normalized.payload)
|
||||||
: normalized.payload;
|
: normalized.payload;
|
||||||
const blockPayload = params.applyReplyToMode(mediaNormalizedPayload);
|
const blockPayload = params.applyReplyToMode(mediaNormalizedPayload);
|
||||||
const blockHasMedia = hasRenderableMedia(blockPayload);
|
const blockHasMedia = resolveSendableOutboundReplyParts(blockPayload).hasMedia;
|
||||||
|
|
||||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it).
|
// Skip empty payloads unless they have audioAsVoice flag (need to track it).
|
||||||
if (!blockPayload.text && !blockHasMedia && !blockPayload.audioAsVoice) {
|
if (!blockPayload.text && !blockHasMedia && !blockPayload.audioAsVoice) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { resolvePathFromInput } from "../../agents/path-policy.js";
|
|||||||
import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js";
|
import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js";
|
||||||
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
|
||||||
const HTTP_URL_RE = /^https?:\/\//i;
|
const HTTP_URL_RE = /^https?:\/\//i;
|
||||||
@ -25,7 +26,7 @@ function isLikelyLocalMediaSource(media: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPayloadMediaList(payload: ReplyPayload): string[] {
|
function getPayloadMediaList(payload: ReplyPayload): string[] {
|
||||||
return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
|
return resolveSendableOutboundReplyParts(payload).mediaUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReplyMediaPathNormalizer(params: {
|
export function createReplyMediaPathNormalizer(params: {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { normalizeChannelId } from "../../channels/plugins/index.js";
|
|||||||
import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js";
|
import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js";
|
||||||
import type { ReplyToMode } from "../../config/types.js";
|
import type { ReplyToMode } from "../../config/types.js";
|
||||||
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
||||||
import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js";
|
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||||
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
||||||
import type { OriginatingChannelType } from "../templating.js";
|
import type { OriginatingChannelType } from "../templating.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
@ -75,14 +75,7 @@ export function applyReplyTagsToPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||||
return hasReplyContent({
|
return hasReplyPayloadContent(payload, { extraContent: payload.audioAsVoice });
|
||||||
text: payload.text,
|
|
||||||
mediaUrl: payload.mediaUrl,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
interactive: payload.interactive,
|
|
||||||
hasChannelData: hasReplyChannelData(payload.channelData),
|
|
||||||
extraContent: payload.audioAsVoice,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean {
|
export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
|||||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
||||||
import { hasReplyContent } from "../../interactive/payload.js";
|
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||||
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
|
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||||
import type { OriginatingChannelType } from "../templating.js";
|
import type { OriginatingChannelType } from "../templating.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
@ -126,12 +126,16 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
|||||||
|
|
||||||
// Skip empty replies.
|
// Skip empty replies.
|
||||||
if (
|
if (
|
||||||
!hasReplyContent({
|
!hasReplyPayloadContent(
|
||||||
text,
|
{
|
||||||
mediaUrls,
|
...externalPayload,
|
||||||
interactive: externalPayload.interactive,
|
text,
|
||||||
hasChannelData,
|
mediaUrls,
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
hasChannelData,
|
||||||
|
},
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { splitMediaFromOutput } from "../../media/parse.js";
|
import { splitMediaFromOutput } from "../../media/parse.js";
|
||||||
|
import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js";
|
||||||
import { parseInlineDirectives } from "../../utils/directive-tags.js";
|
import { parseInlineDirectives } from "../../utils/directive-tags.js";
|
||||||
import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { ReplyDirectiveParseResult } from "./reply-directives.js";
|
import type { ReplyDirectiveParseResult } from "./reply-directives.js";
|
||||||
@ -67,10 +68,7 @@ const parseChunk = (raw: string, options?: { silentToken?: string }): ParsedChun
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasRenderableContent = (parsed: ReplyDirectiveParseResult): boolean =>
|
const hasRenderableContent = (parsed: ReplyDirectiveParseResult): boolean =>
|
||||||
Boolean(parsed.text) ||
|
hasOutboundReplyContent(parsed) || Boolean(parsed.audioAsVoice);
|
||||||
Boolean(parsed.mediaUrl) ||
|
|
||||||
(parsed.mediaUrls?.length ?? 0) > 0 ||
|
|
||||||
Boolean(parsed.audioAsVoice);
|
|
||||||
|
|
||||||
export function createStreamingDirectiveAccumulator() {
|
export function createStreamingDirectiveAccumulator() {
|
||||||
let pendingTail = "";
|
let pendingTail = "";
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { chunkText } from "../../../auto-reply/chunk.js";
|
import { chunkText } from "../../../auto-reply/chunk.js";
|
||||||
import type { OpenClawConfig } from "../../../config/config.js";
|
import type { OpenClawConfig } from "../../../config/config.js";
|
||||||
import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js";
|
import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js";
|
||||||
|
import { resolveOutboundMediaUrls } from "../../../plugin-sdk/reply-payload.js";
|
||||||
import { resolveChannelMediaMaxBytes } from "../media-limits.js";
|
import { resolveChannelMediaMaxBytes } from "../media-limits.js";
|
||||||
import type { ChannelOutboundAdapter } from "../types.js";
|
import type { ChannelOutboundAdapter } from "../types.js";
|
||||||
|
|
||||||
@ -29,7 +30,7 @@ type SendPayloadAdapter = Pick<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] {
|
export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] {
|
||||||
return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
|
return resolveOutboundMediaUrls(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPayloadMediaSequence<TResult>(params: {
|
export async function sendPayloadMediaSequence<TResult>(params: {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { CliDeps } from "../cli/deps.js";
|
|||||||
import { withProgress } from "../cli/progress.js";
|
import { withProgress } from "../cli/progress.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
@ -69,16 +70,16 @@ function formatPayloadForLog(payload: {
|
|||||||
mediaUrls?: string[];
|
mediaUrls?: string[];
|
||||||
mediaUrl?: string | null;
|
mediaUrl?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const parts = resolveSendableOutboundReplyParts({
|
||||||
|
text: payload.text,
|
||||||
|
mediaUrls: payload.mediaUrls,
|
||||||
|
mediaUrl: typeof payload.mediaUrl === "string" ? payload.mediaUrl : undefined,
|
||||||
|
});
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (payload.text) {
|
if (parts.text) {
|
||||||
lines.push(payload.text.trimEnd());
|
lines.push(parts.text.trimEnd());
|
||||||
}
|
}
|
||||||
const mediaUrl =
|
for (const url of parts.mediaUrls) {
|
||||||
typeof payload.mediaUrl === "string" && payload.mediaUrl.trim()
|
|
||||||
? payload.mediaUrl.trim()
|
|
||||||
: undefined;
|
|
||||||
const media = payload.mediaUrls ?? (mediaUrl ? [mediaUrl] : []);
|
|
||||||
for (const url of media) {
|
|
||||||
lines.push(`MEDIA:${url}`);
|
lines.push(`MEDIA:${url}`);
|
||||||
}
|
}
|
||||||
return lines.join("\n").trimEnd();
|
return lines.join("\n").trimEnd();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { stripHeartbeatToken } from "../auto-reply/heartbeat.js";
|
import { stripHeartbeatToken } from "../auto-reply/heartbeat.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js";
|
||||||
|
|
||||||
export type HeartbeatDeliveryPayload = {
|
export type HeartbeatDeliveryPayload = {
|
||||||
text?: string;
|
text?: string;
|
||||||
@ -14,7 +15,7 @@ export function shouldSkipHeartbeatOnlyDelivery(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const hasAnyMedia = payloads.some(
|
const hasAnyMedia = payloads.some(
|
||||||
(payload) => (payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl),
|
(payload) => resolveSendableOutboundReplyParts(payload).hasMedia,
|
||||||
);
|
);
|
||||||
if (hasAnyMedia) {
|
if (hasAnyMedia) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../../auto-reply/heartbeat.js";
|
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../../auto-reply/heartbeat.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
|
import { hasOutboundReplyContent } from "../../plugin-sdk/reply-payload.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { shouldSkipHeartbeatOnlyDelivery } from "../heartbeat-policy.js";
|
import { shouldSkipHeartbeatOnlyDelivery } from "../heartbeat-policy.js";
|
||||||
|
|
||||||
@ -61,11 +62,9 @@ export function pickLastNonEmptyTextFromPayloads(
|
|||||||
|
|
||||||
export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) {
|
export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) {
|
||||||
const isDeliverable = (p: DeliveryPayload) => {
|
const isDeliverable = (p: DeliveryPayload) => {
|
||||||
const text = (p?.text ?? "").trim();
|
|
||||||
const hasMedia = Boolean(p?.mediaUrl) || (p?.mediaUrls?.length ?? 0) > 0;
|
|
||||||
const hasInteractive = (p?.interactive?.blocks?.length ?? 0) > 0;
|
const hasInteractive = (p?.interactive?.blocks?.length ?? 0) > 0;
|
||||||
const hasChannelData = Object.keys(p?.channelData ?? {}).length > 0;
|
const hasChannelData = Object.keys(p?.channelData ?? {}).length > 0;
|
||||||
return text || hasMedia || hasInteractive || hasChannelData;
|
return hasOutboundReplyContent(p, { trimText: true }) || hasInteractive || hasChannelData;
|
||||||
};
|
};
|
||||||
for (let i = payloads.length - 1; i >= 0; i--) {
|
for (let i = payloads.length - 1; i >= 0; i--) {
|
||||||
if (payloads[i]?.isError) {
|
if (payloads[i]?.isError) {
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import {
|
|||||||
import type { AgentDefaultsConfig } from "../../config/types.js";
|
import type { AgentDefaultsConfig } from "../../config/types.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { logWarn } from "../../logger.js";
|
import { logWarn } from "../../logger.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
buildSafeExternalPrompt,
|
buildSafeExternalPrompt,
|
||||||
@ -687,9 +688,9 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
const interimPayloads = interimRunResult.payloads ?? [];
|
const interimPayloads = interimRunResult.payloads ?? [];
|
||||||
const interimDeliveryPayload = pickLastDeliverablePayload(interimPayloads);
|
const interimDeliveryPayload = pickLastDeliverablePayload(interimPayloads);
|
||||||
const interimPayloadHasStructuredContent =
|
const interimPayloadHasStructuredContent =
|
||||||
Boolean(interimDeliveryPayload?.mediaUrl) ||
|
(interimDeliveryPayload
|
||||||
(interimDeliveryPayload?.mediaUrls?.length ?? 0) > 0 ||
|
? resolveSendableOutboundReplyParts(interimDeliveryPayload).hasMedia
|
||||||
Object.keys(interimDeliveryPayload?.channelData ?? {}).length > 0;
|
: false) || Object.keys(interimDeliveryPayload?.channelData ?? {}).length > 0;
|
||||||
const interimText = pickLastNonEmptyTextFromPayloads(interimPayloads)?.trim() ?? "";
|
const interimText = pickLastNonEmptyTextFromPayloads(interimPayloads)?.trim() ?? "";
|
||||||
const hasDescendantsSinceRunStart = listDescendantRunsForRequester(agentSessionKey).some(
|
const hasDescendantsSinceRunStart = listDescendantRunsForRequester(agentSessionKey).some(
|
||||||
(entry) => {
|
(entry) => {
|
||||||
@ -809,8 +810,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
? [{ text: synthesizedText }]
|
? [{ text: synthesizedText }]
|
||||||
: [];
|
: [];
|
||||||
const deliveryPayloadHasStructuredContent =
|
const deliveryPayloadHasStructuredContent =
|
||||||
Boolean(deliveryPayload?.mediaUrl) ||
|
(deliveryPayload ? resolveSendableOutboundReplyParts(deliveryPayload).hasMedia : false) ||
|
||||||
(deliveryPayload?.mediaUrls?.length ?? 0) > 0 ||
|
|
||||||
Object.keys(deliveryPayload?.channelData ?? {}).length > 0;
|
Object.keys(deliveryPayload?.channelData ?? {}).length > 0;
|
||||||
const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job);
|
const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job);
|
||||||
const hasErrorPayload = payloads.some((payload) => payload?.isError === true);
|
const hasErrorPayload = payloads.some((payload) => payload?.isError === true);
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads
|
|||||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
||||||
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
|
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
|
||||||
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
||||||
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import { normalizePollInput } from "../../polls.js";
|
import { normalizePollInput } from "../../polls.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@ -211,8 +211,8 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
.map((payload) => payload.text)
|
.map((payload) => payload.text)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const mirrorMediaUrls = mirrorPayloads.flatMap((payload) =>
|
const mirrorMediaUrls = mirrorPayloads.flatMap(
|
||||||
resolveOutboundMediaUrls(payload),
|
(payload) => resolveSendableOutboundReplyParts(payload).mediaUrls,
|
||||||
);
|
);
|
||||||
const providedSessionKey =
|
const providedSessionKey =
|
||||||
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { isVerbose } from "../globals.js";
|
|||||||
import { shouldLogSubsystemToConsole } from "../logging/console.js";
|
import { shouldLogSubsystemToConsole } from "../logging/console.js";
|
||||||
import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js";
|
import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js";
|
||||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||||
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
||||||
|
|
||||||
@ -204,9 +205,11 @@ export function summarizeAgentEventForWsLog(payload: unknown): Record<string, un
|
|||||||
if (text?.trim()) {
|
if (text?.trim()) {
|
||||||
extra.text = compactPreview(text);
|
extra.text = compactPreview(text);
|
||||||
}
|
}
|
||||||
const mediaUrls = Array.isArray(data.mediaUrls) ? data.mediaUrls : undefined;
|
const mediaCount = resolveSendableOutboundReplyParts({
|
||||||
if (mediaUrls && mediaUrls.length > 0) {
|
mediaUrls: Array.isArray(data.mediaUrls) ? data.mediaUrls : undefined,
|
||||||
extra.media = mediaUrls.length;
|
}).mediaCount;
|
||||||
|
if (mediaCount > 0) {
|
||||||
|
extra.media = mediaCount;
|
||||||
}
|
}
|
||||||
return extra;
|
return extra;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,10 @@ import {
|
|||||||
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
||||||
import { resolveCronSession } from "../cron/isolated-agent/session.js";
|
import { resolveCronSession } from "../cron/isolated-agent/session.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
import {
|
||||||
|
hasOutboundReplyContent,
|
||||||
|
resolveSendableOutboundReplyParts,
|
||||||
|
} from "../plugin-sdk/reply-payload.js";
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
import { CommandLane } from "../process/lanes.js";
|
import { CommandLane } from "../process/lanes.js";
|
||||||
import {
|
import {
|
||||||
@ -368,7 +372,7 @@ function normalizeHeartbeatReply(
|
|||||||
mode: "heartbeat",
|
mode: "heartbeat",
|
||||||
maxAckChars: ackMaxChars,
|
maxAckChars: ackMaxChars,
|
||||||
});
|
});
|
||||||
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||||
if (stripped.shouldSkip && !hasMedia) {
|
if (stripped.shouldSkip && !hasMedia) {
|
||||||
return {
|
return {
|
||||||
shouldSkip: true,
|
shouldSkip: true,
|
||||||
@ -720,10 +724,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
|
? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (
|
if (!replyPayload || !hasOutboundReplyContent(replyPayload)) {
|
||||||
!replyPayload ||
|
|
||||||
(!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length)
|
|
||||||
) {
|
|
||||||
await restoreHeartbeatUpdatedAt({
|
await restoreHeartbeatUpdatedAt({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@ -780,8 +781,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaUrls =
|
const mediaUrls = resolveSendableOutboundReplyParts(replyPayload).mediaUrls;
|
||||||
replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
|
|
||||||
|
|
||||||
// Suppress duplicate heartbeats (same payload) within a short window.
|
// Suppress duplicate heartbeats (same payload) within a short window.
|
||||||
// This prevents "nagging" when nothing changed but the model repeats the same items.
|
// This prevents "nagging" when nothing changed but the model repeats the same items.
|
||||||
|
|||||||
@ -23,11 +23,11 @@ import {
|
|||||||
toPluginMessageContext,
|
toPluginMessageContext,
|
||||||
toPluginMessageSentEvent,
|
toPluginMessageSentEvent,
|
||||||
} from "../../hooks/message-hook-mappers.js";
|
} from "../../hooks/message-hook-mappers.js";
|
||||||
import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js";
|
import { hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||||
import {
|
import {
|
||||||
resolveOutboundMediaUrls,
|
resolveSendableOutboundReplyParts,
|
||||||
sendMediaWithLeadingCaption,
|
sendMediaWithLeadingCaption,
|
||||||
} from "../../plugin-sdk/reply-payload.js";
|
} from "../../plugin-sdk/reply-payload.js";
|
||||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||||
@ -284,17 +284,8 @@ type MessageSentEvent = {
|
|||||||
|
|
||||||
function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload | null {
|
function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload | null {
|
||||||
const text = typeof payload.text === "string" ? payload.text : "";
|
const text = typeof payload.text === "string" ? payload.text : "";
|
||||||
const hasChannelData = hasReplyChannelData(payload.channelData);
|
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
if (
|
if (!hasReplyPayloadContent({ ...payload, text })) {
|
||||||
!hasReplyContent({
|
|
||||||
text,
|
|
||||||
mediaUrl: payload.mediaUrl,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
interactive: payload.interactive,
|
|
||||||
hasChannelData,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (text) {
|
if (text) {
|
||||||
@ -340,9 +331,10 @@ function normalizePayloadsForChannelDelivery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
|
function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
|
||||||
|
const parts = resolveSendableOutboundReplyParts(payload);
|
||||||
return {
|
return {
|
||||||
text: payload.text ?? "",
|
text: parts.text,
|
||||||
mediaUrls: resolveOutboundMediaUrls(payload),
|
mediaUrls: parts.mediaUrls,
|
||||||
interactive: payload.interactive,
|
interactive: payload.interactive,
|
||||||
channelData: payload.channelData,
|
channelData: payload.channelData,
|
||||||
};
|
};
|
||||||
@ -669,10 +661,10 @@ async function deliverOutboundPayloadsCore(
|
|||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
handler.sendPayload &&
|
handler.sendPayload &&
|
||||||
(effectivePayload.channelData ||
|
hasReplyPayloadContent({
|
||||||
hasReplyContent({
|
interactive: effectivePayload.interactive,
|
||||||
interactive: effectivePayload.interactive,
|
channelData: effectivePayload.channelData,
|
||||||
}))
|
})
|
||||||
) {
|
) {
|
||||||
const delivery = await handler.sendPayload(effectivePayload, sendOverrides);
|
const delivery = await handler.sendPayload(effectivePayload, sendOverrides);
|
||||||
results.push(delivery);
|
results.push(delivery);
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import type {
|
|||||||
ChannelThreadingToolContext,
|
ChannelThreadingToolContext,
|
||||||
} from "../../channels/plugins/types.js";
|
} from "../../channels/plugins/types.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { hasInteractiveReplyBlocks, hasReplyContent } from "../../interactive/payload.js";
|
import { hasInteractiveReplyBlocks, hasReplyPayloadContent } from "../../interactive/payload.js";
|
||||||
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||||
import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js";
|
import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js";
|
||||||
import { resolvePollMaxSelections } from "../../polls.js";
|
import { resolvePollMaxSelections } from "../../polls.js";
|
||||||
@ -484,13 +484,17 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!hasReplyContent({
|
!hasReplyPayloadContent(
|
||||||
text: message,
|
{
|
||||||
mediaUrl,
|
text: message,
|
||||||
mediaUrls: mergedMediaUrls,
|
mediaUrl,
|
||||||
interactive: params.interactive,
|
mediaUrls: mergedMediaUrls,
|
||||||
extraContent: hasButtons || hasCard || hasComponents || hasBlocks,
|
interactive: params.interactive,
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
extraContent: hasButtons || hasCard || hasComponents || hasBlocks,
|
||||||
|
},
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error("send requires text or media");
|
throw new Error("send requires text or media");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js";
|
import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js";
|
||||||
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
import type { PollInput } from "../../polls.js";
|
import type { PollInput } from "../../polls.js";
|
||||||
import { normalizePollInput } from "../../polls.js";
|
import { normalizePollInput } from "../../polls.js";
|
||||||
import {
|
import {
|
||||||
@ -203,8 +203,8 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
|||||||
.map((payload) => payload.text)
|
.map((payload) => payload.text)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const mirrorMediaUrls = normalizedPayloads.flatMap((payload) =>
|
const mirrorMediaUrls = normalizedPayloads.flatMap(
|
||||||
resolveOutboundMediaUrls(payload),
|
(payload) => resolveSendableOutboundReplyParts(payload).mediaUrls,
|
||||||
);
|
);
|
||||||
const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null;
|
const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null;
|
||||||
|
|
||||||
|
|||||||
@ -8,10 +8,10 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
|
|||||||
import {
|
import {
|
||||||
hasInteractiveReplyBlocks,
|
hasInteractiveReplyBlocks,
|
||||||
hasReplyChannelData,
|
hasReplyChannelData,
|
||||||
hasReplyContent,
|
hasReplyPayloadContent,
|
||||||
type InteractiveReply,
|
type InteractiveReply,
|
||||||
} from "../../interactive/payload.js";
|
} from "../../interactive/payload.js";
|
||||||
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
||||||
|
|
||||||
export type NormalizedOutboundPayload = {
|
export type NormalizedOutboundPayload = {
|
||||||
text: string;
|
text: string;
|
||||||
@ -97,25 +97,20 @@ export function normalizeOutboundPayloads(
|
|||||||
): NormalizedOutboundPayload[] {
|
): NormalizedOutboundPayload[] {
|
||||||
const normalizedPayloads: NormalizedOutboundPayload[] = [];
|
const normalizedPayloads: NormalizedOutboundPayload[] = [];
|
||||||
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
||||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
const parts = resolveSendableOutboundReplyParts(payload);
|
||||||
const interactive = payload.interactive;
|
const interactive = payload.interactive;
|
||||||
const channelData = payload.channelData;
|
const channelData = payload.channelData;
|
||||||
const hasChannelData = hasReplyChannelData(channelData);
|
const hasChannelData = hasReplyChannelData(channelData);
|
||||||
const hasInteractive = hasInteractiveReplyBlocks(interactive);
|
const hasInteractive = hasInteractiveReplyBlocks(interactive);
|
||||||
const text = payload.text ?? "";
|
const text = parts.text;
|
||||||
if (
|
if (
|
||||||
!hasReplyContent({
|
!hasReplyPayloadContent({ ...payload, text, mediaUrls: parts.mediaUrls }, { hasChannelData })
|
||||||
text,
|
|
||||||
mediaUrls,
|
|
||||||
interactive,
|
|
||||||
hasChannelData,
|
|
||||||
})
|
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
normalizedPayloads.push({
|
normalizedPayloads.push({
|
||||||
text,
|
text,
|
||||||
mediaUrls,
|
mediaUrls: parts.mediaUrls,
|
||||||
...(hasInteractive ? { interactive } : {}),
|
...(hasInteractive ? { interactive } : {}),
|
||||||
...(hasChannelData ? { channelData } : {}),
|
...(hasChannelData ? { channelData } : {}),
|
||||||
});
|
});
|
||||||
@ -128,11 +123,11 @@ export function normalizeOutboundPayloadsForJson(
|
|||||||
): OutboundPayloadJson[] {
|
): OutboundPayloadJson[] {
|
||||||
const normalized: OutboundPayloadJson[] = [];
|
const normalized: OutboundPayloadJson[] = [];
|
||||||
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
||||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
const parts = resolveSendableOutboundReplyParts(payload);
|
||||||
normalized.push({
|
normalized.push({
|
||||||
text: payload.text ?? "",
|
text: parts.text,
|
||||||
mediaUrl: payload.mediaUrl ?? null,
|
mediaUrl: payload.mediaUrl ?? null,
|
||||||
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
|
mediaUrls: parts.mediaUrls.length ? parts.mediaUrls : undefined,
|
||||||
interactive: payload.interactive,
|
interactive: payload.interactive,
|
||||||
channelData: payload.channelData,
|
channelData: payload.channelData,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
hasReplyChannelData,
|
hasReplyChannelData,
|
||||||
hasReplyContent,
|
hasReplyContent,
|
||||||
|
hasReplyPayloadContent,
|
||||||
normalizeInteractiveReply,
|
normalizeInteractiveReply,
|
||||||
resolveInteractiveTextFallback,
|
resolveInteractiveTextFallback,
|
||||||
} from "./payload.js";
|
} from "./payload.js";
|
||||||
@ -44,6 +45,41 @@ describe("hasReplyContent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hasReplyPayloadContent", () => {
|
||||||
|
it("trims text and falls back to channel data by default", () => {
|
||||||
|
expect(
|
||||||
|
hasReplyPayloadContent({
|
||||||
|
text: " ",
|
||||||
|
channelData: { slack: { blocks: [] } },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts explicit channel-data overrides and extra content", () => {
|
||||||
|
expect(
|
||||||
|
hasReplyPayloadContent(
|
||||||
|
{
|
||||||
|
text: " ",
|
||||||
|
channelData: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hasChannelData: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
hasReplyPayloadContent(
|
||||||
|
{
|
||||||
|
text: " ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extraContent: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("interactive payload helpers", () => {
|
describe("interactive payload helpers", () => {
|
||||||
it("normalizes interactive replies and resolves text fallbacks", () => {
|
it("normalizes interactive replies and resolves text fallbacks", () => {
|
||||||
const interactive = normalizeInteractiveReply({
|
const interactive = normalizeInteractiveReply({
|
||||||
|
|||||||
@ -160,6 +160,30 @@ export function hasReplyContent(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasReplyPayloadContent(
|
||||||
|
payload: {
|
||||||
|
text?: string | null;
|
||||||
|
mediaUrl?: string | null;
|
||||||
|
mediaUrls?: ReadonlyArray<string | null | undefined>;
|
||||||
|
interactive?: unknown;
|
||||||
|
channelData?: unknown;
|
||||||
|
},
|
||||||
|
options?: {
|
||||||
|
trimText?: boolean;
|
||||||
|
hasChannelData?: boolean;
|
||||||
|
extraContent?: boolean;
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
return hasReplyContent({
|
||||||
|
text: options?.trimText ? payload.text?.trim() : payload.text,
|
||||||
|
mediaUrl: payload.mediaUrl,
|
||||||
|
mediaUrls: payload.mediaUrls,
|
||||||
|
interactive: payload.interactive,
|
||||||
|
hasChannelData: options?.hasChannelData ?? hasReplyChannelData(payload.channelData),
|
||||||
|
extraContent: options?.extraContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInteractiveTextFallback(params: {
|
export function resolveInteractiveTextFallback(params: {
|
||||||
text?: string;
|
text?: string;
|
||||||
interactive?: InteractiveReply;
|
interactive?: InteractiveReply;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { messagingApi } from "@line/bot-sdk";
|
import type { messagingApi } from "@line/bot-sdk";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import { resolveOutboundMediaUrls } from "../plugin-sdk/reply-payload.js";
|
import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js";
|
||||||
import type { FlexContainer } from "./flex-templates.js";
|
import type { FlexContainer } from "./flex-templates.js";
|
||||||
import type { ProcessedLineMessage } from "./markdown-to-line.js";
|
import type { ProcessedLineMessage } from "./markdown-to-line.js";
|
||||||
import type { SendLineReplyChunksParams } from "./reply-chunks.js";
|
import type { SendLineReplyChunksParams } from "./reply-chunks.js";
|
||||||
@ -124,7 +124,7 @@ export async function deliverLineAutoReply(params: {
|
|||||||
|
|
||||||
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
|
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
|
||||||
|
|
||||||
const mediaUrls = resolveOutboundMediaUrls(payload);
|
const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
|
||||||
const mediaMessages = mediaUrls
|
const mediaMessages = mediaUrls
|
||||||
.map((url) => url?.trim())
|
.map((url) => url?.trim())
|
||||||
.filter((url): url is string => Boolean(url))
|
.filter((url): url is string => Boolean(url))
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export {
|
|||||||
splitSetupEntries,
|
splitSetupEntries,
|
||||||
} from "../channels/plugins/setup-wizard-helpers.js";
|
} from "../channels/plugins/setup-wizard-helpers.js";
|
||||||
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
|
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
|
||||||
export { resolveOutboundMediaUrls } from "./reply-payload.js";
|
export { resolveOutboundMediaUrls, resolveSendableOutboundReplyParts } from "./reply-payload.js";
|
||||||
export type {
|
export type {
|
||||||
BaseProbeResult,
|
BaseProbeResult,
|
||||||
ChannelDirectoryEntry,
|
ChannelDirectoryEntry,
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
countOutboundMedia,
|
||||||
deliverFormattedTextWithAttachments,
|
deliverFormattedTextWithAttachments,
|
||||||
deliverTextOrMediaReply,
|
deliverTextOrMediaReply,
|
||||||
|
hasOutboundMedia,
|
||||||
|
hasOutboundReplyContent,
|
||||||
|
hasOutboundText,
|
||||||
isNumericTargetId,
|
isNumericTargetId,
|
||||||
resolveOutboundMediaUrls,
|
resolveOutboundMediaUrls,
|
||||||
|
resolveSendableOutboundReplyParts,
|
||||||
resolveTextChunksWithFallback,
|
resolveTextChunksWithFallback,
|
||||||
sendMediaWithLeadingCaption,
|
sendMediaWithLeadingCaption,
|
||||||
sendPayloadWithChunkedTextAndMedia,
|
sendPayloadWithChunkedTextAndMedia,
|
||||||
@ -84,6 +89,102 @@ describe("resolveOutboundMediaUrls", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("countOutboundMedia", () => {
|
||||||
|
it("counts normalized media entries", () => {
|
||||||
|
expect(
|
||||||
|
countOutboundMedia({
|
||||||
|
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||||
|
}),
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts legacy single-media payloads", () => {
|
||||||
|
expect(
|
||||||
|
countOutboundMedia({
|
||||||
|
mediaUrl: "https://example.com/legacy.png",
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasOutboundMedia", () => {
|
||||||
|
it("reports whether normalized payloads include media", () => {
|
||||||
|
expect(hasOutboundMedia({ mediaUrls: ["https://example.com/a.png"] })).toBe(true);
|
||||||
|
expect(hasOutboundMedia({ mediaUrl: "https://example.com/legacy.png" })).toBe(true);
|
||||||
|
expect(hasOutboundMedia({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasOutboundText", () => {
|
||||||
|
it("checks raw text presence by default", () => {
|
||||||
|
expect(hasOutboundText({ text: "hello" })).toBe(true);
|
||||||
|
expect(hasOutboundText({ text: " " })).toBe(true);
|
||||||
|
expect(hasOutboundText({})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can trim whitespace-only text", () => {
|
||||||
|
expect(hasOutboundText({ text: " " }, { trim: true })).toBe(false);
|
||||||
|
expect(hasOutboundText({ text: " hi " }, { trim: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasOutboundReplyContent", () => {
|
||||||
|
it("detects text or media content", () => {
|
||||||
|
expect(hasOutboundReplyContent({ text: "hello" })).toBe(true);
|
||||||
|
expect(hasOutboundReplyContent({ mediaUrl: "https://example.com/a.png" })).toBe(true);
|
||||||
|
expect(hasOutboundReplyContent({})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can ignore whitespace-only text unless media exists", () => {
|
||||||
|
expect(hasOutboundReplyContent({ text: " " }, { trimText: true })).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasOutboundReplyContent(
|
||||||
|
{ text: " ", mediaUrls: ["https://example.com/a.png"] },
|
||||||
|
{ trimText: true },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSendableOutboundReplyParts", () => {
|
||||||
|
it("normalizes missing text and trims media urls", () => {
|
||||||
|
expect(
|
||||||
|
resolveSendableOutboundReplyParts({
|
||||||
|
mediaUrls: [" https://example.com/a.png ", " "],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
text: "",
|
||||||
|
trimmedText: "",
|
||||||
|
mediaUrls: ["https://example.com/a.png"],
|
||||||
|
mediaCount: 1,
|
||||||
|
hasText: false,
|
||||||
|
hasMedia: true,
|
||||||
|
hasContent: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts transformed text overrides", () => {
|
||||||
|
expect(
|
||||||
|
resolveSendableOutboundReplyParts(
|
||||||
|
{
|
||||||
|
text: "ignored",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: " hello ",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
text: " hello ",
|
||||||
|
trimmedText: "hello",
|
||||||
|
mediaUrls: [],
|
||||||
|
mediaCount: 0,
|
||||||
|
hasText: true,
|
||||||
|
hasMedia: false,
|
||||||
|
hasContent: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveTextChunksWithFallback", () => {
|
describe("resolveTextChunksWithFallback", () => {
|
||||||
it("returns existing chunks unchanged", () => {
|
it("returns existing chunks unchanged", () => {
|
||||||
expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]);
|
expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]);
|
||||||
@ -161,6 +262,26 @@ describe("deliverTextOrMediaReply", () => {
|
|||||||
expect(sendText).not.toHaveBeenCalled();
|
expect(sendText).not.toHaveBeenCalled();
|
||||||
expect(sendMedia).not.toHaveBeenCalled();
|
expect(sendMedia).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores blank media urls before sending", async () => {
|
||||||
|
const sendMedia = vi.fn(async () => undefined);
|
||||||
|
const sendText = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
deliverTextOrMediaReply({
|
||||||
|
payload: { text: "hello", mediaUrls: [" ", " https://a "] },
|
||||||
|
text: "hello",
|
||||||
|
sendText,
|
||||||
|
sendMedia,
|
||||||
|
}),
|
||||||
|
).resolves.toBe("media");
|
||||||
|
|
||||||
|
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMedia).toHaveBeenCalledWith({
|
||||||
|
mediaUrl: "https://a",
|
||||||
|
caption: "hello",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMediaWithLeadingCaption", () => {
|
describe("sendMediaWithLeadingCaption", () => {
|
||||||
|
|||||||
@ -5,6 +5,16 @@ export type OutboundReplyPayload = {
|
|||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SendableOutboundReplyParts = {
|
||||||
|
text: string;
|
||||||
|
trimmedText: string;
|
||||||
|
mediaUrls: string[];
|
||||||
|
mediaCount: number;
|
||||||
|
hasText: boolean;
|
||||||
|
hasMedia: boolean;
|
||||||
|
hasContent: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/** Extract the supported outbound reply fields from loose tool or agent payload objects. */
|
/** Extract the supported outbound reply fields from loose tool or agent payload objects. */
|
||||||
export function normalizeOutboundReplyPayload(
|
export function normalizeOutboundReplyPayload(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
@ -52,6 +62,54 @@ export function resolveOutboundMediaUrls(payload: {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Count outbound media items after legacy single-media fallback normalization. */
|
||||||
|
export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number {
|
||||||
|
return resolveOutboundMediaUrls(payload).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether an outbound payload includes any media after normalization. */
|
||||||
|
export function hasOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): boolean {
|
||||||
|
return countOutboundMedia(payload) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether an outbound payload includes text, optionally trimming whitespace first. */
|
||||||
|
export function hasOutboundText(payload: { text?: string }, options?: { trim?: boolean }): boolean {
|
||||||
|
const text = options?.trim ? payload.text?.trim() : payload.text;
|
||||||
|
return Boolean(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether an outbound payload includes any sendable text or media. */
|
||||||
|
export function hasOutboundReplyContent(
|
||||||
|
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string },
|
||||||
|
options?: { trimText?: boolean },
|
||||||
|
): boolean {
|
||||||
|
return hasOutboundText(payload, { trim: options?.trimText }) || hasOutboundMedia(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize reply payload text/media into a trimmed, sendable shape for delivery paths. */
|
||||||
|
export function resolveSendableOutboundReplyParts(
|
||||||
|
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string },
|
||||||
|
options?: { text?: string },
|
||||||
|
): SendableOutboundReplyParts {
|
||||||
|
const text = options?.text ?? payload.text ?? "";
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
const mediaUrls = resolveOutboundMediaUrls(payload)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const mediaCount = mediaUrls.length;
|
||||||
|
const hasText = Boolean(trimmedText);
|
||||||
|
const hasMedia = mediaCount > 0;
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
trimmedText,
|
||||||
|
mediaUrls,
|
||||||
|
mediaCount,
|
||||||
|
hasText,
|
||||||
|
hasMedia,
|
||||||
|
hasContent: hasText || hasMedia,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */
|
/** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */
|
||||||
export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] {
|
export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] {
|
||||||
if (chunks.length > 0) {
|
if (chunks.length > 0) {
|
||||||
@ -188,7 +246,9 @@ export async function deliverTextOrMediaReply(params: {
|
|||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
}): Promise<"empty" | "text" | "media"> {
|
}): Promise<"empty" | "text" | "media"> {
|
||||||
const mediaUrls = resolveOutboundMediaUrls(params.payload);
|
const { mediaUrls } = resolveSendableOutboundReplyParts(params.payload, {
|
||||||
|
text: params.text,
|
||||||
|
});
|
||||||
const sentMedia = await sendMediaWithLeadingCaption({
|
const sentMedia = await sendMediaWithLeadingCaption({
|
||||||
mediaUrls,
|
mediaUrls,
|
||||||
caption: params.text,
|
caption: params.text,
|
||||||
|
|||||||
@ -98,9 +98,13 @@ describe("plugin-sdk subpath exports", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exports reply payload helpers from the dedicated subpath", () => {
|
it("exports reply payload helpers from the dedicated subpath", () => {
|
||||||
|
expect(typeof replyPayloadSdk.countOutboundMedia).toBe("function");
|
||||||
expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function");
|
expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function");
|
||||||
expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function");
|
expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function");
|
||||||
expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function");
|
expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function");
|
||||||
|
expect(typeof replyPayloadSdk.hasOutboundMedia).toBe("function");
|
||||||
|
expect(typeof replyPayloadSdk.hasOutboundReplyContent).toBe("function");
|
||||||
|
expect(typeof replyPayloadSdk.hasOutboundText).toBe("function");
|
||||||
expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function");
|
expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function");
|
||||||
expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function");
|
expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function");
|
||||||
expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function");
|
expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function");
|
||||||
|
|||||||
@ -71,6 +71,7 @@ export {
|
|||||||
deliverTextOrMediaReply,
|
deliverTextOrMediaReply,
|
||||||
isNumericTargetId,
|
isNumericTargetId,
|
||||||
resolveOutboundMediaUrls,
|
resolveOutboundMediaUrls,
|
||||||
|
resolveSendableOutboundReplyParts,
|
||||||
sendMediaWithLeadingCaption,
|
sendMediaWithLeadingCaption,
|
||||||
sendPayloadWithChunkedTextAndMedia,
|
sendPayloadWithChunkedTextAndMedia,
|
||||||
} from "./reply-payload.js";
|
} from "./reply-payload.js";
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import type {
|
|||||||
import { logVerbose } from "../globals.js";
|
import { logVerbose } from "../globals.js";
|
||||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { stripMarkdown } from "../line/markdown-to-line.js";
|
import { stripMarkdown } from "../line/markdown-to-line.js";
|
||||||
|
import { resolveSendableOutboundReplyParts } from "../plugin-sdk/reply-payload.js";
|
||||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
getSpeechProvider,
|
getSpeechProvider,
|
||||||
@ -793,7 +794,8 @@ export async function maybeApplyTtsToPayload(params: {
|
|||||||
return params.payload;
|
return params.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = params.payload.text ?? "";
|
const reply = resolveSendableOutboundReplyParts(params.payload);
|
||||||
|
const text = reply.text;
|
||||||
const directives = parseTtsDirectives(text, config.modelOverrides, config.openai.baseUrl);
|
const directives = parseTtsDirectives(text, config.modelOverrides, config.openai.baseUrl);
|
||||||
if (directives.warnings.length > 0) {
|
if (directives.warnings.length > 0) {
|
||||||
logVerbose(`TTS: ignored directive overrides (${directives.warnings.join("; ")})`);
|
logVerbose(`TTS: ignored directive overrides (${directives.warnings.join("; ")})`);
|
||||||
@ -827,7 +829,7 @@ export async function maybeApplyTtsToPayload(params: {
|
|||||||
if (!ttsText.trim()) {
|
if (!ttsText.trim()) {
|
||||||
return nextPayload;
|
return nextPayload;
|
||||||
}
|
}
|
||||||
if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) {
|
if (reply.hasMedia) {
|
||||||
return nextPayload;
|
return nextPayload;
|
||||||
}
|
}
|
||||||
if (text.includes("MEDIA:")) {
|
if (text.includes("MEDIA:")) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user