refactor: move remaining channel seams into plugins

This commit is contained in:
Peter Steinberger
2026-03-15 23:47:22 -07:00
parent ae60094fb5
commit 2054cb9431
42 changed files with 246 additions and 787 deletions

View File

@@ -1,6 +1,7 @@
import { Separator, TextDisplay } from "@buape/carbon";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedAllowlistConfigEditor,
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,
collectOpenGroupPolicyConfiguredRouteWarnings,
@@ -262,16 +263,19 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })),
resolveNames: async ({ cfg, accountId, entries }) =>
await resolveDiscordAllowlistNames({ cfg, accountId, entries }),
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) =>
scope === "dm"
? {
pathPrefix,
writeTarget,
readPaths: [["allowFrom"], ["dm", "allowFrom"]],
writePath: ["allowFrom"],
cleanupPaths: [["dm", "allowFrom"]],
}
: null,
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "discord",
normalize: ({ cfg, accountId, values }) =>
discordConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: (scope) =>
scope === "dm"
? {
readPaths: [["allowFrom"], ["dm", "allowFrom"]],
writePath: ["allowFrom"],
cleanupPaths: [["dm", "allowFrom"]],
}
: null,
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -1,4 +1,5 @@
import {
buildAccountScopedAllowlistConfigEditor,
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/compat";
@@ -135,11 +136,13 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
groupPolicy: account.config.groupPolicy,
};
},
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({
pathPrefix,
writeTarget,
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "imessage",
normalize: ({ values }) => formatTrimmedAllowFromEntries(values),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
}),
}),
},
security: {

View File

@@ -1,4 +1,5 @@
import {
buildAccountScopedAllowlistConfigEditor,
buildAccountScopedDmSecurityPolicy,
createScopedAccountConfigAccessors,
collectAllowlistProviderRestrictSendersWarnings,
@@ -283,11 +284,14 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
groupPolicy: account.config.groupPolicy,
};
},
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({
pathPrefix,
writeTarget,
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "signal",
normalize: ({ cfg, accountId, values }) =>
signalConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
}),
}),
},
security: {

View File

@@ -1,5 +1,6 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedAllowlistConfigEditor,
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,
collectOpenGroupPolicyConfiguredRouteWarnings,
@@ -279,16 +280,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
readSlackAllowlistConfig(resolveSlackAccount({ cfg, accountId })),
resolveNames: async ({ cfg, accountId, entries }) =>
await resolveSlackAllowlistNames({ cfg, accountId, entries }),
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) =>
scope === "dm"
? {
pathPrefix,
writeTarget,
readPaths: [["allowFrom"], ["dm", "allowFrom"]],
writePath: ["allowFrom"],
cleanupPaths: [["dm", "allowFrom"]],
}
: null,
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "slack",
normalize: ({ cfg, accountId, values }) =>
slackConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: (scope) =>
scope === "dm"
? {
readPaths: [["allowFrom"], ["dm", "allowFrom"]],
writePath: ["allowFrom"],
cleanupPaths: [["dm", "allowFrom"]],
}
: null,
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -1,5 +1,6 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedAllowlistConfigEditor,
collectAllowlistProviderGroupPolicyWarnings,
collectOpenGroupPolicyRouteAllowlistWarnings,
createScopedAccountConfigAccessors,
@@ -358,11 +359,14 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
readConfig: ({ cfg, accountId }) =>
readTelegramAllowlistConfig(resolveTelegramAccount({ cfg, accountId })),
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({
pathPrefix,
writeTarget,
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "telegram",
normalize: ({ cfg, accountId, values }) =>
telegramConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
}),
}),
},
acpBindings: {

View File

@@ -1,3 +1,4 @@
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
buildAccountScopedDmSecurityPolicy,
@@ -195,11 +196,13 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
groupPolicy: account.groupPolicy,
};
},
resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({
pathPrefix,
writeTarget,
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "whatsapp",
normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
}),
}),
},
security: {

View File

@@ -1,4 +1,3 @@
import { getChannelDock } from "../channels/dock.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type {
ChannelAgentTool,
@@ -73,8 +72,7 @@ export function resolveChannelMessageToolHints(params: {
if (!channelId) {
return [];
}
const dock = getChannelDock(channelId);
const resolve = dock?.agentPrompt?.messageToolHints;
const resolve = getChannelPlugin(channelId)?.agentPrompt?.messageToolHints;
if (!resolve) {
return [];
}

View File

@@ -1,4 +1,4 @@
import { getChannelDock } from "../channels/dock.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
@@ -315,14 +315,14 @@ export function resolveGroupToolPolicy(params: {
if (!channel) {
return undefined;
}
let dock;
let plugin;
try {
dock = getChannelDock(channel);
plugin = getChannelPlugin(channel);
} catch {
dock = undefined;
plugin = undefined;
}
const toolsConfig =
dock?.groups?.resolveToolPolicy?.({
plugin?.groups?.resolveToolPolicy?.({
cfg: params.config,
groupId,
groupChannel: params.groupChannel,

View File

@@ -1,6 +1,5 @@
import type { ChannelDock } from "../channels/dock.js";
import { getChannelDock, listChannelDocks } from "../channels/dock.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js";
import { normalizeAnyChannelId } from "../channels/registry.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
@@ -52,19 +51,19 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann
return normalized;
}
}
const configured = listChannelDocks()
.map((dock) => {
if (!dock.config?.resolveAllowFrom) {
const configured = listChannelPlugins()
.map((plugin) => {
if (!plugin.config?.resolveAllowFrom) {
return null;
}
const allowFrom = dock.config.resolveAllowFrom({
const allowFrom = plugin.config.resolveAllowFrom({
cfg,
accountId: ctx.AccountId,
});
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
return null;
}
return dock.id;
return plugin.id;
})
.filter((value): value is ChannelId => Boolean(value));
if (configured.length === 1) {
@@ -74,29 +73,29 @@ function resolveProviderFromContext(ctx: MsgContext, cfg: OpenClawConfig): Chann
}
function formatAllowFromList(params: {
dock?: ChannelDock;
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
allowFrom: Array<string | number>;
}): string[] {
const { dock, cfg, accountId, allowFrom } = params;
const { plugin, cfg, accountId, allowFrom } = params;
if (!allowFrom || allowFrom.length === 0) {
return [];
}
if (dock?.config?.formatAllowFrom) {
return dock.config.formatAllowFrom({ cfg, accountId, allowFrom });
if (plugin?.config?.formatAllowFrom) {
return plugin.config.formatAllowFrom({ cfg, accountId, allowFrom });
}
return normalizeStringEntries(allowFrom);
}
function normalizeAllowFromEntry(params: {
dock?: ChannelDock;
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
value: string;
}): string[] {
const normalized = formatAllowFromList({
dock: params.dock,
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
allowFrom: [params.value],
@@ -105,7 +104,7 @@ function normalizeAllowFromEntry(params: {
}
function resolveOwnerAllowFromList(params: {
dock?: ChannelDock;
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
providerId?: ChannelId;
@@ -139,7 +138,7 @@ function resolveOwnerAllowFromList(params: {
filtered.push(trimmed);
}
return formatAllowFromList({
dock: params.dock,
plugin: params.plugin,
cfg: params.cfg,
accountId: params.accountId,
allowFrom: filtered,
@@ -152,12 +151,12 @@ function resolveOwnerAllowFromList(params: {
* Returns null if commands.allowFrom is not configured at all (fall back to channel allowFrom).
*/
function resolveCommandsAllowFromList(params: {
dock?: ChannelDock;
plugin?: ChannelPlugin;
cfg: OpenClawConfig;
accountId?: string | null;
providerId?: ChannelId;
}): string[] | null {
const { dock, cfg, accountId, providerId } = params;
const { plugin, cfg, accountId, providerId } = params;
const commandsAllowFrom = cfg.commands?.allowFrom;
if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") {
return null; // Not configured, fall back to channel allowFrom
@@ -174,7 +173,7 @@ function resolveCommandsAllowFromList(params: {
}
return formatAllowFromList({
dock,
plugin,
cfg,
accountId,
allowFrom: rawList,
@@ -211,7 +210,7 @@ function shouldUseFromAsSenderFallback(params: {
}
function resolveSenderCandidates(params: {
dock?: ChannelDock;
plugin?: ChannelPlugin;
providerId?: ChannelId;
cfg: OpenClawConfig;
accountId?: string | null;
@@ -220,7 +219,7 @@ function resolveSenderCandidates(params: {
from?: string | null;
chatType?: string | null;
}): string[] {
const { dock, cfg, accountId } = params;
const { plugin, cfg, accountId } = params;
const candidates: string[] = [];
const pushCandidate = (value?: string | null) => {
const trimmed = (value ?? "").trim();
@@ -245,7 +244,7 @@ function resolveSenderCandidates(params: {
const normalized: string[] = [];
for (const sender of candidates) {
const entries = normalizeAllowFromEntry({ dock, cfg, accountId, value: sender });
const entries = normalizeAllowFromEntry({ plugin, cfg, accountId, value: sender });
for (const entry of entries) {
if (!normalized.includes(entry)) {
normalized.push(entry);
@@ -262,36 +261,36 @@ export function resolveCommandAuthorization(params: {
}): CommandAuthorization {
const { ctx, cfg, commandAuthorized } = params;
const providerId = resolveProviderFromContext(ctx, cfg);
const dock = providerId ? getChannelDock(providerId) : undefined;
const plugin = providerId ? getChannelPlugin(providerId) : undefined;
const from = (ctx.From ?? "").trim();
const to = (ctx.To ?? "").trim();
// Check if commands.allowFrom is configured (separate command authorization)
const commandsAllowFromList = resolveCommandsAllowFromList({
dock,
plugin,
cfg,
accountId: ctx.AccountId,
providerId,
});
const allowFromRaw = dock?.config?.resolveAllowFrom
? dock.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId })
const allowFromRaw = plugin?.config?.resolveAllowFrom
? plugin.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId })
: [];
const allowFromList = formatAllowFromList({
dock,
plugin,
cfg,
accountId: ctx.AccountId,
allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [],
});
const configOwnerAllowFromList = resolveOwnerAllowFromList({
dock,
plugin,
cfg,
accountId: ctx.AccountId,
providerId,
allowFrom: cfg.commands?.ownerAllowFrom,
});
const contextOwnerAllowFromList = resolveOwnerAllowFromList({
dock,
plugin,
cfg,
accountId: ctx.AccountId,
providerId,
@@ -303,7 +302,7 @@ export function resolveCommandAuthorization(params: {
const ownerCandidatesForCommands = allowAll ? [] : allowFromList.filter((entry) => entry !== "*");
if (!allowAll && ownerCandidatesForCommands.length === 0 && to) {
const normalizedTo = normalizeAllowFromEntry({
dock,
plugin,
cfg,
accountId: ctx.AccountId,
value: to,
@@ -328,7 +327,7 @@ export function resolveCommandAuthorization(params: {
);
const senderCandidates = resolveSenderCandidates({
dock,
plugin,
providerId,
cfg,
accountId: ctx.AccountId,
@@ -345,7 +344,7 @@ export function resolveCommandAuthorization(params: {
: undefined;
const senderId = matchedSender ?? senderCandidates[0];
const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands);
const enforceOwner = Boolean(plugin?.commands?.enforceOwnerForCommands);
const senderIsOwnerByIdentity = Boolean(matchedSender);
const senderIsOwnerByScope =
isInternalMessageChannel(ctx.Provider) &&

View File

@@ -1,4 +1,4 @@
import { listChannelDocks } from "../channels/dock.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";
import type {
@@ -46,14 +46,14 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti
};
}
type ChannelDock = ReturnType<typeof listChannelDocks>[number];
type ChannelPlugin = ReturnType<typeof listChannelPlugins>[number];
function defineDockCommand(dock: ChannelDock): ChatCommandDefinition {
function defineDockCommand(plugin: ChannelPlugin): ChatCommandDefinition {
return defineChatCommand({
key: `dock:${dock.id}`,
nativeName: `dock_${dock.id}`,
description: `Switch to ${dock.id} for replies.`,
textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`],
key: `dock:${plugin.id}`,
nativeName: `dock_${plugin.id}`,
description: `Switch to ${plugin.id} for replies.`,
textAliases: [`/dock-${plugin.id}`, `/dock_${plugin.id}`],
category: "docks",
});
}
@@ -758,9 +758,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
},
],
}),
...listChannelDocks()
.filter((dock) => dock.capabilities.nativeCommands)
.map((dock) => defineDockCommand(dock)),
...listChannelPlugins()
.filter((plugin) => plugin.capabilities.nativeCommands)
.map((plugin) => defineDockCommand(plugin)),
];
registerAlias(commands, "whoami", "/id");
@@ -792,9 +792,9 @@ export function getNativeCommandSurfaces(): Set<string> {
return cachedNativeCommandSurfaces;
}
cachedNativeCommandSurfaces = new Set(
listChannelDocks()
.filter((dock) => dock.capabilities.nativeCommands)
.map((dock) => dock.id),
listChannelPlugins()
.filter((plugin) => plugin.capabilities.nativeCommands)
.map((plugin) => plugin.id),
);
cachedNativeRegistry = registry;
return cachedNativeCommandSurfaces;

View File

@@ -1,6 +1,6 @@
import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js";
import type { NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -44,8 +44,8 @@ export function buildThreadingToolContext(params: {
}
const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider);
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) {
const threading = provider ? getChannelPlugin(provider)?.threading : undefined;
if (!threading?.buildToolContext) {
return {
currentChannelId: originTo?.trim() || undefined,
currentChannelProvider: provider ?? (rawProvider as ChannelId),
@@ -54,7 +54,7 @@ export function buildThreadingToolContext(params: {
};
}
const context =
dock.threading.buildToolContext({
threading.buildToolContext({
cfg: config,
accountId: sessionCtx.AccountId,
context: {
@@ -72,7 +72,7 @@ export function buildThreadingToolContext(params: {
}) ?? {};
return {
...context,
currentChannelProvider: provider!, // guaranteed non-null since dock exists
currentChannelProvider: provider!, // guaranteed non-null since threading exists
currentMessageId: context.currentMessageId ?? currentMessageId,
};
}

View File

@@ -1,5 +1,4 @@
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
import { resolveAccountEntry } from "../../routing/account-lookup.js";
@@ -34,7 +33,7 @@ function resolveProviderChunkContext(
const providerKey = normalizeChunkProvider(provider);
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
const providerChunkLimit = providerId
? getChannelDock(providerId)?.outbound?.textChunkLimit
? getChannelPlugin(providerId)?.outbound?.textChunkLimit
: undefined;
const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, {
fallbackLimit: providerChunkLimit,
@@ -209,7 +208,7 @@ export function resolveBlockStreamingCoalescing(
// when chunkMode="newline", matching the delivery-time splitting behavior.
const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId);
const providerDefaults = providerId
? getChannelDock(providerId)?.streaming?.blockStreamingCoalesceDefaults
? getChannelPlugin(providerId)?.streaming?.blockStreamingCoalesceDefaults
: undefined;
const providerCfg = resolveProviderBlockStreamingCoalesce({
cfg,

View File

@@ -1,5 +1,4 @@
import { getChannelPlugin } from "../../channels/plugins/index.js";
import { listPairingChannels } from "../../channels/plugins/pairing.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -8,7 +7,6 @@ import {
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import { isBlockedObjectKey } from "../../infra/prototype-keys.js";
import {
addChannelAllowFromStoreEntry,
readChannelAllowFromStore,
@@ -198,104 +196,6 @@ async function updatePairingStoreAllowlist(params: {
}
}
function resolveAccountTarget(
parsed: Record<string, unknown>,
channelId: ChannelId,
accountId?: string | null,
) {
const channels = (parsed.channels ??= {}) as Record<string, unknown>;
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
const normalizedAccountId = normalizeAccountId(accountId);
if (isBlockedObjectKey(normalizedAccountId)) {
return {
target: channel,
pathPrefix: `channels.${channelId}`,
accountId: DEFAULT_ACCOUNT_ID,
writeTarget: { kind: "channel", scope: { channelId } } as const,
};
}
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
if (!useAccount) {
return {
target: channel,
pathPrefix: `channels.${channelId}`,
accountId: normalizedAccountId,
writeTarget: { kind: "channel", scope: { channelId } } as const,
};
}
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
const existingAccount = Object.hasOwn(accounts, normalizedAccountId)
? accounts[normalizedAccountId]
: undefined;
if (!existingAccount || typeof existingAccount !== "object") {
accounts[normalizedAccountId] = {};
}
const account = accounts[normalizedAccountId] as Record<string, unknown>;
return {
target: account,
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
accountId: normalizedAccountId,
writeTarget: {
kind: "account",
scope: { channelId, accountId: normalizedAccountId },
} as const,
};
}
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
let current: unknown = root;
for (const key of path) {
if (!current || typeof current !== "object") {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
function ensureNestedObject(
root: Record<string, unknown>,
path: string[],
): Record<string, unknown> {
let current = root;
for (const key of path) {
const existing = current[key];
if (!existing || typeof existing !== "object") {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
return current;
}
function setNestedValue(root: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>)[path[path.length - 1]];
}
function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map<string, string> {
const map = new Map<string, string>();
for (const entry of entries) {
@@ -375,7 +275,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
const plugin = getChannelPlugin(channelId);
if (parsed.action === "list") {
const supportsStore = listPairingChannels().includes(channelId);
const supportsStore = Boolean(plugin?.pairing);
if (!plugin?.allowlist?.readConfig && !supportsStore) {
return {
shouldContinue: false,
@@ -493,7 +393,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
}
const shouldUpdateConfig = parsed.target !== "store";
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
const shouldTouchStore = parsed.target !== "config" && Boolean(plugin?.pairing);
if (shouldUpdateConfig) {
if (parsed.scope === "all") {
@@ -502,19 +402,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
reply: { text: "⚠️ /allowlist add|remove requires scope dm or group." },
};
}
const {
target,
pathPrefix,
accountId: normalizedAccountId,
writeTarget,
} = resolveAccountTarget(structuredClone({ channels: {} }), channelId, accountId);
void target;
const editSpec = plugin?.allowlist?.resolveConfigEdit?.({
scope: parsed.scope,
pathPrefix,
writeTarget,
});
if (!editSpec) {
if (!plugin?.allowlist?.applyConfigEdit) {
return {
shouldContinue: false,
reply: {
@@ -531,14 +419,35 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
};
}
const parsedConfig = structuredClone(snapshot.parsed as Record<string, unknown>);
const resolvedTarget = resolveAccountTarget(parsedConfig, channelId, accountId);
const editResult = await plugin.allowlist.applyConfigEdit({
cfg: params.cfg,
parsedConfig,
accountId,
scope: parsed.scope,
action: parsed.action,
entry: parsed.entry,
});
if (!editResult) {
return {
shouldContinue: false,
reply: {
text: `⚠️ ${channelId} does not support ${parsed.scope} allowlist edits via /allowlist.`,
},
};
}
if (editResult.kind === "invalid-entry") {
return {
shouldContinue: false,
reply: { text: "⚠️ Invalid allowlist entry." },
};
}
const deniedText = resolveConfigWriteDeniedText({
cfg: params.cfg,
channel: params.command.channel,
channelId,
accountId: params.ctx.AccountId,
gatewayClientScopes: params.ctx.GatewayClientScopes,
target: editSpec.writeTarget,
target: editResult.writeTarget,
});
if (deniedText) {
return {
@@ -548,82 +457,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
},
};
}
const existing: string[] = [];
for (const path of editSpec.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 = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: [parsed.entry],
});
if (normalizedEntry.length === 0) {
return {
shouldContinue: false,
reply: { text: "⚠️ Invalid allowlist entry." },
};
}
const existingNormalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: existing,
});
const shouldMatch = (value: string) => normalizedEntry.includes(value);
let configChanged = false;
let next = existing;
const configHasEntry = existingNormalized.some((value) => shouldMatch(value));
if (parsed.action === "add") {
if (!configHasEntry) {
next = [...existing, parsed.entry.trim()];
configChanged = true;
}
}
if (parsed.action === "remove") {
const keep: string[] = [];
for (const entry of existing) {
const normalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: [entry],
});
if (normalized.some((value) => shouldMatch(value))) {
configChanged = true;
continue;
}
keep.push(entry);
}
next = keep;
}
if (configChanged) {
if (next.length === 0) {
deleteNestedValue(resolvedTarget.target, editSpec.writePath);
} else {
setNestedValue(resolvedTarget.target, editSpec.writePath, next);
}
for (const path of editSpec.cleanupPaths ?? []) {
deleteNestedValue(resolvedTarget.target, path);
}
}
const configChanged = editResult.changed;
if (configChanged) {
const validated = validateConfigObjectWithPlugins(parsedConfig);
@@ -655,7 +489,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
const scopeLabel = parsed.scope === "dm" ? "DM" : "group";
const locations: string[] = [];
if (configChanged) {
locations.push(`${resolvedTarget.pathPrefix}.${editSpec.writePath.join(".")}`);
locations.push(editResult.pathLabel);
}
if (shouldTouchStore) {
locations.push("pairing store");

View File

@@ -3,7 +3,7 @@ import { createOpenClawTools } from "../../agents/openclaw-tools.js";
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js";
import { getChannelDock } from "../../channels/dock.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
@@ -402,7 +402,7 @@ export async function handleInlineActions(params: {
const isEmptyConfig = Object.keys(cfg).length === 0;
const skipWhenConfigEmpty = command.channelId
? Boolean(getChannelDock(command.channelId)?.commands?.skipWhenConfigEmpty)
? Boolean(getChannelPlugin(command.channelId)?.commands?.skipWhenConfigEmpty)
: false;
if (
skipWhenConfigEmpty &&

View File

@@ -1,4 +1,3 @@
import { getChannelDock } from "../../channels/dock.js";
import {
getChannelPlugin,
normalizeChannelId as normalizePluginChannelId,
@@ -39,7 +38,7 @@ function resolveDockChannelId(raw?: string | null): ChannelId | null {
return null;
}
try {
if (getChannelDock(normalized as ChannelId)) {
if (getChannelPlugin(normalized as ChannelId)) {
return normalized as ChannelId;
}
} catch {
@@ -68,7 +67,7 @@ export function resolveGroupRequireMention(params: {
const groupSpace = ctx.GroupSpace?.trim();
let requireMention: boolean | undefined;
try {
requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
requireMention = getChannelPlugin(channel)?.groups?.resolveRequireMention?.({
cfg,
groupId,
groupChannel,
@@ -158,7 +157,7 @@ export function buildGroupIntro(params: {
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim();
const groupSpace = params.sessionCtx.GroupSpace?.trim();
const providerIdsLine = providerId
? getChannelDock(providerId)?.groups?.resolveGroupIntroHint?.({
? getChannelPlugin(providerId)?.groups?.resolveGroupIntroHint?.({
cfg: params.cfg,
groupId,
groupChannel,

View File

@@ -1,6 +1,5 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { compileConfigRegexes, type ConfigRegexRejectReason } from "../../security/config-regex.js";
@@ -199,7 +198,7 @@ export function stripMentions(
): string {
let result = text;
const providerId = ctx.Provider ? normalizeChannelId(ctx.Provider) : null;
const providerMentions = providerId ? getChannelDock(providerId)?.mentions : undefined;
const providerMentions = providerId ? getChannelPlugin(providerId)?.mentions : undefined;
const configRegexes = compileMentionPatternsCached({
patterns: normalizeMentionPatterns(resolveMentionPatterns(cfg, agentId)),
flags: "gi",

View File

@@ -1,6 +1,5 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { AgentElevatedAllowFromConfig, OpenClawConfig } from "../../config/config.js";
import { normalizeStringEntries } from "../../shared/string-normalization.js";
import type { MsgContext } from "../templating.js";
@@ -34,8 +33,9 @@ function resolveAllowFromFormatter(params: {
accountId?: string;
}): AllowFromFormatter {
const normalizedProvider = normalizeChannelId(params.provider);
const dock = normalizedProvider ? getChannelDock(normalizedProvider) : undefined;
const formatAllowFrom = dock?.config?.formatAllowFrom;
const formatAllowFrom = normalizedProvider
? getChannelPlugin(normalizedProvider)?.config?.formatAllowFrom
: undefined;
if (!formatAllowFrom) {
return (values) => normalizeStringEntries(values);
}
@@ -192,11 +192,12 @@ export function resolveElevatedPermissions(params: {
}
const normalizedProvider = normalizeChannelId(params.provider);
const dock = normalizedProvider ? getChannelDock(normalizedProvider) : undefined;
const fallbackAllowFrom = dock?.elevated?.allowFromFallback?.({
cfg: params.cfg,
accountId: params.ctx.AccountId,
});
const fallbackAllowFrom = normalizedProvider
? getChannelPlugin(normalizedProvider)?.elevated?.allowFromFallback?.({
cfg: params.cfg,
accountId: params.ctx.AccountId,
})
: undefined;
const formatAllowFrom = resolveAllowFromFormatter({
cfg: params.cfg,
provider: params.provider,

View File

@@ -1,5 +1,4 @@
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OriginatingChannelType } from "../templating.js";
@@ -15,7 +14,7 @@ export function resolveReplyToMode(
if (!provider) {
return "all";
}
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
const resolved = getChannelPlugin(provider)?.threading?.resolveReplyToMode?.({
cfg,
accountId,
chatType,
@@ -59,9 +58,9 @@ export function createReplyToModeFilterForChannel(
const isWebchat = normalized === "webchat";
// Default: allow explicit reply tags/directives even when replyToMode is "off".
// Unknown channels fail closed; internal webchat stays allowed.
const dock = provider ? getChannelDock(provider) : undefined;
const threading = provider ? getChannelPlugin(provider)?.threading : undefined;
const allowExplicitReplyTagsWhenOff = provider
? (dock?.threading?.allowExplicitReplyTagsWhenOff ?? dock?.threading?.allowTagsWhenOff ?? true)
? (threading?.allowExplicitReplyTagsWhenOff ?? threading?.allowTagsWhenOff ?? true)
: isWebchat;
return createReplyToModeFilter(mode, {
allowExplicitReplyTagsWhenOff,

View File

@@ -1,12 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js";
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
import { discordOutbound } from "../../channels/plugins/outbound/discord.js";
import { imessageOutbound } from "../../channels/plugins/outbound/imessage.js";
import { signalOutbound } from "../../channels/plugins/outbound/signal.js";
import { slackOutbound } from "../../channels/plugins/outbound/slack.js";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
import {
discordOutbound,
imessageOutbound,
signalOutbound,
slackOutbound,
telegramOutbound,
whatsappOutbound,
} from "../../../test/channel-outbounds.js";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginRegistry } from "../../plugins/registry.js";

View File

@@ -1,2 +0,0 @@
// Shim: re-exports from extension
export * from "../../../../extensions/discord/src/outbound-adapter.js";

View File

@@ -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 = {

View File

@@ -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,
}),
});

View File

@@ -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 = {

View File

@@ -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 };
},
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
});
},
};

View File

@@ -1 +0,0 @@
export * from "../../../../extensions/telegram/src/outbound-adapter.js";

View File

@@ -1,2 +0,0 @@
// Shim: re-exports from extensions/whatsapp/src/outbound-adapter.ts
export * from "../../../../extensions/whatsapp/src/outbound-adapter.js";

View File

@@ -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({

View File

@@ -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;
};

View File

@@ -1,8 +1,7 @@
import type { MsgContext } from "../../auto-reply/templating.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { resolveConversationLabel } from "../../channels/conversation-label.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { buildGroupDisplayName, resolveGroupSessionKey } from "./group.js";
import type { GroupKeyResolution, SessionEntry, SessionOrigin } from "./types.js";
@@ -111,7 +110,7 @@ export function deriveGroupSessionPatch(params: {
const normalizedChannel = normalizeChannelId(channel);
const isChannelProvider = Boolean(
normalizedChannel &&
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"),
getChannelPlugin(normalizedChannel)?.capabilities.chatTypes.includes("channel"),
);
const nextGroupChannel =
explicitChannel ??

View File

@@ -1,12 +1,14 @@
import "./isolated-agent.mocks.js";
import { beforeEach, describe, expect, it } from "vitest";
import {
discordOutbound,
imessageOutbound,
signalOutbound,
slackOutbound,
telegramOutbound,
whatsappOutbound,
} from "../../test/channel-outbounds.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import { discordOutbound } from "../channels/plugins/outbound/discord.js";
import { imessageOutbound } from "../channels/plugins/outbound/imessage.js";
import { signalOutbound } from "../channels/plugins/outbound/signal.js";
import { slackOutbound } from "../channels/plugins/outbound/slack.js";
import { telegramOutbound } from "../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js";
import type { CliDeps } from "../cli/deps.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";

View File

@@ -1,9 +1,8 @@
import { vi } from "vitest";
import { signalOutbound, telegramOutbound } from "../../test/channel-outbounds.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import { signalOutbound } from "../channels/plugins/outbound/signal.js";
import { telegramOutbound } from "../channels/plugins/outbound/telegram.js";
import { callGateway } from "../gateway/call.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";

View File

@@ -1,8 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { telegramOutbound } from "../channels/plugins/outbound/telegram.js";
import { discordPlugin } from "../../extensions/discord/src/channel.js";
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
const baseRequest = {
@@ -25,7 +26,12 @@ const emptyRegistry = createTestRegistry([]);
const defaultRegistry = createTestRegistry([
{
pluginId: "telegram",
plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }),
plugin: telegramPlugin,
source: "test",
},
{
pluginId: "discord",
plugin: discordPlugin,
source: "test",
},
]);

View File

@@ -3,9 +3,9 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js";
import { whatsappOutbound } from "../../test/channel-outbounds.js";
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
import * as replyModule from "../auto-reply/reply.js";
import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveAgentIdFromSessionKey,

View File

@@ -1,7 +1,9 @@
import { vi } from "vitest";
import { signalOutbound } from "../../channels/plugins/outbound/signal.js";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
import {
signalOutbound,
telegramOutbound,
whatsappOutbound,
} from "../../../test/channel-outbounds.js";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";

View File

@@ -1,9 +1,11 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { markdownToSignalTextChunks } from "../../../extensions/signal/src/format.js";
import { signalOutbound } from "../../channels/plugins/outbound/signal.js";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
import {
signalOutbound,
telegramOutbound,
whatsappOutbound,
} from "../../../test/channel-outbounds.js";
import type { OpenClawConfig } from "../../config/config.js";
import { STATE_DIR } from "../../config/paths.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js";
import { telegramOutbound, whatsappOutbound } from "../../../test/channel-outbounds.js";
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";

View File

@@ -241,6 +241,7 @@ export { buildChannelSendResult } from "./channel-send-result.js";
export type { ChannelSendRawResult } from "./channel-send-result.js";
export { createPluginRuntimeStore } from "./runtime-store.js";
export { createScopedChannelConfigBase } from "./channel-config-helpers.js";
export { buildAccountScopedAllowlistConfigEditor } from "./allowlist-config-edit.js";
export {
AllowFromEntrySchema,
AllowFromListSchema,

View File

@@ -54,7 +54,7 @@ export {
parseTelegramThreadId,
} from "../../extensions/telegram/src/outbound-params.js";
export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js";
export { sendTelegramPayloadMessages } from "../channels/plugins/outbound/telegram.js";
export { sendTelegramPayloadMessages } from "../../extensions/telegram/src/outbound-adapter.js";
export {
resolveAllowlistProviderRuntimeGroupPolicy,

View File

@@ -1,5 +1,5 @@
import { normalizeIMessageHandle } from "../../extensions/imessage/src/targets.js";
import { imessageOutbound } from "../channels/plugins/outbound/imessage.js";
import { imessageOutbound } from "../../test/channel-outbounds.js";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js";
import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js";