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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,13 @@ import { normalizeDiscordOutboundTarget } from "./normalize.js";
const hoisted = vi.hoisted(() => {
const sendMessageDiscordMock = vi.fn();
const sendDiscordComponentMessageMock = vi.fn();
const sendPollDiscordMock = vi.fn();
const sendWebhookMessageDiscordMock = vi.fn();
const getThreadBindingManagerMock = vi.fn();
return {
sendMessageDiscordMock,
sendDiscordComponentMessageMock,
sendPollDiscordMock,
sendWebhookMessageDiscordMock,
getThreadBindingManagerMock,
@@ -19,6 +21,8 @@ vi.mock("./send.js", async (importOriginal) => {
return {
...actual,
sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args),
sendDiscordComponentMessage: (...args: unknown[]) =>
hoisted.sendDiscordComponentMessageMock(...args),
sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args),
sendWebhookMessageDiscord: (...args: unknown[]) =>
hoisted.sendWebhookMessageDiscordMock(...args),
@@ -114,6 +118,10 @@ describe("discordOutbound", () => {
messageId: "msg-1",
channelId: "ch-1",
});
hoisted.sendDiscordComponentMessageMock.mockClear().mockResolvedValue({
messageId: "component-1",
channelId: "ch-1",
});
hoisted.sendPollDiscordMock.mockClear().mockResolvedValue({
messageId: "poll-1",
channelId: "ch-1",
@@ -249,8 +257,61 @@ describe("discordOutbound", () => {
}),
);
expect(result).toEqual({
channel: "discord",
messageId: "poll-1",
channelId: "ch-1",
});
});
it("sends component payload media sequences with the component message first", async () => {
hoisted.sendDiscordComponentMessageMock.mockResolvedValueOnce({
messageId: "component-1",
channelId: "ch-1",
});
hoisted.sendMessageDiscordMock.mockResolvedValueOnce({
messageId: "msg-2",
channelId: "ch-1",
});
const result = await discordOutbound.sendPayload?.({
cfg: {},
to: "channel:123456",
text: "",
payload: {
text: "hello",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
channelData: {
discord: {
components: { text: "hello", components: [] },
},
},
},
accountId: "default",
mediaLocalRoots: ["/tmp/media"],
});
expect(hoisted.sendDiscordComponentMessageMock).toHaveBeenCalledWith(
"channel:123456",
expect.objectContaining({ text: "hello" }),
expect.objectContaining({
mediaUrl: "https://example.com/1.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
}),
);
expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith(
"channel:123456",
"",
expect.objectContaining({
mediaUrl: "https://example.com/2.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
}),
);
expect(result).toEqual({
channel: "discord",
messageId: "msg-2",
channelId: "ch-1",
});
});
});

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import fs from "fs";
import path from "path";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import type { ChannelOutboundAdapter } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { sendMediaFeishu } from "./media.js";
@@ -81,128 +82,124 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({
cfg,
to,
text,
accountId,
replyToId,
threadId,
mediaLocalRoots,
identity,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text,
// auto-upload and send as Feishu image message instead of leaking path text.
const localImagePath = normalizePossibleLocalImagePath(text);
if (localImagePath) {
try {
const result = await sendMediaFeishu({
cfg,
to,
mediaUrl: localImagePath,
accountId: accountId ?? undefined,
replyToMessageId,
mediaLocalRoots,
});
return { channel: "feishu", ...result };
} catch (err) {
console.error(`[feishu] local image path auto-send failed:`, err);
// fall through to plain text as last resort
}
}
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
const renderMode = account.config?.renderMode ?? "auto";
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (useCard) {
const header = identity
? {
title: identity.emoji
? `${identity.emoji} ${identity.name ?? ""}`.trim()
: (identity.name ?? ""),
template: "blue" as const,
}
: undefined;
const result = await sendStructuredCardFeishu({
cfg,
to,
text,
replyToMessageId,
replyInThread: threadId != null && !replyToId,
accountId: accountId ?? undefined,
header: header?.title ? header : undefined,
});
return { channel: "feishu", ...result };
}
const result = await sendOutboundText({
...createAttachedChannelResultAdapter({
channel: "feishu",
sendText: async ({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
accountId,
mediaLocalRoots,
replyToId,
threadId,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Send text first if provided
if (text?.trim()) {
await sendOutboundText({
accountId,
replyToId,
threadId,
mediaLocalRoots,
identity,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text,
// auto-upload and send as Feishu image message instead of leaking path text.
const localImagePath = normalizePossibleLocalImagePath(text);
if (localImagePath) {
try {
return await sendMediaFeishu({
cfg,
to,
mediaUrl: localImagePath,
accountId: accountId ?? undefined,
replyToMessageId,
mediaLocalRoots,
});
} catch (err) {
console.error(`[feishu] local image path auto-send failed:`, err);
// fall through to plain text as last resort
}
}
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
const renderMode = account.config?.renderMode ?? "auto";
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (useCard) {
const header = identity
? {
title: identity.emoji
? `${identity.emoji} ${identity.name ?? ""}`.trim()
: (identity.name ?? ""),
template: "blue" as const,
}
: undefined;
return await sendStructuredCardFeishu({
cfg,
to,
text,
replyToMessageId,
replyInThread: threadId != null && !replyToId,
accountId: accountId ?? undefined,
header: header?.title ? header : undefined,
});
}
return await sendOutboundText({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToMessageId,
});
}
// Upload and send media if URL or local path provided
if (mediaUrl) {
try {
const result = await sendMediaFeishu({
cfg,
to,
mediaUrl,
accountId: accountId ?? undefined,
mediaLocalRoots,
replyToMessageId,
});
return { channel: "feishu", ...result };
} catch (err) {
// Log the error for debugging
console.error(`[feishu] sendMediaFeishu failed:`, err);
// Fallback to URL link if upload fails
const fallbackText = `📎 ${mediaUrl}`;
const result = await sendOutboundText({
cfg,
to,
text: fallbackText,
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
}
}
// No media URL, just return text result
const result = await sendOutboundText({
},
sendMedia: async ({
cfg,
to,
text: text ?? "",
accountId: accountId ?? undefined,
replyToMessageId,
});
return { channel: "feishu", ...result };
},
text,
mediaUrl,
accountId,
mediaLocalRoots,
replyToId,
threadId,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Send text first if provided
if (text?.trim()) {
await sendOutboundText({
cfg,
to,
text,
accountId: accountId ?? undefined,
replyToMessageId,
});
}
// Upload and send media if URL or local path provided
if (mediaUrl) {
try {
return await sendMediaFeishu({
cfg,
to,
mediaUrl,
accountId: accountId ?? undefined,
mediaLocalRoots,
replyToMessageId,
});
} catch (err) {
// Log the error for debugging
console.error(`[feishu] sendMediaFeishu failed:`, err);
// Fallback to URL link if upload fails
return await sendOutboundText({
cfg,
to,
text: `📎 ${mediaUrl}`,
accountId: accountId ?? undefined,
replyToMessageId,
});
}
}
// No media URL, just return text result
return await sendOutboundText({
cfg,
to,
text: text ?? "",
accountId: accountId ?? undefined,
replyToMessageId,
});
},
}),
};

View File

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

View File

@@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { deliverTextOrMediaReply } from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig } from "../runtime-api.js";
import {
createWebhookInFlightLimiter,
@@ -375,14 +376,12 @@ async function deliverGoogleChatReply(params: {
}): Promise<void> {
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } =
params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const hasMedia = Boolean(payload.mediaUrls?.length) || Boolean(payload.mediaUrl);
const text = payload.text ?? "";
let firstTextChunk = true;
let suppressCaption = false;
if (mediaList.length > 0) {
let suppressCaption = false;
if (hasMedia) {
if (typingMessageName) {
try {
await deleteGoogleChatMessage({
@@ -391,9 +390,10 @@ async function deliverGoogleChatReply(params: {
});
} catch (err) {
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
const fallbackText = payload.text?.trim()
? payload.text
: mediaList.length > 1
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
const fallbackText = text.trim()
? text
: mediaCount > 1
? "Sent attachments."
: "Sent attachment.";
try {
@@ -402,16 +402,43 @@ async function deliverGoogleChatReply(params: {
messageName: typingMessageName,
text: fallbackText,
});
suppressCaption = Boolean(payload.text?.trim());
suppressCaption = Boolean(text.trim());
} catch (updateErr) {
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
}
}
}
let first = true;
for (const mediaUrl of mediaList) {
const caption = first && !suppressCaption ? payload.text : undefined;
first = false;
}
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
await deliverTextOrMediaReply({
payload,
text: suppressCaption ? "" : text,
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
sendText: async (chunk) => {
try {
if (firstTextChunk && typingMessageName) {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: chunk,
});
} else {
await sendGoogleChatMessage({
account,
space: spaceId,
text: chunk,
thread: payload.replyToId,
});
}
firstTextChunk = false;
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
}
},
sendMedia: async ({ mediaUrl, caption }) => {
try {
const loaded = await core.channel.media.fetchRemoteMedia({
url: mediaUrl,
@@ -440,38 +467,8 @@ async function deliverGoogleChatReply(params: {
} catch (err) {
runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
}
}
return;
}
if (payload.text) {
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
// Edit typing message with first chunk if available
if (i === 0 && typingMessageName) {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: chunk,
});
} else {
await sendGoogleChatMessage({
account,
space: spaceId,
text: chunk,
thread: payload.replyToId,
});
}
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
}
}
}
},
});
}
async function uploadAttachmentForReply(params: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime";
import {
attachChannelToResult,
attachChannelToResults,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { markdownToSignalTextChunks } from "./format.js";
@@ -53,9 +58,9 @@ export const signalOutbound: ChannelOutboundAdapter = {
textMode: "plain",
textStyles: chunk.styles,
});
results.push({ channel: "signal" as const, ...result });
results.push(result);
}
return results;
return attachChannelToResults("signal", results);
},
sendFormattedMedia: async ({
cfg,
@@ -89,34 +94,35 @@ export const signalOutbound: ChannelOutboundAdapter = {
textStyles: formatted.styles,
mediaLocalRoots,
});
return { channel: "signal", ...result };
},
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
const result = await send(to, text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
const result = await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
});
return { channel: "signal", ...result };
return attachChannelToResult("signal", result);
},
...createAttachedChannelResultAdapter({
channel: "signal",
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
return await send(to, text, {
cfg,
maxBytes,
accountId: accountId ?? undefined,
});
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
accountId: accountId ?? undefined,
});
return await send(to, text, {
cfg,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
});
},
}),
};

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/slack";
import { describe, expect, it, vi } from "vitest";
import { slackOutbound } from "./outbound-adapter.js";
const handleSlackActionMock = vi.fn();
@@ -169,6 +170,79 @@ describe("slackPlugin outbound", () => {
);
expect(result).toEqual({ channel: "slack", messageId: "m-media-local" });
});
it("sends block payload media first, then the final block message", async () => {
const sendSlack = vi
.fn()
.mockResolvedValueOnce({ messageId: "m-media-1" })
.mockResolvedValueOnce({ messageId: "m-media-2" })
.mockResolvedValueOnce({ messageId: "m-final" });
const sendPayload = slackOutbound.sendPayload;
expect(sendPayload).toBeDefined();
const result = await sendPayload!({
cfg,
to: "C999",
text: "",
payload: {
text: "hello",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
channelData: {
slack: {
blocks: [
{
type: "section",
text: {
type: "plain_text",
text: "Block body",
},
},
],
},
},
},
accountId: "default",
deps: { sendSlack },
mediaLocalRoots: ["/tmp/media"],
});
expect(sendSlack).toHaveBeenCalledTimes(3);
expect(sendSlack).toHaveBeenNthCalledWith(
1,
"C999",
"",
expect.objectContaining({
mediaUrl: "https://example.com/1.png",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(sendSlack).toHaveBeenNthCalledWith(
2,
"C999",
"",
expect.objectContaining({
mediaUrl: "https://example.com/2.png",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(sendSlack).toHaveBeenNthCalledWith(
3,
"C999",
"hello",
expect.objectContaining({
blocks: [
{
type: "section",
text: {
type: "plain_text",
text: "Block body",
},
},
],
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-final" });
});
});
describe("slackPlugin directory", () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import {
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
attachChannelToResult,
createEmptyChannelDirectoryAdapter,
createTextPairingAdapter,
} from "openclaw/plugin-sdk/channel-runtime";
@@ -188,7 +189,7 @@ export function createSynologyChatPlugin() {
if (!ok) {
throw new Error("Failed to send message to Synology Chat");
}
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to });
},
sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => {
@@ -205,7 +206,7 @@ export function createSynologyChatPlugin() {
if (!ok) {
throw new Error("Failed to send media to Synology Chat");
}
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to });
},
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 = {
ok: boolean;
messageId?: 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. */
export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) {
return {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js";
import { describe, expect, it, vi } from "vitest";
import {
deliverFormattedTextWithAttachments,
deliverTextOrMediaReply,
isNumericTargetId,
resolveOutboundMediaUrls,
resolveTextChunksWithFallback,
sendMediaWithLeadingCaption,
sendPayloadWithChunkedTextAndMedia,
} from "./reply-payload.js";
describe("sendPayloadWithChunkedTextAndMedia", () => {
it("returns empty result when payload has no text and no media", async () => {
@@ -56,3 +64,155 @@ describe("sendPayloadWithChunkedTextAndMedia", () => {
expect(isNumericTargetId("")).toBe(false);
});
});
describe("resolveOutboundMediaUrls", () => {
it("prefers mediaUrls over the legacy single-media field", () => {
expect(
resolveOutboundMediaUrls({
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
mediaUrl: "https://example.com/legacy.png",
}),
).toEqual(["https://example.com/a.png", "https://example.com/b.png"]);
});
it("falls back to the legacy single-media field", () => {
expect(
resolveOutboundMediaUrls({
mediaUrl: "https://example.com/legacy.png",
}),
).toEqual(["https://example.com/legacy.png"]);
});
});
describe("resolveTextChunksWithFallback", () => {
it("returns existing chunks unchanged", () => {
expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]);
});
it("falls back to the full text when chunkers return nothing", () => {
expect(resolveTextChunksWithFallback("hello", [])).toEqual(["hello"]);
});
it("returns empty for empty text with no chunks", () => {
expect(resolveTextChunksWithFallback("", [])).toEqual([]);
});
});
describe("deliverTextOrMediaReply", () => {
it("sends media first with caption only on the first attachment", async () => {
const sendMedia = vi.fn(async () => undefined);
const sendText = vi.fn(async () => undefined);
await expect(
deliverTextOrMediaReply({
payload: { text: "hello", mediaUrls: ["https://a", "https://b"] },
text: "hello",
sendText,
sendMedia,
}),
).resolves.toBe("media");
expect(sendMedia).toHaveBeenNthCalledWith(1, {
mediaUrl: "https://a",
caption: "hello",
});
expect(sendMedia).toHaveBeenNthCalledWith(2, {
mediaUrl: "https://b",
caption: undefined,
});
expect(sendText).not.toHaveBeenCalled();
});
it("falls back to chunked text delivery when there is no media", async () => {
const sendMedia = vi.fn(async () => undefined);
const sendText = vi.fn(async () => undefined);
await expect(
deliverTextOrMediaReply({
payload: { text: "alpha beta gamma" },
text: "alpha beta gamma",
chunkText: () => ["alpha", "beta", "gamma"],
sendText,
sendMedia,
}),
).resolves.toBe("text");
expect(sendText).toHaveBeenCalledTimes(3);
expect(sendText).toHaveBeenNthCalledWith(1, "alpha");
expect(sendText).toHaveBeenNthCalledWith(2, "beta");
expect(sendText).toHaveBeenNthCalledWith(3, "gamma");
expect(sendMedia).not.toHaveBeenCalled();
});
it("returns empty when chunking produces no sendable text", async () => {
const sendMedia = vi.fn(async () => undefined);
const sendText = vi.fn(async () => undefined);
await expect(
deliverTextOrMediaReply({
payload: { text: " " },
text: " ",
chunkText: () => [],
sendText,
sendMedia,
}),
).resolves.toBe("empty");
expect(sendText).not.toHaveBeenCalled();
expect(sendMedia).not.toHaveBeenCalled();
});
});
describe("sendMediaWithLeadingCaption", () => {
it("passes leading-caption metadata to async error handlers", async () => {
const send = vi
.fn<({ mediaUrl, caption }: { mediaUrl: string; caption?: string }) => Promise<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 [];
}
/** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */
export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] {
if (chunks.length > 0) {
return [...chunks];
}
if (!text) {
return [];
}
return [text];
}
/** Send media-first payloads intact, or chunk text-only payloads through the caller's transport hooks. */
export async function sendPayloadWithChunkedTextAndMedia<
TContext extends { payload: object },
@@ -129,21 +140,32 @@ export async function sendMediaWithLeadingCaption(params: {
mediaUrls: string[];
caption: string;
send: (payload: { mediaUrl: string; caption?: string }) => Promise<void>;
onError?: (error: unknown, mediaUrl: string) => void;
onError?: (params: {
error: unknown;
mediaUrl: string;
caption?: string;
index: number;
isFirst: boolean;
}) => Promise<void> | void;
}): Promise<boolean> {
if (params.mediaUrls.length === 0) {
return false;
}
let first = true;
for (const mediaUrl of params.mediaUrls) {
const caption = first ? params.caption : undefined;
first = false;
for (const [index, mediaUrl] of params.mediaUrls.entries()) {
const isFirst = index === 0;
const caption = isFirst ? params.caption : undefined;
try {
await params.send({ mediaUrl, caption });
} catch (error) {
if (params.onError) {
params.onError(error, mediaUrl);
await params.onError({
error,
mediaUrl,
caption,
index,
isFirst,
});
continue;
}
throw error;
@@ -151,3 +173,60 @@ export async function sendMediaWithLeadingCaption(params: {
}
return true;
}
export async function deliverTextOrMediaReply(params: {
payload: OutboundReplyPayload;
text: string;
chunkText?: (text: string) => readonly string[];
sendText: (text: string) => Promise<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 channelSendResultSdk from "openclaw/plugin-sdk/channel-send-result";
import * as compatSdk from "openclaw/plugin-sdk/compat";
import * as coreSdk from "openclaw/plugin-sdk/core";
import type {
@@ -16,6 +17,7 @@ import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
import * as nostrSdk from "openclaw/plugin-sdk/nostr";
import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup";
import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup";
import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload";
import * as routingSdk from "openclaw/plugin-sdk/routing";
import * as runtimeSdk from "openclaw/plugin-sdk/runtime";
import * as sandboxSdk from "openclaw/plugin-sdk/sandbox";
@@ -93,6 +95,16 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof routingSdk.resolveThreadSessionKeys).toBe("function");
});
it("exports reply payload helpers from the dedicated subpath", () => {
expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function");
expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function");
expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function");
expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function");
expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function");
expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function");
expect(typeof replyPayloadSdk.sendPayloadWithChunkedTextAndMedia).toBe("function");
});
it("exports account helper builders from the dedicated subpath", () => {
expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function");
});
@@ -122,17 +134,36 @@ describe("plugin-sdk subpath exports", () => {
});
it("exports channel runtime helpers from the dedicated subpath", () => {
expect(typeof channelRuntimeSdk.attachChannelToResult).toBe("function");
expect(typeof channelRuntimeSdk.attachChannelToResults).toBe("function");
expect(typeof channelRuntimeSdk.buildUnresolvedTargetResults).toBe("function");
expect(typeof channelRuntimeSdk.createAttachedChannelResultAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createEmptyChannelResult).toBe("function");
expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createRawChannelSendResultAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function");
expect(typeof channelRuntimeSdk.createPairingPrefixStripper).toBe("function");
expect(typeof channelRuntimeSdk.createScopedAccountReplyToModeResolver).toBe("function");
expect(typeof channelRuntimeSdk.createStaticReplyToModeResolver).toBe("function");
expect(typeof channelRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function");
expect(typeof channelRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function");
expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceAndFinalize).toBe("function");
expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceOrFallback).toBe("function");
expect(typeof channelRuntimeSdk.resolveTargetsWithOptionalToken).toBe("function");
expect(typeof channelRuntimeSdk.createTextPairingAdapter).toBe("function");
});
it("exports channel send-result helpers from the dedicated subpath", () => {
expect(typeof channelSendResultSdk.attachChannelToResult).toBe("function");
expect(typeof channelSendResultSdk.attachChannelToResults).toBe("function");
expect(typeof channelSendResultSdk.buildChannelSendResult).toBe("function");
expect(typeof channelSendResultSdk.createAttachedChannelResultAdapter).toBe("function");
expect(typeof channelSendResultSdk.createEmptyChannelResult).toBe("function");
expect(typeof channelSendResultSdk.createRawChannelSendResultAdapter).toBe("function");
});
it("exports provider setup helpers from the dedicated subpath", () => {
expect(typeof providerSetupSdk.buildVllmProvider).toBe("function");
expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function");

View File

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

View File

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