mirror of https://github.com/openclaw/openclaw.git
refactor: deduplicate reply payload helpers
This commit is contained in:
parent
656679e6e0
commit
8d73bc77fa
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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()}`,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue