mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 23:40:21 +00:00
refactor: move remaining channel seams into plugins
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
// Shim: re-exports from extension
|
||||
export * from "../../../../extensions/discord/src/outbound-adapter.js";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { imessageOutbound } from "../../../../test/channel-outbounds.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { imessageOutbound } from "./imessage.js";
|
||||
|
||||
describe("imessageOutbound", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { sendMessageIMessage } from "../../../../extensions/imessage/src/send.js";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../infra/outbound/send-deps.js";
|
||||
import {
|
||||
createScopedChannelMediaMaxBytesResolver,
|
||||
createDirectTextMediaOutbound,
|
||||
} from "./direct-text-media.js";
|
||||
|
||||
function resolveIMessageSender(deps: OutboundSendDeps | undefined) {
|
||||
return (
|
||||
resolveOutboundSendDep<typeof sendMessageIMessage>(deps, "imessage") ?? sendMessageIMessage
|
||||
);
|
||||
}
|
||||
|
||||
export const imessageOutbound = createDirectTextMediaOutbound({
|
||||
channel: "imessage",
|
||||
resolveSender: resolveIMessageSender,
|
||||
resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("imessage"),
|
||||
buildTextOptions: ({ cfg, maxBytes, accountId, replyToId }) => ({
|
||||
config: cfg,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
}),
|
||||
buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, replyToId, mediaLocalRoots }) => ({
|
||||
config: cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
mediaLocalRoots,
|
||||
}),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { signalOutbound } from "../../../../test/channel-outbounds.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { signalOutbound } from "./signal.js";
|
||||
|
||||
describe("signalOutbound", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { markdownToSignalTextChunks } from "../../../../extensions/signal/src/format.js";
|
||||
import { sendMessageSignal } from "../../../../extensions/signal/src/send.js";
|
||||
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
||||
import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../infra/outbound/send-deps.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { createScopedChannelMediaMaxBytesResolver } from "./direct-text-media.js";
|
||||
|
||||
function resolveSignalSender(deps: OutboundSendDeps | undefined) {
|
||||
return resolveOutboundSendDep<typeof sendMessageSignal>(deps, "signal") ?? sendMessageSignal;
|
||||
}
|
||||
|
||||
const resolveSignalMaxBytes = createScopedChannelMediaMaxBytesResolver("signal");
|
||||
type SignalSendOpts = NonNullable<Parameters<typeof sendMessageSignal>[2]>;
|
||||
|
||||
function inferSignalTableMode(params: { cfg: SignalSendOpts["cfg"]; accountId?: string | null }) {
|
||||
return resolveMarkdownTableMode({
|
||||
cfg: params.cfg,
|
||||
channel: "signal",
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export const signalOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, _limit) => text.split(/\n{2,}/).flatMap((chunk) => (chunk ? [chunk] : [])),
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 4000,
|
||||
sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const limit = resolveTextChunkLimit(cfg, "signal", accountId ?? undefined, {
|
||||
fallbackLimit: 4000,
|
||||
});
|
||||
const tableMode = inferSignalTableMode({ cfg, accountId });
|
||||
let chunks =
|
||||
limit === undefined
|
||||
? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { tableMode })
|
||||
: markdownToSignalTextChunks(text, limit, { tableMode });
|
||||
if (chunks.length === 0 && text) {
|
||||
chunks = [{ text, styles: [] }];
|
||||
}
|
||||
const results = [];
|
||||
for (const chunk of chunks) {
|
||||
abortSignal?.throwIfAborted();
|
||||
const result = await send(to, chunk.text, {
|
||||
cfg,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: chunk.styles,
|
||||
});
|
||||
results.push({ channel: "signal" as const, ...result });
|
||||
}
|
||||
return results;
|
||||
},
|
||||
sendFormattedMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
abortSignal,
|
||||
}) => {
|
||||
abortSignal?.throwIfAborted();
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const tableMode = inferSignalTableMode({ cfg, accountId });
|
||||
const formatted = markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, {
|
||||
tableMode,
|
||||
})[0] ?? {
|
||||
text,
|
||||
styles: [],
|
||||
};
|
||||
const result = await send(to, formatted.text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
textMode: "plain",
|
||||
textStyles: formatted.styles,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
return { channel: "signal", ...result };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
cfg,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return { channel: "signal", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
|
||||
const send = resolveSignalSender(deps);
|
||||
const maxBytes = resolveSignalMaxBytes({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const result = await send(to, text, {
|
||||
cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaLocalRoots,
|
||||
});
|
||||
return { channel: "signal", ...result };
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { slackOutbound } from "../../../../test/channel-outbounds.js";
|
||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||
import {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../test-utils/send-payload-contract.js";
|
||||
import { slackOutbound } from "./slack.js";
|
||||
|
||||
function createHarness(params: {
|
||||
payload: ReplyPayload;
|
||||
|
||||
@@ -10,8 +10,8 @@ vi.mock("../../../plugins/hook-runner-global.js", () => ({
|
||||
}));
|
||||
|
||||
import { sendMessageSlack } from "../../../../extensions/slack/src/send.js";
|
||||
import { slackOutbound } from "../../../../test/channel-outbounds.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import { slackOutbound } from "./slack.js";
|
||||
|
||||
type SlackSendTextCtx = {
|
||||
to: string;
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import { parseSlackBlocksInput } from "../../../../extensions/slack/src/blocks-input.js";
|
||||
import {
|
||||
buildSlackInteractiveBlocks,
|
||||
type SlackBlock,
|
||||
} from "../../../../extensions/slack/src/blocks-render.js";
|
||||
import { sendMessageSlack, type SlackSendIdentity } from "../../../../extensions/slack/src/send.js";
|
||||
import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
|
||||
import { resolveOutboundSendDep } from "../../../infra/outbound/send-deps.js";
|
||||
import {
|
||||
resolveInteractiveTextFallback,
|
||||
type InteractiveReply,
|
||||
} from "../../../interactive/payload.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequence,
|
||||
sendTextMediaPayload,
|
||||
} from "./direct-text-media.js";
|
||||
|
||||
const SLACK_MAX_BLOCKS = 50;
|
||||
|
||||
function resolveRenderedInteractiveBlocks(
|
||||
interactive?: InteractiveReply,
|
||||
): SlackBlock[] | undefined {
|
||||
if (!interactive) {
|
||||
return undefined;
|
||||
}
|
||||
const blocks = buildSlackInteractiveBlocks(interactive);
|
||||
return blocks.length > 0 ? blocks : undefined;
|
||||
}
|
||||
|
||||
function resolveSlackSendIdentity(identity?: OutboundIdentity): SlackSendIdentity | undefined {
|
||||
if (!identity) {
|
||||
return undefined;
|
||||
}
|
||||
const username = identity.name?.trim() || undefined;
|
||||
const iconUrl = identity.avatarUrl?.trim() || undefined;
|
||||
const rawEmoji = identity.emoji?.trim();
|
||||
const iconEmoji = !iconUrl && rawEmoji && /^:[^:\s]+:$/.test(rawEmoji) ? rawEmoji : undefined;
|
||||
if (!username && !iconUrl && !iconEmoji) {
|
||||
return undefined;
|
||||
}
|
||||
return { username, iconUrl, iconEmoji };
|
||||
}
|
||||
|
||||
async function applySlackMessageSendingHooks(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
threadTs?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
}): Promise<{ cancelled: boolean; text: string }> {
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (!hookRunner?.hasHooks("message_sending")) {
|
||||
return { cancelled: false, text: params.text };
|
||||
}
|
||||
const hookResult = await hookRunner.runMessageSending(
|
||||
{
|
||||
to: params.to,
|
||||
content: params.text,
|
||||
metadata: {
|
||||
threadTs: params.threadTs,
|
||||
channelId: params.to,
|
||||
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
|
||||
},
|
||||
},
|
||||
{ channelId: "slack", accountId: params.accountId ?? undefined },
|
||||
);
|
||||
if (hookResult?.cancel) {
|
||||
return { cancelled: true, text: params.text };
|
||||
}
|
||||
return { cancelled: false, text: hookResult?.content ?? params.text };
|
||||
}
|
||||
|
||||
async function sendSlackOutboundMessage(params: {
|
||||
cfg: NonNullable<Parameters<typeof sendMessageSlack>[2]>["cfg"];
|
||||
to: string;
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
blocks?: NonNullable<Parameters<typeof sendMessageSlack>[2]>["blocks"];
|
||||
accountId?: string | null;
|
||||
deps?: { [channelId: string]: unknown } | null;
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
identity?: OutboundIdentity;
|
||||
}) {
|
||||
const send =
|
||||
resolveOutboundSendDep<typeof sendMessageSlack>(params.deps, "slack") ?? sendMessageSlack;
|
||||
// Use threadId fallback so routed tool notifications stay in the Slack thread.
|
||||
const threadTs =
|
||||
params.replyToId ?? (params.threadId != null ? String(params.threadId) : undefined);
|
||||
const hookResult = await applySlackMessageSendingHooks({
|
||||
to: params.to,
|
||||
text: params.text,
|
||||
threadTs,
|
||||
mediaUrl: params.mediaUrl,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
if (hookResult.cancelled) {
|
||||
return {
|
||||
channel: "slack" as const,
|
||||
messageId: "cancelled-by-hook",
|
||||
channelId: params.to,
|
||||
meta: { cancelled: true },
|
||||
};
|
||||
}
|
||||
|
||||
const slackIdentity = resolveSlackSendIdentity(params.identity);
|
||||
const result = await send(params.to, hookResult.text, {
|
||||
cfg: params.cfg,
|
||||
threadTs,
|
||||
accountId: params.accountId ?? undefined,
|
||||
...(params.mediaUrl
|
||||
? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots }
|
||||
: {}),
|
||||
...(params.blocks ? { blocks: params.blocks } : {}),
|
||||
...(slackIdentity ? { identity: slackIdentity } : {}),
|
||||
});
|
||||
return { channel: "slack" as const, ...result };
|
||||
}
|
||||
|
||||
function resolveSlackBlocks(payload: {
|
||||
channelData?: Record<string, unknown>;
|
||||
interactive?: InteractiveReply;
|
||||
}) {
|
||||
const slackData = payload.channelData?.slack;
|
||||
const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive);
|
||||
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
|
||||
return renderedInteractive;
|
||||
}
|
||||
let existingBlocks: SlackBlock[] | undefined;
|
||||
existingBlocks = parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as
|
||||
| SlackBlock[]
|
||||
| undefined;
|
||||
const mergedBlocks = [...(existingBlocks ?? []), ...(renderedInteractive ?? [])];
|
||||
if (mergedBlocks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (mergedBlocks.length > SLACK_MAX_BLOCKS) {
|
||||
throw new Error(
|
||||
`Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`,
|
||||
);
|
||||
}
|
||||
return mergedBlocks;
|
||||
}
|
||||
|
||||
export const slackOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: null,
|
||||
textChunkLimit: 4000,
|
||||
sendPayload: async (ctx) => {
|
||||
const payload = {
|
||||
...ctx.payload,
|
||||
text:
|
||||
resolveInteractiveTextFallback({
|
||||
text: ctx.payload.text,
|
||||
interactive: ctx.payload.interactive,
|
||||
}) ?? "",
|
||||
};
|
||||
const blocks = resolveSlackBlocks(payload);
|
||||
if (!blocks) {
|
||||
return await sendTextMediaPayload({
|
||||
channel: "slack",
|
||||
ctx: {
|
||||
...ctx,
|
||||
payload,
|
||||
},
|
||||
adapter: slackOutbound,
|
||||
});
|
||||
}
|
||||
const mediaUrls = resolvePayloadMediaUrls(payload);
|
||||
if (mediaUrls.length === 0) {
|
||||
return await sendSlackOutboundMessage({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: payload.text ?? "",
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
blocks,
|
||||
accountId: ctx.accountId,
|
||||
deps: ctx.deps,
|
||||
replyToId: ctx.replyToId,
|
||||
threadId: ctx.threadId,
|
||||
identity: ctx.identity,
|
||||
});
|
||||
}
|
||||
await sendPayloadMediaSequence({
|
||||
text: "",
|
||||
mediaUrls,
|
||||
send: async ({ text, mediaUrl }) =>
|
||||
await sendSlackOutboundMessage({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
accountId: ctx.accountId,
|
||||
deps: ctx.deps,
|
||||
replyToId: ctx.replyToId,
|
||||
threadId: ctx.threadId,
|
||||
identity: ctx.identity,
|
||||
}),
|
||||
});
|
||||
return await sendSlackOutboundMessage({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: payload.text ?? "",
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
blocks,
|
||||
accountId: ctx.accountId,
|
||||
deps: ctx.deps,
|
||||
replyToId: ctx.replyToId,
|
||||
threadId: ctx.threadId,
|
||||
identity: ctx.identity,
|
||||
});
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => {
|
||||
return await sendSlackOutboundMessage({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
identity,
|
||||
});
|
||||
},
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
identity,
|
||||
}) => {
|
||||
return await sendSlackOutboundMessage({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
deps,
|
||||
replyToId,
|
||||
threadId,
|
||||
identity,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../../../extensions/telegram/src/outbound-adapter.js";
|
||||
@@ -1,2 +0,0 @@
|
||||
// Shim: re-exports from extensions/whatsapp/src/outbound-adapter.ts
|
||||
export * from "../../../../extensions/whatsapp/src/outbound-adapter.js";
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizeSignalAccountInput } from "../../../extensions/signal/src/setup-surface.js";
|
||||
import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js";
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js";
|
||||
import { telegramOutbound } from "./outbound/telegram.js";
|
||||
import { whatsappOutbound } from "./outbound/whatsapp.js";
|
||||
|
||||
function expectWhatsAppTargetResolutionError(result: unknown) {
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -481,6 +481,35 @@ export type ChannelExecApprovalAdapter = {
|
||||
};
|
||||
|
||||
export type ChannelAllowlistAdapter = {
|
||||
applyConfigEdit?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
parsedConfig: Record<string, unknown>;
|
||||
accountId?: string | null;
|
||||
scope: "dm" | "group";
|
||||
action: "add" | "remove";
|
||||
entry: string;
|
||||
}) =>
|
||||
| {
|
||||
kind: "ok";
|
||||
changed: boolean;
|
||||
pathLabel: string;
|
||||
writeTarget: ConfigWriteTarget;
|
||||
}
|
||||
| {
|
||||
kind: "invalid-entry";
|
||||
}
|
||||
| Promise<
|
||||
| {
|
||||
kind: "ok";
|
||||
changed: boolean;
|
||||
pathLabel: string;
|
||||
writeTarget: ConfigWriteTarget;
|
||||
}
|
||||
| {
|
||||
kind: "invalid-entry";
|
||||
}
|
||||
>
|
||||
| null;
|
||||
readConfig?: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
|
||||
| {
|
||||
dmAllowFrom?: Array<string | number>;
|
||||
@@ -504,17 +533,6 @@ export type ChannelAllowlistAdapter = {
|
||||
}) =>
|
||||
| Array<{ input: string; resolved: boolean; name?: string | null }>
|
||||
| Promise<Array<{ input: string; resolved: boolean; name?: string | null }>>;
|
||||
resolveConfigEdit?: (params: {
|
||||
scope: "dm" | "group";
|
||||
pathPrefix: string;
|
||||
writeTarget: ConfigWriteTarget;
|
||||
}) => {
|
||||
pathPrefix: string;
|
||||
writeTarget: ConfigWriteTarget;
|
||||
readPaths: string[][];
|
||||
writePath: string[];
|
||||
cleanupPaths?: string[][];
|
||||
} | null;
|
||||
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user