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