diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index b13d21f71fd..4d4b411a639 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -9,6 +9,7 @@ import { projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createPairingPrefixStripper, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; @@ -262,46 +263,44 @@ export const bluebubblesPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const runtime = await loadBlueBubblesChannelRuntime(); - const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; - // Resolve short ID (e.g., "5") to full UUID - const replyToMessageGuid = rawReplyToId - ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) - : ""; - const result = await runtime.sendMessageBlueBubbles(to, text, { - cfg: cfg, - accountId: accountId ?? undefined, - replyToMessageGuid: replyToMessageGuid || undefined, - }); - return { channel: "bluebubbles", ...result }; - }, - sendMedia: async (ctx) => { - const runtime = await loadBlueBubblesChannelRuntime(); - const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; - const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { - mediaPath?: string; - mediaBuffer?: Uint8Array; - contentType?: string; - filename?: string; - caption?: string; - }; - const resolvedCaption = caption ?? text; - const result = await runtime.sendBlueBubblesMedia({ - cfg: cfg, - to, - mediaUrl, - mediaPath, - mediaBuffer, - contentType, - filename, - caption: resolvedCaption ?? undefined, - replyToId: replyToId ?? null, - accountId: accountId ?? undefined, - }); - - return { channel: "bluebubbles", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "bluebubbles", + sendText: async ({ cfg, to, text, accountId, replyToId }) => { + const runtime = await loadBlueBubblesChannelRuntime(); + const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; + const replyToMessageGuid = rawReplyToId + ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + : ""; + return await runtime.sendMessageBlueBubbles(to, text, { + cfg: cfg, + accountId: accountId ?? undefined, + replyToMessageGuid: replyToMessageGuid || undefined, + }); + }, + sendMedia: async (ctx) => { + const runtime = await loadBlueBubblesChannelRuntime(); + const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; + const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { + mediaPath?: string; + mediaBuffer?: Uint8Array; + contentType?: string; + filename?: string; + caption?: string; + }; + return await runtime.sendBlueBubblesMedia({ + cfg: cfg, + to, + mediaUrl, + mediaPath, + mediaBuffer, + contentType, + filename, + caption: caption ?? text ?? undefined, + replyToId: replyToId ?? null, + accountId: accountId ?? undefined, + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 958c629f766..ef01150487b 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -1,3 +1,8 @@ +import { + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { downloadBlueBubblesAttachment } from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { fetchBlueBubblesHistory } from "./history.js"; @@ -1243,11 +1248,7 @@ export async function processMessage( const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) : ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const mediaList = resolveOutboundMediaUrls(payload); if (mediaList.length > 0) { const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: config, @@ -1257,43 +1258,44 @@ export async function processMessage( const text = sanitizeReplyDirectiveText( core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), ); - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - const cachedBody = (caption ?? "").trim() || ""; - const pendingId = rememberPendingOutboundMessageId({ - accountId: account.accountId, - sessionKey: route.sessionKey, - outboundTarget, - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - snippet: cachedBody, - }); - let result: Awaited>; - try { - result = await sendBlueBubblesMedia({ - cfg: config, - to: outboundTarget, - mediaUrl, - caption: caption ?? undefined, - replyToId: replyToMessageGuid || null, + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList, + caption: text, + send: async ({ mediaUrl, caption }) => { + const cachedBody = (caption ?? "").trim() || ""; + const pendingId = rememberPendingOutboundMessageId({ accountId: account.accountId, + sessionKey: route.sessionKey, + outboundTarget, + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + chatId, + snippet: cachedBody, }); - } catch (err) { - forgetPendingOutboundMessageId(pendingId); - throw err; - } - if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) { - forgetPendingOutboundMessageId(pendingId); - } - sentMessage = true; - statusSink?.({ lastOutboundAt: Date.now() }); - if (info.kind === "block") { - restartTypingSoon(); - } - } + let result: Awaited>; + try { + result = await sendBlueBubblesMedia({ + cfg: config, + to: outboundTarget, + mediaUrl, + caption: caption ?? undefined, + replyToId: replyToMessageGuid || null, + accountId: account.accountId, + }); + } catch (err) { + forgetPendingOutboundMessageId(pendingId); + throw err; + } + if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) { + forgetPendingOutboundMessageId(pendingId); + } + sentMessage = true; + statusSink?.({ lastOutboundAt: Date.now() }); + if (info.kind === "block") { + restartTypingSoon(); + } + }, + }); return; } @@ -1312,11 +1314,14 @@ export async function processMessage( ); const chunks = chunkMode === "newline" - ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) - : core.channel.text.chunkMarkdownText(text, textLimit); - if (!chunks.length && text) { - chunks.push(text); - } + ? resolveTextChunksWithFallback( + text, + core.channel.text.chunkTextWithMode(text, textLimit, chunkMode), + ) + : resolveTextChunksWithFallback( + text, + core.channel.text.chunkMarkdownText(text, textLimit), + ); if (!chunks.length) { return; } diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 24a8577af3a..0ddb5c9e19f 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -7,8 +7,10 @@ import { import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createPairingPrefixStripper, + createTopLevelChannelReplyToModeResolver, createRuntimeDirectoryLiveAdapter, createTextPairingAdapter, normalizeMessageChannel, @@ -323,7 +325,7 @@ export const discordPlugin: ChannelPlugin = { stripPatterns: () => ["<@!?\\d+>"], }, threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"), }, agentPrompt: { messageToolHints: () => [ @@ -420,50 +422,51 @@ export const discordPlugin: ChannelPlugin = { textChunkLimit: 2000, pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), - sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? - getDiscordRuntime().channel.discord.sendMessageDiscord; - const result = await send(to, text, { - verbose: false, - cfg, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }); - return { channel: "discord", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - silent, - }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? - getDiscordRuntime().channel.discord.sendMessageDiscord; - const result = await send(to, text, { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; + return await send(to, text, { + verbose: false, + cfg, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }); - return { channel: "discord", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId, silent }) => - await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { - cfg, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }), + accountId, + deps, + replyToId, + silent, + }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; + return await send(to, text, { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId, silent }) => + await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { + cfg, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }), + }), }, bindings: { compileConfiguredBinding: ({ conversationId }) => diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 58e6083eef0..61e225d4f32 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -25,6 +25,10 @@ import { import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; +import { + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import type { ChatCommandDefinition, @@ -887,7 +891,7 @@ async function deliverDiscordInteractionReply(params: { chunkMode: "length" | "newline"; }) { const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaList = resolveOutboundMediaUrls(payload); const text = payload.text ?? ""; const discordData = payload.channelData?.discord as | { components?: TopLevelComponents[] } @@ -945,14 +949,14 @@ async function deliverDiscordInteractionReply(params: { }; }), ); - const chunks = chunkDiscordTextWithMode(text, { - maxChars: textLimit, - maxLines: maxLinesPerMessage, - chunkMode, - }); - if (!chunks.length && text) { - chunks.push(text); - } + const chunks = resolveTextChunksWithFallback( + text, + chunkDiscordTextWithMode(text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + chunkMode, + }), + ); const caption = chunks[0] ?? ""; await sendMessage(caption, media, firstMessageComponents); for (const chunk of chunks.slice(1)) { @@ -967,14 +971,17 @@ async function deliverDiscordInteractionReply(params: { if (!text.trim() && !firstMessageComponents) { return; } - const chunks = chunkDiscordTextWithMode(text, { - maxChars: textLimit, - maxLines: maxLinesPerMessage, - chunkMode, - }); - if (!chunks.length && (text || firstMessageComponents)) { - chunks.push(text); - } + const chunks = + text || firstMessageComponents + ? resolveTextChunksWithFallback( + text, + chunkDiscordTextWithMode(text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + chunkMode, + }), + ) + : []; for (const chunk of chunks) { if (!chunk.trim() && !firstMessageComponents) { continue; diff --git a/extensions/discord/src/monitor/reply-delivery.test.ts b/extensions/discord/src/monitor/reply-delivery.test.ts index bd4d0e91dfd..bbfbe6eeae8 100644 --- a/extensions/discord/src/monitor/reply-delivery.test.ts +++ b/extensions/discord/src/monitor/reply-delivery.test.ts @@ -12,11 +12,15 @@ const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn()); const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn()); const sendDiscordTextMock = vi.hoisted(() => vi.fn()); -vi.mock("../send.js", () => ({ - sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args), - sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args), - sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args), -})); +vi.mock("../send.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args), + sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args), + sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args), + }; +}); vi.mock("../send.shared.js", () => ({ sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args), diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index 6e495d420ce..84efdb24237 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -8,6 +8,11 @@ import { retryAsync, type RetryConfig, } from "openclaw/plugin-sdk/infra-runtime"; +import { + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -209,35 +214,6 @@ async function sendDiscordChunkWithFallback(params: { ); } -async function sendAdditionalDiscordMedia(params: { - cfg: OpenClawConfig; - target: string; - token: string; - rest?: RequestClient; - accountId?: string; - mediaUrls: string[]; - mediaLocalRoots?: readonly string[]; - resolveReplyTo: () => string | undefined; - retryConfig: ResolvedRetryConfig; -}) { - for (const mediaUrl of params.mediaUrls) { - const replyTo = params.resolveReplyTo(); - await sendWithRetry( - () => - sendMessageDiscord(params.target, "", { - cfg: params.cfg, - token: params.token, - rest: params.rest, - mediaUrl, - accountId: params.accountId, - mediaLocalRoots: params.mediaLocalRoots, - replyTo, - }), - params.retryConfig, - ); - } -} - export async function deliverDiscordReply(params: { cfg: OpenClawConfig; replies: ReplyPayload[]; @@ -292,7 +268,7 @@ export async function deliverDiscordReply(params: { : undefined; let deliveredAny = false; for (const payload of params.replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaList = resolveOutboundMediaUrls(payload); const rawText = payload.text ?? ""; const tableMode = params.tableMode ?? "code"; const text = convertMarkdownTables(rawText, tableMode); @@ -301,14 +277,14 @@ export async function deliverDiscordReply(params: { } if (mediaList.length === 0) { const mode = params.chunkMode ?? "length"; - const chunks = chunkDiscordTextWithMode(text, { - maxChars: chunkLimit, - maxLines: params.maxLinesPerMessage, - chunkMode: mode, - }); - if (!chunks.length && text) { - chunks.push(text); - } + const chunks = resolveTextChunksWithFallback( + text, + chunkDiscordTextWithMode(text, { + maxChars: chunkLimit, + maxLines: params.maxLinesPerMessage, + chunkMode: mode, + }), + ); for (const chunk of chunks) { if (!chunk.trim()) { continue; @@ -340,19 +316,6 @@ export async function deliverDiscordReply(params: { if (!firstMedia) { continue; } - const sendRemainingMedia = () => - sendAdditionalDiscordMedia({ - cfg: params.cfg, - target: params.target, - token: params.token, - rest: params.rest, - accountId: params.accountId, - mediaUrls: mediaList.slice(1), - mediaLocalRoots: params.mediaLocalRoots, - resolveReplyTo, - retryConfig, - }); - // Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord. if (payload.audioAsVoice) { const replyTo = resolveReplyTo(); @@ -383,22 +346,50 @@ export async function deliverDiscordReply(params: { retryConfig, }); // Additional media items are sent as regular attachments (voice is single-file only). - await sendRemainingMedia(); + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList.slice(1), + caption: "", + send: async ({ mediaUrl }) => { + const replyTo = resolveReplyTo(); + await sendWithRetry( + () => + sendMessageDiscord(params.target, "", { + cfg: params.cfg, + token: params.token, + rest: params.rest, + mediaUrl, + accountId: params.accountId, + mediaLocalRoots: params.mediaLocalRoots, + replyTo, + }), + retryConfig, + ); + }, + }); continue; } - const replyTo = resolveReplyTo(); - await sendMessageDiscord(params.target, text, { - cfg: params.cfg, - token: params.token, - rest: params.rest, - mediaUrl: firstMedia, - accountId: params.accountId, - mediaLocalRoots: params.mediaLocalRoots, - replyTo, + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList, + caption: text, + send: async ({ mediaUrl, caption }) => { + const replyTo = resolveReplyTo(); + await sendWithRetry( + () => + sendMessageDiscord(params.target, caption ?? "", { + cfg: params.cfg, + token: params.token, + rest: params.rest, + mediaUrl, + accountId: params.accountId, + mediaLocalRoots: params.mediaLocalRoots, + replyTo, + }), + retryConfig, + ); + }, }); deliveredAny = true; - await sendRemainingMedia(); } if (binding && deliveredAny) { diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 3321a9cb59b..c3833972f44 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -3,11 +3,13 @@ import { normalizeDiscordOutboundTarget } from "./normalize.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscordMock = vi.fn(); + const sendDiscordComponentMessageMock = vi.fn(); const sendPollDiscordMock = vi.fn(); const sendWebhookMessageDiscordMock = vi.fn(); const getThreadBindingManagerMock = vi.fn(); return { sendMessageDiscordMock, + sendDiscordComponentMessageMock, sendPollDiscordMock, sendWebhookMessageDiscordMock, getThreadBindingManagerMock, @@ -19,6 +21,8 @@ vi.mock("./send.js", async (importOriginal) => { return { ...actual, sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), + sendDiscordComponentMessage: (...args: unknown[]) => + hoisted.sendDiscordComponentMessageMock(...args), sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args), sendWebhookMessageDiscord: (...args: unknown[]) => hoisted.sendWebhookMessageDiscordMock(...args), @@ -114,6 +118,10 @@ describe("discordOutbound", () => { messageId: "msg-1", channelId: "ch-1", }); + hoisted.sendDiscordComponentMessageMock.mockClear().mockResolvedValue({ + messageId: "component-1", + channelId: "ch-1", + }); hoisted.sendPollDiscordMock.mockClear().mockResolvedValue({ messageId: "poll-1", channelId: "ch-1", @@ -249,8 +257,61 @@ describe("discordOutbound", () => { }), ); expect(result).toEqual({ + channel: "discord", messageId: "poll-1", channelId: "ch-1", }); }); + + it("sends component payload media sequences with the component message first", async () => { + hoisted.sendDiscordComponentMessageMock.mockResolvedValueOnce({ + messageId: "component-1", + channelId: "ch-1", + }); + hoisted.sendMessageDiscordMock.mockResolvedValueOnce({ + messageId: "msg-2", + channelId: "ch-1", + }); + + const result = await discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: "", + payload: { + text: "hello", + mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"], + channelData: { + discord: { + components: { text: "hello", components: [] }, + }, + }, + }, + accountId: "default", + mediaLocalRoots: ["/tmp/media"], + }); + + expect(hoisted.sendDiscordComponentMessageMock).toHaveBeenCalledWith( + "channel:123456", + expect.objectContaining({ text: "hello" }), + expect.objectContaining({ + mediaUrl: "https://example.com/1.png", + mediaLocalRoots: ["/tmp/media"], + accountId: "default", + }), + ); + expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( + "channel:123456", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/2.png", + mediaLocalRoots: ["/tmp/media"], + accountId: "default", + }), + ); + expect(result).toEqual({ + channel: "discord", + messageId: "msg-2", + channelId: "ch-1", + }); + }); }); diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 93fd1cb8bfb..8b18fffec90 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -1,10 +1,14 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceOrFallback, sendTextMediaPayload, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import type { DiscordComponentMessageSpec } from "./components.js"; @@ -123,18 +127,17 @@ export const discordOutbound: ChannelOutboundAdapter = { resolveOutboundSendDep(ctx.deps, "discord") ?? sendMessageDiscord; const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId }); const mediaUrls = resolvePayloadMediaUrls(payload); - if (mediaUrls.length === 0) { - const result = await sendDiscordComponentMessage(target, componentSpec, { - replyTo: ctx.replyToId ?? undefined, - accountId: ctx.accountId ?? undefined, - silent: ctx.silent ?? undefined, - cfg: ctx.cfg, - }); - return { channel: "discord", ...result }; - } - const lastResult = await sendPayloadMediaSequence({ + const result = await sendPayloadMediaSequenceOrFallback({ text: payload.text ?? "", mediaUrls, + fallbackResult: { messageId: "", channelId: target }, + sendNoMedia: async () => + await sendDiscordComponentMessage(target, componentSpec, { + replyTo: ctx.replyToId ?? undefined, + accountId: ctx.accountId ?? undefined, + silent: ctx.silent ?? undefined, + cfg: ctx.cfg, + }), send: async ({ text, mediaUrl, isFirst }) => { if (isFirst) { return await sendDiscordComponentMessage(target, componentSpec, { @@ -157,68 +160,63 @@ export const discordOutbound: ChannelOutboundAdapter = { }); }, }); - return lastResult - ? { channel: "discord", ...lastResult } - : { channel: "discord", messageId: "" }; + return attachChannelToResult("discord", result); }, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { - if (!silent) { - const webhookResult = await maybeSendDiscordWebhookText({ - cfg, - text, - threadId, - accountId, - identity, - replyToId, - }).catch(() => null); - if (webhookResult) { - return { channel: "discord", ...webhookResult }; + ...createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { + if (!silent) { + const webhookResult = await maybeSendDiscordWebhookText({ + cfg, + text, + threadId, + accountId, + identity, + replyToId, + }).catch(() => null); + if (webhookResult) { + return webhookResult; + } } - } - const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; - const target = resolveDiscordOutboundTarget({ to, threadId }); - const result = await send(target, text, { - verbose: false, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { + verbose: false, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + }, + sendMedia: async ({ cfg, - }); - return { channel: "discord", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - silent, - }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; - const target = resolveDiscordOutboundTarget({ to, threadId }); - const result = await send(target, text, { - verbose: false, + to, + text, mediaUrl, mediaLocalRoots, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - return { channel: "discord", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => { - const target = resolveDiscordOutboundTarget({ to, threadId }); - return await sendPollDiscord(target, poll, { - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - }, + accountId, + deps, + replyToId, + threadId, + silent, + }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { + verbose: false, + mediaUrl, + mediaLocalRoots, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => + await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, { + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }), + }), }; diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index d3b248a3c6f..8cdc8ce2805 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -17,6 +17,7 @@ import { normalizePollInput, type PollInput, } from "openclaw/plugin-sdk/media-runtime"; +import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; @@ -276,10 +277,7 @@ export function buildDiscordTextChunks( maxLines: opts.maxLinesPerMessage, chunkMode: opts.chunkMode, }); - if (!chunks.length && text) { - chunks.push(text); - } - return chunks; + return resolveTextChunksWithFallback(text, chunks); } function hasV2Components(components?: TopLevelComponents[]): boolean { diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index fd79bff869f..0c449f82bd2 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { sendMediaFeishu } from "./media.js"; @@ -81,128 +82,124 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ - cfg, - to, - text, - accountId, - replyToId, - threadId, - mediaLocalRoots, - identity, - }) => { - const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); - // Scheme A compatibility shim: - // when upstream accidentally returns a local image path as plain text, - // auto-upload and send as Feishu image message instead of leaking path text. - const localImagePath = normalizePossibleLocalImagePath(text); - if (localImagePath) { - try { - const result = await sendMediaFeishu({ - cfg, - to, - mediaUrl: localImagePath, - accountId: accountId ?? undefined, - replyToMessageId, - mediaLocalRoots, - }); - return { channel: "feishu", ...result }; - } catch (err) { - console.error(`[feishu] local image path auto-send failed:`, err); - // fall through to plain text as last resort - } - } - - const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); - const renderMode = account.config?.renderMode ?? "auto"; - const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); - if (useCard) { - const header = identity - ? { - title: identity.emoji - ? `${identity.emoji} ${identity.name ?? ""}`.trim() - : (identity.name ?? ""), - template: "blue" as const, - } - : undefined; - const result = await sendStructuredCardFeishu({ - cfg, - to, - text, - replyToMessageId, - replyInThread: threadId != null && !replyToId, - accountId: accountId ?? undefined, - header: header?.title ? header : undefined, - }); - return { channel: "feishu", ...result }; - } - const result = await sendOutboundText({ + ...createAttachedChannelResultAdapter({ + channel: "feishu", + sendText: async ({ cfg, to, text, - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - accountId, - mediaLocalRoots, - replyToId, - threadId, - }) => { - const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); - // Send text first if provided - if (text?.trim()) { - await sendOutboundText({ + accountId, + replyToId, + threadId, + mediaLocalRoots, + identity, + }) => { + const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); + // Scheme A compatibility shim: + // when upstream accidentally returns a local image path as plain text, + // auto-upload and send as Feishu image message instead of leaking path text. + const localImagePath = normalizePossibleLocalImagePath(text); + if (localImagePath) { + try { + return await sendMediaFeishu({ + cfg, + to, + mediaUrl: localImagePath, + accountId: accountId ?? undefined, + replyToMessageId, + mediaLocalRoots, + }); + } catch (err) { + console.error(`[feishu] local image path auto-send failed:`, err); + // fall through to plain text as last resort + } + } + + const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); + const renderMode = account.config?.renderMode ?? "auto"; + const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + if (useCard) { + const header = identity + ? { + title: identity.emoji + ? `${identity.emoji} ${identity.name ?? ""}`.trim() + : (identity.name ?? ""), + template: "blue" as const, + } + : undefined; + return await sendStructuredCardFeishu({ + cfg, + to, + text, + replyToMessageId, + replyInThread: threadId != null && !replyToId, + accountId: accountId ?? undefined, + header: header?.title ? header : undefined, + }); + } + return await sendOutboundText({ cfg, to, text, accountId: accountId ?? undefined, replyToMessageId, }); - } - - // Upload and send media if URL or local path provided - if (mediaUrl) { - try { - const result = await sendMediaFeishu({ - cfg, - to, - mediaUrl, - accountId: accountId ?? undefined, - mediaLocalRoots, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - } catch (err) { - // Log the error for debugging - console.error(`[feishu] sendMediaFeishu failed:`, err); - // Fallback to URL link if upload fails - const fallbackText = `📎 ${mediaUrl}`; - const result = await sendOutboundText({ - cfg, - to, - text: fallbackText, - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - } - } - - // No media URL, just return text result - const result = await sendOutboundText({ + }, + sendMedia: async ({ cfg, to, - text: text ?? "", - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - }, + text, + mediaUrl, + accountId, + mediaLocalRoots, + replyToId, + threadId, + }) => { + const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); + // Send text first if provided + if (text?.trim()) { + await sendOutboundText({ + cfg, + to, + text, + accountId: accountId ?? undefined, + replyToMessageId, + }); + } + + // Upload and send media if URL or local path provided + if (mediaUrl) { + try { + return await sendMediaFeishu({ + cfg, + to, + mediaUrl, + accountId: accountId ?? undefined, + mediaLocalRoots, + replyToMessageId, + }); + } catch (err) { + // Log the error for debugging + console.error(`[feishu] sendMediaFeishu failed:`, err); + // Fallback to URL link if upload fails + return await sendOutboundText({ + cfg, + to, + text: `📎 ${mediaUrl}`, + accountId: accountId ?? undefined, + replyToMessageId, + }); + } + } + + // No media URL, just return text result + return await sendOutboundText({ + cfg, + to, + text: text ?? "", + accountId: accountId ?? undefined, + replyToMessageId, + }); + }, + }), }; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 856891cfb48..29dfeae6ac0 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -10,7 +10,9 @@ import { createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, + createTopLevelChannelReplyToModeResolver, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; import { @@ -192,7 +194,7 @@ export const googlechatPlugin: ChannelPlugin = { resolveRequireMention: resolveGoogleChatGroupRequireMention, }, threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("googlechat"), }, messaging: { normalizeTarget: normalizeGoogleChatTarget, @@ -266,91 +268,97 @@ export const googlechatPlugin: ChannelPlugin = { error: missingTargetError("Google Chat", ""), }; }, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); - const thread = (threadId ?? replyToId ?? undefined) as string | undefined; - const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); - const result = await sendGoogleChatMessage({ - account, - space, + ...createAttachedChannelResultAdapter({ + channel: "googlechat", + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); + const result = await sendGoogleChatMessage({ + account, + space, + text, + thread, + }); + return { + messageId: result?.messageName ?? "", + chatId: space, + }; + }, + sendMedia: async ({ + cfg, + to, text, - thread, - }); - return { - channel: "googlechat", - messageId: result?.messageName ?? "", - chatId: space, - }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - replyToId, - threadId, - }) => { - if (!mediaUrl) { - throw new Error("Google Chat mediaUrl is required."); - } - const account = resolveGoogleChatAccount({ - cfg: cfg, + mediaUrl, + mediaLocalRoots, accountId, - }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); - const thread = (threadId ?? replyToId ?? undefined) as string | undefined; - const runtime = getGoogleChatRuntime(); - const maxBytes = resolveChannelMediaMaxBytes({ - cfg: cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - ( - cfg.channels?.["googlechat"] as - | { accounts?: Record; mediaMaxMb?: number } - | undefined - )?.accounts?.[accountId]?.mediaMaxMb ?? - (cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, - accountId, - }); - const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024; - const loaded = /^https?:\/\//i.test(mediaUrl) - ? await runtime.channel.media.fetchRemoteMedia({ - url: mediaUrl, - maxBytes: effectiveMaxBytes, - }) - : await runtime.media.loadWebMedia(mediaUrl, { - maxBytes: effectiveMaxBytes, - localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, - }); - const { sendGoogleChatMessage, uploadGoogleChatAttachment } = - await loadGoogleChatChannelRuntime(); - const upload = await uploadGoogleChatAttachment({ - account, - space, - filename: loaded.fileName ?? "attachment", - buffer: loaded.buffer, - contentType: loaded.contentType, - }); - const result = await sendGoogleChatMessage({ - account, - space, - text, - thread, - attachments: upload.attachmentUploadToken - ? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName }] - : undefined, - }); - return { - channel: "googlechat", - messageId: result?.messageName ?? "", - chatId: space, - }; - }, + replyToId, + threadId, + }) => { + if (!mediaUrl) { + throw new Error("Google Chat mediaUrl is required."); + } + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const runtime = getGoogleChatRuntime(); + const maxBytes = resolveChannelMediaMaxBytes({ + cfg: cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + ( + cfg.channels?.["googlechat"] as + | { accounts?: Record; mediaMaxMb?: number } + | undefined + )?.accounts?.[accountId]?.mediaMaxMb ?? + (cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, + accountId, + }); + const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024; + const loaded = /^https?:\/\//i.test(mediaUrl) + ? await runtime.channel.media.fetchRemoteMedia({ + url: mediaUrl, + maxBytes: effectiveMaxBytes, + }) + : await runtime.media.loadWebMedia(mediaUrl, { + maxBytes: effectiveMaxBytes, + localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, + }); + const { sendGoogleChatMessage, uploadGoogleChatAttachment } = + await loadGoogleChatChannelRuntime(); + const upload = await uploadGoogleChatAttachment({ + account, + space, + filename: loaded.fileName ?? "attachment", + buffer: loaded.buffer, + contentType: loaded.contentType, + }); + const result = await sendGoogleChatMessage({ + account, + space, + text, + thread, + attachments: upload.attachmentUploadToken + ? [ + { + attachmentUploadToken: upload.attachmentUploadToken, + contentName: loaded.fileName, + }, + ] + : undefined, + }); + return { + messageId: result?.messageName ?? "", + chatId: space, + }; + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 80ba9ff3939..e6eeecb5138 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig } from "../runtime-api.js"; import { createWebhookInFlightLimiter, @@ -375,14 +376,12 @@ async function deliverGoogleChatReply(params: { }): Promise { const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const hasMedia = Boolean(payload.mediaUrls?.length) || Boolean(payload.mediaUrl); + const text = payload.text ?? ""; + let firstTextChunk = true; + let suppressCaption = false; - if (mediaList.length > 0) { - let suppressCaption = false; + if (hasMedia) { if (typingMessageName) { try { await deleteGoogleChatMessage({ @@ -391,9 +390,10 @@ async function deliverGoogleChatReply(params: { }); } catch (err) { runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); - const fallbackText = payload.text?.trim() - ? payload.text - : mediaList.length > 1 + const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0); + const fallbackText = text.trim() + ? text + : mediaCount > 1 ? "Sent attachments." : "Sent attachment."; try { @@ -402,16 +402,43 @@ async function deliverGoogleChatReply(params: { messageName: typingMessageName, text: fallbackText, }); - suppressCaption = Boolean(payload.text?.trim()); + suppressCaption = Boolean(text.trim()); } catch (updateErr) { runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`); } } } - let first = true; - for (const mediaUrl of mediaList) { - const caption = first && !suppressCaption ? payload.text : undefined; - first = false; + } + + const chunkLimit = account.config.textChunkLimit ?? 4000; + const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); + await deliverTextOrMediaReply({ + payload, + text: suppressCaption ? "" : text, + chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode), + sendText: async (chunk) => { + try { + if (firstTextChunk && typingMessageName) { + await updateGoogleChatMessage({ + account, + messageName: typingMessageName, + text: chunk, + }); + } else { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: chunk, + thread: payload.replyToId, + }); + } + firstTextChunk = false; + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`Google Chat message send failed: ${String(err)}`); + } + }, + sendMedia: async ({ mediaUrl, caption }) => { try { const loaded = await core.channel.media.fetchRemoteMedia({ url: mediaUrl, @@ -440,38 +467,8 @@ async function deliverGoogleChatReply(params: { } catch (err) { runtime.error?.(`Google Chat attachment send failed: ${String(err)}`); } - } - return; - } - - if (payload.text) { - const chunkLimit = account.config.textChunkLimit ?? 4000; - const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); - const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode); - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - try { - // Edit typing message with first chunk if available - if (i === 0 && typingMessageName) { - await updateGoogleChatMessage({ - account, - messageName: typingMessageName, - text: chunk, - }); - } else { - await sendGoogleChatMessage({ - account, - space: spaceId, - text: chunk, - thread: payload.replyToId, - }); - } - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Google Chat message send failed: ${String(err)}`); - } - } - } + }, + }); } async function uploadAttachmentForReply(params: { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index bd7df04e249..514b798b7df 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,5 +1,8 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAttachedChannelResultAdapter, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { type RoutePeer } from "openclaw/plugin-sdk/routing"; @@ -160,34 +163,33 @@ export const imessagePlugin: ChannelPlugin = { chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), chunkerMode: "text", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => { - const result = await ( - await loadIMessageChannelRuntime() - ).sendIMessageOutbound({ - cfg, - to, - text, - accountId: accountId ?? undefined, - deps, - replyToId: replyToId ?? undefined, - }); - return { channel: "imessage", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => { - const result = await ( - await loadIMessageChannelRuntime() - ).sendIMessageOutbound({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId: accountId ?? undefined, - deps, - replyToId: replyToId ?? undefined, - }); - return { channel: "imessage", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "imessage", + sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => + await ( + await loadIMessageChannelRuntime() + ).sendIMessageOutbound({ + cfg, + to, + text, + accountId: accountId ?? undefined, + deps, + replyToId: replyToId ?? undefined, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => + await ( + await loadIMessageChannelRuntime() + ).sendIMessageOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + deps, + replyToId: replyToId ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index 65dc125be68..d7b434a4e2d 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -1,5 +1,6 @@ import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -30,15 +31,17 @@ export async function deliverReplies(params: { }); const chunkMode = resolveChunkMode(cfg, "imessage", accountId); for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const rawText = sanitizeOutboundText(payload.text ?? ""); const text = convertMarkdownTables(rawText, tableMode); - if (!text && mediaList.length === 0) { - continue; - } - if (mediaList.length === 0) { + const hasMedia = Boolean(payload.mediaUrls?.length ?? payload.mediaUrl); + if (!hasMedia && text) { sentMessageCache?.remember(scope, { text }); - for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { + } + const delivered = await deliverTextOrMediaReply({ + payload, + text, + chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), + sendText: async (chunk) => { const sent = await sendMessageIMessage(target, chunk, { maxBytes, client, @@ -46,14 +49,10 @@ export async function deliverReplies(params: { replyToId: payload.replyToId, }); sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId }); - } - } else { - let first = true; - for (const url of mediaList) { - const caption = first ? text : ""; - first = false; - const sent = await sendMessageIMessage(target, caption, { - mediaUrl: url, + }, + sendMedia: async ({ mediaUrl, caption }) => { + const sent = await sendMessageIMessage(target, caption ?? "", { + mediaUrl, maxBytes, client, accountId, @@ -63,8 +62,10 @@ export async function deliverReplies(params: { text: caption || undefined, messageId: sent.messageId, }); - } + }, + }); + if (delivered !== "empty") { + runtime.log?.(`imessage: delivered reply to ${target}`); } - runtime.log?.(`imessage: delivered reply to ${target}`); } } diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 216ce997d16..a4e75f72af5 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -9,6 +9,7 @@ import { createConditionalWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createTextPairingAdapter, listResolvedDirectoryEntriesFromSources, @@ -271,23 +272,21 @@ export const ircPlugin: ChannelPlugin = { chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 350, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const result = await sendMessageIrc(to, text, { - cfg: cfg as CoreConfig, - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - }); - return { channel: "irc", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { - const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; - const result = await sendMessageIrc(to, combined, { - cfg: cfg as CoreConfig, - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - }); - return { channel: "irc", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "irc", + sendText: async ({ cfg, to, text, accountId, replyToId }) => + await sendMessageIrc(to, text, { + cfg: cfg as CoreConfig, + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => + await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { + cfg: cfg as CoreConfig, + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 8d1995336b4..aa763d4c561 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -10,14 +10,13 @@ import { import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, + deliverFormattedTextWithAttachments, dispatchInboundReplyWithBase, - formatTextWithAttachmentLinks, issuePairingChallenge, logInboundDrop, isDangerousNameMatchingEnabled, readStoreAllowFromForDmPolicy, resolveControlCommandGate, - resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveEffectiveAllowFromLists, @@ -61,23 +60,23 @@ async function deliverIrcReply(params: { sendReply?: (target: string, text: string, replyToId?: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { - const combined = formatTextWithAttachmentLinks( - params.payload.text, - resolveOutboundMediaUrls(params.payload), - ); - if (!combined) { + const delivered = await deliverFormattedTextWithAttachments({ + payload: params.payload, + send: async ({ text, replyToId }) => { + if (params.sendReply) { + await params.sendReply(params.target, text, replyToId); + } else { + await sendMessageIrc(params.target, text, { + accountId: params.accountId, + replyTo: replyToId, + }); + } + params.statusSink?.({ lastOutboundAt: Date.now() }); + }, + }); + if (!delivered) { return; } - - if (params.sendReply) { - await params.sendReply(params.target, combined, params.payload.replyToId); - } else { - await sendMessageIrc(params.target, combined, { - accountId: params.accountId, - replyTo: params.payload.replyToId, - }); - } - params.statusSink?.({ lastOutboundAt: Date.now() }); } export async function handleIrcInbound(params: { diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index edc9f861d28..d983d2a0172 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,10 +1,13 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createEmptyChannelDirectoryAdapter, + createEmptyChannelResult, createPairingPrefixStripper, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { buildChannelConfigSchema, buildComputedAccountStatusSnapshot, @@ -184,7 +187,7 @@ export const linePlugin: ChannelPlugin = { const chunks = processed.text ? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit) : []; - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaUrls = resolveOutboundMediaUrls(payload); const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies; const sendMediaMessages = async () => { for (const url of mediaUrls) { @@ -317,54 +320,45 @@ export const linePlugin: ChannelPlugin = { } if (lastResult) { - return { channel: "line", ...lastResult }; + return createEmptyChannelResult("line", { ...lastResult }); } - return { channel: "line", messageId: "empty", chatId: to }; + return createEmptyChannelResult("line", { messageId: "empty", chatId: to }); }, - sendText: async ({ cfg, to, text, accountId }) => { - const runtime = getLineRuntime(); - const sendText = runtime.channel.line.pushMessageLine; - const sendFlex = runtime.channel.line.pushFlexMessage; - - // Process markdown: extract tables/code blocks, strip formatting - const processed = processLineMessage(text); - - // Send cleaned text first (if non-empty) - let result: { messageId: string; chatId: string }; - if (processed.text.trim()) { - result = await sendText(to, processed.text, { + ...createAttachedChannelResultAdapter({ + channel: "line", + sendText: async ({ cfg, to, text, accountId }) => { + const runtime = getLineRuntime(); + const sendText = runtime.channel.line.pushMessageLine; + const sendFlex = runtime.channel.line.pushFlexMessage; + const processed = processLineMessage(text); + let result: { messageId: string; chatId: string }; + if (processed.text.trim()) { + result = await sendText(to, processed.text, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + }); + } else { + result = { messageId: "processed", chatId: to }; + } + for (const flexMsg of processed.flexMessages) { + const flexContents = flexMsg.contents as Parameters[2]; + await sendFlex(to, flexMsg.altText, flexContents, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + }); + } + return result; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => + await getLineRuntime().channel.line.sendMessageLine(to, text, { verbose: false, + mediaUrl, cfg, accountId: accountId ?? undefined, - }); - } else { - // If text is empty after processing, still need a result - result = { messageId: "processed", chatId: to }; - } - - // Send flex messages for tables/code blocks - for (const flexMsg of processed.flexMessages) { - // LINE SDK expects FlexContainer but we receive contents as unknown - const flexContents = flexMsg.contents as Parameters[2]; - await sendFlex(to, flexMsg.altText, flexContents, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - }); - } - - return { channel: "line", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { - const send = getLineRuntime().channel.line.sendMessageLine; - const result = await send(to, text, { - verbose: false, - mediaUrl, - cfg, - accountId: accountId ?? undefined, - }); - return { channel: "line", ...result }; - }, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 2334476c224..4c83f627261 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -9,6 +9,7 @@ import { import { createChannelDirectoryAdapter, createPairingPrefixStripper, + createScopedAccountReplyToModeResolver, createRuntimeDirectoryLiveAdapter, createRuntimeOutboundDelegates, createTextPairingAdapter, @@ -168,8 +169,11 @@ export const matrixPlugin: ChannelPlugin = { resolveToolPolicy: resolveMatrixGroupToolPolicy, }, threading: { - resolveReplyToMode: ({ cfg, accountId }) => - resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off", + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), + resolveReplyToMode: (account) => account.replyToMode, + }), buildToolContext: ({ context, hasRepliedRef }) => { const currentTarget = context.To; return { diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 004701edae4..b1ab30b20ef 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,4 +1,5 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js"; import { getMatrixRuntime } from "../../runtime.js"; import { sendMessageMatrix } from "../send.js"; @@ -60,45 +61,34 @@ export async function deliverMatrixReplies(params: { Boolean(id) && (params.replyToMode === "all" || !hasReplied); const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined; - if (mediaList.length === 0) { - let sentTextChunk = false; - for (const chunk of core.channel.text.chunkMarkdownTextWithMode( - text, - chunkLimit, - chunkMode, - )) { - const trimmed = chunk.trim(); - if (!trimmed) { - continue; - } + const delivered = await deliverTextOrMediaReply({ + payload: reply, + text, + chunkText: (value) => + core.channel.text + .chunkMarkdownTextWithMode(value, chunkLimit, chunkMode) + .map((chunk) => chunk.trim()) + .filter(Boolean), + sendText: async (trimmed) => { await sendMessageMatrix(params.roomId, trimmed, { client: params.client, replyToId: replyToIdForReply, threadId: params.threadId, accountId: params.accountId, }); - sentTextChunk = true; - } - if (replyToIdForReply && !hasReplied && sentTextChunk) { - hasReplied = true; - } - continue; - } - - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - await sendMessageMatrix(params.roomId, caption, { - client: params.client, - mediaUrl, - replyToId: replyToIdForReply, - threadId: params.threadId, - audioAsVoice: reply.audioAsVoice, - accountId: params.accountId, - }); - first = false; - } - if (replyToIdForReply && !hasReplied) { + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageMatrix(params.roomId, caption ?? "", { + client: params.client, + mediaUrl, + replyToId: replyToIdForReply, + threadId: params.threadId, + audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, + }); + }, + }); + if (replyToIdForReply && !hasReplied && delivered !== "empty") { hasReplied = true; } } diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 511d46b76e6..cf8f51c245c 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -5,9 +5,11 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createLoggedPairingApprovalNotifier, createMessageToolButtonsSchema, + createScopedAccountReplyToModeResolver, type ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -308,14 +310,17 @@ export const mattermostPlugin: ChannelPlugin = { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, threading: { - resolveReplyToMode: ({ cfg, accountId, chatType }) => { - const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }); - const kind = - chatType === "direct" || chatType === "group" || chatType === "channel" - ? chatType - : "channel"; - return resolveMattermostReplyToMode(account, kind); - }, + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }), + resolveReplyToMode: (account, chatType) => + resolveMattermostReplyToMode( + account, + chatType === "direct" || chatType === "group" || chatType === "channel" + ? chatType + : "channel", + ), + }), }, reload: { configPrefixes: ["channels.mattermost"] }, configSchema: buildChannelConfigSchema(MattermostConfigSchema), @@ -385,33 +390,32 @@ export const mattermostPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const result = await sendMessageMattermost(to, text, { + ...createAttachedChannelResultAdapter({ + channel: "mattermost", + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => + await sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + sendMedia: async ({ cfg, - accountId: accountId ?? undefined, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - }); - return { channel: "mattermost", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - replyToId, - threadId, - }) => { - const result = await sendMessageMattermost(to, text, { - cfg, - accountId: accountId ?? undefined, + to, + text, mediaUrl, mediaLocalRoots, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - }); - return { channel: "mattermost", ...result }; - }, + accountId, + replyToId, + threadId, + }) => + await sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + mediaUrl, + mediaLocalRoots, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 6fc88c8ba83..492d31ba0fc 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -1,3 +1,4 @@ +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js"; import { getAgentScopedMediaLocalRoots } from "../runtime-api.js"; @@ -26,46 +27,34 @@ export async function deliverMattermostReplyPayload(params: { tableMode: MarkdownTableMode; sendMessage: SendMattermostMessage; }): Promise { - const mediaUrls = - params.payload.mediaUrls ?? (params.payload.mediaUrl ? [params.payload.mediaUrl] : []); const text = params.core.channel.text.convertMarkdownTables( params.payload.text ?? "", params.tableMode, ); - - if (mediaUrls.length === 0) { - const chunkMode = params.core.channel.text.resolveChunkMode( - params.cfg, - "mattermost", - params.accountId, - ); - const chunks = params.core.channel.text.chunkMarkdownTextWithMode( - text, - params.textLimit, - chunkMode, - ); - for (const chunk of chunks.length > 0 ? chunks : [text]) { - if (!chunk) { - continue; - } + const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); + const chunkMode = params.core.channel.text.resolveChunkMode( + params.cfg, + "mattermost", + params.accountId, + ); + await deliverTextOrMediaReply({ + payload: params.payload, + text, + chunkText: (value) => + params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode), + sendText: async (chunk) => { await params.sendMessage(params.to, chunk, { accountId: params.accountId, replyToId: params.replyToId, }); - } - return; - } - - const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); - let first = true; - for (const mediaUrl of mediaUrls) { - const caption = first ? text : ""; - first = false; - await params.sendMessage(params.to, caption, { - accountId: params.accountId, - mediaUrl, - mediaLocalRoots, - replyToId: params.replyToId, - }); - } + }, + sendMedia: async ({ mediaUrl, caption }) => { + await params.sendMessage(params.to, caption ?? "", { + accountId: params.accountId, + mediaUrl, + mediaLocalRoots, + replyToId: params.replyToId, + }); + }, + }); } diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index f03431391ed..b024b53c1f5 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -5,6 +5,7 @@ import { type MarkdownTableMode, type MSTeamsReplyStyle, type ReplyPayload, + resolveOutboundMediaUrls, SILENT_REPLY_TOKEN, sleep, } from "../runtime-api.js"; @@ -216,7 +217,7 @@ export function renderReplyPayloadsToMessages( }); for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaList = resolveOutboundMediaUrls(payload); const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( payload.text ?? "", tableMode, diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 6334bb8c6b5..cf482825ed2 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,4 +1,5 @@ import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; @@ -10,56 +11,57 @@ export const msteamsOutbound: ChannelOutboundAdapter = { chunkerMode: "markdown", textChunkLimit: 4000, pollMaxOptions: 12, - sendText: async ({ cfg, to, text, deps }) => { - type SendFn = ( - to: string, - text: string, - ) => Promise<{ messageId: string; conversationId: string }>; - const send = - resolveOutboundSendDep(deps, "msteams") ?? - ((to, text) => sendMessageMSTeams({ cfg, to, text })); - const result = await send(to, text); - return { channel: "msteams", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { - type SendFn = ( - to: string, - text: string, - opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, - ) => Promise<{ messageId: string; conversationId: string }>; - const send = - resolveOutboundSendDep(deps, "msteams") ?? - ((to, text, opts) => - sendMessageMSTeams({ - cfg, - to, - text, - mediaUrl: opts?.mediaUrl, - mediaLocalRoots: opts?.mediaLocalRoots, - })); - const result = await send(to, text, { mediaUrl, mediaLocalRoots }); - return { channel: "msteams", ...result }; - }, - sendPoll: async ({ cfg, to, poll }) => { - const maxSelections = poll.maxSelections ?? 1; - const result = await sendPollMSTeams({ - cfg, - to, - question: poll.question, - options: poll.options, - maxSelections, - }); - const pollStore = createMSTeamsPollStoreFs(); - await pollStore.createPoll({ - id: result.pollId, - question: poll.question, - options: poll.options, - maxSelections, - createdAt: new Date().toISOString(), - conversationId: result.conversationId, - messageId: result.messageId, - votes: {}, - }); - return result; - }, + ...createAttachedChannelResultAdapter({ + channel: "msteams", + sendText: async ({ cfg, to, text, deps }) => { + type SendFn = ( + to: string, + text: string, + ) => Promise<{ messageId: string; conversationId: string }>; + const send = + resolveOutboundSendDep(deps, "msteams") ?? + ((to, text) => sendMessageMSTeams({ cfg, to, text })); + return await send(to, text); + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { + type SendFn = ( + to: string, + text: string, + opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, + ) => Promise<{ messageId: string; conversationId: string }>; + const send = + resolveOutboundSendDep(deps, "msteams") ?? + ((to, text, opts) => + sendMessageMSTeams({ + cfg, + to, + text, + mediaUrl: opts?.mediaUrl, + mediaLocalRoots: opts?.mediaLocalRoots, + })); + return await send(to, text, { mediaUrl, mediaLocalRoots }); + }, + sendPoll: async ({ cfg, to, poll }) => { + const maxSelections = poll.maxSelections ?? 1; + const result = await sendPollMSTeams({ + cfg, + to, + question: poll.question, + options: poll.options, + maxSelections, + }); + const pollStore = createMSTeamsPollStoreFs(); + await pollStore.createPoll({ + id: result.pollId, + question: poll.question, + options: poll.options, + maxSelections, + createdAt: new Date().toISOString(), + conversationId: result.conversationId, + messageId: result.messageId, + votes: {}, + }); + return result; + }, + }), }; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 5416a71f9af..d24822efb26 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -6,6 +6,7 @@ import { import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createLoggedPairingApprovalNotifier, createPairingPrefixStripper, } from "openclaw/plugin-sdk/channel-runtime"; @@ -174,23 +175,21 @@ export const nextcloudTalkPlugin: ChannelPlugin = chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const result = await sendMessageNextcloudTalk(to, text, { - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - cfg: cfg as CoreConfig, - }); - return { channel: "nextcloud-talk", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { - const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; - const result = await sendMessageNextcloudTalk(to, messageWithMedia, { - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - cfg: cfg as CoreConfig, - }); - return { channel: "nextcloud-talk", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "nextcloud-talk", + sendText: async ({ cfg, to, text, accountId, replyToId }) => + await sendMessageNextcloudTalk(to, text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => + await sendMessageNextcloudTalk(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 9eefe831835..d9f4de2f9a2 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,13 +1,12 @@ import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, + deliverFormattedTextWithAttachments, dispatchInboundReplyWithBase, - formatTextWithAttachmentLinks, issuePairingChallenge, logInboundDrop, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithCommandGate, - resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, @@ -38,16 +37,16 @@ async function deliverNextcloudTalkReply(params: { statusSink?: (patch: { lastOutboundAt?: number }) => void; }): Promise { const { payload, roomToken, accountId, statusSink } = params; - const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload)); - if (!combined) { - return; - } - - await sendMessageNextcloudTalk(roomToken, combined, { - accountId, - replyTo: payload.replyToId, + await deliverFormattedTextWithAttachments({ + payload, + send: async ({ text, replyToId }) => { + await sendMessageNextcloudTalk(roomToken, text, { + accountId, + replyTo: replyToId, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + }, }); - statusSink?.({ lastOutboundAt: Date.now() }); } export async function handleNextcloudTalkInbound(params: { diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 3db834e8ad6..a11a882b81e 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -2,6 +2,7 @@ import { createScopedDmSecurityResolver, createTopLevelChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary, @@ -176,11 +177,10 @@ export const nostrPlugin: ChannelPlugin = { const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); const normalizedTo = normalizePubkey(to); await bus.sendDm(normalizedTo, message); - return { - channel: "nostr" as const, + return attachChannelToResult("nostr", { to: normalizedTo, messageId: `nostr-${Date.now()}`, - }; + }); }, }, diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index e5f8f392202..6ba7fce6084 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,9 +1,12 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { + attachChannelToResult, + createAttachedChannelResultAdapter, createPairingPrefixStripper, createTextPairingAdapter, resolveOutboundSendDep, } from "openclaw/plugin-sdk/channel-runtime"; +import { attachChannelToResults } from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; @@ -223,9 +226,9 @@ async function sendFormattedSignalText(ctx: { textMode: "plain", textStyles: chunk.styles, }); - results.push({ channel: "signal" as const, ...result }); + results.push(result); } - return results; + return attachChannelToResults("signal", results); } async function sendFormattedSignalMedia(ctx: { @@ -264,7 +267,7 @@ async function sendFormattedSignalMedia(ctx: { textMode: "plain", textStyles: formatted.styles, }); - return { channel: "signal" as const, ...result }; + return attachChannelToResult("signal", result); } export const signalPlugin: ChannelPlugin = { @@ -340,28 +343,27 @@ export const signalPlugin: ChannelPlugin = { deps, abortSignal, }), - sendText: async ({ cfg, to, text, accountId, deps }) => { - const result = await sendSignalOutbound({ - cfg, - to, - text, - accountId: accountId ?? undefined, - deps, - }); - return { channel: "signal", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { - const result = await sendSignalOutbound({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId: accountId ?? undefined, - deps, - }); - return { channel: "signal", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "signal", + sendText: async ({ cfg, to, text, accountId, deps }) => + await sendSignalOutbound({ + cfg, + to, + text, + accountId: accountId ?? undefined, + deps, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => + await sendSignalOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + deps, + }), + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 02fd94ff8b8..5a4882b1068 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -9,6 +9,7 @@ import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config- import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import { chunkTextWithMode, resolveChunkMode, @@ -296,35 +297,31 @@ async function deliverReplies(params: { const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = params; for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; - if (!text && mediaList.length === 0) { - continue; - } - if (mediaList.length === 0) { - for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { + const delivered = await deliverTextOrMediaReply({ + payload, + text: payload.text ?? "", + chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), + sendText: async (chunk) => { await sendMessageSignal(target, chunk, { baseUrl, account, maxBytes, accountId, }); - } - } else { - let first = true; - for (const url of mediaList) { - const caption = first ? text : ""; - first = false; - await sendMessageSignal(target, caption, { + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageSignal(target, caption ?? "", { baseUrl, account, - mediaUrl: url, + mediaUrl, maxBytes, accountId, }); - } + }, + }); + if (delivered !== "empty") { + runtime.log?.(`delivered reply to ${target}`); } - runtime.log?.(`delivered reply to ${target}`); } } diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts index cd61b825981..4471871b69b 100644 --- a/extensions/signal/src/outbound-adapter.ts +++ b/extensions/signal/src/outbound-adapter.ts @@ -1,6 +1,11 @@ import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + attachChannelToResults, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { markdownToSignalTextChunks } from "./format.js"; @@ -53,9 +58,9 @@ export const signalOutbound: ChannelOutboundAdapter = { textMode: "plain", textStyles: chunk.styles, }); - results.push({ channel: "signal" as const, ...result }); + results.push(result); } - return results; + return attachChannelToResults("signal", results); }, sendFormattedMedia: async ({ cfg, @@ -89,34 +94,35 @@ export const signalOutbound: ChannelOutboundAdapter = { textStyles: formatted.styles, mediaLocalRoots, }); - return { channel: "signal", ...result }; - }, - sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const result = await send(to, text, { - cfg, - maxBytes, - accountId: accountId ?? undefined, - }); - return { channel: "signal", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const result = await send(to, text, { - cfg, - mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - mediaLocalRoots, - }); - return { channel: "signal", ...result }; + return attachChannelToResult("signal", result); }, + ...createAttachedChannelResultAdapter({ + channel: "signal", + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + return await send(to, text, { + cfg, + maxBytes, + accountId: accountId ?? undefined, + }); + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + return await send(to, text, { + cfg, + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + mediaLocalRoots, + }); + }, + }), }; diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index e8d03f88b45..93b10d6522d 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; import { describe, expect, it, vi } from "vitest"; +import { slackOutbound } from "./outbound-adapter.js"; const handleSlackActionMock = vi.fn(); @@ -169,6 +170,79 @@ describe("slackPlugin outbound", () => { ); expect(result).toEqual({ channel: "slack", messageId: "m-media-local" }); }); + + it("sends block payload media first, then the final block message", async () => { + const sendSlack = vi + .fn() + .mockResolvedValueOnce({ messageId: "m-media-1" }) + .mockResolvedValueOnce({ messageId: "m-media-2" }) + .mockResolvedValueOnce({ messageId: "m-final" }); + const sendPayload = slackOutbound.sendPayload; + expect(sendPayload).toBeDefined(); + + const result = await sendPayload!({ + cfg, + to: "C999", + text: "", + payload: { + text: "hello", + mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"], + channelData: { + slack: { + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: "Block body", + }, + }, + ], + }, + }, + }, + accountId: "default", + deps: { sendSlack }, + mediaLocalRoots: ["/tmp/media"], + }); + + expect(sendSlack).toHaveBeenCalledTimes(3); + expect(sendSlack).toHaveBeenNthCalledWith( + 1, + "C999", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/1.png", + mediaLocalRoots: ["/tmp/media"], + }), + ); + expect(sendSlack).toHaveBeenNthCalledWith( + 2, + "C999", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/2.png", + mediaLocalRoots: ["/tmp/media"], + }), + ); + expect(sendSlack).toHaveBeenNthCalledWith( + 3, + "C999", + "hello", + expect.objectContaining({ + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: "Block body", + }, + }, + ], + }), + ); + expect(result).toEqual({ channel: "slack", messageId: "m-final" }); + }); }); describe("slackPlugin directory", () => { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 1942d3674ed..379d0537e2b 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -6,8 +6,10 @@ import { import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createPairingPrefixStripper, + createScopedAccountReplyToModeResolver, createRuntimeDirectoryLiveAdapter, createTextPairingAdapter, resolveOutboundSendDep, @@ -374,8 +376,10 @@ export const slackPlugin: ChannelPlugin = { resolveToolPolicy: resolveSlackGroupToolPolicy, }, threading: { - resolveReplyToMode: ({ cfg, accountId, chatType }) => - resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType), + }), allowExplicitReplyTagsWhenOff: false, buildToolContext: (params) => buildSlackThreadingToolContext(params), resolveAutoThreadId: ({ cfg, accountId, to, toolContext, replyToId }) => @@ -479,50 +483,51 @@ export const slackPlugin: ChannelPlugin = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { - const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ - cfg, - accountId: accountId ?? undefined, - deps, - replyToId, - threadId, - }); - const result = await send(to, text, { - cfg, - threadTs: threadTsValue != null ? String(threadTsValue) : undefined, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - return { channel: "slack", ...result }; - }, - sendMedia: async ({ - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - cfg, - }) => { - const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ - cfg, - accountId: accountId ?? undefined, - deps, - replyToId, - threadId, - }); - const result = await send(to, text, { - cfg, + ...createAttachedChannelResultAdapter({ + channel: "slack", + sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { + const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ + cfg, + accountId: accountId ?? undefined, + deps, + replyToId, + threadId, + }); + return await send(to, text, { + cfg, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, + accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), + }); + }, + sendMedia: async ({ + to, + text, mediaUrl, mediaLocalRoots, - threadTs: threadTsValue != null ? String(threadTsValue) : undefined, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - return { channel: "slack", ...result }; - }, + accountId, + deps, + replyToId, + threadId, + cfg, + }) => { + const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ + cfg, + accountId: accountId ?? undefined, + deps, + replyToId, + threadId, + }); + return await send(to, text, { + cfg, + mediaUrl, + mediaLocalRoots, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, + accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index a8ef26510f0..935adaab3bc 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -1,4 +1,5 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; @@ -44,7 +45,7 @@ export async function deliverReplies(params: { continue; } - if (mediaList.length === 0) { + if (mediaList.length === 0 && slackBlocks?.length) { const trimmed = text.trim(); if (!trimmed && !slackBlocks?.length) { continue; @@ -59,21 +60,44 @@ export async function deliverReplies(params: { ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), ...(params.identity ? { identity: params.identity } : {}), }); - } else { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - first = false; - await sendMessageSlack(params.target, caption, { + params.runtime.log?.(`delivered reply to ${params.target}`); + continue; + } + + const delivered = await deliverTextOrMediaReply({ + payload, + text, + chunkText: + mediaList.length === 0 + ? (value) => { + const trimmed = value.trim(); + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { + return []; + } + return [trimmed]; + } + : undefined, + sendText: async (trimmed) => { + await sendMessageSlack(params.target, trimmed, { + token: params.token, + threadTs, + accountId: params.accountId, + ...(params.identity ? { identity: params.identity } : {}), + }); + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageSlack(params.target, caption ?? "", { token: params.token, mediaUrl, threadTs, accountId: params.accountId, ...(params.identity ? { identity: params.identity } : {}), }); - } + }, + }); + if (delivered !== "empty") { + params.runtime.log?.(`delivered reply to ${params.target}`); } - params.runtime.log?.(`delivered reply to ${params.target}`); } } diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 42888ea12b4..ed107d4c63f 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -1,10 +1,14 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceAndFinalize, sendTextMediaPayload, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import { resolveInteractiveTextFallback, @@ -96,7 +100,6 @@ async function sendSlackOutboundMessage(params: { }); if (hookResult.cancelled) { return { - channel: "slack" as const, messageId: "cancelled-by-hook", channelId: params.to, meta: { cancelled: true }, @@ -114,7 +117,7 @@ async function sendSlackOutboundMessage(params: { ...(params.blocks ? { blocks: params.blocks } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}), }); - return { channel: "slack" as const, ...result }; + return result; } function resolveSlackBlocks(payload: { @@ -166,75 +169,54 @@ export const slackOutbound: ChannelOutboundAdapter = { }); } const mediaUrls = resolvePayloadMediaUrls(payload); - if (mediaUrls.length === 0) { - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: payload.text ?? "", - mediaLocalRoots: ctx.mediaLocalRoots, - blocks, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }); - } - await sendPayloadMediaSequence({ - text: "", - mediaUrls, - send: async ({ text, mediaUrl }) => - await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text, - mediaUrl, - mediaLocalRoots: ctx.mediaLocalRoots, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }), - }); - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: payload.text ?? "", - mediaLocalRoots: ctx.mediaLocalRoots, - blocks, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }); + return attachChannelToResult( + "slack", + await sendPayloadMediaSequenceAndFinalize({ + text: "", + mediaUrls, + send: async ({ text, mediaUrl }) => + await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text, + mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }), + finalize: async () => + await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text: payload.text ?? "", + mediaLocalRoots: ctx.mediaLocalRoots, + blocks, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }), + }), + ); }, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { - return await sendSlackOutboundMessage({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - identity, - }); - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - identity, - }) => { - return await sendSlackOutboundMessage({ + ...createAttachedChannelResultAdapter({ + channel: "slack", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => + await sendSlackOutboundMessage({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + identity, + }), + sendMedia: async ({ cfg, to, text, @@ -245,6 +227,18 @@ export const slackOutbound: ChannelOutboundAdapter = { replyToId, threadId, identity, - }); - }, + }) => + await sendSlackOutboundMessage({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + identity, + }), + }), }; diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 65f6203a57e..547013dc398 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -5,6 +5,7 @@ import { fetchWithSsrFGuard, withTrustedEnvProxyGuardedFetchMode, } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload"; import { chunkMarkdownTextWithMode, resolveChunkMode, @@ -310,9 +311,7 @@ export async function sendMessageSlack( const chunks = markdownChunks.flatMap((markdown) => markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }), ); - if (!chunks.length && trimmedMessage) { - chunks.push(trimmedMessage); - } + const resolvedChunks = resolveTextChunksWithFallback(trimmedMessage, chunks); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024 @@ -320,7 +319,7 @@ export async function sendMessageSlack( let lastMessageId = ""; if (opts.mediaUrl) { - const [firstChunk, ...rest] = chunks; + const [firstChunk, ...rest] = resolvedChunks; lastMessageId = await uploadSlackFile({ client, channelId, @@ -341,7 +340,7 @@ export async function sendMessageSlack( lastMessageId = response.ts ?? lastMessageId; } } else { - for (const chunk of chunks.length ? chunks : [""]) { + for (const chunk of resolvedChunks.length ? resolvedChunks : [""]) { const response = await postSlackMessageBestEffort({ client, channelId, diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 1b53185cb0f..9617dc129ae 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -13,6 +13,7 @@ import { projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { + attachChannelToResult, createEmptyChannelDirectoryAdapter, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; @@ -188,7 +189,7 @@ export function createSynologyChatPlugin() { if (!ok) { throw new Error("Failed to send message to Synology Chat"); } - return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to }; + return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to }); }, sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => { @@ -205,7 +206,7 @@ export function createSynologyChatPlugin() { if (!ok) { throw new Error("Failed to send media to Synology Chat"); } - return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to }; + return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to }); }, }, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index d37b65fc447..6cfed61829e 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -5,8 +5,11 @@ import { import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { + attachChannelToResult, + createAttachedChannelResultAdapter, createChannelDirectoryAdapter, createPairingPrefixStripper, + createTopLevelChannelReplyToModeResolver, createTextPairingAdapter, normalizeMessageChannel, type OutboundSendDeps, @@ -358,7 +361,7 @@ export const telegramPlugin: ChannelPlugin cfg.channels?.telegram?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"), resolveAutoThreadId: ({ to, toolContext, replyToId }) => replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }), }, @@ -496,34 +499,22 @@ export const telegramPlugin: ChannelPlugin { - const result = await sendTelegramOutbound({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - silent, - }); - return { channel: "telegram", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - silent, - }) => { - const result = await sendTelegramOutbound({ + ...createAttachedChannelResultAdapter({ + channel: "telegram", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => + await sendTelegramOutbound({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + silent, + }), + sendMedia: async ({ cfg, to, text, @@ -534,17 +525,28 @@ export const telegramPlugin: ChannelPlugin - await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { - cfg, - accountId: accountId ?? undefined, - messageThreadId: parseTelegramThreadId(threadId), - silent: silent ?? undefined, - isAnonymous: isAnonymous ?? undefined, - }), + }) => + await sendTelegramOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + }), + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) => + await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { + cfg, + accountId: accountId ?? undefined, + messageThreadId: parseTelegramThreadId(threadId), + silent: silent ?? undefined, + isAnonymous: isAnonymous ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 16ef036d93d..b5cb70a2c66 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -1,9 +1,13 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceOrFallback, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; @@ -75,17 +79,16 @@ export async function sendTelegramPayloadMessages(params: { quoteText, }; - if (mediaUrls.length === 0) { - return await params.send(params.to, text, { - ...payloadOpts, - buttons, - }); - } - // Telegram allows reply_markup on media; attach buttons only to the first send. - const finalResult = await sendPayloadMediaSequence({ + return await sendPayloadMediaSequenceOrFallback({ text, mediaUrls, + fallbackResult: { messageId: "unknown", chatId: params.to }, + sendNoMedia: async () => + await params.send(params.to, text, { + ...payloadOpts, + buttons, + }), send: async ({ text, mediaUrl, isFirst }) => await params.send(params.to, text, { ...payloadOpts, @@ -93,7 +96,6 @@ export async function sendTelegramPayloadMessages(params: { ...(isFirst ? { buttons } : {}), }), }); - return finalResult ?? { messageId: "unknown", chatId: params.to }; } export const telegramOutbound: ChannelOutboundAdapter = { @@ -104,46 +106,47 @@ export const telegramOutbound: ChannelOutboundAdapter = { shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { - const { send, baseOpts } = resolveTelegramSendContext({ + ...createAttachedChannelResultAdapter({ + channel: "telegram", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + return await send(to, text, { + ...baseOpts, + }); + }, + sendMedia: async ({ cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await send(to, text, { - ...baseOpts, - }); - return { channel: "telegram", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - forceDocument, - }) => { - const { send, baseOpts } = resolveTelegramSendContext({ - cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await send(to, text, { - ...baseOpts, + to, + text, mediaUrl, mediaLocalRoots, - forceDocument: forceDocument ?? false, - }); - return { channel: "telegram", ...result }; - }, + accountId, + deps, + replyToId, + threadId, + forceDocument, + }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + return await send(to, text, { + ...baseOpts, + mediaUrl, + mediaLocalRoots, + forceDocument: forceDocument ?? false, + }); + }, + }), sendPayload: async ({ cfg, to, @@ -172,6 +175,6 @@ export const telegramOutbound: ChannelOutboundAdapter = { forceDocument: forceDocument ?? false, }, }); - return { channel: "telegram", ...result }; + return attachChannelToResult("telegram", result); }, }; diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 6d9d8b541ae..92501c46fdd 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -1,4 +1,8 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -52,11 +56,7 @@ export async function deliverWebReply(params: { convertMarkdownTables(replyResult.text || "", tableMode), ); const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode); - const mediaList = replyResult.mediaUrls?.length - ? replyResult.mediaUrls - : replyResult.mediaUrl - ? [replyResult.mediaUrl] - : []; + const mediaList = resolveOutboundMediaUrls(replyResult); const sendWithRetry = async (fn: () => Promise, label: string, maxAttempts = 3) => { let lastErr: unknown; @@ -114,9 +114,11 @@ export async function deliverWebReply(params: { const remainingText = [...textChunks]; // Media (with optional caption on first item) - for (const [index, mediaUrl] of mediaList.entries()) { - const caption = index === 0 ? remainingText.shift() || undefined : undefined; - try { + const leadingCaption = remainingText.shift() || ""; + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList, + caption: leadingCaption, + send: async ({ mediaUrl, caption }) => { const media = await loadWebMedia(mediaUrl, { maxBytes: maxMediaBytes, localRoots: params.mediaLocalRoots, @@ -189,21 +191,24 @@ export async function deliverWebReply(params: { }, "auto-reply sent (media)", ); - } catch (err) { - whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(err)}`); - replyLogger.warn({ err, mediaUrl }, "failed to send web media reply"); - if (index === 0) { - const warning = - err instanceof Error ? `⚠️ Media failed: ${err.message}` : "⚠️ Media failed."; - const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean); - const fallbackText = fallbackTextParts.join("\n"); - if (fallbackText) { - whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); - await msg.reply(fallbackText); - } + }, + onError: async ({ error, mediaUrl, caption, isFirst }) => { + whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(error)}`); + replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply"); + if (!isFirst) { + return; } - } - } + const warning = + error instanceof Error ? `⚠️ Media failed: ${error.message}` : "⚠️ Media failed."; + const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean); + const fallbackText = fallbackTextParts.join("\n"); + if (!fallbackText) { + return; + } + whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); + await msg.reply(fallbackText); + }, + }); // Remaining text chunks after media for (const chunk of remainingText) { diff --git a/extensions/whatsapp/src/outbound-adapter.poll.test.ts b/extensions/whatsapp/src/outbound-adapter.poll.test.ts index 46c9696cc98..5e23748a233 100644 --- a/extensions/whatsapp/src/outbound-adapter.poll.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.poll.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../../src/config/config.js"; const hoisted = vi.hoisted(() => ({ sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })), + sendReactionWhatsApp: vi.fn(async () => undefined), })); vi.mock("../../../src/globals.js", () => ({ @@ -11,6 +12,7 @@ vi.mock("../../../src/globals.js", () => ({ vi.mock("./send.js", () => ({ sendPollWhatsApp: hoisted.sendPollWhatsApp, + sendReactionWhatsApp: hoisted.sendReactionWhatsApp, })); import { whatsappOutbound } from "./outbound-adapter.js"; @@ -36,6 +38,10 @@ describe("whatsappOutbound sendPoll", () => { accountId: "work", cfg, }); - expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" }); + expect(result).toEqual({ + channel: "whatsapp", + messageId: "poll-1", + toJid: "1555@s.whatsapp.net", + }); }); }); diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index ffc0306d80b..d9710afb557 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -1,6 +1,10 @@ import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAttachedChannelResultAdapter, + createEmptyChannelResult, +} from "openclaw/plugin-sdk/channel-send-result"; import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; @@ -22,7 +26,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = { const text = trimLeadingWhitespace(ctx.payload.text); const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0; if (!text && !hasMedia) { - return { channel: "whatsapp", messageId: "" }; + return createEmptyChannelResult("whatsapp"); } return await sendTextMediaPayload({ channel: "whatsapp", @@ -36,41 +40,51 @@ export const whatsappOutbound: ChannelOutboundAdapter = { adapter: whatsappOutbound, }); }, - sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { - const normalizedText = trimLeadingWhitespace(text); - if (!normalizedText) { - return { channel: "whatsapp", messageId: "" }; - } - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? - (await import("./send.js")).sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { - const normalizedText = trimLeadingWhitespace(text); - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? - (await import("./send.js")).sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "whatsapp", + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { + const normalizedText = trimLeadingWhitespace(text); + if (!normalizedText) { + return createEmptyChannelResult("whatsapp"); + } + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? + (await import("./send.js")).sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - accountId: accountId ?? undefined, + accountId, + deps, gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), - accountId: accountId ?? undefined, - cfg, - }), + }) => { + const normalizedText = trimLeadingWhitespace(text); + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? + (await import("./send.js")).sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + cfg, + }), + }), }; diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 8bd6be02612..b8d11b50937 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -8,7 +8,12 @@ import { buildOpenGroupPolicyWarning, createOpenProviderGroupPolicyWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; -import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-runtime"; +import { + createChannelDirectoryAdapter, + createEmptyChannelResult, + createRawChannelSendResultAdapter, + createStaticReplyToModeResolver, +} from "openclaw/plugin-sdk/channel-runtime"; import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { @@ -23,7 +28,6 @@ import { buildBaseAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, - buildChannelSendResult, DEFAULT_ACCOUNT_ID, chunkTextForOutbound, formatAllowFromLowercase, @@ -150,7 +154,7 @@ export const zaloPlugin: ChannelPlugin = { resolveRequireMention: () => true, }, threading: { - resolveReplyToMode: () => "off", + resolveReplyToMode: createStaticReplyToModeResolver("off"), }, actions: zaloMessageActions, messaging: { @@ -189,31 +193,30 @@ export const zaloPlugin: ChannelPlugin = { chunker: zaloPlugin.outbound!.chunker, sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx), sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx), - emptyResult: { channel: "zalo", messageId: "" }, + emptyResult: createEmptyChannelResult("zalo"), }), - sendText: async ({ to, text, accountId, cfg }) => { - const result = await ( - await loadZaloChannelRuntime() - ).sendZaloText({ - to, - text, - accountId: accountId ?? undefined, - cfg: cfg, - }); - return buildChannelSendResult("zalo", result); - }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { - const result = await ( - await loadZaloChannelRuntime() - ).sendZaloText({ - to, - text, - accountId: accountId ?? undefined, - mediaUrl, - cfg: cfg, - }); - return buildChannelSendResult("zalo", result); - }, + ...createRawChannelSendResultAdapter({ + channel: "zalo", + sendText: async ({ to, text, accountId, cfg }) => + await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, + accountId: accountId ?? undefined, + cfg: cfg, + }), + sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => + await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, + accountId: accountId ?? undefined, + mediaUrl, + cfg: cfg, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 8452fb661e2..768c556fd7b 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -32,15 +32,14 @@ import { createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, + deliverTextOrMediaReply, issuePairingChallenge, - logTypingFailure, - resolveDirectDmAuthorizationOutcome, - resolveSenderCommandAuthorizationWithRuntime, - resolveOutboundMediaUrls, - resolveDefaultGroupPolicy, - resolveInboundRouteEnvelopeBuilderWithRuntime, - sendMediaWithLeadingCaption, resolveWebhookPath, + logTypingFailure, + resolveDefaultGroupPolicy, + resolveDirectDmAuthorizationOutcome, + resolveInboundRouteEnvelopeBuilderWithRuntime, + resolveSenderCommandAuthorizationWithRuntime, waitForAbortSignal, warnMissingProviderGroupPolicyFallbackOnce, } from "./runtime-api.js"; @@ -581,33 +580,28 @@ async function deliverZaloReply(params: { const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params; const tableMode = params.tableMode ?? "code"; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const sentMedia = await sendMediaWithLeadingCaption({ - mediaUrls: resolveOutboundMediaUrls(payload), - caption: text, - send: async ({ mediaUrl, caption }) => { - await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); - statusSink?.({ lastOutboundAt: Date.now() }); - }, - onError: (error) => { - runtime.error?.(`Zalo photo send failed: ${String(error)}`); - }, - }); - if (sentMedia) { - return; - } - - if (text) { - const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); - const chunks = core.channel.text.chunkMarkdownTextWithMode(text, ZALO_TEXT_LIMIT, chunkMode); - for (const chunk of chunks) { + const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); + await deliverTextOrMediaReply({ + payload, + text, + chunkText: (value) => + core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode), + sendText: async (chunk) => { try { await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`Zalo message send failed: ${String(err)}`); } - } - } + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onMediaError: (error) => { + runtime.error?.(`Zalo photo send failed: ${String(error)}`); + }, + }); } export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise { diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 629125fb120..b6cf6111580 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,7 +1,10 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { + createEmptyChannelResult, createPairingPrefixStripper, + createRawChannelSendResultAdapter, + createStaticReplyToModeResolver, createTextPairingAdapter, } from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -15,7 +18,6 @@ import type { GroupToolPolicyConfig, } from "../runtime-api.js"; import { - buildChannelSendResult, buildBaseAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, isDangerousNameMatchingEnabled, @@ -312,7 +314,7 @@ export const zalouserPlugin: ChannelPlugin = { resolveToolPolicy: resolveZalouserGroupToolPolicy, }, threading: { - resolveReplyToMode: () => "off", + resolveReplyToMode: createStaticReplyToModeResolver("off"), }, actions: zalouserMessageActions, messaging: { @@ -493,34 +495,35 @@ export const zalouserPlugin: ChannelPlugin = { ctx, sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx), sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx), - emptyResult: { channel: "zalouser", messageId: "" }, + emptyResult: createEmptyChannelResult("zalouser"), }), - sendText: async ({ to, text, accountId, cfg }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - const result = await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); - return buildChannelSendResult("zalouser", result); - }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - const result = await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - mediaUrl, - mediaLocalRoots, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); - return buildChannelSendResult("zalouser", result); - }, + ...createRawChannelSendResultAdapter({ + channel: "zalouser", + sendText: async ({ to, text, accountId, cfg }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); + }, + sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + mediaUrl, + mediaLocalRoots, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 5ae729c703e..d269345572c 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -21,17 +21,16 @@ import { createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, + deliverTextOrMediaReply, evaluateGroupRouteAccessForPolicy, isDangerousNameMatchingEnabled, issuePairingChallenge, - resolveOutboundMediaUrls, mergeAllowlist, resolveMentionGatingWithBypass, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, resolveSenderScopedGroupPolicy, - sendMediaWithLeadingCaption, summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "../runtime-api.js"; @@ -712,11 +711,24 @@ async function deliverZalouserReply(params: { const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, { fallbackLimit: ZALOUSER_TEXT_LIMIT, }); - - const sentMedia = await sendMediaWithLeadingCaption({ - mediaUrls: resolveOutboundMediaUrls(payload), - caption: text, - send: async ({ mediaUrl, caption }) => { + await deliverTextOrMediaReply({ + payload, + text, + sendText: async (chunk) => { + try { + await sendMessageZalouser(chatId, chunk, { + profile, + isGroup, + textMode: "markdown", + textChunkMode: chunkMode, + textChunkLimit, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error(`Zalouser message send failed: ${String(err)}`); + } + }, + sendMedia: async ({ mediaUrl, caption }) => { logVerbose(core, runtime, `Sending media to ${chatId}`); await sendMessageZalouser(chatId, caption ?? "", { profile, @@ -728,28 +740,10 @@ async function deliverZalouserReply(params: { }); statusSink?.({ lastOutboundAt: Date.now() }); }, - onError: (error) => { + onMediaError: (error) => { runtime.error(`Zalouser media send failed: ${String(error)}`); }, }); - if (sentMedia) { - return; - } - - if (text) { - try { - await sendMessageZalouser(chatId, text, { - profile, - isGroup, - textMode: "markdown", - textChunkMode: chunkMode, - textChunkLimit, - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error(`Zalouser message send failed: ${String(err)}`); - } - } } export async function monitorZalouserProvider( diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 555c9e54bb7..e55bea9d053 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -13,6 +13,7 @@ "setup-tools", "config-runtime", "reply-runtime", + "reply-payload", "channel-runtime", "interactive-runtime", "infra-runtime", @@ -88,6 +89,7 @@ "channel-config-schema", "channel-lifecycle", "channel-policy", + "channel-send-result", "group-access", "directory-runtime", "json-store", diff --git a/src/channels/plugins/outbound/direct-text-media.test.ts b/src/channels/plugins/outbound/direct-text-media.test.ts new file mode 100644 index 00000000000..de979a7704d --- /dev/null +++ b/src/channels/plugins/outbound/direct-text-media.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { + sendPayloadMediaSequenceAndFinalize, + sendPayloadMediaSequenceOrFallback, +} from "./direct-text-media.js"; + +describe("sendPayloadMediaSequenceOrFallback", () => { + it("uses the no-media sender when no media entries exist", async () => { + const send = vi.fn(); + const sendNoMedia = vi.fn(async () => ({ messageId: "text-1" })); + + await expect( + sendPayloadMediaSequenceOrFallback({ + text: "hello", + mediaUrls: [], + send, + sendNoMedia, + fallbackResult: { messageId: "" }, + }), + ).resolves.toEqual({ messageId: "text-1" }); + + expect(send).not.toHaveBeenCalled(); + expect(sendNoMedia).toHaveBeenCalledOnce(); + }); + + it("returns the last media send result and clears text after the first media", async () => { + const calls: Array<{ text: string; mediaUrl: string; isFirst: boolean }> = []; + + await expect( + sendPayloadMediaSequenceOrFallback({ + text: "caption", + mediaUrls: ["a", "b"], + send: async ({ text, mediaUrl, isFirst }) => { + calls.push({ text, mediaUrl, isFirst }); + return { messageId: mediaUrl }; + }, + fallbackResult: { messageId: "" }, + }), + ).resolves.toEqual({ messageId: "b" }); + + expect(calls).toEqual([ + { text: "caption", mediaUrl: "a", isFirst: true }, + { text: "", mediaUrl: "b", isFirst: false }, + ]); + }); +}); + +describe("sendPayloadMediaSequenceAndFinalize", () => { + it("skips media sends and finalizes directly when no media entries exist", async () => { + const send = vi.fn(); + const finalize = vi.fn(async () => ({ messageId: "final-1" })); + + await expect( + sendPayloadMediaSequenceAndFinalize({ + text: "hello", + mediaUrls: [], + send, + finalize, + }), + ).resolves.toEqual({ messageId: "final-1" }); + + expect(send).not.toHaveBeenCalled(); + expect(finalize).toHaveBeenCalledOnce(); + }); + + it("sends the media sequence before the finalizing send", async () => { + const send = vi.fn(async ({ mediaUrl }: { mediaUrl: string }) => ({ messageId: mediaUrl })); + const finalize = vi.fn(async () => ({ messageId: "final-2" })); + + await expect( + sendPayloadMediaSequenceAndFinalize({ + text: "", + mediaUrls: ["a", "b"], + send, + finalize, + }), + ).resolves.toEqual({ messageId: "final-2" }); + + expect(send).toHaveBeenCalledTimes(2); + expect(finalize).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index ea813fcf75b..d6e13a4fce7 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -58,6 +58,41 @@ export async function sendPayloadMediaSequence(params: { return lastResult; } +export async function sendPayloadMediaSequenceOrFallback(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; + fallbackResult: TResult; + sendNoMedia?: () => Promise; +}): Promise { + if (params.mediaUrls.length === 0) { + return params.sendNoMedia ? await params.sendNoMedia() : params.fallbackResult; + } + return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult; +} + +export async function sendPayloadMediaSequenceAndFinalize(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; + finalize: () => Promise; +}): Promise { + if (params.mediaUrls.length > 0) { + await sendPayloadMediaSequence(params); + } + return await params.finalize(); +} + export async function sendTextMediaPayload(params: { channel: string; ctx: SendPayloadContext; diff --git a/src/channels/plugins/threading-helpers.test.ts b/src/channels/plugins/threading-helpers.test.ts new file mode 100644 index 00000000000..48688d33ed0 --- /dev/null +++ b/src/channels/plugins/threading-helpers.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + createScopedAccountReplyToModeResolver, + createStaticReplyToModeResolver, + createTopLevelChannelReplyToModeResolver, +} from "./threading-helpers.js"; + +describe("createStaticReplyToModeResolver", () => { + it("always returns the configured mode", () => { + expect(createStaticReplyToModeResolver("off")({ cfg: {} as OpenClawConfig })).toBe("off"); + expect(createStaticReplyToModeResolver("all")({ cfg: {} as OpenClawConfig })).toBe("all"); + }); +}); + +describe("createTopLevelChannelReplyToModeResolver", () => { + it("reads the top-level channel config", () => { + const resolver = createTopLevelChannelReplyToModeResolver("discord"); + expect( + resolver({ + cfg: { channels: { discord: { replyToMode: "first" } } } as OpenClawConfig, + }), + ).toBe("first"); + }); + + it("falls back to off", () => { + const resolver = createTopLevelChannelReplyToModeResolver("discord"); + expect(resolver({ cfg: {} as OpenClawConfig })).toBe("off"); + }); +}); + +describe("createScopedAccountReplyToModeResolver", () => { + it("reads the scoped account reply mode", () => { + const resolver = createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + (( + cfg.channels as { + matrix?: { accounts?: Record }; + } + ).matrix?.accounts?.[accountId?.toLowerCase() ?? "default"] ?? {}) as { + replyToMode?: "off" | "first" | "all"; + }, + resolveReplyToMode: (account) => account.replyToMode, + }); + + const cfg = { + channels: { + matrix: { + accounts: { + assistant: { replyToMode: "all" }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolver({ cfg, accountId: "assistant" })).toBe("all"); + expect(resolver({ cfg, accountId: "default" })).toBe("off"); + }); + + it("passes chatType through", () => { + const seen: Array = []; + const resolver = createScopedAccountReplyToModeResolver({ + resolveAccount: () => ({ replyToMode: "first" as const }), + resolveReplyToMode: (account, chatType) => { + seen.push(chatType); + return account.replyToMode; + }, + }); + + expect(resolver({ cfg: {} as OpenClawConfig, chatType: "group" })).toBe("first"); + expect(seen).toEqual(["group"]); + }); +}); diff --git a/src/channels/plugins/threading-helpers.ts b/src/channels/plugins/threading-helpers.ts new file mode 100644 index 00000000000..360e4a7048b --- /dev/null +++ b/src/channels/plugins/threading-helpers.ts @@ -0,0 +1,32 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ReplyToMode } from "../../config/types.base.js"; +import type { ChannelThreadingAdapter } from "./types.core.js"; + +type ReplyToModeResolver = NonNullable; + +export function createStaticReplyToModeResolver(mode: ReplyToMode): ReplyToModeResolver { + return () => mode; +} + +export function createTopLevelChannelReplyToModeResolver(channelId: string): ReplyToModeResolver { + return ({ cfg }) => { + const channelConfig = ( + cfg.channels as Record | undefined + )?.[channelId]; + return channelConfig?.replyToMode ?? "off"; + }; +} + +export function createScopedAccountReplyToModeResolver(params: { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount; + resolveReplyToMode: ( + account: TAccount, + chatType?: string | null, + ) => ReplyToMode | null | undefined; + fallback?: ReplyToMode; +}): ReplyToModeResolver { + return ({ cfg, accountId, chatType }) => + params.resolveReplyToMode(params.resolveAccount(cfg, accountId), chatType) ?? + params.fallback ?? + "off"; +} diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts index c798e7fe3ca..efbd832dd09 100644 --- a/src/channels/plugins/whatsapp-shared.ts +++ b/src/channels/plugins/whatsapp-shared.ts @@ -1,4 +1,5 @@ import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js"; +import { createAttachedChannelResultAdapter } from "../../plugin-sdk/channel-send-result.js"; import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js"; import { escapeRegExp } from "../../utils.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; @@ -62,48 +63,49 @@ export function createWhatsAppOutboundBase({ textChunkLimit: 4000, pollMaxOptions: 12, resolveTarget, - sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { - const normalizedText = normalizeText(text); - if (skipEmptyText && !normalizedText) { - return { channel: "whatsapp", messageId: "" }; - } - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - gifPlayback, - }) => { - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; - const result = await send(to, normalizeText(text), { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "whatsapp", + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { + const normalizedText = normalizeText(text); + if (skipEmptyText && !normalizedText) { + return { messageId: "" }; + } + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - accountId: accountId ?? undefined, + accountId, + deps, gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), - accountId: accountId ?? undefined, - cfg, - }), + }) => { + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; + return await send(to, normalizeText(text), { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + cfg, + }), + }), }; } diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 4dcdd1f61f9..5cf36e39af2 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -13,6 +13,7 @@ import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; +import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js"; import { normalizePollInput } from "../../polls.js"; import { ErrorCodes, @@ -210,8 +211,8 @@ export const sendHandlers: GatewayRequestHandlers = { .map((payload) => payload.text) .filter(Boolean) .join("\n"); - const mirrorMediaUrls = mirrorPayloads.flatMap( - (payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + const mirrorMediaUrls = mirrorPayloads.flatMap((payload) => + resolveOutboundMediaUrls(payload), ); const providedSessionKey = typeof request.sessionKey === "string" && request.sessionKey.trim() diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 452875d9cff..b8bbc115988 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -26,6 +26,10 @@ import { import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +import { + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, +} from "../../plugin-sdk/reply-payload.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { throwIfAborted } from "./abort.js"; import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; @@ -338,7 +342,7 @@ function normalizePayloadsForChannelDelivery( function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload { return { text: payload.text ?? "", - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + mediaUrls: resolveOutboundMediaUrls(payload), interactive: payload.interactive, channelData: payload.channelData, }; @@ -721,22 +725,27 @@ async function deliverOutboundPayloadsCore( continue; } - let first = true; let lastMessageId: string | undefined; - for (const url of payloadSummary.mediaUrls) { - throwIfAborted(abortSignal); - const caption = first ? payloadSummary.text : ""; - first = false; - if (handler.sendFormattedMedia) { - const delivery = await handler.sendFormattedMedia(caption, url, sendOverrides); + await sendMediaWithLeadingCaption({ + mediaUrls: payloadSummary.mediaUrls, + caption: payloadSummary.text, + send: async ({ mediaUrl, caption }) => { + throwIfAborted(abortSignal); + if (handler.sendFormattedMedia) { + const delivery = await handler.sendFormattedMedia( + caption ?? "", + mediaUrl, + sendOverrides, + ); + results.push(delivery); + lastMessageId = delivery.messageId; + return; + } + const delivery = await handler.sendMedia(caption ?? "", mediaUrl, sendOverrides); results.push(delivery); lastMessageId = delivery.messageId; - } else { - const delivery = await handler.sendMedia(caption, url, sendOverrides); - results.push(delivery); - lastMessageId = delivery.messageId; - } - } + }, + }); emitMessageSent({ success: true, content: payloadSummary.text, diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index d6e27b8a65f..806e3285aca 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js"; +import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js"; import type { PollInput } from "../../polls.js"; import { normalizePollInput } from "../../polls.js"; import { @@ -202,8 +203,8 @@ export async function sendMessage(params: MessageSendParams): Promise payload.text) .filter(Boolean) .join("\n"); - const mirrorMediaUrls = normalizedPayloads.flatMap( - (payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + const mirrorMediaUrls = normalizedPayloads.flatMap((payload) => + resolveOutboundMediaUrls(payload), ); const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index d98bf22c218..fa9790888a4 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -11,6 +11,7 @@ import { hasReplyContent, type InteractiveReply, } from "../../interactive/payload.js"; +import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js"; export type NormalizedOutboundPayload = { text: string; @@ -96,7 +97,7 @@ export function normalizeOutboundPayloads( ): NormalizedOutboundPayload[] { const normalizedPayloads: NormalizedOutboundPayload[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaUrls = resolveOutboundMediaUrls(payload); const interactive = payload.interactive; const channelData = payload.channelData; const hasChannelData = hasReplyChannelData(channelData); @@ -127,10 +128,11 @@ export function normalizeOutboundPayloadsForJson( ): OutboundPayloadJson[] { const normalized: OutboundPayloadJson[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { + const mediaUrls = resolveOutboundMediaUrls(payload); normalized.push({ text: payload.text ?? "", mediaUrl: payload.mediaUrl ?? null, - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined), + mediaUrls: mediaUrls.length ? mediaUrls : undefined, interactive: payload.interactive, channelData: payload.channelData, }); diff --git a/src/line/auto-reply-delivery.ts b/src/line/auto-reply-delivery.ts index aa5443a536e..aea6210dda4 100644 --- a/src/line/auto-reply-delivery.ts +++ b/src/line/auto-reply-delivery.ts @@ -1,5 +1,6 @@ import type { messagingApi } from "@line/bot-sdk"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { resolveOutboundMediaUrls } from "../plugin-sdk/reply-payload.js"; import type { FlexContainer } from "./flex-templates.js"; import type { ProcessedLineMessage } from "./markdown-to-line.js"; import type { SendLineReplyChunksParams } from "./reply-chunks.js"; @@ -123,7 +124,7 @@ export async function deliverLineAutoReply(params: { const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : []; - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaUrls = resolveOutboundMediaUrls(payload); const mediaMessages = mediaUrls .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)) diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index a7630924997..67e4ceef1ea 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -42,6 +42,7 @@ export * from "../channels/plugins/outbound/interactive.js"; export * from "../channels/plugins/pairing-adapters.js"; export * from "../channels/plugins/runtime-forwarders.js"; export * from "../channels/plugins/target-resolvers.js"; +export * from "../channels/plugins/threading-helpers.js"; export * from "../channels/plugins/status-issues/shared.js"; export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; @@ -49,6 +50,7 @@ export * from "../polls.js"; export * from "../utils/message-channel.js"; export * from "../whatsapp/normalize.js"; export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js"; +export * from "./channel-send-result.js"; export * from "./channel-lifecycle.js"; export * from "./directory-runtime.js"; export type { diff --git a/src/plugin-sdk/channel-send-result.test.ts b/src/plugin-sdk/channel-send-result.test.ts new file mode 100644 index 00000000000..37d29a5a190 --- /dev/null +++ b/src/plugin-sdk/channel-send-result.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + attachChannelToResult, + attachChannelToResults, + buildChannelSendResult, + createAttachedChannelResultAdapter, + createEmptyChannelResult, + createRawChannelSendResultAdapter, +} from "./channel-send-result.js"; + +describe("attachChannelToResult", () => { + it("preserves the existing result shape and stamps the channel", () => { + expect( + attachChannelToResult("discord", { + messageId: "m1", + ok: true, + extra: "value", + }), + ).toEqual({ + channel: "discord", + messageId: "m1", + ok: true, + extra: "value", + }); + }); +}); + +describe("attachChannelToResults", () => { + it("stamps each result in a list with the shared channel id", () => { + expect( + attachChannelToResults("signal", [ + { messageId: "m1", timestamp: 1 }, + { messageId: "m2", timestamp: 2 }, + ]), + ).toEqual([ + { channel: "signal", messageId: "m1", timestamp: 1 }, + { channel: "signal", messageId: "m2", timestamp: 2 }, + ]); + }); +}); + +describe("buildChannelSendResult", () => { + it("normalizes raw send results", () => { + const result = buildChannelSendResult("zalo", { + ok: false, + messageId: null, + error: "boom", + }); + + expect(result.channel).toBe("zalo"); + expect(result.ok).toBe(false); + expect(result.messageId).toBe(""); + expect(result.error).toEqual(new Error("boom")); + }); +}); + +describe("createEmptyChannelResult", () => { + it("builds an empty outbound result with channel metadata", () => { + expect(createEmptyChannelResult("line", { chatId: "u1" })).toEqual({ + channel: "line", + messageId: "", + chatId: "u1", + }); + }); +}); + +describe("createAttachedChannelResultAdapter", () => { + it("wraps outbound delivery and poll results", async () => { + const adapter = createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async () => ({ messageId: "m1", channelId: "c1" }), + sendMedia: async () => ({ messageId: "m2" }), + sendPoll: async () => ({ messageId: "m3", pollId: "p1" }), + }); + + await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "discord", + messageId: "m1", + channelId: "c1", + }); + await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "discord", + messageId: "m2", + }); + await expect( + adapter.sendPoll!({ + cfg: {} as never, + to: "x", + poll: { question: "t", options: ["a", "b"] }, + }), + ).resolves.toEqual({ + channel: "discord", + messageId: "m3", + pollId: "p1", + }); + }); +}); + +describe("createRawChannelSendResultAdapter", () => { + it("normalizes raw send results", async () => { + const adapter = createRawChannelSendResultAdapter({ + channel: "zalo", + sendText: async () => ({ ok: true, messageId: "m1" }), + sendMedia: async () => ({ ok: false, error: "boom" }), + }); + + await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "zalo", + ok: true, + messageId: "m1", + error: undefined, + }); + await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "zalo", + ok: false, + messageId: "", + error: new Error("boom"), + }); + }); +}); diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts index b73df6f0448..12e74741264 100644 --- a/src/plugin-sdk/channel-send-result.ts +++ b/src/plugin-sdk/channel-send-result.ts @@ -1,9 +1,74 @@ +import type { ChannelOutboundAdapter, ChannelPollResult } from "../channels/plugins/types.js"; +import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; + export type ChannelSendRawResult = { ok: boolean; messageId?: string | null; error?: string | null; }; +export function attachChannelToResult(channel: string, result: T) { + return { + channel, + ...result, + }; +} + +export function attachChannelToResults(channel: string, results: readonly T[]) { + return results.map((result) => attachChannelToResult(channel, result)); +} + +export function createEmptyChannelResult( + channel: string, + result: Partial> & { + messageId?: string; + } = {}, +): OutboundDeliveryResult { + return attachChannelToResult(channel, { + messageId: "", + ...result, + }); +} + +type MaybePromise = T | Promise; +type SendTextParams = Parameters>[0]; +type SendMediaParams = Parameters>[0]; +type SendPollParams = Parameters>[0]; + +export function createAttachedChannelResultAdapter(params: { + channel: string; + sendText?: (ctx: SendTextParams) => MaybePromise>; + sendMedia?: (ctx: SendMediaParams) => MaybePromise>; + sendPoll?: (ctx: SendPollParams) => MaybePromise>; +}): Pick { + return { + sendText: params.sendText + ? async (ctx) => attachChannelToResult(params.channel, await params.sendText!(ctx)) + : undefined, + sendMedia: params.sendMedia + ? async (ctx) => attachChannelToResult(params.channel, await params.sendMedia!(ctx)) + : undefined, + sendPoll: params.sendPoll + ? async (ctx) => attachChannelToResult(params.channel, await params.sendPoll!(ctx)) + : undefined, + }; +} + +export function createRawChannelSendResultAdapter(params: { + channel: string; + sendText?: (ctx: SendTextParams) => MaybePromise; + sendMedia?: (ctx: SendMediaParams) => MaybePromise; +}): Pick { + return { + sendText: params.sendText + ? async (ctx) => buildChannelSendResult(params.channel, await params.sendText!(ctx)) + : undefined, + sendMedia: params.sendMedia + ? async (ctx) => buildChannelSendResult(params.channel, await params.sendMedia!(ctx)) + : undefined, + }; +} + /** Normalize raw channel send results into the shape shared outbound callers expect. */ export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) { return { diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts index 679b5109a5e..7870bc2f2fa 100644 --- a/src/plugin-sdk/discord-send.ts +++ b/src/plugin-sdk/discord-send.ts @@ -1,4 +1,5 @@ import type { DiscordSendResult } from "../../extensions/discord/api.js"; +import { attachChannelToResult } from "./channel-send-result.js"; type DiscordSendOptionInput = { replyToId?: string | null; @@ -32,5 +33,5 @@ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) /** Stamp raw Discord send results with the channel id expected by shared outbound flows. */ export function tagDiscordChannelResult(result: DiscordSendResult) { - return { channel: "discord" as const, ...result }; + return attachChannelToResult("discord", result); } diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 47ba490ec42..b64614348cb 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -76,6 +76,7 @@ export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/api.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, + deliverFormattedTextWithAttachments, formatTextWithAttachmentLinks, resolveOutboundMediaUrls, } from "./reply-payload.js"; diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 803dd999a62..02650a4a009 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -46,6 +46,7 @@ export { splitSetupEntries, } from "../channels/plugins/setup-wizard-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; +export { resolveOutboundMediaUrls } from "./reply-payload.js"; export type { BaseProbeResult, ChannelDirectoryEntry, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 4ce53e1ec15..e3be0cd868d 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -94,6 +94,7 @@ export { createPersistentDedupe } from "./persistent-dedupe.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, + deliverFormattedTextWithAttachments, formatTextWithAttachmentLinks, resolveOutboundMediaUrls, } from "./reply-payload.js"; diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts index 780b75686a1..171b17f0e7e 100644 --- a/src/plugin-sdk/reply-payload.test.ts +++ b/src/plugin-sdk/reply-payload.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from "vitest"; -import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js"; +import { describe, expect, it, vi } from "vitest"; +import { + deliverFormattedTextWithAttachments, + deliverTextOrMediaReply, + isNumericTargetId, + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, + sendPayloadWithChunkedTextAndMedia, +} from "./reply-payload.js"; describe("sendPayloadWithChunkedTextAndMedia", () => { it("returns empty result when payload has no text and no media", async () => { @@ -56,3 +64,155 @@ describe("sendPayloadWithChunkedTextAndMedia", () => { expect(isNumericTargetId("")).toBe(false); }); }); + +describe("resolveOutboundMediaUrls", () => { + it("prefers mediaUrls over the legacy single-media field", () => { + expect( + resolveOutboundMediaUrls({ + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + mediaUrl: "https://example.com/legacy.png", + }), + ).toEqual(["https://example.com/a.png", "https://example.com/b.png"]); + }); + + it("falls back to the legacy single-media field", () => { + expect( + resolveOutboundMediaUrls({ + mediaUrl: "https://example.com/legacy.png", + }), + ).toEqual(["https://example.com/legacy.png"]); + }); +}); + +describe("resolveTextChunksWithFallback", () => { + it("returns existing chunks unchanged", () => { + expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]); + }); + + it("falls back to the full text when chunkers return nothing", () => { + expect(resolveTextChunksWithFallback("hello", [])).toEqual(["hello"]); + }); + + it("returns empty for empty text with no chunks", () => { + expect(resolveTextChunksWithFallback("", [])).toEqual([]); + }); +}); + +describe("deliverTextOrMediaReply", () => { + it("sends media first with caption only on the first attachment", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: "hello", mediaUrls: ["https://a", "https://b"] }, + text: "hello", + sendText, + sendMedia, + }), + ).resolves.toBe("media"); + + expect(sendMedia).toHaveBeenNthCalledWith(1, { + mediaUrl: "https://a", + caption: "hello", + }); + expect(sendMedia).toHaveBeenNthCalledWith(2, { + mediaUrl: "https://b", + caption: undefined, + }); + expect(sendText).not.toHaveBeenCalled(); + }); + + it("falls back to chunked text delivery when there is no media", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: "alpha beta gamma" }, + text: "alpha beta gamma", + chunkText: () => ["alpha", "beta", "gamma"], + sendText, + sendMedia, + }), + ).resolves.toBe("text"); + + expect(sendText).toHaveBeenCalledTimes(3); + expect(sendText).toHaveBeenNthCalledWith(1, "alpha"); + expect(sendText).toHaveBeenNthCalledWith(2, "beta"); + expect(sendText).toHaveBeenNthCalledWith(3, "gamma"); + expect(sendMedia).not.toHaveBeenCalled(); + }); + + it("returns empty when chunking produces no sendable text", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: " " }, + text: " ", + chunkText: () => [], + sendText, + sendMedia, + }), + ).resolves.toBe("empty"); + + expect(sendText).not.toHaveBeenCalled(); + expect(sendMedia).not.toHaveBeenCalled(); + }); +}); + +describe("sendMediaWithLeadingCaption", () => { + it("passes leading-caption metadata to async error handlers", async () => { + const send = vi + .fn<({ mediaUrl, caption }: { mediaUrl: string; caption?: string }) => Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(undefined); + const onError = vi.fn(async () => undefined); + + await expect( + sendMediaWithLeadingCaption({ + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + caption: "hello", + send, + onError, + }), + ).resolves.toBe(true); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + caption: "hello", + index: 0, + isFirst: true, + }), + ); + expect(send).toHaveBeenNthCalledWith(2, { + mediaUrl: "https://example.com/b.png", + caption: undefined, + }); + }); +}); + +describe("deliverFormattedTextWithAttachments", () => { + it("combines attachment links and forwards replyToId", async () => { + const send = vi.fn(async () => undefined); + + await expect( + deliverFormattedTextWithAttachments({ + payload: { + text: "hello", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + replyToId: "r1", + }, + send, + }), + ).resolves.toBe(true); + + expect(send).toHaveBeenCalledWith({ + text: "hello\n\nAttachment: https://example.com/a.png\nAttachment: https://example.com/b.png", + replyToId: "r1", + }); + }); +}); diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index a35380f5250..3bee0c9e81b 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -52,6 +52,17 @@ export function resolveOutboundMediaUrls(payload: { return []; } +/** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */ +export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] { + if (chunks.length > 0) { + return [...chunks]; + } + if (!text) { + return []; + } + return [text]; +} + /** Send media-first payloads intact, or chunk text-only payloads through the caller's transport hooks. */ export async function sendPayloadWithChunkedTextAndMedia< TContext extends { payload: object }, @@ -129,21 +140,32 @@ export async function sendMediaWithLeadingCaption(params: { mediaUrls: string[]; caption: string; send: (payload: { mediaUrl: string; caption?: string }) => Promise; - onError?: (error: unknown, mediaUrl: string) => void; + onError?: (params: { + error: unknown; + mediaUrl: string; + caption?: string; + index: number; + isFirst: boolean; + }) => Promise | void; }): Promise { if (params.mediaUrls.length === 0) { return false; } - let first = true; - for (const mediaUrl of params.mediaUrls) { - const caption = first ? params.caption : undefined; - first = false; + for (const [index, mediaUrl] of params.mediaUrls.entries()) { + const isFirst = index === 0; + const caption = isFirst ? params.caption : undefined; try { await params.send({ mediaUrl, caption }); } catch (error) { if (params.onError) { - params.onError(error, mediaUrl); + await params.onError({ + error, + mediaUrl, + caption, + index, + isFirst, + }); continue; } throw error; @@ -151,3 +173,60 @@ export async function sendMediaWithLeadingCaption(params: { } return true; } + +export async function deliverTextOrMediaReply(params: { + payload: OutboundReplyPayload; + text: string; + chunkText?: (text: string) => readonly string[]; + sendText: (text: string) => Promise; + sendMedia: (payload: { mediaUrl: string; caption?: string }) => Promise; + onMediaError?: (params: { + error: unknown; + mediaUrl: string; + caption?: string; + index: number; + isFirst: boolean; + }) => Promise | void; +}): Promise<"empty" | "text" | "media"> { + const mediaUrls = resolveOutboundMediaUrls(params.payload); + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls, + caption: params.text, + send: params.sendMedia, + onError: params.onMediaError, + }); + if (sentMedia) { + return "media"; + } + if (!params.text) { + return "empty"; + } + const chunks = params.chunkText ? params.chunkText(params.text) : [params.text]; + let sentText = false; + for (const chunk of chunks) { + if (!chunk) { + continue; + } + await params.sendText(chunk); + sentText = true; + } + return sentText ? "text" : "empty"; +} + +export async function deliverFormattedTextWithAttachments(params: { + payload: OutboundReplyPayload; + send: (params: { text: string; replyToId?: string }) => Promise; +}): Promise { + const text = formatTextWithAttachmentLinks( + params.payload.text, + resolveOutboundMediaUrls(params.payload), + ); + if (!text) { + return false; + } + await params.send({ + text, + replyToId: params.payload.replyToId, + }); + return true; +} diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 079fa8b3a01..93ad61651e0 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,4 +1,5 @@ import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime"; +import * as channelSendResultSdk from "openclaw/plugin-sdk/channel-send-result"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as coreSdk from "openclaw/plugin-sdk/core"; import type { @@ -16,6 +17,7 @@ import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; +import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload"; import * as routingSdk from "openclaw/plugin-sdk/routing"; import * as runtimeSdk from "openclaw/plugin-sdk/runtime"; import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; @@ -93,6 +95,16 @@ describe("plugin-sdk subpath exports", () => { expect(typeof routingSdk.resolveThreadSessionKeys).toBe("function"); }); + it("exports reply payload helpers from the dedicated subpath", () => { + expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function"); + expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function"); + expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function"); + expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function"); + expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function"); + expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function"); + expect(typeof replyPayloadSdk.sendPayloadWithChunkedTextAndMedia).toBe("function"); + }); + it("exports account helper builders from the dedicated subpath", () => { expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); }); @@ -122,17 +134,36 @@ describe("plugin-sdk subpath exports", () => { }); it("exports channel runtime helpers from the dedicated subpath", () => { + expect(typeof channelRuntimeSdk.attachChannelToResult).toBe("function"); + expect(typeof channelRuntimeSdk.attachChannelToResults).toBe("function"); expect(typeof channelRuntimeSdk.buildUnresolvedTargetResults).toBe("function"); + expect(typeof channelRuntimeSdk.createAttachedChannelResultAdapter).toBe("function"); expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createEmptyChannelResult).toBe("function"); expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createRawChannelSendResultAdapter).toBe("function"); expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function"); expect(typeof channelRuntimeSdk.createPairingPrefixStripper).toBe("function"); + expect(typeof channelRuntimeSdk.createScopedAccountReplyToModeResolver).toBe("function"); + expect(typeof channelRuntimeSdk.createStaticReplyToModeResolver).toBe("function"); + expect(typeof channelRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function"); expect(typeof channelRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function"); expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); + expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceAndFinalize).toBe("function"); + expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceOrFallback).toBe("function"); expect(typeof channelRuntimeSdk.resolveTargetsWithOptionalToken).toBe("function"); expect(typeof channelRuntimeSdk.createTextPairingAdapter).toBe("function"); }); + it("exports channel send-result helpers from the dedicated subpath", () => { + expect(typeof channelSendResultSdk.attachChannelToResult).toBe("function"); + expect(typeof channelSendResultSdk.attachChannelToResults).toBe("function"); + expect(typeof channelSendResultSdk.buildChannelSendResult).toBe("function"); + expect(typeof channelSendResultSdk.createAttachedChannelResultAdapter).toBe("function"); + expect(typeof channelSendResultSdk.createEmptyChannelResult).toBe("function"); + expect(typeof channelSendResultSdk.createRawChannelSendResultAdapter).toBe("function"); + }); + it("exports provider setup helpers from the dedicated subpath", () => { expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 2655e26e18f..21a5dd09b89 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -77,6 +77,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { + deliverTextOrMediaReply, isNumericTargetId, resolveOutboundMediaUrls, sendMediaWithLeadingCaption, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index e2ab63e0e7a..b02800880ec 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -68,6 +68,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { + deliverTextOrMediaReply, isNumericTargetId, resolveOutboundMediaUrls, sendMediaWithLeadingCaption,