From 7cdd8a84a67b50cfdbcdb49489184a2c5dfe8337 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:47:43 -0700 Subject: [PATCH] refactor: add plugin-owned outbound adapters --- extensions/imessage/src/outbound-adapter.ts | 35 +++ extensions/signal/src/outbound-adapter.ts | 125 ++++++++++ extensions/slack/src/outbound-adapter.ts | 250 ++++++++++++++++++++ src/plugin-sdk/allowlist-config-edit.ts | 210 ++++++++++++++++ test/channel-outbounds.ts | 6 + 5 files changed, 626 insertions(+) create mode 100644 extensions/imessage/src/outbound-adapter.ts create mode 100644 extensions/signal/src/outbound-adapter.ts create mode 100644 extensions/slack/src/outbound-adapter.ts create mode 100644 src/plugin-sdk/allowlist-config-edit.ts create mode 100644 test/channel-outbounds.ts diff --git a/extensions/imessage/src/outbound-adapter.ts b/extensions/imessage/src/outbound-adapter.ts new file mode 100644 index 00000000000..ae5e7c2836a --- /dev/null +++ b/extensions/imessage/src/outbound-adapter.ts @@ -0,0 +1,35 @@ +import { + createScopedChannelMediaMaxBytesResolver, + createDirectTextMediaOutbound, +} from "../../../src/channels/plugins/outbound/direct-text-media.js"; +import { + resolveOutboundSendDep, + type OutboundSendDeps, +} from "../../../src/infra/outbound/send-deps.js"; +import { sendMessageIMessage } from "./send.js"; + +function resolveIMessageSender(deps: OutboundSendDeps | undefined) { + return ( + resolveOutboundSendDep(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, + }), +}); diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts new file mode 100644 index 00000000000..b0d77c12bd0 --- /dev/null +++ b/extensions/signal/src/outbound-adapter.ts @@ -0,0 +1,125 @@ +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { createScopedChannelMediaMaxBytesResolver } from "../../../src/channels/plugins/outbound/direct-text-media.js"; +import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; +import { + resolveOutboundSendDep, + type OutboundSendDeps, +} from "../../../src/infra/outbound/send-deps.js"; +import { markdownToSignalTextChunks } from "./format.js"; +import { sendMessageSignal } from "./send.js"; + +function resolveSignalSender(deps: OutboundSendDeps | undefined) { + return resolveOutboundSendDep(deps, "signal") ?? sendMessageSignal; +} + +const resolveSignalMaxBytes = createScopedChannelMediaMaxBytesResolver("signal"); +type SignalSendOpts = NonNullable[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 }; + }, +}; diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts new file mode 100644 index 00000000000..1c851c8f69e --- /dev/null +++ b/extensions/slack/src/outbound-adapter.ts @@ -0,0 +1,250 @@ +import { + resolvePayloadMediaUrls, + sendPayloadMediaSequence, + sendTextMediaPayload, +} from "../../../src/channels/plugins/outbound/direct-text-media.js"; +import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; +import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { + resolveInteractiveTextFallback, + type InteractiveReply, +} from "../../../src/interactive/payload.js"; +import { getGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js"; +import { sendMessageSlack, type SlackSendIdentity } from "./send.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[2]>["cfg"]; + to: string; + text: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + blocks?: NonNullable[2]>["blocks"]; + accountId?: string | null; + deps?: { [channelId: string]: unknown } | null; + replyToId?: string | null; + threadId?: string | number | null; + identity?: OutboundIdentity; +}) { + const send = + resolveOutboundSendDep(params.deps, "slack") ?? sendMessageSlack; + 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; + interactive?: InteractiveReply; +}) { + const slackData = payload.channelData?.slack; + const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive); + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return renderedInteractive; + } + const 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, + }); + }, +}; diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts new file mode 100644 index 00000000000..4c9f10ec278 --- /dev/null +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -0,0 +1,210 @@ +import type { ConfigWriteTarget } from "../channels/plugins/config-writes.js"; +import type { ChannelAllowlistAdapter } from "../channels/plugins/types.adapters.js"; +import type { ChannelId } from "../channels/plugins/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +type AllowlistConfigPaths = { + readPaths: string[][]; + writePath: string[]; + cleanupPaths?: string[][]; +}; + +function resolveAccountScopedWriteTarget( + parsed: Record, + channelId: ChannelId, + accountId?: string | null, +) { + const channels = (parsed.channels ??= {}) as Record; + const channel = (channels[channelId] ??= {}) as Record; + const normalizedAccountId = normalizeAccountId(accountId); + if (isBlockedObjectKey(normalizedAccountId)) { + return { + target: channel, + pathPrefix: `channels.${channelId}`, + writeTarget: { kind: "channel", scope: { channelId } } as const satisfies ConfigWriteTarget, + }; + } + const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object"); + const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts; + if (!useAccount) { + return { + target: channel, + pathPrefix: `channels.${channelId}`, + writeTarget: { kind: "channel", scope: { channelId } } as const satisfies ConfigWriteTarget, + }; + } + const accounts = (channel.accounts ??= {}) as Record; + const existingAccount = Object.hasOwn(accounts, normalizedAccountId) + ? accounts[normalizedAccountId] + : undefined; + if (!existingAccount || typeof existingAccount !== "object") { + accounts[normalizedAccountId] = {}; + } + const account = accounts[normalizedAccountId] as Record; + return { + target: account, + pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`, + writeTarget: { + kind: "account", + scope: { channelId, accountId: normalizedAccountId }, + } as const satisfies ConfigWriteTarget, + }; +} + +function getNestedValue(root: Record, path: string[]): unknown { + let current: unknown = root; + for (const key of path) { + if (!current || typeof current !== "object") { + return undefined; + } + current = (current as Record)[key]; + } + return current; +} + +function ensureNestedObject( + root: Record, + path: string[], +): Record { + let current = root; + for (const key of path) { + const existing = current[key]; + if (!existing || typeof existing !== "object") { + current[key] = {}; + } + current = current[key] as Record; + } + return current; +} + +function setNestedValue(root: Record, path: string[], value: unknown) { + if (path.length === 0) { + return; + } + if (path.length === 1) { + root[path[0]] = value; + return; + } + const parent = ensureNestedObject(root, path.slice(0, -1)); + parent[path[path.length - 1]] = value; +} + +function deleteNestedValue(root: Record, path: string[]) { + if (path.length === 0) { + return; + } + if (path.length === 1) { + delete root[path[0]]; + return; + } + const parent = getNestedValue(root, path.slice(0, -1)); + if (!parent || typeof parent !== "object") { + return; + } + delete (parent as Record)[path[path.length - 1]]; +} + +function applyAccountScopedAllowlistConfigEdit(params: { + parsedConfig: Record; + channelId: ChannelId; + accountId?: string | null; + action: "add" | "remove"; + entry: string; + normalize: (values: Array) => string[]; + paths: AllowlistConfigPaths; +}): NonNullable>>> { + const resolvedTarget = resolveAccountScopedWriteTarget( + params.parsedConfig, + params.channelId, + params.accountId, + ); + const existing: string[] = []; + for (const path of params.paths.readPaths) { + const existingRaw = getNestedValue(resolvedTarget.target, path); + if (!Array.isArray(existingRaw)) { + continue; + } + for (const entry of existingRaw) { + const value = String(entry).trim(); + if (!value || existing.includes(value)) { + continue; + } + existing.push(value); + } + } + + const normalizedEntry = params.normalize([params.entry]); + if (normalizedEntry.length === 0) { + return { kind: "invalid-entry" }; + } + + const existingNormalized = params.normalize(existing); + const shouldMatch = (value: string) => normalizedEntry.includes(value); + + let changed = false; + let next = existing; + const configHasEntry = existingNormalized.some((value) => shouldMatch(value)); + if (params.action === "add") { + if (!configHasEntry) { + next = [...existing, params.entry.trim()]; + changed = true; + } + } else { + const keep: string[] = []; + for (const entry of existing) { + const normalized = params.normalize([entry]); + if (normalized.some((value) => shouldMatch(value))) { + changed = true; + continue; + } + keep.push(entry); + } + next = keep; + } + + if (changed) { + if (next.length === 0) { + deleteNestedValue(resolvedTarget.target, params.paths.writePath); + } else { + setNestedValue(resolvedTarget.target, params.paths.writePath, next); + } + for (const path of params.paths.cleanupPaths ?? []) { + deleteNestedValue(resolvedTarget.target, path); + } + } + + return { + kind: "ok", + changed, + pathLabel: `${resolvedTarget.pathPrefix}.${params.paths.writePath.join(".")}`, + writeTarget: resolvedTarget.writeTarget, + }; +} + +export function buildAccountScopedAllowlistConfigEditor(params: { + channelId: ChannelId; + normalize: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + values: Array; + }) => string[]; + resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; +}): NonNullable { + return ({ cfg, parsedConfig, accountId, scope, action, entry }) => { + const paths = params.resolvePaths(scope); + if (!paths) { + return null; + } + return applyAccountScopedAllowlistConfigEdit({ + parsedConfig, + channelId: params.channelId, + accountId, + action, + entry, + normalize: (values) => params.normalize({ cfg, accountId, values }), + paths, + }); + }; +} diff --git a/test/channel-outbounds.ts b/test/channel-outbounds.ts new file mode 100644 index 00000000000..a6da5a1c333 --- /dev/null +++ b/test/channel-outbounds.ts @@ -0,0 +1,6 @@ +export { discordOutbound } from "../extensions/discord/src/outbound-adapter.js"; +export { imessageOutbound } from "../extensions/imessage/src/outbound-adapter.js"; +export { signalOutbound } from "../extensions/signal/src/outbound-adapter.js"; +export { slackOutbound } from "../extensions/slack/src/outbound-adapter.js"; +export { telegramOutbound } from "../extensions/telegram/src/outbound-adapter.js"; +export { whatsappOutbound } from "../extensions/whatsapp/src/outbound-adapter.js";