refactor: deduplicate reply payload helpers

This commit is contained in:
Peter Steinberger 2026-03-18 17:29:54 +00:00
parent 656679e6e0
commit 8d73bc77fa
67 changed files with 2246 additions and 1366 deletions

View File

@ -9,6 +9,7 @@ import {
projectWarningCollector, projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy"; } from "openclaw/plugin-sdk/channel-policy";
import { import {
createAttachedChannelResultAdapter,
createPairingPrefixStripper, createPairingPrefixStripper,
createTextPairingAdapter, createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
@ -262,46 +263,44 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
} }
return { ok: true, to: trimmed }; return { ok: true, to: trimmed };
}, },
sendText: async ({ cfg, to, text, accountId, replyToId }) => { ...createAttachedChannelResultAdapter({
const runtime = await loadBlueBubblesChannelRuntime(); channel: "bluebubbles",
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; sendText: async ({ cfg, to, text, accountId, replyToId }) => {
// Resolve short ID (e.g., "5") to full UUID const runtime = await loadBlueBubblesChannelRuntime();
const replyToMessageGuid = rawReplyToId const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) const replyToMessageGuid = rawReplyToId
: ""; ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
const result = await runtime.sendMessageBlueBubbles(to, text, { : "";
cfg: cfg, return await runtime.sendMessageBlueBubbles(to, text, {
accountId: accountId ?? undefined, cfg: cfg,
replyToMessageGuid: replyToMessageGuid || undefined, accountId: accountId ?? undefined,
}); replyToMessageGuid: replyToMessageGuid || undefined,
return { channel: "bluebubbles", ...result }; });
}, },
sendMedia: async (ctx) => { sendMedia: async (ctx) => {
const runtime = await loadBlueBubblesChannelRuntime(); const runtime = await loadBlueBubblesChannelRuntime();
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
mediaPath?: string; mediaPath?: string;
mediaBuffer?: Uint8Array; mediaBuffer?: Uint8Array;
contentType?: string; contentType?: string;
filename?: string; filename?: string;
caption?: string; caption?: string;
}; };
const resolvedCaption = caption ?? text; return await runtime.sendBlueBubblesMedia({
const result = await runtime.sendBlueBubblesMedia({ cfg: cfg,
cfg: cfg, to,
to, mediaUrl,
mediaUrl, mediaPath,
mediaPath, mediaBuffer,
mediaBuffer, contentType,
contentType, filename,
filename, caption: caption ?? text ?? undefined,
caption: resolvedCaption ?? undefined, replyToId: replyToId ?? null,
replyToId: replyToId ?? null, accountId: accountId ?? undefined,
accountId: accountId ?? undefined, });
}); },
}),
return { channel: "bluebubbles", ...result };
},
}, },
status: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -1,3 +1,8 @@
import {
resolveOutboundMediaUrls,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
} from "openclaw/plugin-sdk/reply-payload";
import { downloadBlueBubblesAttachment } from "./attachments.js"; import { downloadBlueBubblesAttachment } from "./attachments.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { fetchBlueBubblesHistory } from "./history.js"; import { fetchBlueBubblesHistory } from "./history.js";
@ -1243,11 +1248,7 @@ export async function processMessage(
const replyToMessageGuid = rawReplyToId const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: ""; : "";
const mediaList = payload.mediaUrls?.length const mediaList = resolveOutboundMediaUrls(payload);
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) { if (mediaList.length > 0) {
const tableMode = core.channel.text.resolveMarkdownTableMode({ const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config, cfg: config,
@ -1257,43 +1258,44 @@ export async function processMessage(
const text = sanitizeReplyDirectiveText( const text = sanitizeReplyDirectiveText(
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
); );
let first = true; await sendMediaWithLeadingCaption({
for (const mediaUrl of mediaList) { mediaUrls: mediaList,
const caption = first ? text : undefined; caption: text,
first = false; send: async ({ mediaUrl, caption }) => {
const cachedBody = (caption ?? "").trim() || "<media:attachment>"; const cachedBody = (caption ?? "").trim() || "<media:attachment>";
const pendingId = rememberPendingOutboundMessageId({ const pendingId = rememberPendingOutboundMessageId({
accountId: account.accountId,
sessionKey: route.sessionKey,
outboundTarget,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
snippet: cachedBody,
});
let result: Awaited<ReturnType<typeof sendBlueBubblesMedia>>;
try {
result = await sendBlueBubblesMedia({
cfg: config,
to: outboundTarget,
mediaUrl,
caption: caption ?? undefined,
replyToId: replyToMessageGuid || null,
accountId: account.accountId, accountId: account.accountId,
sessionKey: route.sessionKey,
outboundTarget,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
snippet: cachedBody,
}); });
} catch (err) { let result: Awaited<ReturnType<typeof sendBlueBubblesMedia>>;
forgetPendingOutboundMessageId(pendingId); try {
throw err; result = await sendBlueBubblesMedia({
} cfg: config,
if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) { to: outboundTarget,
forgetPendingOutboundMessageId(pendingId); mediaUrl,
} caption: caption ?? undefined,
sentMessage = true; replyToId: replyToMessageGuid || null,
statusSink?.({ lastOutboundAt: Date.now() }); accountId: account.accountId,
if (info.kind === "block") { });
restartTypingSoon(); } 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; return;
} }
@ -1312,11 +1314,14 @@ export async function processMessage(
); );
const chunks = const chunks =
chunkMode === "newline" chunkMode === "newline"
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) ? resolveTextChunksWithFallback(
: core.channel.text.chunkMarkdownText(text, textLimit); text,
if (!chunks.length && text) { core.channel.text.chunkTextWithMode(text, textLimit, chunkMode),
chunks.push(text); )
} : resolveTextChunksWithFallback(
text,
core.channel.text.chunkMarkdownText(text, textLimit),
);
if (!chunks.length) { if (!chunks.length) {
return; return;
} }

View File

@ -7,8 +7,10 @@ import {
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { import {
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter, createChannelDirectoryAdapter,
createPairingPrefixStripper, createPairingPrefixStripper,
createTopLevelChannelReplyToModeResolver,
createRuntimeDirectoryLiveAdapter, createRuntimeDirectoryLiveAdapter,
createTextPairingAdapter, createTextPairingAdapter,
normalizeMessageChannel, normalizeMessageChannel,
@ -323,7 +325,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
stripPatterns: () => ["<@!?\\d+>"], stripPatterns: () => ["<@!?\\d+>"],
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"),
}, },
agentPrompt: { agentPrompt: {
messageToolHints: () => [ messageToolHints: () => [
@ -420,50 +422,51 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
textChunkLimit: 2000, textChunkLimit: 2000,
pollMaxOptions: 10, pollMaxOptions: 10,
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { ...createAttachedChannelResultAdapter({
const send = channel: "discord",
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ?? sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => {
getDiscordRuntime().channel.discord.sendMessageDiscord; const send =
const result = await send(to, text, { resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
verbose: false, getDiscordRuntime().channel.discord.sendMessageDiscord;
cfg, return await send(to, text, {
replyTo: replyToId ?? undefined, verbose: false,
accountId: accountId ?? undefined, cfg,
silent: silent ?? undefined, replyTo: replyToId ?? undefined,
}); accountId: accountId ?? undefined,
return { channel: "discord", ...result }; silent: silent ?? undefined,
}, });
sendMedia: async ({ },
cfg, sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
silent,
}) => {
const send =
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
getDiscordRuntime().channel.discord.sendMessageDiscord;
const result = await send(to, text, {
verbose: false,
cfg, cfg,
to,
text,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
replyTo: replyToId ?? undefined, accountId,
accountId: accountId ?? undefined, deps,
silent: silent ?? undefined, replyToId,
}); silent,
return { channel: "discord", ...result }; }) => {
}, const send =
sendPoll: async ({ cfg, to, poll, accountId, silent }) => resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { getDiscordRuntime().channel.discord.sendMessageDiscord;
cfg, return await send(to, text, {
accountId: accountId ?? undefined, verbose: false,
silent: silent ?? undefined, 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: { bindings: {
compileConfiguredBinding: ({ conversationId }) => compileConfiguredBinding: ({ conversationId }) =>

View File

@ -25,6 +25,10 @@ import {
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import {
resolveOutboundMediaUrls,
resolveTextChunksWithFallback,
} from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import type { import type {
ChatCommandDefinition, ChatCommandDefinition,
@ -887,7 +891,7 @@ async function deliverDiscordInteractionReply(params: {
chunkMode: "length" | "newline"; chunkMode: "length" | "newline";
}) { }) {
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = resolveOutboundMediaUrls(payload);
const text = payload.text ?? ""; const text = payload.text ?? "";
const discordData = payload.channelData?.discord as const discordData = payload.channelData?.discord as
| { components?: TopLevelComponents[] } | { components?: TopLevelComponents[] }
@ -945,14 +949,14 @@ async function deliverDiscordInteractionReply(params: {
}; };
}), }),
); );
const chunks = chunkDiscordTextWithMode(text, { const chunks = resolveTextChunksWithFallback(
maxChars: textLimit, text,
maxLines: maxLinesPerMessage, chunkDiscordTextWithMode(text, {
chunkMode, maxChars: textLimit,
}); maxLines: maxLinesPerMessage,
if (!chunks.length && text) { chunkMode,
chunks.push(text); }),
} );
const caption = chunks[0] ?? ""; const caption = chunks[0] ?? "";
await sendMessage(caption, media, firstMessageComponents); await sendMessage(caption, media, firstMessageComponents);
for (const chunk of chunks.slice(1)) { for (const chunk of chunks.slice(1)) {
@ -967,14 +971,17 @@ async function deliverDiscordInteractionReply(params: {
if (!text.trim() && !firstMessageComponents) { if (!text.trim() && !firstMessageComponents) {
return; return;
} }
const chunks = chunkDiscordTextWithMode(text, { const chunks =
maxChars: textLimit, text || firstMessageComponents
maxLines: maxLinesPerMessage, ? resolveTextChunksWithFallback(
chunkMode, text,
}); chunkDiscordTextWithMode(text, {
if (!chunks.length && (text || firstMessageComponents)) { maxChars: textLimit,
chunks.push(text); maxLines: maxLinesPerMessage,
} chunkMode,
}),
)
: [];
for (const chunk of chunks) { for (const chunk of chunks) {
if (!chunk.trim() && !firstMessageComponents) { if (!chunk.trim() && !firstMessageComponents) {
continue; continue;

View File

@ -12,11 +12,15 @@ const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn()); const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn());
const sendDiscordTextMock = vi.hoisted(() => vi.fn()); const sendDiscordTextMock = vi.hoisted(() => vi.fn());
vi.mock("../send.js", () => ({ vi.mock("../send.js", async (importOriginal) => {
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args), const actual = await importOriginal<typeof import("../send.js")>();
sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args), return {
sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args), ...actual,
})); sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args),
sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args),
};
});
vi.mock("../send.shared.js", () => ({ vi.mock("../send.shared.js", () => ({
sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args), sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args),

View File

@ -8,6 +8,11 @@ import {
retryAsync, retryAsync,
type RetryConfig, type RetryConfig,
} from "openclaw/plugin-sdk/infra-runtime"; } 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 { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@ -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: { export async function deliverDiscordReply(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
replies: ReplyPayload[]; replies: ReplyPayload[];
@ -292,7 +268,7 @@ export async function deliverDiscordReply(params: {
: undefined; : undefined;
let deliveredAny = false; let deliveredAny = false;
for (const payload of params.replies) { for (const payload of params.replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = resolveOutboundMediaUrls(payload);
const rawText = payload.text ?? ""; const rawText = payload.text ?? "";
const tableMode = params.tableMode ?? "code"; const tableMode = params.tableMode ?? "code";
const text = convertMarkdownTables(rawText, tableMode); const text = convertMarkdownTables(rawText, tableMode);
@ -301,14 +277,14 @@ export async function deliverDiscordReply(params: {
} }
if (mediaList.length === 0) { if (mediaList.length === 0) {
const mode = params.chunkMode ?? "length"; const mode = params.chunkMode ?? "length";
const chunks = chunkDiscordTextWithMode(text, { const chunks = resolveTextChunksWithFallback(
maxChars: chunkLimit, text,
maxLines: params.maxLinesPerMessage, chunkDiscordTextWithMode(text, {
chunkMode: mode, maxChars: chunkLimit,
}); maxLines: params.maxLinesPerMessage,
if (!chunks.length && text) { chunkMode: mode,
chunks.push(text); }),
} );
for (const chunk of chunks) { for (const chunk of chunks) {
if (!chunk.trim()) { if (!chunk.trim()) {
continue; continue;
@ -340,19 +316,6 @@ export async function deliverDiscordReply(params: {
if (!firstMedia) { if (!firstMedia) {
continue; 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. // Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord.
if (payload.audioAsVoice) { if (payload.audioAsVoice) {
const replyTo = resolveReplyTo(); const replyTo = resolveReplyTo();
@ -383,22 +346,50 @@ export async function deliverDiscordReply(params: {
retryConfig, retryConfig,
}); });
// Additional media items are sent as regular attachments (voice is single-file only). // Additional media items are sent as regular attachments (voice is single-file only).
await 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; continue;
} }
const replyTo = resolveReplyTo(); await sendMediaWithLeadingCaption({
await sendMessageDiscord(params.target, text, { mediaUrls: mediaList,
cfg: params.cfg, caption: text,
token: params.token, send: async ({ mediaUrl, caption }) => {
rest: params.rest, const replyTo = resolveReplyTo();
mediaUrl: firstMedia, await sendWithRetry(
accountId: params.accountId, () =>
mediaLocalRoots: params.mediaLocalRoots, sendMessageDiscord(params.target, caption ?? "", {
replyTo, cfg: params.cfg,
token: params.token,
rest: params.rest,
mediaUrl,
accountId: params.accountId,
mediaLocalRoots: params.mediaLocalRoots,
replyTo,
}),
retryConfig,
);
},
}); });
deliveredAny = true; deliveredAny = true;
await sendRemainingMedia();
} }
if (binding && deliveredAny) { if (binding && deliveredAny) {

View File

@ -3,11 +3,13 @@ import { normalizeDiscordOutboundTarget } from "./normalize.js";
const hoisted = vi.hoisted(() => { const hoisted = vi.hoisted(() => {
const sendMessageDiscordMock = vi.fn(); const sendMessageDiscordMock = vi.fn();
const sendDiscordComponentMessageMock = vi.fn();
const sendPollDiscordMock = vi.fn(); const sendPollDiscordMock = vi.fn();
const sendWebhookMessageDiscordMock = vi.fn(); const sendWebhookMessageDiscordMock = vi.fn();
const getThreadBindingManagerMock = vi.fn(); const getThreadBindingManagerMock = vi.fn();
return { return {
sendMessageDiscordMock, sendMessageDiscordMock,
sendDiscordComponentMessageMock,
sendPollDiscordMock, sendPollDiscordMock,
sendWebhookMessageDiscordMock, sendWebhookMessageDiscordMock,
getThreadBindingManagerMock, getThreadBindingManagerMock,
@ -19,6 +21,8 @@ vi.mock("./send.js", async (importOriginal) => {
return { return {
...actual, ...actual,
sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args),
sendDiscordComponentMessage: (...args: unknown[]) =>
hoisted.sendDiscordComponentMessageMock(...args),
sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args), sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args),
sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscord: (...args: unknown[]) =>
hoisted.sendWebhookMessageDiscordMock(...args), hoisted.sendWebhookMessageDiscordMock(...args),
@ -114,6 +118,10 @@ describe("discordOutbound", () => {
messageId: "msg-1", messageId: "msg-1",
channelId: "ch-1", channelId: "ch-1",
}); });
hoisted.sendDiscordComponentMessageMock.mockClear().mockResolvedValue({
messageId: "component-1",
channelId: "ch-1",
});
hoisted.sendPollDiscordMock.mockClear().mockResolvedValue({ hoisted.sendPollDiscordMock.mockClear().mockResolvedValue({
messageId: "poll-1", messageId: "poll-1",
channelId: "ch-1", channelId: "ch-1",
@ -249,8 +257,61 @@ describe("discordOutbound", () => {
}), }),
); );
expect(result).toEqual({ expect(result).toEqual({
channel: "discord",
messageId: "poll-1", messageId: "poll-1",
channelId: "ch-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",
});
});
}); });

View File

@ -1,10 +1,14 @@
import { import {
resolvePayloadMediaUrls, resolvePayloadMediaUrls,
sendPayloadMediaSequence, sendPayloadMediaSequenceOrFallback,
sendTextMediaPayload, sendTextMediaPayload,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep } 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 { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import type { DiscordComponentMessageSpec } from "./components.js"; import type { DiscordComponentMessageSpec } from "./components.js";
@ -123,18 +127,17 @@ export const discordOutbound: ChannelOutboundAdapter = {
resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord; resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId }); const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId });
const mediaUrls = resolvePayloadMediaUrls(payload); const mediaUrls = resolvePayloadMediaUrls(payload);
if (mediaUrls.length === 0) { const result = await sendPayloadMediaSequenceOrFallback({
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({
text: payload.text ?? "", text: payload.text ?? "",
mediaUrls, 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 }) => { send: async ({ text, mediaUrl, isFirst }) => {
if (isFirst) { if (isFirst) {
return await sendDiscordComponentMessage(target, componentSpec, { return await sendDiscordComponentMessage(target, componentSpec, {
@ -157,68 +160,63 @@ export const discordOutbound: ChannelOutboundAdapter = {
}); });
}, },
}); });
return lastResult return attachChannelToResult("discord", result);
? { channel: "discord", ...lastResult }
: { channel: "discord", messageId: "" };
}, },
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { ...createAttachedChannelResultAdapter({
if (!silent) { channel: "discord",
const webhookResult = await maybeSendDiscordWebhookText({ sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
cfg, if (!silent) {
text, const webhookResult = await maybeSendDiscordWebhookText({
threadId, cfg,
accountId, text,
identity, threadId,
replyToId, accountId,
}).catch(() => null); identity,
if (webhookResult) { replyToId,
return { channel: "discord", ...webhookResult }; }).catch(() => null);
if (webhookResult) {
return webhookResult;
}
} }
} const send =
const send = resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord; return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
const target = resolveDiscordOutboundTarget({ to, threadId }); verbose: false,
const result = await send(target, text, { replyTo: replyToId ?? undefined,
verbose: false, accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined, silent: silent ?? undefined,
accountId: accountId ?? undefined, cfg,
silent: silent ?? undefined, });
},
sendMedia: async ({
cfg, cfg,
}); to,
return { channel: "discord", ...result }; text,
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, {
verbose: false,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
replyTo: replyToId ?? undefined, accountId,
accountId: accountId ?? undefined, deps,
silent: silent ?? undefined, replyToId,
cfg, threadId,
}); silent,
return { channel: "discord", ...result }; }) => {
}, const send =
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => { resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId }); return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
return await sendPollDiscord(target, poll, { verbose: false,
accountId: accountId ?? undefined, mediaUrl,
silent: silent ?? undefined, mediaLocalRoots,
cfg, 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,
}),
}),
}; };

View File

@ -17,6 +17,7 @@ import {
normalizePollInput, normalizePollInput,
type PollInput, type PollInput,
} from "openclaw/plugin-sdk/media-runtime"; } from "openclaw/plugin-sdk/media-runtime";
import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { resolveDiscordAccount } from "./accounts.js"; import { resolveDiscordAccount } from "./accounts.js";
@ -276,10 +277,7 @@ export function buildDiscordTextChunks(
maxLines: opts.maxLinesPerMessage, maxLines: opts.maxLinesPerMessage,
chunkMode: opts.chunkMode, chunkMode: opts.chunkMode,
}); });
if (!chunks.length && text) { return resolveTextChunksWithFallback(text, chunks);
chunks.push(text);
}
return chunks;
} }
function hasV2Components(components?: TopLevelComponents[]): boolean { function hasV2Components(components?: TopLevelComponents[]): boolean {

View File

@ -1,5 +1,6 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import type { ChannelOutboundAdapter } from "../runtime-api.js"; import type { ChannelOutboundAdapter } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js"; import { resolveFeishuAccount } from "./accounts.js";
import { sendMediaFeishu } from "./media.js"; import { sendMediaFeishu } from "./media.js";
@ -81,128 +82,124 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown", chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ ...createAttachedChannelResultAdapter({
cfg, channel: "feishu",
to, sendText: async ({
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({
cfg, cfg,
to, to,
text, text,
accountId: accountId ?? undefined, accountId,
replyToMessageId, replyToId,
}); threadId,
return { channel: "feishu", ...result }; mediaLocalRoots,
}, identity,
sendMedia: async ({ }) => {
cfg, const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
to, // Scheme A compatibility shim:
text, // when upstream accidentally returns a local image path as plain text,
mediaUrl, // auto-upload and send as Feishu image message instead of leaking path text.
accountId, const localImagePath = normalizePossibleLocalImagePath(text);
mediaLocalRoots, if (localImagePath) {
replyToId, try {
threadId, return await sendMediaFeishu({
}) => { cfg,
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); to,
// Send text first if provided mediaUrl: localImagePath,
if (text?.trim()) { accountId: accountId ?? undefined,
await sendOutboundText({ 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, cfg,
to, to,
text, text,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
replyToMessageId, replyToMessageId,
}); });
} },
sendMedia: async ({
// 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({
cfg, cfg,
to, to,
text: text ?? "", text,
accountId: accountId ?? undefined, mediaUrl,
replyToMessageId, accountId,
}); mediaLocalRoots,
return { channel: "feishu", ...result }; 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,
});
},
}),
}; };

View File

@ -10,7 +10,9 @@ import {
createAllowlistProviderOpenWarningCollector, createAllowlistProviderOpenWarningCollector,
} from "openclaw/plugin-sdk/channel-policy"; } from "openclaw/plugin-sdk/channel-policy";
import { import {
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter, createChannelDirectoryAdapter,
createTopLevelChannelReplyToModeResolver,
createTextPairingAdapter, createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
import { import {
@ -192,7 +194,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
resolveRequireMention: resolveGoogleChatGroupRequireMention, resolveRequireMention: resolveGoogleChatGroupRequireMention,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", resolveReplyToMode: createTopLevelChannelReplyToModeResolver("googlechat"),
}, },
messaging: { messaging: {
normalizeTarget: normalizeGoogleChatTarget, normalizeTarget: normalizeGoogleChatTarget,
@ -266,91 +268,97 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"), error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
}; };
}, },
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { ...createAttachedChannelResultAdapter({
const account = resolveGoogleChatAccount({ channel: "googlechat",
cfg: cfg, sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
accountId, const account = resolveGoogleChatAccount({
}); cfg: cfg,
const space = await resolveGoogleChatOutboundSpace({ account, target: to }); accountId,
const thread = (threadId ?? replyToId ?? undefined) as string | undefined; });
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const result = await sendGoogleChatMessage({ const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
account, const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
space, const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
});
return {
messageId: result?.messageName ?? "",
chatId: space,
};
},
sendMedia: async ({
cfg,
to,
text, text,
thread, mediaUrl,
}); mediaLocalRoots,
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,
accountId, accountId,
}); replyToId,
const space = await resolveGoogleChatOutboundSpace({ account, target: to }); threadId,
const thread = (threadId ?? replyToId ?? undefined) as string | undefined; }) => {
const runtime = getGoogleChatRuntime(); if (!mediaUrl) {
const maxBytes = resolveChannelMediaMaxBytes({ throw new Error("Google Chat mediaUrl is required.");
cfg: cfg, }
resolveChannelLimitMb: ({ cfg, accountId }) => const account = resolveGoogleChatAccount({
( cfg: cfg,
cfg.channels?.["googlechat"] as accountId,
| { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number } });
| undefined const space = await resolveGoogleChatOutboundSpace({ account, target: to });
)?.accounts?.[accountId]?.mediaMaxMb ?? const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
(cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, const runtime = getGoogleChatRuntime();
accountId, const maxBytes = resolveChannelMediaMaxBytes({
}); cfg: cfg,
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024; resolveChannelLimitMb: ({ cfg, accountId }) =>
const loaded = /^https?:\/\//i.test(mediaUrl) (
? await runtime.channel.media.fetchRemoteMedia({ cfg.channels?.["googlechat"] as
url: mediaUrl, | { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number }
maxBytes: effectiveMaxBytes, | undefined
}) )?.accounts?.[accountId]?.mediaMaxMb ??
: await runtime.media.loadWebMedia(mediaUrl, { (cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
maxBytes: effectiveMaxBytes, accountId,
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, });
}); const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const { sendGoogleChatMessage, uploadGoogleChatAttachment } = const loaded = /^https?:\/\//i.test(mediaUrl)
await loadGoogleChatChannelRuntime(); ? await runtime.channel.media.fetchRemoteMedia({
const upload = await uploadGoogleChatAttachment({ url: mediaUrl,
account, maxBytes: effectiveMaxBytes,
space, })
filename: loaded.fileName ?? "attachment", : await runtime.media.loadWebMedia(mediaUrl, {
buffer: loaded.buffer, maxBytes: effectiveMaxBytes,
contentType: loaded.contentType, localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
}); });
const result = await sendGoogleChatMessage({ const { sendGoogleChatMessage, uploadGoogleChatAttachment } =
account, await loadGoogleChatChannelRuntime();
space, const upload = await uploadGoogleChatAttachment({
text, account,
thread, space,
attachments: upload.attachmentUploadToken filename: loaded.fileName ?? "attachment",
? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName }] buffer: loaded.buffer,
: undefined, contentType: loaded.contentType,
}); });
return { const result = await sendGoogleChatMessage({
channel: "googlechat", account,
messageId: result?.messageName ?? "", space,
chatId: space, text,
}; thread,
}, attachments: upload.attachmentUploadToken
? [
{
attachmentUploadToken: upload.attachmentUploadToken,
contentName: loaded.fileName,
},
]
: undefined,
});
return {
messageId: result?.messageName ?? "",
chatId: space,
};
},
}),
}, },
status: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig } from "../runtime-api.js"; import type { OpenClawConfig } from "../runtime-api.js";
import { import {
createWebhookInFlightLimiter, createWebhookInFlightLimiter,
@ -375,14 +376,12 @@ async function deliverGoogleChatReply(params: {
}): Promise<void> { }): Promise<void> {
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } =
params; params;
const mediaList = payload.mediaUrls?.length const hasMedia = Boolean(payload.mediaUrls?.length) || Boolean(payload.mediaUrl);
? payload.mediaUrls const text = payload.text ?? "";
: payload.mediaUrl let firstTextChunk = true;
? [payload.mediaUrl] let suppressCaption = false;
: [];
if (mediaList.length > 0) { if (hasMedia) {
let suppressCaption = false;
if (typingMessageName) { if (typingMessageName) {
try { try {
await deleteGoogleChatMessage({ await deleteGoogleChatMessage({
@ -391,9 +390,10 @@ async function deliverGoogleChatReply(params: {
}); });
} catch (err) { } catch (err) {
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
const fallbackText = payload.text?.trim() const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
? payload.text const fallbackText = text.trim()
: mediaList.length > 1 ? text
: mediaCount > 1
? "Sent attachments." ? "Sent attachments."
: "Sent attachment."; : "Sent attachment.";
try { try {
@ -402,16 +402,43 @@ async function deliverGoogleChatReply(params: {
messageName: typingMessageName, messageName: typingMessageName,
text: fallbackText, text: fallbackText,
}); });
suppressCaption = Boolean(payload.text?.trim()); suppressCaption = Boolean(text.trim());
} catch (updateErr) { } catch (updateErr) {
runtime.error?.(`Google Chat typing update failed: ${String(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; const chunkLimit = account.config.textChunkLimit ?? 4000;
first = false; 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 { try {
const loaded = await core.channel.media.fetchRemoteMedia({ const loaded = await core.channel.media.fetchRemoteMedia({
url: mediaUrl, url: mediaUrl,
@ -440,38 +467,8 @@ async function deliverGoogleChatReply(params: {
} catch (err) { } catch (err) {
runtime.error?.(`Google Chat attachment send failed: ${String(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: { async function uploadAttachmentForReply(params: {

View File

@ -1,5 +1,8 @@
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; 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 { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { type RoutePeer } from "openclaw/plugin-sdk/routing"; import { type RoutePeer } from "openclaw/plugin-sdk/routing";
@ -160,34 +163,33 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text", chunkerMode: "text",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => { ...createAttachedChannelResultAdapter({
const result = await ( channel: "imessage",
await loadIMessageChannelRuntime() sendText: async ({ cfg, to, text, accountId, deps, replyToId }) =>
).sendIMessageOutbound({ await (
cfg, await loadIMessageChannelRuntime()
to, ).sendIMessageOutbound({
text, cfg,
accountId: accountId ?? undefined, to,
deps, text,
replyToId: replyToId ?? undefined, accountId: accountId ?? undefined,
}); deps,
return { channel: "imessage", ...result }; replyToId: replyToId ?? undefined,
}, }),
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => { sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) =>
const result = await ( await (
await loadIMessageChannelRuntime() await loadIMessageChannelRuntime()
).sendIMessageOutbound({ ).sendIMessageOutbound({
cfg, cfg,
to, to,
text, text,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
deps, deps,
replyToId: replyToId ?? undefined, replyToId: replyToId ?? undefined,
}); }),
return { channel: "imessage", ...result }; }),
},
}, },
status: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -1,5 +1,6 @@
import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@ -30,15 +31,17 @@ export async function deliverReplies(params: {
}); });
const chunkMode = resolveChunkMode(cfg, "imessage", accountId); const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = sanitizeOutboundText(payload.text ?? ""); const rawText = sanitizeOutboundText(payload.text ?? "");
const text = convertMarkdownTables(rawText, tableMode); const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) { const hasMedia = Boolean(payload.mediaUrls?.length ?? payload.mediaUrl);
continue; if (!hasMedia && text) {
}
if (mediaList.length === 0) {
sentMessageCache?.remember(scope, { 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, { const sent = await sendMessageIMessage(target, chunk, {
maxBytes, maxBytes,
client, client,
@ -46,14 +49,10 @@ export async function deliverReplies(params: {
replyToId: payload.replyToId, replyToId: payload.replyToId,
}); });
sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId }); sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId });
} },
} else { sendMedia: async ({ mediaUrl, caption }) => {
let first = true; const sent = await sendMessageIMessage(target, caption ?? "", {
for (const url of mediaList) { mediaUrl,
const caption = first ? text : "";
first = false;
const sent = await sendMessageIMessage(target, caption, {
mediaUrl: url,
maxBytes, maxBytes,
client, client,
accountId, accountId,
@ -63,8 +62,10 @@ export async function deliverReplies(params: {
text: caption || undefined, text: caption || undefined,
messageId: sent.messageId, messageId: sent.messageId,
}); });
} },
});
if (delivered !== "empty") {
runtime.log?.(`imessage: delivered reply to ${target}`);
} }
runtime.log?.(`imessage: delivered reply to ${target}`);
} }
} }

View File

@ -9,6 +9,7 @@ import {
createConditionalWarningCollector, createConditionalWarningCollector,
} from "openclaw/plugin-sdk/channel-policy"; } from "openclaw/plugin-sdk/channel-policy";
import { import {
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter, createChannelDirectoryAdapter,
createTextPairingAdapter, createTextPairingAdapter,
listResolvedDirectoryEntriesFromSources, listResolvedDirectoryEntriesFromSources,
@ -271,23 +272,21 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown", chunkerMode: "markdown",
textChunkLimit: 350, textChunkLimit: 350,
sendText: async ({ cfg, to, text, accountId, replyToId }) => { ...createAttachedChannelResultAdapter({
const result = await sendMessageIrc(to, text, { channel: "irc",
cfg: cfg as CoreConfig, sendText: async ({ cfg, to, text, accountId, replyToId }) =>
accountId: accountId ?? undefined, await sendMessageIrc(to, text, {
replyTo: replyToId ?? undefined, cfg: cfg as CoreConfig,
}); accountId: accountId ?? undefined,
return { channel: "irc", ...result }; replyTo: replyToId ?? undefined,
}, }),
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
const result = await sendMessageIrc(to, combined, { cfg: cfg as CoreConfig,
cfg: cfg as CoreConfig, accountId: accountId ?? undefined,
accountId: accountId ?? undefined, replyTo: replyToId ?? undefined,
replyTo: replyToId ?? undefined, }),
}); }),
return { channel: "irc", ...result };
},
}, },
status: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -10,14 +10,13 @@ import {
import { import {
GROUP_POLICY_BLOCKED_LABEL, GROUP_POLICY_BLOCKED_LABEL,
createScopedPairingAccess, createScopedPairingAccess,
deliverFormattedTextWithAttachments,
dispatchInboundReplyWithBase, dispatchInboundReplyWithBase,
formatTextWithAttachmentLinks,
issuePairingChallenge, issuePairingChallenge,
logInboundDrop, logInboundDrop,
isDangerousNameMatchingEnabled, isDangerousNameMatchingEnabled,
readStoreAllowFromForDmPolicy, readStoreAllowFromForDmPolicy,
resolveControlCommandGate, resolveControlCommandGate,
resolveOutboundMediaUrls,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
resolveEffectiveAllowFromLists, resolveEffectiveAllowFromLists,
@ -61,23 +60,23 @@ async function deliverIrcReply(params: {
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>; sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
statusSink?: (patch: { lastOutboundAt?: number }) => void; statusSink?: (patch: { lastOutboundAt?: number }) => void;
}) { }) {
const combined = formatTextWithAttachmentLinks( const delivered = await deliverFormattedTextWithAttachments({
params.payload.text, payload: params.payload,
resolveOutboundMediaUrls(params.payload), send: async ({ text, replyToId }) => {
); if (params.sendReply) {
if (!combined) { 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; 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: { export async function handleIrcInbound(params: {

View File

@ -1,10 +1,13 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { import {
createAttachedChannelResultAdapter,
createEmptyChannelDirectoryAdapter, createEmptyChannelDirectoryAdapter,
createEmptyChannelResult,
createPairingPrefixStripper, createPairingPrefixStripper,
createTextPairingAdapter, createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
import { import {
buildChannelConfigSchema, buildChannelConfigSchema,
buildComputedAccountStatusSnapshot, buildComputedAccountStatusSnapshot,
@ -184,7 +187,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
const chunks = processed.text const chunks = processed.text
? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit) ? 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 shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies;
const sendMediaMessages = async () => { const sendMediaMessages = async () => {
for (const url of mediaUrls) { for (const url of mediaUrls) {
@ -317,54 +320,45 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
} }
if (lastResult) { 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 }) => { ...createAttachedChannelResultAdapter({
const runtime = getLineRuntime(); channel: "line",
const sendText = runtime.channel.line.pushMessageLine; sendText: async ({ cfg, to, text, accountId }) => {
const sendFlex = runtime.channel.line.pushFlexMessage; const runtime = getLineRuntime();
const sendText = runtime.channel.line.pushMessageLine;
// Process markdown: extract tables/code blocks, strip formatting const sendFlex = runtime.channel.line.pushFlexMessage;
const processed = processLineMessage(text); const processed = processLineMessage(text);
let result: { messageId: string; chatId: string };
// Send cleaned text first (if non-empty) if (processed.text.trim()) {
let result: { messageId: string; chatId: string }; result = await sendText(to, processed.text, {
if (processed.text.trim()) { verbose: false,
result = await sendText(to, processed.text, { cfg,
accountId: accountId ?? undefined,
});
} else {
result = { messageId: "processed", chatId: to };
}
for (const flexMsg of processed.flexMessages) {
const flexContents = flexMsg.contents as Parameters<typeof sendFlex>[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, verbose: false,
mediaUrl,
cfg, cfg,
accountId: accountId ?? undefined, 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<typeof sendFlex>[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: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -9,6 +9,7 @@ import {
import { import {
createChannelDirectoryAdapter, createChannelDirectoryAdapter,
createPairingPrefixStripper, createPairingPrefixStripper,
createScopedAccountReplyToModeResolver,
createRuntimeDirectoryLiveAdapter, createRuntimeDirectoryLiveAdapter,
createRuntimeOutboundDelegates, createRuntimeOutboundDelegates,
createTextPairingAdapter, createTextPairingAdapter,
@ -168,8 +169,11 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
resolveToolPolicy: resolveMatrixGroupToolPolicy, resolveToolPolicy: resolveMatrixGroupToolPolicy,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId }) => resolveReplyToMode: createScopedAccountReplyToModeResolver({
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off", resolveAccount: (cfg, accountId) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }),
resolveReplyToMode: (account) => account.replyToMode,
}),
buildToolContext: ({ context, hasRepliedRef }) => { buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To; const currentTarget = context.To;
return { return {

View File

@ -1,4 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
import { sendMessageMatrix } from "../send.js"; import { sendMessageMatrix } from "../send.js";
@ -60,45 +61,34 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied); Boolean(id) && (params.replyToMode === "all" || !hasReplied);
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined; const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
if (mediaList.length === 0) { const delivered = await deliverTextOrMediaReply({
let sentTextChunk = false; payload: reply,
for (const chunk of core.channel.text.chunkMarkdownTextWithMode( text,
text, chunkText: (value) =>
chunkLimit, core.channel.text
chunkMode, .chunkMarkdownTextWithMode(value, chunkLimit, chunkMode)
)) { .map((chunk) => chunk.trim())
const trimmed = chunk.trim(); .filter(Boolean),
if (!trimmed) { sendText: async (trimmed) => {
continue;
}
await sendMessageMatrix(params.roomId, trimmed, { await sendMessageMatrix(params.roomId, trimmed, {
client: params.client, client: params.client,
replyToId: replyToIdForReply, replyToId: replyToIdForReply,
threadId: params.threadId, threadId: params.threadId,
accountId: params.accountId, accountId: params.accountId,
}); });
sentTextChunk = true; },
} sendMedia: async ({ mediaUrl, caption }) => {
if (replyToIdForReply && !hasReplied && sentTextChunk) { await sendMessageMatrix(params.roomId, caption ?? "", {
hasReplied = true; client: params.client,
} mediaUrl,
continue; replyToId: replyToIdForReply,
} threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
let first = true; accountId: params.accountId,
for (const mediaUrl of mediaList) { });
const caption = first ? text : ""; },
await sendMessageMatrix(params.roomId, caption, { });
client: params.client, if (replyToIdForReply && !hasReplied && delivered !== "empty") {
mediaUrl,
replyToId: replyToIdForReply,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
first = false;
}
if (replyToIdForReply && !hasReplied) {
hasReplied = true; hasReplied = true;
} }
} }

View File

@ -5,9 +5,11 @@ import {
} from "openclaw/plugin-sdk/channel-config-helpers"; } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { import {
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter, createChannelDirectoryAdapter,
createLoggedPairingApprovalNotifier, createLoggedPairingApprovalNotifier,
createMessageToolButtonsSchema, createMessageToolButtonsSchema,
createScopedAccountReplyToModeResolver,
type ChannelMessageToolDiscovery, type ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
@ -308,14 +310,17 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => { resolveReplyToMode: createScopedAccountReplyToModeResolver({
const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }); resolveAccount: (cfg, accountId) =>
const kind = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }),
chatType === "direct" || chatType === "group" || chatType === "channel" resolveReplyToMode: (account, chatType) =>
? chatType resolveMattermostReplyToMode(
: "channel"; account,
return resolveMattermostReplyToMode(account, kind); chatType === "direct" || chatType === "group" || chatType === "channel"
}, ? chatType
: "channel",
),
}),
}, },
reload: { configPrefixes: ["channels.mattermost"] }, reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema), configSchema: buildChannelConfigSchema(MattermostConfigSchema),
@ -385,33 +390,32 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
} }
return { ok: true, to: trimmed }; return { ok: true, to: trimmed };
}, },
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { ...createAttachedChannelResultAdapter({
const result = await sendMessageMattermost(to, text, { 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, cfg,
accountId: accountId ?? undefined, to,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), text,
});
return { channel: "mattermost", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
replyToId,
threadId,
}) => {
const result = await sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), accountId,
}); replyToId,
return { channel: "mattermost", ...result }; threadId,
}, }) =>
await sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
mediaUrl,
mediaLocalRoots,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
}),
}, },
status: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -1,3 +1,4 @@
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js"; import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js";
import { getAgentScopedMediaLocalRoots } from "../runtime-api.js"; import { getAgentScopedMediaLocalRoots } from "../runtime-api.js";
@ -26,46 +27,34 @@ export async function deliverMattermostReplyPayload(params: {
tableMode: MarkdownTableMode; tableMode: MarkdownTableMode;
sendMessage: SendMattermostMessage; sendMessage: SendMattermostMessage;
}): Promise<void> { }): Promise<void> {
const mediaUrls =
params.payload.mediaUrls ?? (params.payload.mediaUrl ? [params.payload.mediaUrl] : []);
const text = params.core.channel.text.convertMarkdownTables( const text = params.core.channel.text.convertMarkdownTables(
params.payload.text ?? "", params.payload.text ?? "",
params.tableMode, params.tableMode,
); );
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
if (mediaUrls.length === 0) { const chunkMode = params.core.channel.text.resolveChunkMode(
const chunkMode = params.core.channel.text.resolveChunkMode( params.cfg,
params.cfg, "mattermost",
"mattermost", params.accountId,
params.accountId, );
); await deliverTextOrMediaReply({
const chunks = params.core.channel.text.chunkMarkdownTextWithMode( payload: params.payload,
text, text,
params.textLimit, chunkText: (value) =>
chunkMode, params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode),
); sendText: async (chunk) => {
for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) {
continue;
}
await params.sendMessage(params.to, chunk, { await params.sendMessage(params.to, chunk, {
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
}); });
} },
return; sendMedia: async ({ mediaUrl, caption }) => {
} await params.sendMessage(params.to, caption ?? "", {
accountId: params.accountId,
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); mediaUrl,
let first = true; mediaLocalRoots,
for (const mediaUrl of mediaUrls) { replyToId: params.replyToId,
const caption = first ? text : ""; });
first = false; },
await params.sendMessage(params.to, caption, { });
accountId: params.accountId,
mediaUrl,
mediaLocalRoots,
replyToId: params.replyToId,
});
}
} }

View File

@ -5,6 +5,7 @@ import {
type MarkdownTableMode, type MarkdownTableMode,
type MSTeamsReplyStyle, type MSTeamsReplyStyle,
type ReplyPayload, type ReplyPayload,
resolveOutboundMediaUrls,
SILENT_REPLY_TOKEN, SILENT_REPLY_TOKEN,
sleep, sleep,
} from "../runtime-api.js"; } from "../runtime-api.js";
@ -216,7 +217,7 @@ export function renderReplyPayloadsToMessages(
}); });
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = resolveOutboundMediaUrls(payload);
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
payload.text ?? "", payload.text ?? "",
tableMode, tableMode,

View File

@ -1,4 +1,5 @@
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; 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 type { ChannelOutboundAdapter } from "../runtime-api.js";
import { createMSTeamsPollStoreFs } from "./polls.js"; import { createMSTeamsPollStoreFs } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js"; import { getMSTeamsRuntime } from "./runtime.js";
@ -10,56 +11,57 @@ export const msteamsOutbound: ChannelOutboundAdapter = {
chunkerMode: "markdown", chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
sendText: async ({ cfg, to, text, deps }) => { ...createAttachedChannelResultAdapter({
type SendFn = ( channel: "msteams",
to: string, sendText: async ({ cfg, to, text, deps }) => {
text: string, type SendFn = (
) => Promise<{ messageId: string; conversationId: string }>; to: string,
const send = text: string,
resolveOutboundSendDep<SendFn>(deps, "msteams") ?? ) => Promise<{ messageId: string; conversationId: string }>;
((to, text) => sendMessageMSTeams({ cfg, to, text })); const send =
const result = await send(to, text); resolveOutboundSendDep<SendFn>(deps, "msteams") ??
return { channel: "msteams", ...result }; ((to, text) => sendMessageMSTeams({ cfg, to, text }));
}, return await send(to, text);
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { },
type SendFn = ( sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => {
to: string, type SendFn = (
text: string, to: string,
opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, text: string,
) => Promise<{ messageId: string; conversationId: string }>; opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] },
const send = ) => Promise<{ messageId: string; conversationId: string }>;
resolveOutboundSendDep<SendFn>(deps, "msteams") ?? const send =
((to, text, opts) => resolveOutboundSendDep<SendFn>(deps, "msteams") ??
sendMessageMSTeams({ ((to, text, opts) =>
cfg, sendMessageMSTeams({
to, cfg,
text, to,
mediaUrl: opts?.mediaUrl, text,
mediaLocalRoots: opts?.mediaLocalRoots, mediaUrl: opts?.mediaUrl,
})); mediaLocalRoots: opts?.mediaLocalRoots,
const result = await send(to, text, { mediaUrl, mediaLocalRoots }); }));
return { channel: "msteams", ...result }; return await send(to, text, { mediaUrl, mediaLocalRoots });
}, },
sendPoll: async ({ cfg, to, poll }) => { sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1; const maxSelections = poll.maxSelections ?? 1;
const result = await sendPollMSTeams({ const result = await sendPollMSTeams({
cfg, cfg,
to, to,
question: poll.question, question: poll.question,
options: poll.options, options: poll.options,
maxSelections, maxSelections,
}); });
const pollStore = createMSTeamsPollStoreFs(); const pollStore = createMSTeamsPollStoreFs();
await pollStore.createPoll({ await pollStore.createPoll({
id: result.pollId, id: result.pollId,
question: poll.question, question: poll.question,
options: poll.options, options: poll.options,
maxSelections, maxSelections,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
conversationId: result.conversationId, conversationId: result.conversationId,
messageId: result.messageId, messageId: result.messageId,
votes: {}, votes: {},
}); });
return result; return result;
}, },
}),
}; };

View File

@ -6,6 +6,7 @@ import {
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { import {
createAttachedChannelResultAdapter,
createLoggedPairingApprovalNotifier, createLoggedPairingApprovalNotifier,
createPairingPrefixStripper, createPairingPrefixStripper,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
@ -174,23 +175,21 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown", chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, replyToId }) => { ...createAttachedChannelResultAdapter({
const result = await sendMessageNextcloudTalk(to, text, { channel: "nextcloud-talk",
accountId: accountId ?? undefined, sendText: async ({ cfg, to, text, accountId, replyToId }) =>
replyTo: replyToId ?? undefined, await sendMessageNextcloudTalk(to, text, {
cfg: cfg as CoreConfig, accountId: accountId ?? undefined,
}); replyTo: replyToId ?? undefined,
return { channel: "nextcloud-talk", ...result }; cfg: cfg as CoreConfig,
}, }),
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; await sendMessageNextcloudTalk(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
const result = await sendMessageNextcloudTalk(to, messageWithMedia, { accountId: accountId ?? undefined,
accountId: accountId ?? undefined, replyTo: replyToId ?? undefined,
replyTo: replyToId ?? undefined, cfg: cfg as CoreConfig,
cfg: cfg as CoreConfig, }),
}); }),
return { channel: "nextcloud-talk", ...result };
},
}, },
status: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -1,13 +1,12 @@
import { import {
GROUP_POLICY_BLOCKED_LABEL, GROUP_POLICY_BLOCKED_LABEL,
createScopedPairingAccess, createScopedPairingAccess,
deliverFormattedTextWithAttachments,
dispatchInboundReplyWithBase, dispatchInboundReplyWithBase,
formatTextWithAttachmentLinks,
issuePairingChallenge, issuePairingChallenge,
logInboundDrop, logInboundDrop,
readStoreAllowFromForDmPolicy, readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithCommandGate, resolveDmGroupAccessWithCommandGate,
resolveOutboundMediaUrls,
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce, warnMissingProviderGroupPolicyFallbackOnce,
@ -38,16 +37,16 @@ async function deliverNextcloudTalkReply(params: {
statusSink?: (patch: { lastOutboundAt?: number }) => void; statusSink?: (patch: { lastOutboundAt?: number }) => void;
}): Promise<void> { }): Promise<void> {
const { payload, roomToken, accountId, statusSink } = params; const { payload, roomToken, accountId, statusSink } = params;
const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload)); await deliverFormattedTextWithAttachments({
if (!combined) { payload,
return; send: async ({ text, replyToId }) => {
} await sendMessageNextcloudTalk(roomToken, text, {
accountId,
await sendMessageNextcloudTalk(roomToken, combined, { replyTo: replyToId,
accountId, });
replyTo: payload.replyToId, statusSink?.({ lastOutboundAt: Date.now() });
},
}); });
statusSink?.({ lastOutboundAt: Date.now() });
} }
export async function handleNextcloudTalkInbound(params: { export async function handleNextcloudTalkInbound(params: {

View File

@ -2,6 +2,7 @@ import {
createScopedDmSecurityResolver, createScopedDmSecurityResolver,
createTopLevelChannelConfigAdapter, createTopLevelChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers"; } from "openclaw/plugin-sdk/channel-config-helpers";
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
import { import {
buildPassiveChannelStatusSummary, buildPassiveChannelStatusSummary,
buildTrafficStatusSummary, buildTrafficStatusSummary,
@ -176,11 +177,10 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
const normalizedTo = normalizePubkey(to); const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, message); await bus.sendDm(normalizedTo, message);
return { return attachChannelToResult("nostr", {
channel: "nostr" as const,
to: normalizedTo, to: normalizedTo,
messageId: `nostr-${Date.now()}`, messageId: `nostr-${Date.now()}`,
}; });
}, },
}, },

View File

@ -1,9 +1,12 @@
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import { import {
attachChannelToResult,
createAttachedChannelResultAdapter,
createPairingPrefixStripper, createPairingPrefixStripper,
createTextPairingAdapter, createTextPairingAdapter,
resolveOutboundSendDep, resolveOutboundSendDep,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
import { attachChannelToResults } from "openclaw/plugin-sdk/channel-send-result";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
@ -223,9 +226,9 @@ async function sendFormattedSignalText(ctx: {
textMode: "plain", textMode: "plain",
textStyles: chunk.styles, textStyles: chunk.styles,
}); });
results.push({ channel: "signal" as const, ...result }); results.push(result);
} }
return results; return attachChannelToResults("signal", results);
} }
async function sendFormattedSignalMedia(ctx: { async function sendFormattedSignalMedia(ctx: {
@ -264,7 +267,7 @@ async function sendFormattedSignalMedia(ctx: {
textMode: "plain", textMode: "plain",
textStyles: formatted.styles, textStyles: formatted.styles,
}); });
return { channel: "signal" as const, ...result }; return attachChannelToResult("signal", result);
} }
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = { export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
@ -340,28 +343,27 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
deps, deps,
abortSignal, abortSignal,
}), }),
sendText: async ({ cfg, to, text, accountId, deps }) => { ...createAttachedChannelResultAdapter({
const result = await sendSignalOutbound({ channel: "signal",
cfg, sendText: async ({ cfg, to, text, accountId, deps }) =>
to, await sendSignalOutbound({
text, cfg,
accountId: accountId ?? undefined, to,
deps, text,
}); accountId: accountId ?? undefined,
return { channel: "signal", ...result }; deps,
}, }),
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) =>
const result = await sendSignalOutbound({ await sendSignalOutbound({
cfg, cfg,
to, to,
text, text,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
deps, deps,
}); }),
return { channel: "signal", ...result }; }),
},
}, },
status: { status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),

View File

@ -9,6 +9,7 @@ import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import { import {
chunkTextWithMode, chunkTextWithMode,
resolveChunkMode, resolveChunkMode,
@ -296,35 +297,31 @@ async function deliverReplies(params: {
const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } =
params; params;
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const delivered = await deliverTextOrMediaReply({
const text = payload.text ?? ""; payload,
if (!text && mediaList.length === 0) { text: payload.text ?? "",
continue; chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
} sendText: async (chunk) => {
if (mediaList.length === 0) {
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
await sendMessageSignal(target, chunk, { await sendMessageSignal(target, chunk, {
baseUrl, baseUrl,
account, account,
maxBytes, maxBytes,
accountId, accountId,
}); });
} },
} else { sendMedia: async ({ mediaUrl, caption }) => {
let first = true; await sendMessageSignal(target, caption ?? "", {
for (const url of mediaList) {
const caption = first ? text : "";
first = false;
await sendMessageSignal(target, caption, {
baseUrl, baseUrl,
account, account,
mediaUrl: url, mediaUrl,
maxBytes, maxBytes,
accountId, accountId,
}); });
} },
});
if (delivered !== "empty") {
runtime.log?.(`delivered reply to ${target}`);
} }
runtime.log?.(`delivered reply to ${target}`);
} }
} }

View File

@ -1,6 +1,11 @@
import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime"; import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } 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 { 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 { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { markdownToSignalTextChunks } from "./format.js"; import { markdownToSignalTextChunks } from "./format.js";
@ -53,9 +58,9 @@ export const signalOutbound: ChannelOutboundAdapter = {
textMode: "plain", textMode: "plain",
textStyles: chunk.styles, textStyles: chunk.styles,
}); });
results.push({ channel: "signal" as const, ...result }); results.push(result);
} }
return results; return attachChannelToResults("signal", results);
}, },
sendFormattedMedia: async ({ sendFormattedMedia: async ({
cfg, cfg,
@ -89,34 +94,35 @@ export const signalOutbound: ChannelOutboundAdapter = {
textStyles: formatted.styles, textStyles: formatted.styles,
mediaLocalRoots, mediaLocalRoots,
}); });
return { channel: "signal", ...result }; return attachChannelToResult("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 };
}, },
...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,
});
},
}),
}; };

View File

@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; import type { OpenClawConfig } from "openclaw/plugin-sdk/slack";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { slackOutbound } from "./outbound-adapter.js";
const handleSlackActionMock = vi.fn(); const handleSlackActionMock = vi.fn();
@ -169,6 +170,79 @@ describe("slackPlugin outbound", () => {
); );
expect(result).toEqual({ channel: "slack", messageId: "m-media-local" }); 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", () => { describe("slackPlugin directory", () => {

View File

@ -6,8 +6,10 @@ import {
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { import {
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter, createChannelDirectoryAdapter,
createPairingPrefixStripper, createPairingPrefixStripper,
createScopedAccountReplyToModeResolver,
createRuntimeDirectoryLiveAdapter, createRuntimeDirectoryLiveAdapter,
createTextPairingAdapter, createTextPairingAdapter,
resolveOutboundSendDep, resolveOutboundSendDep,
@ -374,8 +376,10 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
resolveToolPolicy: resolveSlackGroupToolPolicy, resolveToolPolicy: resolveSlackGroupToolPolicy,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveReplyToMode: createScopedAccountReplyToModeResolver({
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType),
}),
allowExplicitReplyTagsWhenOff: false, allowExplicitReplyTagsWhenOff: false,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
resolveAutoThreadId: ({ cfg, accountId, to, toolContext, replyToId }) => resolveAutoThreadId: ({ cfg, accountId, to, toolContext, replyToId }) =>
@ -479,50 +483,51 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: null, chunker: null,
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { ...createAttachedChannelResultAdapter({
const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ channel: "slack",
cfg, sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => {
accountId: accountId ?? undefined, const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({
deps, cfg,
replyToId, accountId: accountId ?? undefined,
threadId, deps,
}); replyToId,
const result = await send(to, text, { threadId,
cfg, });
threadTs: threadTsValue != null ? String(threadTsValue) : undefined, return await send(to, text, {
accountId: accountId ?? undefined, cfg,
...(tokenOverride ? { token: tokenOverride } : {}), threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
}); accountId: accountId ?? undefined,
return { channel: "slack", ...result }; ...(tokenOverride ? { token: tokenOverride } : {}),
}, });
sendMedia: async ({ },
to, sendMedia: async ({
text, to,
mediaUrl, text,
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,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined, accountId,
accountId: accountId ?? undefined, deps,
...(tokenOverride ? { token: tokenOverride } : {}), replyToId,
}); threadId,
return { channel: "slack", ...result }; 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: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -1,4 +1,5 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime"; import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
@ -44,7 +45,7 @@ export async function deliverReplies(params: {
continue; continue;
} }
if (mediaList.length === 0) { if (mediaList.length === 0 && slackBlocks?.length) {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed && !slackBlocks?.length) { if (!trimmed && !slackBlocks?.length) {
continue; continue;
@ -59,21 +60,44 @@ export async function deliverReplies(params: {
...(slackBlocks?.length ? { blocks: slackBlocks } : {}), ...(slackBlocks?.length ? { blocks: slackBlocks } : {}),
...(params.identity ? { identity: params.identity } : {}), ...(params.identity ? { identity: params.identity } : {}),
}); });
} else { params.runtime.log?.(`delivered reply to ${params.target}`);
let first = true; continue;
for (const mediaUrl of mediaList) { }
const caption = first ? text : "";
first = false; const delivered = await deliverTextOrMediaReply({
await sendMessageSlack(params.target, caption, { 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, token: params.token,
mediaUrl, mediaUrl,
threadTs, threadTs,
accountId: params.accountId, accountId: params.accountId,
...(params.identity ? { identity: params.identity } : {}), ...(params.identity ? { identity: params.identity } : {}),
}); });
} },
});
if (delivered !== "empty") {
params.runtime.log?.(`delivered reply to ${params.target}`);
} }
params.runtime.log?.(`delivered reply to ${params.target}`);
} }
} }

View File

@ -1,10 +1,14 @@
import { import {
resolvePayloadMediaUrls, resolvePayloadMediaUrls,
sendPayloadMediaSequence, sendPayloadMediaSequenceAndFinalize,
sendTextMediaPayload, sendTextMediaPayload,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep } 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 type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import { import {
resolveInteractiveTextFallback, resolveInteractiveTextFallback,
@ -96,7 +100,6 @@ async function sendSlackOutboundMessage(params: {
}); });
if (hookResult.cancelled) { if (hookResult.cancelled) {
return { return {
channel: "slack" as const,
messageId: "cancelled-by-hook", messageId: "cancelled-by-hook",
channelId: params.to, channelId: params.to,
meta: { cancelled: true }, meta: { cancelled: true },
@ -114,7 +117,7 @@ async function sendSlackOutboundMessage(params: {
...(params.blocks ? { blocks: params.blocks } : {}), ...(params.blocks ? { blocks: params.blocks } : {}),
...(slackIdentity ? { identity: slackIdentity } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}),
}); });
return { channel: "slack" as const, ...result }; return result;
} }
function resolveSlackBlocks(payload: { function resolveSlackBlocks(payload: {
@ -166,75 +169,54 @@ export const slackOutbound: ChannelOutboundAdapter = {
}); });
} }
const mediaUrls = resolvePayloadMediaUrls(payload); const mediaUrls = resolvePayloadMediaUrls(payload);
if (mediaUrls.length === 0) { return attachChannelToResult(
return await sendSlackOutboundMessage({ "slack",
cfg: ctx.cfg, await sendPayloadMediaSequenceAndFinalize({
to: ctx.to, text: "",
text: payload.text ?? "", mediaUrls,
mediaLocalRoots: ctx.mediaLocalRoots, send: async ({ text, mediaUrl }) =>
blocks, await sendSlackOutboundMessage({
accountId: ctx.accountId, cfg: ctx.cfg,
deps: ctx.deps, to: ctx.to,
replyToId: ctx.replyToId, text,
threadId: ctx.threadId, mediaUrl,
identity: ctx.identity, mediaLocalRoots: ctx.mediaLocalRoots,
}); accountId: ctx.accountId,
} deps: ctx.deps,
await sendPayloadMediaSequence({ replyToId: ctx.replyToId,
text: "", threadId: ctx.threadId,
mediaUrls, identity: ctx.identity,
send: async ({ text, mediaUrl }) => }),
await sendSlackOutboundMessage({ finalize: async () =>
cfg: ctx.cfg, await sendSlackOutboundMessage({
to: ctx.to, cfg: ctx.cfg,
text, to: ctx.to,
mediaUrl, text: payload.text ?? "",
mediaLocalRoots: ctx.mediaLocalRoots, mediaLocalRoots: ctx.mediaLocalRoots,
accountId: ctx.accountId, blocks,
deps: ctx.deps, accountId: ctx.accountId,
replyToId: ctx.replyToId, deps: ctx.deps,
threadId: ctx.threadId, replyToId: ctx.replyToId,
identity: ctx.identity, 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,
});
}, },
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { ...createAttachedChannelResultAdapter({
return await sendSlackOutboundMessage({ channel: "slack",
cfg, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) =>
to, await sendSlackOutboundMessage({
text, cfg,
accountId, to,
deps, text,
replyToId, accountId,
threadId, deps,
identity, replyToId,
}); threadId,
}, identity,
sendMedia: async ({ }),
cfg, sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
identity,
}) => {
return await sendSlackOutboundMessage({
cfg, cfg,
to, to,
text, text,
@ -245,6 +227,18 @@ export const slackOutbound: ChannelOutboundAdapter = {
replyToId, replyToId,
threadId, threadId,
identity, identity,
}); }) =>
}, await sendSlackOutboundMessage({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
identity,
}),
}),
}; };

View File

@ -5,6 +5,7 @@ import {
fetchWithSsrFGuard, fetchWithSsrFGuard,
withTrustedEnvProxyGuardedFetchMode, withTrustedEnvProxyGuardedFetchMode,
} from "openclaw/plugin-sdk/infra-runtime"; } from "openclaw/plugin-sdk/infra-runtime";
import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload";
import { import {
chunkMarkdownTextWithMode, chunkMarkdownTextWithMode,
resolveChunkMode, resolveChunkMode,
@ -310,9 +311,7 @@ export async function sendMessageSlack(
const chunks = markdownChunks.flatMap((markdown) => const chunks = markdownChunks.flatMap((markdown) =>
markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }), markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }),
); );
if (!chunks.length && trimmedMessage) { const resolvedChunks = resolveTextChunksWithFallback(trimmedMessage, chunks);
chunks.push(trimmedMessage);
}
const mediaMaxBytes = const mediaMaxBytes =
typeof account.config.mediaMaxMb === "number" typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024 ? account.config.mediaMaxMb * 1024 * 1024
@ -320,7 +319,7 @@ export async function sendMessageSlack(
let lastMessageId = ""; let lastMessageId = "";
if (opts.mediaUrl) { if (opts.mediaUrl) {
const [firstChunk, ...rest] = chunks; const [firstChunk, ...rest] = resolvedChunks;
lastMessageId = await uploadSlackFile({ lastMessageId = await uploadSlackFile({
client, client,
channelId, channelId,
@ -341,7 +340,7 @@ export async function sendMessageSlack(
lastMessageId = response.ts ?? lastMessageId; lastMessageId = response.ts ?? lastMessageId;
} }
} else { } else {
for (const chunk of chunks.length ? chunks : [""]) { for (const chunk of resolvedChunks.length ? resolvedChunks : [""]) {
const response = await postSlackMessageBestEffort({ const response = await postSlackMessageBestEffort({
client, client,
channelId, channelId,

View File

@ -13,6 +13,7 @@ import {
projectWarningCollector, projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy"; } from "openclaw/plugin-sdk/channel-policy";
import { import {
attachChannelToResult,
createEmptyChannelDirectoryAdapter, createEmptyChannelDirectoryAdapter,
createTextPairingAdapter, createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
@ -188,7 +189,7 @@ export function createSynologyChatPlugin() {
if (!ok) { if (!ok) {
throw new Error("Failed to send message to Synology Chat"); 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) => { sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => {
@ -205,7 +206,7 @@ export function createSynologyChatPlugin() {
if (!ok) { if (!ok) {
throw new Error("Failed to send media to Synology Chat"); 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 });
}, },
}, },

View File

@ -5,8 +5,11 @@ import {
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { import {
attachChannelToResult,
createAttachedChannelResultAdapter,
createChannelDirectoryAdapter, createChannelDirectoryAdapter,
createPairingPrefixStripper, createPairingPrefixStripper,
createTopLevelChannelReplyToModeResolver,
createTextPairingAdapter, createTextPairingAdapter,
normalizeMessageChannel, normalizeMessageChannel,
type OutboundSendDeps, type OutboundSendDeps,
@ -358,7 +361,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
resolveToolPolicy: resolveTelegramGroupToolPolicy, resolveToolPolicy: resolveTelegramGroupToolPolicy,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off", resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"),
resolveAutoThreadId: ({ to, toolContext, replyToId }) => resolveAutoThreadId: ({ to, toolContext, replyToId }) =>
replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }), replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }),
}, },
@ -496,34 +499,22 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
forceDocument, forceDocument,
}), }),
}); });
return { channel: "telegram", ...result }; return attachChannelToResult("telegram", result);
}, },
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { ...createAttachedChannelResultAdapter({
const result = await sendTelegramOutbound({ channel: "telegram",
cfg, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) =>
to, await sendTelegramOutbound({
text, cfg,
accountId, to,
deps, text,
replyToId, accountId,
threadId, deps,
silent, replyToId,
}); threadId,
return { channel: "telegram", ...result }; silent,
}, }),
sendMedia: async ({ sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const result = await sendTelegramOutbound({
cfg, cfg,
to, to,
text, text,
@ -534,17 +525,28 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
replyToId, replyToId,
threadId, threadId,
silent, silent,
}); }) =>
return { channel: "telegram", ...result }; await sendTelegramOutbound({
}, cfg,
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) => to,
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { text,
cfg, mediaUrl,
accountId: accountId ?? undefined, mediaLocalRoots,
messageThreadId: parseTelegramThreadId(threadId), accountId,
silent: silent ?? undefined, deps,
isAnonymous: isAnonymous ?? undefined, 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: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -1,9 +1,13 @@
import { import {
resolvePayloadMediaUrls, resolvePayloadMediaUrls,
sendPayloadMediaSequence, sendPayloadMediaSequenceOrFallback,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } 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 { 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 { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramInlineButtons } from "./button-types.js";
@ -75,17 +79,16 @@ export async function sendTelegramPayloadMessages(params: {
quoteText, 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. // Telegram allows reply_markup on media; attach buttons only to the first send.
const finalResult = await sendPayloadMediaSequence({ return await sendPayloadMediaSequenceOrFallback({
text, text,
mediaUrls, mediaUrls,
fallbackResult: { messageId: "unknown", chatId: params.to },
sendNoMedia: async () =>
await params.send(params.to, text, {
...payloadOpts,
buttons,
}),
send: async ({ text, mediaUrl, isFirst }) => send: async ({ text, mediaUrl, isFirst }) =>
await params.send(params.to, text, { await params.send(params.to, text, {
...payloadOpts, ...payloadOpts,
@ -93,7 +96,6 @@ export async function sendTelegramPayloadMessages(params: {
...(isFirst ? { buttons } : {}), ...(isFirst ? { buttons } : {}),
}), }),
}); });
return finalResult ?? { messageId: "unknown", chatId: params.to };
} }
export const telegramOutbound: ChannelOutboundAdapter = { export const telegramOutbound: ChannelOutboundAdapter = {
@ -104,46 +106,47 @@ export const telegramOutbound: ChannelOutboundAdapter = {
shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData),
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { ...createAttachedChannelResultAdapter({
const { send, baseOpts } = resolveTelegramSendContext({ 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, cfg,
deps, to,
accountId, text,
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,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
forceDocument: forceDocument ?? false, accountId,
}); deps,
return { channel: "telegram", ...result }; 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 ({ sendPayload: async ({
cfg, cfg,
to, to,
@ -172,6 +175,6 @@ export const telegramOutbound: ChannelOutboundAdapter = {
forceDocument: forceDocument ?? false, forceDocument: forceDocument ?? false,
}, },
}); });
return { channel: "telegram", ...result }; return attachChannelToResult("telegram", result);
}, },
}; };

View File

@ -1,4 +1,8 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; 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 { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
@ -52,11 +56,7 @@ export async function deliverWebReply(params: {
convertMarkdownTables(replyResult.text || "", tableMode), convertMarkdownTables(replyResult.text || "", tableMode),
); );
const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode); const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
const mediaList = replyResult.mediaUrls?.length const mediaList = resolveOutboundMediaUrls(replyResult);
? replyResult.mediaUrls
: replyResult.mediaUrl
? [replyResult.mediaUrl]
: [];
const sendWithRetry = async (fn: () => Promise<unknown>, label: string, maxAttempts = 3) => { const sendWithRetry = async (fn: () => Promise<unknown>, label: string, maxAttempts = 3) => {
let lastErr: unknown; let lastErr: unknown;
@ -114,9 +114,11 @@ export async function deliverWebReply(params: {
const remainingText = [...textChunks]; const remainingText = [...textChunks];
// Media (with optional caption on first item) // Media (with optional caption on first item)
for (const [index, mediaUrl] of mediaList.entries()) { const leadingCaption = remainingText.shift() || "";
const caption = index === 0 ? remainingText.shift() || undefined : undefined; await sendMediaWithLeadingCaption({
try { mediaUrls: mediaList,
caption: leadingCaption,
send: async ({ mediaUrl, caption }) => {
const media = await loadWebMedia(mediaUrl, { const media = await loadWebMedia(mediaUrl, {
maxBytes: maxMediaBytes, maxBytes: maxMediaBytes,
localRoots: params.mediaLocalRoots, localRoots: params.mediaLocalRoots,
@ -189,21 +191,24 @@ export async function deliverWebReply(params: {
}, },
"auto-reply sent (media)", "auto-reply sent (media)",
); );
} catch (err) { },
whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(err)}`); onError: async ({ error, mediaUrl, caption, isFirst }) => {
replyLogger.warn({ err, mediaUrl }, "failed to send web media reply"); whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(error)}`);
if (index === 0) { replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply");
const warning = if (!isFirst) {
err instanceof Error ? `⚠️ Media failed: ${err.message}` : "⚠️ Media failed."; return;
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);
}
} }
} 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 // Remaining text chunks after media
for (const chunk of remainingText) { for (const chunk of remainingText) {

View File

@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../../src/config/config.js";
const hoisted = vi.hoisted(() => ({ const hoisted = vi.hoisted(() => ({
sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })), sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })),
sendReactionWhatsApp: vi.fn(async () => undefined),
})); }));
vi.mock("../../../src/globals.js", () => ({ vi.mock("../../../src/globals.js", () => ({
@ -11,6 +12,7 @@ vi.mock("../../../src/globals.js", () => ({
vi.mock("./send.js", () => ({ vi.mock("./send.js", () => ({
sendPollWhatsApp: hoisted.sendPollWhatsApp, sendPollWhatsApp: hoisted.sendPollWhatsApp,
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
})); }));
import { whatsappOutbound } from "./outbound-adapter.js"; import { whatsappOutbound } from "./outbound-adapter.js";
@ -36,6 +38,10 @@ describe("whatsappOutbound sendPoll", () => {
accountId: "work", accountId: "work",
cfg, 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",
});
}); });
}); });

View File

@ -1,6 +1,10 @@
import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep } 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 { chunkText } from "openclaw/plugin-sdk/reply-runtime";
import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; import { resolveWhatsAppOutboundTarget } from "./runtime-api.js";
@ -22,7 +26,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
const text = trimLeadingWhitespace(ctx.payload.text); const text = trimLeadingWhitespace(ctx.payload.text);
const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0; const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0;
if (!text && !hasMedia) { if (!text && !hasMedia) {
return { channel: "whatsapp", messageId: "" }; return createEmptyChannelResult("whatsapp");
} }
return await sendTextMediaPayload({ return await sendTextMediaPayload({
channel: "whatsapp", channel: "whatsapp",
@ -36,41 +40,51 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
adapter: whatsappOutbound, adapter: whatsappOutbound,
}); });
}, },
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { ...createAttachedChannelResultAdapter({
const normalizedText = trimLeadingWhitespace(text); channel: "whatsapp",
if (!normalizedText) { sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
return { channel: "whatsapp", messageId: "" }; const normalizedText = trimLeadingWhitespace(text);
} if (!normalizedText) {
const send = return createEmptyChannelResult("whatsapp");
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ?? }
(await import("./send.js")).sendMessageWhatsApp; const send =
const result = await send(to, normalizedText, { resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
verbose: false, (await import("./send.js")).sendMessageWhatsApp;
cfg, return await send(to, normalizedText, {
accountId: accountId ?? undefined, verbose: false,
gifPlayback, cfg,
}); accountId: accountId ?? undefined,
return { channel: "whatsapp", ...result }; gifPlayback,
}, });
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { },
const normalizedText = trimLeadingWhitespace(text); sendMedia: async ({
const send =
resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
(await import("./send.js")).sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,
cfg, cfg,
to,
text,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
accountId: accountId ?? undefined, accountId,
deps,
gifPlayback, gifPlayback,
}); }) => {
return { channel: "whatsapp", ...result }; const normalizedText = trimLeadingWhitespace(text);
}, const send =
sendPoll: async ({ cfg, to, poll, accountId }) => resolveOutboundSendDep<typeof import("./send.js").sendMessageWhatsApp>(deps, "whatsapp") ??
await sendPollWhatsApp(to, poll, { (await import("./send.js")).sendMessageWhatsApp;
verbose: shouldLogVerbose(), return await send(to, normalizedText, {
accountId: accountId ?? undefined, verbose: false,
cfg, cfg,
}), mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
gifPlayback,
});
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
}),
}; };

View File

@ -8,7 +8,12 @@ import {
buildOpenGroupPolicyWarning, buildOpenGroupPolicyWarning,
createOpenProviderGroupPolicyWarningCollector, createOpenProviderGroupPolicyWarningCollector,
} from "openclaw/plugin-sdk/channel-policy"; } 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 { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { import {
@ -23,7 +28,6 @@ import {
buildBaseAccountStatusSnapshot, buildBaseAccountStatusSnapshot,
buildChannelConfigSchema, buildChannelConfigSchema,
buildTokenChannelStatusSummary, buildTokenChannelStatusSummary,
buildChannelSendResult,
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
chunkTextForOutbound, chunkTextForOutbound,
formatAllowFromLowercase, formatAllowFromLowercase,
@ -150,7 +154,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
resolveRequireMention: () => true, resolveRequireMention: () => true,
}, },
threading: { threading: {
resolveReplyToMode: () => "off", resolveReplyToMode: createStaticReplyToModeResolver("off"),
}, },
actions: zaloMessageActions, actions: zaloMessageActions,
messaging: { messaging: {
@ -189,31 +193,30 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
chunker: zaloPlugin.outbound!.chunker, chunker: zaloPlugin.outbound!.chunker,
sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx), sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx), sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalo", messageId: "" }, emptyResult: createEmptyChannelResult("zalo"),
}), }),
sendText: async ({ to, text, accountId, cfg }) => { ...createRawChannelSendResultAdapter({
const result = await ( channel: "zalo",
await loadZaloChannelRuntime() sendText: async ({ to, text, accountId, cfg }) =>
).sendZaloText({ await (
to, await loadZaloChannelRuntime()
text, ).sendZaloText({
accountId: accountId ?? undefined, to,
cfg: cfg, text,
}); accountId: accountId ?? undefined,
return buildChannelSendResult("zalo", result); cfg: cfg,
}, }),
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) =>
const result = await ( await (
await loadZaloChannelRuntime() await loadZaloChannelRuntime()
).sendZaloText({ ).sendZaloText({
to, to,
text, text,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
mediaUrl, mediaUrl,
cfg: cfg, cfg: cfg,
}); }),
return buildChannelSendResult("zalo", result); }),
},
}, },
status: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -32,15 +32,14 @@ import {
createTypingCallbacks, createTypingCallbacks,
createScopedPairingAccess, createScopedPairingAccess,
createReplyPrefixOptions, createReplyPrefixOptions,
deliverTextOrMediaReply,
issuePairingChallenge, issuePairingChallenge,
logTypingFailure,
resolveDirectDmAuthorizationOutcome,
resolveSenderCommandAuthorizationWithRuntime,
resolveOutboundMediaUrls,
resolveDefaultGroupPolicy,
resolveInboundRouteEnvelopeBuilderWithRuntime,
sendMediaWithLeadingCaption,
resolveWebhookPath, resolveWebhookPath,
logTypingFailure,
resolveDefaultGroupPolicy,
resolveDirectDmAuthorizationOutcome,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveSenderCommandAuthorizationWithRuntime,
waitForAbortSignal, waitForAbortSignal,
warnMissingProviderGroupPolicyFallbackOnce, warnMissingProviderGroupPolicyFallbackOnce,
} from "./runtime-api.js"; } from "./runtime-api.js";
@ -581,33 +580,28 @@ async function deliverZaloReply(params: {
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params; const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code"; const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const sentMedia = await sendMediaWithLeadingCaption({ const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
mediaUrls: resolveOutboundMediaUrls(payload), await deliverTextOrMediaReply({
caption: text, payload,
send: async ({ mediaUrl, caption }) => { text,
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); chunkText: (value) =>
statusSink?.({ lastOutboundAt: Date.now() }); core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode),
}, sendText: async (chunk) => {
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) {
try { try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) { } catch (err) {
runtime.error?.(`Zalo message send failed: ${String(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<void> { export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {

View File

@ -1,7 +1,10 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { import {
createEmptyChannelResult,
createPairingPrefixStripper, createPairingPrefixStripper,
createRawChannelSendResultAdapter,
createStaticReplyToModeResolver,
createTextPairingAdapter, createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime"; } from "openclaw/plugin-sdk/channel-runtime";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
@ -15,7 +18,6 @@ import type {
GroupToolPolicyConfig, GroupToolPolicyConfig,
} from "../runtime-api.js"; } from "../runtime-api.js";
import { import {
buildChannelSendResult,
buildBaseAccountStatusSnapshot, buildBaseAccountStatusSnapshot,
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
isDangerousNameMatchingEnabled, isDangerousNameMatchingEnabled,
@ -312,7 +314,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
resolveToolPolicy: resolveZalouserGroupToolPolicy, resolveToolPolicy: resolveZalouserGroupToolPolicy,
}, },
threading: { threading: {
resolveReplyToMode: () => "off", resolveReplyToMode: createStaticReplyToModeResolver("off"),
}, },
actions: zalouserMessageActions, actions: zalouserMessageActions,
messaging: { messaging: {
@ -493,34 +495,35 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
ctx, ctx,
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx), sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx), sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalouser", messageId: "" }, emptyResult: createEmptyChannelResult("zalouser"),
}), }),
sendText: async ({ to, text, accountId, cfg }) => { ...createRawChannelSendResultAdapter({
const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); channel: "zalouser",
const target = parseZalouserOutboundTarget(to); sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalouser(target.threadId, text, { const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
profile: account.profile, const target = parseZalouserOutboundTarget(to);
isGroup: target.isGroup, return await sendMessageZalouser(target.threadId, text, {
textMode: "markdown", profile: account.profile,
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), isGroup: target.isGroup,
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), textMode: "markdown",
}); textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
return buildChannelSendResult("zalouser", result); textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
}, });
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { },
const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
const target = parseZalouserOutboundTarget(to); const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const result = await sendMessageZalouser(target.threadId, text, { const target = parseZalouserOutboundTarget(to);
profile: account.profile, return await sendMessageZalouser(target.threadId, text, {
isGroup: target.isGroup, profile: account.profile,
mediaUrl, isGroup: target.isGroup,
mediaLocalRoots, mediaUrl,
textMode: "markdown", mediaLocalRoots,
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), textMode: "markdown",
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
}); textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
return buildChannelSendResult("zalouser", result); });
}, },
}),
}, },
status: { status: {
defaultRuntime: { defaultRuntime: {

View File

@ -21,17 +21,16 @@ import {
createTypingCallbacks, createTypingCallbacks,
createScopedPairingAccess, createScopedPairingAccess,
createReplyPrefixOptions, createReplyPrefixOptions,
deliverTextOrMediaReply,
evaluateGroupRouteAccessForPolicy, evaluateGroupRouteAccessForPolicy,
isDangerousNameMatchingEnabled, isDangerousNameMatchingEnabled,
issuePairingChallenge, issuePairingChallenge,
resolveOutboundMediaUrls,
mergeAllowlist, mergeAllowlist,
resolveMentionGatingWithBypass, resolveMentionGatingWithBypass,
resolveOpenProviderRuntimeGroupPolicy, resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
resolveSenderCommandAuthorization, resolveSenderCommandAuthorization,
resolveSenderScopedGroupPolicy, resolveSenderScopedGroupPolicy,
sendMediaWithLeadingCaption,
summarizeMapping, summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce, warnMissingProviderGroupPolicyFallbackOnce,
} from "../runtime-api.js"; } from "../runtime-api.js";
@ -712,11 +711,24 @@ async function deliverZalouserReply(params: {
const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, { const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, {
fallbackLimit: ZALOUSER_TEXT_LIMIT, fallbackLimit: ZALOUSER_TEXT_LIMIT,
}); });
await deliverTextOrMediaReply({
const sentMedia = await sendMediaWithLeadingCaption({ payload,
mediaUrls: resolveOutboundMediaUrls(payload), text,
caption: text, sendText: async (chunk) => {
send: async ({ mediaUrl, caption }) => { 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}`); logVerbose(core, runtime, `Sending media to ${chatId}`);
await sendMessageZalouser(chatId, caption ?? "", { await sendMessageZalouser(chatId, caption ?? "", {
profile, profile,
@ -728,28 +740,10 @@ async function deliverZalouserReply(params: {
}); });
statusSink?.({ lastOutboundAt: Date.now() }); statusSink?.({ lastOutboundAt: Date.now() });
}, },
onError: (error) => { onMediaError: (error) => {
runtime.error(`Zalouser media send failed: ${String(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( export async function monitorZalouserProvider(

View File

@ -13,6 +13,7 @@
"setup-tools", "setup-tools",
"config-runtime", "config-runtime",
"reply-runtime", "reply-runtime",
"reply-payload",
"channel-runtime", "channel-runtime",
"interactive-runtime", "interactive-runtime",
"infra-runtime", "infra-runtime",
@ -88,6 +89,7 @@
"channel-config-schema", "channel-config-schema",
"channel-lifecycle", "channel-lifecycle",
"channel-policy", "channel-policy",
"channel-send-result",
"group-access", "group-access",
"directory-runtime", "directory-runtime",
"json-store", "json-store",

View File

@ -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();
});
});

View File

@ -58,6 +58,41 @@ export async function sendPayloadMediaSequence<TResult>(params: {
return lastResult; return lastResult;
} }
export async function sendPayloadMediaSequenceOrFallback<TResult>(params: {
text: string;
mediaUrls: readonly string[];
send: (input: {
text: string;
mediaUrl: string;
index: number;
isFirst: boolean;
}) => Promise<TResult>;
fallbackResult: TResult;
sendNoMedia?: () => Promise<TResult>;
}): Promise<TResult> {
if (params.mediaUrls.length === 0) {
return params.sendNoMedia ? await params.sendNoMedia() : params.fallbackResult;
}
return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult;
}
export async function sendPayloadMediaSequenceAndFinalize<TMediaResult, TResult>(params: {
text: string;
mediaUrls: readonly string[];
send: (input: {
text: string;
mediaUrl: string;
index: number;
isFirst: boolean;
}) => Promise<TMediaResult>;
finalize: () => Promise<TResult>;
}): Promise<TResult> {
if (params.mediaUrls.length > 0) {
await sendPayloadMediaSequence(params);
}
return await params.finalize();
}
export async function sendTextMediaPayload(params: { export async function sendTextMediaPayload(params: {
channel: string; channel: string;
ctx: SendPayloadContext; ctx: SendPayloadContext;

View File

@ -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<string, { replyToMode?: "off" | "first" | "all" }> };
}
).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<string | null | undefined> = [];
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"]);
});
});

View File

@ -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<ChannelThreadingAdapter["resolveReplyToMode"]>;
export function createStaticReplyToModeResolver(mode: ReplyToMode): ReplyToModeResolver {
return () => mode;
}
export function createTopLevelChannelReplyToModeResolver(channelId: string): ReplyToModeResolver {
return ({ cfg }) => {
const channelConfig = (
cfg.channels as Record<string, { replyToMode?: ReplyToMode }> | undefined
)?.[channelId];
return channelConfig?.replyToMode ?? "off";
};
}
export function createScopedAccountReplyToModeResolver<TAccount>(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";
}

View File

@ -1,4 +1,5 @@
import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js"; 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 type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js";
import { escapeRegExp } from "../../utils.js"; import { escapeRegExp } from "../../utils.js";
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
@ -62,48 +63,49 @@ export function createWhatsAppOutboundBase({
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
resolveTarget, resolveTarget,
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { ...createAttachedChannelResultAdapter({
const normalizedText = normalizeText(text); channel: "whatsapp",
if (skipEmptyText && !normalizedText) { sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
return { channel: "whatsapp", messageId: "" }; const normalizedText = normalizeText(text);
} if (skipEmptyText && !normalizedText) {
const send = return { messageId: "" };
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp; }
const result = await send(to, normalizedText, { const send =
verbose: false, resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
cfg, return await send(to, normalizedText, {
accountId: accountId ?? undefined, verbose: false,
gifPlayback, cfg,
}); accountId: accountId ?? undefined,
return { channel: "whatsapp", ...result }; gifPlayback,
}, });
sendMedia: async ({ },
cfg, sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
gifPlayback,
}) => {
const send =
resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
const result = await send(to, normalizeText(text), {
verbose: false,
cfg, cfg,
to,
text,
mediaUrl, mediaUrl,
mediaLocalRoots, mediaLocalRoots,
accountId: accountId ?? undefined, accountId,
deps,
gifPlayback, gifPlayback,
}); }) => {
return { channel: "whatsapp", ...result }; const send =
}, resolveOutboundSendDep<WhatsAppSendMessage>(deps, "whatsapp") ?? sendMessageWhatsApp;
sendPoll: async ({ cfg, to, poll, accountId }) => return await send(to, normalizeText(text), {
await sendPollWhatsApp(to, poll, { verbose: false,
verbose: shouldLogVerbose(), cfg,
accountId: accountId ?? undefined, mediaUrl,
cfg, mediaLocalRoots,
}), accountId: accountId ?? undefined,
gifPlayback,
});
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
}),
}; };
} }

View File

@ -13,6 +13,7 @@ import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js"; import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
import { normalizePollInput } from "../../polls.js"; import { normalizePollInput } from "../../polls.js";
import { import {
ErrorCodes, ErrorCodes,
@ -210,8 +211,8 @@ export const sendHandlers: GatewayRequestHandlers = {
.map((payload) => payload.text) .map((payload) => payload.text)
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
const mirrorMediaUrls = mirrorPayloads.flatMap( const mirrorMediaUrls = mirrorPayloads.flatMap((payload) =>
(payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), resolveOutboundMediaUrls(payload),
); );
const providedSessionKey = const providedSessionKey =
typeof request.sessionKey === "string" && request.sessionKey.trim() typeof request.sessionKey === "string" && request.sessionKey.trim()

View File

@ -26,6 +26,10 @@ import {
import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js";
import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import {
resolveOutboundMediaUrls,
sendMediaWithLeadingCaption,
} from "../../plugin-sdk/reply-payload.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { throwIfAborted } from "./abort.js"; import { throwIfAborted } from "./abort.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
@ -338,7 +342,7 @@ function normalizePayloadsForChannelDelivery(
function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload { function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
return { return {
text: payload.text ?? "", text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), mediaUrls: resolveOutboundMediaUrls(payload),
interactive: payload.interactive, interactive: payload.interactive,
channelData: payload.channelData, channelData: payload.channelData,
}; };
@ -721,22 +725,27 @@ async function deliverOutboundPayloadsCore(
continue; continue;
} }
let first = true;
let lastMessageId: string | undefined; let lastMessageId: string | undefined;
for (const url of payloadSummary.mediaUrls) { await sendMediaWithLeadingCaption({
throwIfAborted(abortSignal); mediaUrls: payloadSummary.mediaUrls,
const caption = first ? payloadSummary.text : ""; caption: payloadSummary.text,
first = false; send: async ({ mediaUrl, caption }) => {
if (handler.sendFormattedMedia) { throwIfAborted(abortSignal);
const delivery = await handler.sendFormattedMedia(caption, url, sendOverrides); 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); results.push(delivery);
lastMessageId = delivery.messageId; lastMessageId = delivery.messageId;
} else { },
const delivery = await handler.sendMedia(caption, url, sendOverrides); });
results.push(delivery);
lastMessageId = delivery.messageId;
}
}
emitMessageSent({ emitMessageSent({
success: true, success: true,
content: payloadSummary.text, content: payloadSummary.text,

View File

@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js";
import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js"; import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js";
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
import type { PollInput } from "../../polls.js"; import type { PollInput } from "../../polls.js";
import { normalizePollInput } from "../../polls.js"; import { normalizePollInput } from "../../polls.js";
import { import {
@ -202,8 +203,8 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
.map((payload) => payload.text) .map((payload) => payload.text)
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
const mirrorMediaUrls = normalizedPayloads.flatMap( const mirrorMediaUrls = normalizedPayloads.flatMap((payload) =>
(payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), resolveOutboundMediaUrls(payload),
); );
const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null; const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null;

View File

@ -11,6 +11,7 @@ import {
hasReplyContent, hasReplyContent,
type InteractiveReply, type InteractiveReply,
} from "../../interactive/payload.js"; } from "../../interactive/payload.js";
import { resolveOutboundMediaUrls } from "../../plugin-sdk/reply-payload.js";
export type NormalizedOutboundPayload = { export type NormalizedOutboundPayload = {
text: string; text: string;
@ -96,7 +97,7 @@ export function normalizeOutboundPayloads(
): NormalizedOutboundPayload[] { ): NormalizedOutboundPayload[] {
const normalizedPayloads: NormalizedOutboundPayload[] = []; const normalizedPayloads: NormalizedOutboundPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaUrls = resolveOutboundMediaUrls(payload);
const interactive = payload.interactive; const interactive = payload.interactive;
const channelData = payload.channelData; const channelData = payload.channelData;
const hasChannelData = hasReplyChannelData(channelData); const hasChannelData = hasReplyChannelData(channelData);
@ -127,10 +128,11 @@ export function normalizeOutboundPayloadsForJson(
): OutboundPayloadJson[] { ): OutboundPayloadJson[] {
const normalized: OutboundPayloadJson[] = []; const normalized: OutboundPayloadJson[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
const mediaUrls = resolveOutboundMediaUrls(payload);
normalized.push({ normalized.push({
text: payload.text ?? "", text: payload.text ?? "",
mediaUrl: payload.mediaUrl ?? null, mediaUrl: payload.mediaUrl ?? null,
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined), mediaUrls: mediaUrls.length ? mediaUrls : undefined,
interactive: payload.interactive, interactive: payload.interactive,
channelData: payload.channelData, channelData: payload.channelData,
}); });

View File

@ -1,5 +1,6 @@
import type { messagingApi } from "@line/bot-sdk"; import type { messagingApi } from "@line/bot-sdk";
import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyPayload } from "../auto-reply/types.js";
import { resolveOutboundMediaUrls } from "../plugin-sdk/reply-payload.js";
import type { FlexContainer } from "./flex-templates.js"; import type { FlexContainer } from "./flex-templates.js";
import type { ProcessedLineMessage } from "./markdown-to-line.js"; import type { ProcessedLineMessage } from "./markdown-to-line.js";
import type { SendLineReplyChunksParams } from "./reply-chunks.js"; import type { SendLineReplyChunksParams } from "./reply-chunks.js";
@ -123,7 +124,7 @@ export async function deliverLineAutoReply(params: {
const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : []; const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaUrls = resolveOutboundMediaUrls(payload);
const mediaMessages = mediaUrls const mediaMessages = mediaUrls
.map((url) => url?.trim()) .map((url) => url?.trim())
.filter((url): url is string => Boolean(url)) .filter((url): url is string => Boolean(url))

View File

@ -42,6 +42,7 @@ export * from "../channels/plugins/outbound/interactive.js";
export * from "../channels/plugins/pairing-adapters.js"; export * from "../channels/plugins/pairing-adapters.js";
export * from "../channels/plugins/runtime-forwarders.js"; export * from "../channels/plugins/runtime-forwarders.js";
export * from "../channels/plugins/target-resolvers.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/status-issues/shared.js";
export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../channels/plugins/whatsapp-heartbeat.js";
export * from "../infra/outbound/send-deps.js"; export * from "../infra/outbound/send-deps.js";
@ -49,6 +50,7 @@ export * from "../polls.js";
export * from "../utils/message-channel.js"; export * from "../utils/message-channel.js";
export * from "../whatsapp/normalize.js"; export * from "../whatsapp/normalize.js";
export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js"; export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js";
export * from "./channel-send-result.js";
export * from "./channel-lifecycle.js"; export * from "./channel-lifecycle.js";
export * from "./directory-runtime.js"; export * from "./directory-runtime.js";
export type { export type {

View File

@ -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"),
});
});
});

View File

@ -1,9 +1,74 @@
import type { ChannelOutboundAdapter, ChannelPollResult } from "../channels/plugins/types.js";
import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
export type ChannelSendRawResult = { export type ChannelSendRawResult = {
ok: boolean; ok: boolean;
messageId?: string | null; messageId?: string | null;
error?: string | null; error?: string | null;
}; };
export function attachChannelToResult<T extends object>(channel: string, result: T) {
return {
channel,
...result,
};
}
export function attachChannelToResults<T extends object>(channel: string, results: readonly T[]) {
return results.map((result) => attachChannelToResult(channel, result));
}
export function createEmptyChannelResult(
channel: string,
result: Partial<Omit<OutboundDeliveryResult, "channel" | "messageId">> & {
messageId?: string;
} = {},
): OutboundDeliveryResult {
return attachChannelToResult(channel, {
messageId: "",
...result,
});
}
type MaybePromise<T> = T | Promise<T>;
type SendTextParams = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0];
type SendMediaParams = Parameters<NonNullable<ChannelOutboundAdapter["sendMedia"]>>[0];
type SendPollParams = Parameters<NonNullable<ChannelOutboundAdapter["sendPoll"]>>[0];
export function createAttachedChannelResultAdapter(params: {
channel: string;
sendText?: (ctx: SendTextParams) => MaybePromise<Omit<OutboundDeliveryResult, "channel">>;
sendMedia?: (ctx: SendMediaParams) => MaybePromise<Omit<OutboundDeliveryResult, "channel">>;
sendPoll?: (ctx: SendPollParams) => MaybePromise<Omit<ChannelPollResult, "channel">>;
}): Pick<ChannelOutboundAdapter, "sendText" | "sendMedia" | "sendPoll"> {
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<ChannelSendRawResult>;
sendMedia?: (ctx: SendMediaParams) => MaybePromise<ChannelSendRawResult>;
}): Pick<ChannelOutboundAdapter, "sendText" | "sendMedia"> {
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. */ /** Normalize raw channel send results into the shape shared outbound callers expect. */
export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) { export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) {
return { return {

View File

@ -1,4 +1,5 @@
import type { DiscordSendResult } from "../../extensions/discord/api.js"; import type { DiscordSendResult } from "../../extensions/discord/api.js";
import { attachChannelToResult } from "./channel-send-result.js";
type DiscordSendOptionInput = { type DiscordSendOptionInput = {
replyToId?: string | null; 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. */ /** Stamp raw Discord send results with the channel id expected by shared outbound flows. */
export function tagDiscordChannelResult(result: DiscordSendResult) { export function tagDiscordChannelResult(result: DiscordSendResult) {
return { channel: "discord" as const, ...result }; return attachChannelToResult("discord", result);
} }

View File

@ -76,6 +76,7 @@ export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/api.js";
export type { OutboundReplyPayload } from "./reply-payload.js"; export type { OutboundReplyPayload } from "./reply-payload.js";
export { export {
createNormalizedOutboundDeliverer, createNormalizedOutboundDeliverer,
deliverFormattedTextWithAttachments,
formatTextWithAttachmentLinks, formatTextWithAttachmentLinks,
resolveOutboundMediaUrls, resolveOutboundMediaUrls,
} from "./reply-payload.js"; } from "./reply-payload.js";

View File

@ -46,6 +46,7 @@ export {
splitSetupEntries, splitSetupEntries,
} from "../channels/plugins/setup-wizard-helpers.js"; } from "../channels/plugins/setup-wizard-helpers.js";
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
export { resolveOutboundMediaUrls } from "./reply-payload.js";
export type { export type {
BaseProbeResult, BaseProbeResult,
ChannelDirectoryEntry, ChannelDirectoryEntry,

View File

@ -94,6 +94,7 @@ export { createPersistentDedupe } from "./persistent-dedupe.js";
export type { OutboundReplyPayload } from "./reply-payload.js"; export type { OutboundReplyPayload } from "./reply-payload.js";
export { export {
createNormalizedOutboundDeliverer, createNormalizedOutboundDeliverer,
deliverFormattedTextWithAttachments,
formatTextWithAttachmentLinks, formatTextWithAttachmentLinks,
resolveOutboundMediaUrls, resolveOutboundMediaUrls,
} from "./reply-payload.js"; } from "./reply-payload.js";

View File

@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js"; import {
deliverFormattedTextWithAttachments,
deliverTextOrMediaReply,
isNumericTargetId,
resolveOutboundMediaUrls,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
sendPayloadWithChunkedTextAndMedia,
} from "./reply-payload.js";
describe("sendPayloadWithChunkedTextAndMedia", () => { describe("sendPayloadWithChunkedTextAndMedia", () => {
it("returns empty result when payload has no text and no media", async () => { it("returns empty result when payload has no text and no media", async () => {
@ -56,3 +64,155 @@ describe("sendPayloadWithChunkedTextAndMedia", () => {
expect(isNumericTargetId("")).toBe(false); 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<void>>()
.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",
});
});
});

View File

@ -52,6 +52,17 @@ export function resolveOutboundMediaUrls(payload: {
return []; 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. */ /** Send media-first payloads intact, or chunk text-only payloads through the caller's transport hooks. */
export async function sendPayloadWithChunkedTextAndMedia< export async function sendPayloadWithChunkedTextAndMedia<
TContext extends { payload: object }, TContext extends { payload: object },
@ -129,21 +140,32 @@ export async function sendMediaWithLeadingCaption(params: {
mediaUrls: string[]; mediaUrls: string[];
caption: string; caption: string;
send: (payload: { mediaUrl: string; caption?: string }) => Promise<void>; send: (payload: { mediaUrl: string; caption?: string }) => Promise<void>;
onError?: (error: unknown, mediaUrl: string) => void; onError?: (params: {
error: unknown;
mediaUrl: string;
caption?: string;
index: number;
isFirst: boolean;
}) => Promise<void> | void;
}): Promise<boolean> { }): Promise<boolean> {
if (params.mediaUrls.length === 0) { if (params.mediaUrls.length === 0) {
return false; return false;
} }
let first = true; for (const [index, mediaUrl] of params.mediaUrls.entries()) {
for (const mediaUrl of params.mediaUrls) { const isFirst = index === 0;
const caption = first ? params.caption : undefined; const caption = isFirst ? params.caption : undefined;
first = false;
try { try {
await params.send({ mediaUrl, caption }); await params.send({ mediaUrl, caption });
} catch (error) { } catch (error) {
if (params.onError) { if (params.onError) {
params.onError(error, mediaUrl); await params.onError({
error,
mediaUrl,
caption,
index,
isFirst,
});
continue; continue;
} }
throw error; throw error;
@ -151,3 +173,60 @@ export async function sendMediaWithLeadingCaption(params: {
} }
return true; return true;
} }
export async function deliverTextOrMediaReply(params: {
payload: OutboundReplyPayload;
text: string;
chunkText?: (text: string) => readonly string[];
sendText: (text: string) => Promise<void>;
sendMedia: (payload: { mediaUrl: string; caption?: string }) => Promise<void>;
onMediaError?: (params: {
error: unknown;
mediaUrl: string;
caption?: string;
index: number;
isFirst: boolean;
}) => Promise<void> | 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<void>;
}): Promise<boolean> {
const text = formatTextWithAttachmentLinks(
params.payload.text,
resolveOutboundMediaUrls(params.payload),
);
if (!text) {
return false;
}
await params.send({
text,
replyToId: params.payload.replyToId,
});
return true;
}

View File

@ -1,4 +1,5 @@
import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime"; 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 compatSdk from "openclaw/plugin-sdk/compat";
import * as coreSdk from "openclaw/plugin-sdk/core"; import * as coreSdk from "openclaw/plugin-sdk/core";
import type { import type {
@ -16,6 +17,7 @@ import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as nostrSdk from "openclaw/plugin-sdk/nostr";
import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup";
import * as providerSetupSdk from "openclaw/plugin-sdk/provider-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 routingSdk from "openclaw/plugin-sdk/routing";
import * as runtimeSdk from "openclaw/plugin-sdk/runtime"; import * as runtimeSdk from "openclaw/plugin-sdk/runtime";
import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; import * as sandboxSdk from "openclaw/plugin-sdk/sandbox";
@ -93,6 +95,16 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof routingSdk.resolveThreadSessionKeys).toBe("function"); 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", () => { it("exports account helper builders from the dedicated subpath", () => {
expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function");
}); });
@ -122,17 +134,36 @@ describe("plugin-sdk subpath exports", () => {
}); });
it("exports channel runtime helpers from the dedicated subpath", () => { 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.buildUnresolvedTargetResults).toBe("function");
expect(typeof channelRuntimeSdk.createAttachedChannelResultAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createEmptyChannelResult).toBe("function");
expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function"); expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createRawChannelSendResultAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function"); expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function");
expect(typeof channelRuntimeSdk.createPairingPrefixStripper).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.createRuntimeDirectoryLiveAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).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.resolveTargetsWithOptionalToken).toBe("function");
expect(typeof channelRuntimeSdk.createTextPairingAdapter).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", () => { it("exports provider setup helpers from the dedicated subpath", () => {
expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.buildVllmProvider).toBe("function");
expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function");

View File

@ -77,6 +77,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { buildChannelSendResult } from "./channel-send-result.js"; export { buildChannelSendResult } from "./channel-send-result.js";
export type { OutboundReplyPayload } from "./reply-payload.js"; export type { OutboundReplyPayload } from "./reply-payload.js";
export { export {
deliverTextOrMediaReply,
isNumericTargetId, isNumericTargetId,
resolveOutboundMediaUrls, resolveOutboundMediaUrls,
sendMediaWithLeadingCaption, sendMediaWithLeadingCaption,

View File

@ -68,6 +68,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { buildChannelSendResult } from "./channel-send-result.js"; export { buildChannelSendResult } from "./channel-send-result.js";
export type { OutboundReplyPayload } from "./reply-payload.js"; export type { OutboundReplyPayload } from "./reply-payload.js";
export { export {
deliverTextOrMediaReply,
isNumericTargetId, isNumericTargetId,
resolveOutboundMediaUrls, resolveOutboundMediaUrls,
sendMediaWithLeadingCaption, sendMediaWithLeadingCaption,