From d163278e9cdfafcf0fd28be9c7d819a965695db9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 23:24:18 -0700 Subject: [PATCH] refactor: move channel delivery and ACP seams into plugins --- extensions/discord/src/channel.ts | 104 ++++++ extensions/feishu/src/channel.ts | 78 +++++ extensions/imessage/src/channel.ts | 18 ++ extensions/signal/src/channel.ts | 185 ++++++++++- extensions/slack/src/channel.ts | 57 ++++ extensions/telegram/src/channel.ts | 92 ++++++ extensions/telegram/src/outbound-adapter.ts | 3 + extensions/whatsapp/src/channel.ts | 62 +++- src/acp/persistent-bindings.resolve.ts | 260 +++------------ src/acp/persistent-bindings.test.ts | 12 + src/acp/persistent-bindings.types.ts | 3 +- src/auto-reply/reply/commands-allowlist.ts | 304 ++++++------------ src/auto-reply/reply/commands.test.ts | 28 ++ src/channels/plugins/outbound/signal.ts | 130 ++++++-- src/channels/plugins/types.adapters.ts | 71 ++++ src/channels/plugins/types.core.ts | 6 + src/channels/plugins/types.plugin.ts | 2 + src/channels/plugins/types.ts | 1 + src/commands/channel-test-helpers.ts | 2 + ...tbeat-runner.returns-default-unset.test.ts | 15 + src/infra/outbound/deliver.ts | 191 +++++------ src/infra/outbound/targets.test.ts | 82 ++++- src/infra/outbound/targets.ts | 114 ++----- src/test-utils/channel-plugins.ts | 3 + 24 files changed, 1177 insertions(+), 646 deletions(-) diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 82532f4c43b..a16574bfb70 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -39,6 +39,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js"; import type { DiscordProbe } from "./probe.js"; +import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; @@ -116,6 +117,84 @@ function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean { }); } +function readDiscordAllowlistConfig(account: ResolvedDiscordAccount) { + const groupOverrides: Array<{ label: string; entries: string[] }> = []; + for (const [guildKey, guildCfg] of Object.entries(account.config.guilds ?? {})) { + const entries = (guildCfg?.users ?? []).map(String).filter(Boolean); + if (entries.length > 0) { + groupOverrides.push({ label: `guild ${guildKey}`, entries }); + } + for (const [channelKey, channelCfg] of Object.entries(guildCfg?.channels ?? {})) { + const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean); + if (channelEntries.length > 0) { + groupOverrides.push({ + label: `guild ${guildKey} / channel ${channelKey}`, + entries: channelEntries, + }); + } + } + } + return { + dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), + groupPolicy: account.config.groupPolicy, + groupOverrides, + }; +} + +async function resolveDiscordAllowlistNames(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; + entries: string[]; +}) { + const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); + const token = account.token?.trim(); + if (!token) { + return []; + } + return await resolveDiscordUserAllowlist({ token, entries: params.entries }); +} + +function normalizeDiscordAcpConversationId(conversationId: string) { + const normalized = conversationId.trim(); + return normalized ? { conversationId: normalized } : null; +} + +function matchDiscordAcpConversation(params: { + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; +}) { + if (params.bindingConversationId === params.conversationId) { + return { conversationId: params.conversationId, matchPriority: 2 }; + } + if ( + params.parentConversationId && + params.parentConversationId !== params.conversationId && + params.bindingConversationId === params.parentConversationId + ) { + return { + conversationId: params.parentConversationId, + matchPriority: 1, + }; + } + return null; +} + +function parseDiscordExplicitTarget(raw: string) { + try { + const target = parseDiscordTarget(raw, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + to: target.id, + chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const), + }; + } catch { + return null; + } +} + const discordConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, @@ -177,6 +256,23 @@ export const discordPlugin: ChannelPlugin = { }), ...discordConfigAccessors, }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm", + readConfig: ({ cfg, accountId }) => + 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, + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ @@ -238,6 +334,8 @@ export const discordPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, + parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType, buildCrossContextComponents: buildDiscordCrossContextComponents, targetResolver: { looksLikeId: looksLikeDiscordTargetId, @@ -356,6 +454,12 @@ export const discordPlugin: ChannelPlugin = { silent: silent ?? undefined, }), }, + acpBindings: { + normalizeConfiguredBindingTarget: ({ conversationId }) => + normalizeDiscordAcpConversationId(conversationId), + matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => + matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index a9aed9f870d..450b1fbe88f 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -22,6 +22,7 @@ import { resolveDefaultFeishuAccountId, } from "./accounts.js"; import { FeishuConfigSchema } from "./config-schema.js"; +import { parseFeishuConversationId } from "./conversation-id.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; @@ -95,6 +96,77 @@ function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean { return false; } +function isSupportedFeishuDirectConversationId(conversationId: string): boolean { + const trimmed = conversationId.trim(); + if (!trimmed || trimmed.includes(":")) { + return false; + } + if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { + return false; + } + return true; +} + +function normalizeFeishuAcpConversationId(conversationId: string) { + const parsed = parseFeishuConversationId({ conversationId }); + if ( + !parsed || + (parsed.scope !== "group_topic" && + parsed.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) + ) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: + parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" + ? parsed.chatId + : undefined, + }; +} + +function matchFeishuAcpConversation(params: { + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; +}) { + const binding = normalizeFeishuAcpConversationId(params.bindingConversationId); + if (!binding) { + return null; + } + const incoming = parseFeishuConversationId({ + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if ( + !incoming || + (incoming.scope !== "group_topic" && + incoming.scope !== "group_topic_sender" && + !isSupportedFeishuDirectConversationId(incoming.canonicalConversationId)) + ) { + return null; + } + const matchesCanonicalConversation = binding.conversationId === incoming.canonicalConversationId; + const matchesParentTopicForSenderScopedConversation = + incoming.scope === "group_topic_sender" && + binding.parentConversationId === incoming.chatId && + binding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`; + if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { + return null; + } + return { + conversationId: matchesParentTopicForSenderScopedConversation + ? binding.conversationId + : incoming.canonicalConversationId, + parentConversationId: + incoming.scope === "group_topic" || incoming.scope === "group_topic_sender" + ? incoming.chatId + : undefined, + matchPriority: matchesCanonicalConversation ? 2 : 1, + }; +} + export const feishuPlugin: ChannelPlugin = { id: "feishu", meta: { @@ -393,6 +465,12 @@ export const feishuPlugin: ChannelPlugin = { }); }, }, + acpBindings: { + normalizeConfiguredBindingTarget: ({ conversationId }) => + normalizeFeishuAcpConversationId(conversationId), + matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => + matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + }, setup: feishuSetupAdapter, setupWizard: feishuSetupWizard, messaging: { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index f2621dea5c2..aec66694ef8 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -124,6 +124,24 @@ export const imessagePlugin: ChannelPlugin = { formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + readConfig: ({ cfg, accountId }) => { + const account = resolveIMessageAccount({ cfg, accountId }); + return { + dmAllowFrom: (account.config.allowFrom ?? []).map(String), + groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), + dmPolicy: account.config.dmPolicy, + groupPolicy: account.config.groupPolicy, + }; + }, + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ + pathPrefix, + writeTarget, + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 010df26d390..80291872143 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -26,7 +26,10 @@ import { type ChannelPlugin, type ResolvedSignalAccount, } from "openclaw/plugin-sdk/signal"; +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { markdownToSignalTextChunks } from "./format.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; @@ -66,12 +69,8 @@ const signalConfigAccessors = createScopedAccountConfigAccessors({ type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; -async function sendSignalOutbound(params: { +function resolveSignalSendContext(params: { cfg: Parameters[0]["cfg"]; - to: string; - text: string; - mediaUrl?: string; - mediaLocalRoots?: readonly string[]; accountId?: string; deps?: { [channelId: string]: unknown }; }) { @@ -84,6 +83,19 @@ async function sendSignalOutbound(params: { cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb, accountId: params.accountId, }); + return { send, maxBytes }; +} + +async function sendSignalOutbound(params: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string; + deps?: { [channelId: string]: unknown }; +}) { + const { send, maxBytes } = resolveSignalSendContext(params); return await send(params.to, params.text, { cfg: params.cfg, ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), @@ -93,6 +105,120 @@ async function sendSignalOutbound(params: { }); } +function inferSignalTargetChatType(rawTo: string) { + let to = rawTo.trim(); + if (!to) { + return undefined; + } + if (/^signal:/i.test(to)) { + to = to.replace(/^signal:/i, "").trim(); + } + if (!to) { + return undefined; + } + const lower = to.toLowerCase(); + if (lower.startsWith("group:")) { + return "group" as const; + } + if (lower.startsWith("username:") || lower.startsWith("u:")) { + return "direct" as const; + } + return "direct" as const; +} + +function parseSignalExplicitTarget(raw: string) { + const normalized = normalizeSignalMessagingTarget(raw); + if (!normalized) { + return null; + } + return { + to: normalized, + chatType: inferSignalTargetChatType(normalized), + }; +} + +async function sendFormattedSignalText(ctx: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + accountId?: string | null; + deps?: { [channelId: string]: unknown }; + abortSignal?: AbortSignal; +}) { + const { send, maxBytes } = resolveSignalSendContext({ + cfg: ctx.cfg, + accountId: ctx.accountId ?? undefined, + deps: ctx.deps, + }); + const limit = resolveTextChunkLimit(ctx.cfg, "signal", ctx.accountId ?? undefined, { + fallbackLimit: 4000, + }); + const tableMode = resolveMarkdownTableMode({ + cfg: ctx.cfg, + channel: "signal", + accountId: ctx.accountId ?? undefined, + }); + let chunks = + limit === undefined + ? markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, { tableMode }) + : markdownToSignalTextChunks(ctx.text, limit, { tableMode }); + if (chunks.length === 0 && ctx.text) { + chunks = [{ text: ctx.text, styles: [] }]; + } + const results = []; + for (const chunk of chunks) { + ctx.abortSignal?.throwIfAborted(); + const result = await send(ctx.to, chunk.text, { + cfg: ctx.cfg, + maxBytes, + accountId: ctx.accountId ?? undefined, + textMode: "plain", + textStyles: chunk.styles, + }); + results.push({ channel: "signal" as const, ...result }); + } + return results; +} + +async function sendFormattedSignalMedia(ctx: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + mediaUrl: string; + mediaLocalRoots?: readonly string[]; + accountId?: string | null; + deps?: { [channelId: string]: unknown }; + abortSignal?: AbortSignal; +}) { + ctx.abortSignal?.throwIfAborted(); + const { send, maxBytes } = resolveSignalSendContext({ + cfg: ctx.cfg, + accountId: ctx.accountId ?? undefined, + deps: ctx.deps, + }); + const tableMode = resolveMarkdownTableMode({ + cfg: ctx.cfg, + channel: "signal", + accountId: ctx.accountId ?? undefined, + }); + const formatted = markdownToSignalTextChunks(ctx.text, Number.POSITIVE_INFINITY, { + tableMode, + })[0] ?? { + text: ctx.text, + styles: [], + }; + const result = await send(ctx.to, formatted.text, { + cfg: ctx.cfg, + mediaUrl: ctx.mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + maxBytes, + accountId: ctx.accountId ?? undefined, + textMode: "plain", + textStyles: formatted.styles, + }); + return { channel: "signal" as const, ...result }; +} + export const signalPlugin: ChannelPlugin = { id: "signal", meta: { @@ -146,6 +272,24 @@ export const signalPlugin: ChannelPlugin = { }), ...signalConfigAccessors, }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + readConfig: ({ cfg, accountId }) => { + const account = resolveSignalAccount({ cfg, accountId }); + return { + dmAllowFrom: (account.config.allowFrom ?? []).map(String), + groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), + dmPolicy: account.config.dmPolicy, + groupPolicy: account.config.groupPolicy, + }; + }, + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ + pathPrefix, + writeTarget, + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ @@ -174,6 +318,8 @@ export const signalPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeSignalMessagingTarget, + parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw), + inferTargetChatType: ({ to }) => inferSignalTargetChatType(to), targetResolver: { looksLikeId: looksLikeSignalTargetId, hint: "", @@ -185,6 +331,35 @@ export const signalPlugin: ChannelPlugin = { chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit), chunkerMode: "text", textChunkLimit: 4000, + sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => + await sendFormattedSignalText({ + cfg, + to, + text, + accountId, + deps, + abortSignal, + }), + sendFormattedMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + abortSignal, + }) => + await sendFormattedSignalMedia({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + abortSignal, + }), sendText: async ({ cfg, to, text, accountId, deps }) => { const result = await sendSignalOutbound({ cfg, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index f658b93d2c3..2a8849b1671 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -38,6 +38,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import type { SlackProbe } from "./probe.js"; +import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; @@ -129,6 +130,17 @@ function resolveSlackAutoThreadId(params: { return context.currentThreadTs; } +function parseSlackExplicitTarget(raw: string) { + const target = parseSlackTarget(raw, { defaultKind: "channel" }); + if (!target) { + return null; + } + return { + to: target.id, + chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const), + }; +} + function formatSlackScopeDiagnostic(params: { tokenType: "bot" | "user"; result: Awaited>; @@ -144,6 +156,32 @@ function formatSlackScopeDiagnostic(params: { } as const; } +function readSlackAllowlistConfig(account: ResolvedSlackAccount) { + return { + dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), + groupPolicy: account.groupPolicy, + groupOverrides: Object.entries(account.channels ?? {}) + .map(([key, value]) => { + const entries = (value?.users ?? []).map(String).filter(Boolean); + return entries.length > 0 ? { label: key, entries } : null; + }) + .filter(Boolean) as Array<{ label: string; entries: string[] }>, + }; +} + +async function resolveSlackAllowlistNames(params: { + cfg: Parameters[0]["cfg"]; + accountId?: string | null; + entries: string[]; +}) { + const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + const token = account.config.userToken?.trim() || account.botToken?.trim(); + if (!token) { + return []; + } + return await resolveSlackUserAllowlist({ token, entries: params.entries }); +} + const slackConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, @@ -235,6 +273,23 @@ export const slackPlugin: ChannelPlugin = { }), ...slackConfigAccessors, }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm", + readConfig: ({ cfg, accountId }) => + 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, + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ @@ -301,6 +356,8 @@ export const slackPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeSlackMessagingTarget, + parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType, enableInteractiveReplies: ({ cfg, accountId }) => isSlackInteractiveRepliesEnabled({ cfg, accountId }), hasStructuredReplyPayload: ({ payload }) => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index cbdb146b608..be09a186baf 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -37,6 +37,7 @@ import { type ResolvedTelegramAccount, type TelegramProbe, } from "openclaw/plugin-sdk/telegram"; +import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js"; import { buildExecApprovalPendingReplyPayload } from "../../../src/infra/exec-approval-reply.js"; import { @@ -166,6 +167,52 @@ function resolveTelegramAutoThreadId(params: { return context.currentThreadTs; } +function normalizeTelegramAcpConversationId(conversationId: string) { + const parsed = parseTelegramTopicConversation({ conversationId }); + if (!parsed || !parsed.chatId.startsWith("-")) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: parsed.chatId, + }; +} + +function matchTelegramAcpConversation(params: { + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; +}) { + const binding = normalizeTelegramAcpConversationId(params.bindingConversationId); + if (!binding) { + return null; + } + const incoming = parseTelegramTopicConversation({ + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if (!incoming || !incoming.chatId.startsWith("-")) { + return null; + } + if (binding.conversationId !== incoming.canonicalConversationId) { + return null; + } + return { + conversationId: incoming.canonicalConversationId, + parentConversationId: incoming.chatId, + matchPriority: 2, + }; +} + +function parseTelegramExplicitTarget(raw: string) { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; +} + function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean { return listTelegramAccountIds(cfg).some((accountId) => { if (!isTelegramExecApprovalClientEnabled({ cfg, accountId })) { @@ -217,6 +264,29 @@ const resolveTelegramDmPolicy = createScopedDmSecurityResolver raw.replace(/^(telegram|tg):/i, ""), }); +function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { + const groupOverrides: Array<{ label: string; entries: string[] }> = []; + for (const [groupId, groupCfg] of Object.entries(account.config.groups ?? {})) { + const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); + if (entries.length > 0) { + groupOverrides.push({ label: groupId, entries }); + } + for (const [topicId, topicCfg] of Object.entries(groupCfg?.topics ?? {})) { + const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean); + if (topicEntries.length > 0) { + groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries }); + } + } + } + return { + dmAllowFrom: (account.config.allowFrom ?? []).map(String), + groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), + dmPolicy: account.config.dmPolicy, + groupPolicy: account.config.groupPolicy, + groupOverrides, + }; +} + export const telegramPlugin: ChannelPlugin = { id: "telegram", meta: { @@ -284,6 +354,23 @@ export const telegramPlugin: ChannelPlugin 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"], + }), + }, + acpBindings: { + normalizeConfiguredBindingTarget: ({ conversationId }) => + normalizeTelegramAcpConversationId(conversationId), + matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) => + matchTelegramAcpConversation({ bindingConversationId, conversationId, parentConversationId }), + }, security: { resolveDmPolicy: resolveTelegramDmPolicy, collectWarnings: ({ account, cfg }) => { @@ -325,6 +412,8 @@ export const telegramPlugin: ChannelPlugin parseTelegramExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType, targetResolver: { looksLikeId: looksLikeTelegramTargetId, hint: "", @@ -423,6 +512,9 @@ export const telegramPlugin: ChannelPlugin Boolean(payload.channelData), + resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => + typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, sendPayload: async ({ cfg, to, diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index e8c0530d06b..0ab050bbd06 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -102,6 +102,9 @@ export const telegramOutbound: ChannelOutboundAdapter = { chunker: markdownToTelegramHtmlChunks, chunkerMode: "markdown", textChunkLimit: 4000, + shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), + resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => + typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { const { send, baseOpts } = resolveTelegramSendContext({ cfg, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d73c951a054..cf506e6912b 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -24,6 +24,7 @@ import { type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk/whatsapp"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) import { listWhatsAppAccountIds, @@ -42,6 +43,21 @@ async function loadWhatsAppChannelRuntime() { return await import("./channel.runtime.js"); } +function normalizeWhatsAppPayloadText(text: string | undefined): string { + return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); +} + +function parseWhatsAppExplicitTarget(raw: string) { + const normalized = normalizeWhatsAppTarget(raw); + if (!normalized) { + return null; + } + return { + to: normalized, + chatType: isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const), + }; +} + const whatsappSetupWizardProxy = { channel: "whatsapp", status: { @@ -168,6 +184,24 @@ export const whatsappPlugin: ChannelPlugin = { formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), }, + allowlist: { + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + readConfig: ({ cfg, accountId }) => { + const account = resolveWhatsAppAccount({ cfg, accountId }); + return { + dmAllowFrom: (account.allowFrom ?? []).map(String), + groupAllowFrom: (account.groupAllowFrom ?? []).map(String), + dmPolicy: account.dmPolicy, + groupPolicy: account.groupPolicy, + }; + }, + resolveConfigEdit: ({ scope, pathPrefix, writeTarget }) => ({ + pathPrefix, + writeTarget, + readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], + writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], + }), + }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { return buildAccountScopedDmSecurityPolicy({ @@ -224,6 +258,8 @@ export const whatsappPlugin: ChannelPlugin = { }, messaging: { normalizeTarget: normalizeWhatsAppMessagingTarget, + parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw), + inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType, targetResolver: { looksLikeId: looksLikeWhatsAppTargetId, hint: "", @@ -288,16 +324,22 @@ export const whatsappPlugin: ChannelPlugin = { ); }, }, - outbound: createWhatsAppOutboundBase({ - chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit), - sendMessageWhatsApp: async (...args) => - await getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp(...args), - sendPollWhatsApp: async (...args) => - await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(...args), - shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(), - resolveTarget: ({ to, allowFrom, mode }) => - resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), - }), + outbound: { + ...createWhatsAppOutboundBase({ + chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit), + sendMessageWhatsApp: async (...args) => + await getWhatsAppRuntime().channel.whatsapp.sendMessageWhatsApp(...args), + sendPollWhatsApp: async (...args) => + await getWhatsAppRuntime().channel.whatsapp.sendPollWhatsApp(...args), + shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(), + resolveTarget: ({ to, allowFrom, mode }) => + resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), + }), + normalizePayload: ({ payload }) => ({ + ...payload, + text: normalizeWhatsAppPayloadText(payload.text), + }), + }, auth: { login: async ({ cfg, accountId, runtime, verbose }) => { const resolvedAccountId = accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index 66464535eae..d0039078378 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -1,4 +1,4 @@ -import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js"; +import { getChannelPlugin } from "../channels/plugins/index.js"; import { listAcpBindings } from "../config/bindings.js"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentAcpBinding } from "../config/types.js"; @@ -8,7 +8,6 @@ import { normalizeAccountId, parseAgentSessionKey, } from "../routing/session-key.js"; -import { parseTelegramTopicConversation } from "./conversation-id.js"; import { buildConfiguredAcpSessionKey, normalizeBindingConfig, @@ -22,21 +21,11 @@ import { function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null { const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "discord" || normalized === "telegram" || normalized === "feishu") { - return normalized; + if (!normalized) { + return null; } - return null; -} - -function isSupportedFeishuDirectConversationId(conversationId: string): boolean { - const trimmed = conversationId.trim(); - if (!trimmed || trimmed.includes(":")) { - return false; - } - if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { - return false; - } - return true; + const plugin = getChannelPlugin(normalized); + return plugin?.acpBindings ? plugin.id : null; } function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 { @@ -71,10 +60,9 @@ function parseConfiguredBindingSessionKey(params: { if (!channel) { return null; } - const accountId = normalizeAccountId(tokens[3]); return { channel, - accountId, + accountId: normalizeAccountId(tokens[3]), }; } @@ -231,6 +219,12 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: { if (!parsedSessionKey) { return null; } + const plugin = getChannelPlugin(parsedSessionKey.channel); + const acpBindings = plugin?.acpBindings; + if (!acpBindings?.normalizeConfiguredBindingTarget) { + return null; + } + let wildcardMatch: ConfiguredAcpBindingSpec | null = null; for (const binding of listAcpBindings(params.cfg)) { const channel = normalizeBindingChannel(binding.match.channel); @@ -248,81 +242,29 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: { if (!targetConversationId) { continue; } - if (channel === "discord") { - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: "discord", - accountId: parsedSessionKey.accountId, - conversationId: targetConversationId, - binding, - }); - if (buildConfiguredAcpSessionKey(spec) === sessionKey) { - if (accountMatchPriority === 2) { - return spec; - } - if (!wildcardMatch) { - wildcardMatch = spec; - } - } - continue; - } - if (channel === "feishu") { - const targetParsed = parseFeishuConversationId({ - conversationId: targetConversationId, - }); - if ( - !targetParsed || - (targetParsed.scope !== "group_topic" && - targetParsed.scope !== "group_topic_sender" && - !isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId)) - ) { - continue; - } - const spec = toConfiguredBindingSpec({ - cfg: params.cfg, - channel: "feishu", - accountId: parsedSessionKey.accountId, - conversationId: targetParsed.canonicalConversationId, - // Session-key recovery deliberately collapses sender-scoped topic bindings onto the - // canonical topic conversation id so `group_topic` and `group_topic_sender` reuse - // the same configured ACP session identity. - parentConversationId: - targetParsed.scope === "group_topic" || targetParsed.scope === "group_topic_sender" - ? targetParsed.chatId - : undefined, - binding, - }); - if (buildConfiguredAcpSessionKey(spec) === sessionKey) { - if (accountMatchPriority === 2) { - return spec; - } - if (!wildcardMatch) { - wildcardMatch = spec; - } - } - continue; - } - const parsedTopic = parseTelegramTopicConversation({ + const target = acpBindings.normalizeConfiguredBindingTarget({ + binding, conversationId: targetConversationId, }); - if (!parsedTopic || !parsedTopic.chatId.startsWith("-")) { + if (!target) { continue; } const spec = toConfiguredBindingSpec({ cfg: params.cfg, - channel: "telegram", + channel, accountId: parsedSessionKey.accountId, - conversationId: parsedTopic.canonicalConversationId, - parentConversationId: parsedTopic.chatId, + conversationId: target.conversationId, + parentConversationId: target.parentConversationId, binding, }); - if (buildConfiguredAcpSessionKey(spec) === sessionKey) { - if (accountMatchPriority === 2) { - return spec; - } - if (!wildcardMatch) { - wildcardMatch = spec; - } + if (buildConfiguredAcpSessionKey(spec) !== sessionKey) { + continue; + } + if (accountMatchPriority === 2) { + return spec; + } + if (!wildcardMatch) { + wildcardMatch = spec; } } return wildcardMatch; @@ -335,136 +277,36 @@ export function resolveConfiguredAcpBindingRecord(params: { conversationId: string; parentConversationId?: string; }): ResolvedConfiguredAcpBinding | null { - const channel = params.channel.trim().toLowerCase(); + const channel = normalizeBindingChannel(params.channel); const accountId = normalizeAccountId(params.accountId); const conversationId = params.conversationId.trim(); const parentConversationId = params.parentConversationId?.trim() || undefined; - if (!conversationId) { + if (!channel || !conversationId) { return null; } + const plugin = getChannelPlugin(channel); + const acpBindings = plugin?.acpBindings; + if (!acpBindings?.matchConfiguredBinding) { + return null; + } + const matchConfiguredBinding = acpBindings.matchConfiguredBinding; - if (channel === "discord") { - const bindings = listAcpBindings(params.cfg); - const resolveDiscordBindingForConversation = (targetConversationId: string) => - resolveConfiguredBindingRecord({ - cfg: params.cfg, - bindings, - channel: "discord", - accountId, - selectConversation: (binding) => { - const bindingConversationId = resolveBindingConversationId(binding); - if (!bindingConversationId || bindingConversationId !== targetConversationId) { - return null; - } - return { conversationId: targetConversationId }; - }, - }); - - const directMatch = resolveDiscordBindingForConversation(conversationId); - if (directMatch) { - return directMatch; - } - if (parentConversationId && parentConversationId !== conversationId) { - const inheritedMatch = resolveDiscordBindingForConversation(parentConversationId); - if (inheritedMatch) { - return inheritedMatch; + return resolveConfiguredBindingRecord({ + cfg: params.cfg, + bindings: listAcpBindings(params.cfg), + channel, + accountId, + selectConversation: (binding) => { + const bindingConversationId = resolveBindingConversationId(binding); + if (!bindingConversationId) { + return null; } - } - return null; - } - - if (channel === "telegram") { - const parsed = parseTelegramTopicConversation({ - conversationId, - parentConversationId, - }); - if (!parsed || !parsed.chatId.startsWith("-")) { - return null; - } - return resolveConfiguredBindingRecord({ - cfg: params.cfg, - bindings: listAcpBindings(params.cfg), - channel: "telegram", - accountId, - selectConversation: (binding) => { - const targetConversationId = resolveBindingConversationId(binding); - if (!targetConversationId) { - return null; - } - const targetParsed = parseTelegramTopicConversation({ - conversationId: targetConversationId, - }); - if (!targetParsed || !targetParsed.chatId.startsWith("-")) { - return null; - } - if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) { - return null; - } - return { - conversationId: parsed.canonicalConversationId, - parentConversationId: parsed.chatId, - }; - }, - }); - } - - if (channel === "feishu") { - const parsed = parseFeishuConversationId({ - conversationId, - parentConversationId, - }); - if ( - !parsed || - (parsed.scope !== "group_topic" && - parsed.scope !== "group_topic_sender" && - !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) - ) { - return null; - } - return resolveConfiguredBindingRecord({ - cfg: params.cfg, - bindings: listAcpBindings(params.cfg), - channel: "feishu", - accountId, - selectConversation: (binding) => { - const targetConversationId = resolveBindingConversationId(binding); - if (!targetConversationId) { - return null; - } - const targetParsed = parseFeishuConversationId({ - conversationId: targetConversationId, - }); - if ( - !targetParsed || - (targetParsed.scope !== "group_topic" && - targetParsed.scope !== "group_topic_sender" && - !isSupportedFeishuDirectConversationId(targetParsed.canonicalConversationId)) - ) { - return null; - } - const matchesCanonicalConversation = - targetParsed.canonicalConversationId === parsed.canonicalConversationId; - const matchesParentTopicForSenderScopedConversation = - parsed.scope === "group_topic_sender" && - targetParsed.scope === "group_topic" && - parsed.chatId === targetParsed.chatId && - parsed.topicId === targetParsed.topicId; - if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { - return null; - } - return { - conversationId: matchesParentTopicForSenderScopedConversation - ? targetParsed.canonicalConversationId - : parsed.canonicalConversationId, - parentConversationId: - parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" - ? parsed.chatId - : undefined, - matchPriority: matchesCanonicalConversation ? 2 : 1, - }; - }, - }); - } - - return null; + return matchConfiguredBinding({ + binding, + bindingConversationId, + conversationId, + parentConversationId, + }); + }, + }); } diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index 06bfba46d57..147c4a455c9 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { discordPlugin } from "../../extensions/discord/src/channel.js"; +import { feishuPlugin } from "../../extensions/feishu/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 { createTestRegistry } from "../test-utils/channel-plugins.js"; const managerMocks = vi.hoisted(() => ({ resolveSession: vi.fn(), closeSession: vi.fn(), @@ -162,6 +167,13 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) { } beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "discord", plugin: discordPlugin, source: "test" }, + { pluginId: "telegram", plugin: telegramPlugin, source: "test" }, + { pluginId: "feishu", plugin: feishuPlugin, source: "test" }, + ]), + ); managerMocks.resolveSession.mockReset(); managerMocks.closeSession.mockReset().mockResolvedValue({ runtimeClosed: true, diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts index 3864392c96c..3583fc4cd9f 100644 --- a/src/acp/persistent-bindings.types.ts +++ b/src/acp/persistent-bindings.types.ts @@ -1,9 +1,10 @@ import { createHash } from "node:crypto"; +import type { ChannelId } from "../channels/plugins/types.js"; import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; import { sanitizeAgentId } from "../routing/session-key.js"; import type { AcpRuntimeSessionMode } from "./runtime/types.js"; -export type ConfiguredAcpBindingChannel = "discord" | "telegram" | "feishu"; +export type ConfiguredAcpBindingChannel = ChannelId; export type ConfiguredAcpBindingSpec = { channel: ConfiguredAcpBindingChannel; diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index 83d263b828c..f371fcd0b62 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -1,13 +1,4 @@ -import { resolveDiscordAccount } from "../../../extensions/discord/src/accounts.js"; -import { resolveDiscordUserAllowlist } from "../../../extensions/discord/src/resolve-users.js"; -import { resolveIMessageAccount } from "../../../extensions/imessage/src/accounts.js"; -import { resolveSignalAccount } from "../../../extensions/signal/src/accounts.js"; -import { resolveSlackAccount } from "../../../extensions/slack/src/accounts.js"; -import { resolveSlackUserAllowlist } from "../../../extensions/slack/src/resolve-users.js"; -import { resolveTelegramAccount } from "../../../extensions/telegram/src/accounts.js"; -import { resolveWhatsAppAccount } from "../../../extensions/whatsapp/src/accounts.js"; -import { getChannelDock } from "../../channels/dock.js"; -import { resolveExplicitConfigWriteTarget } from "../../channels/plugins/config-writes.js"; +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"; @@ -159,9 +150,9 @@ function normalizeAllowFrom(params: { accountId?: string | null; values: Array; }): string[] { - const dock = getChannelDock(params.channelId); - if (dock?.config?.formatAllowFrom) { - return dock.config.formatAllowFrom({ + const plugin = getChannelPlugin(params.channelId); + if (plugin?.config.formatAllowFrom) { + return plugin.config.formatAllowFrom({ cfg: params.cfg, accountId: params.accountId, allowFrom: params.values, @@ -182,22 +173,6 @@ function formatEntryList(entries: string[], resolved?: Map): str .join(", "); } -function extractConfigAllowlist(account: { - config?: { - allowFrom?: Array; - groupAllowFrom?: Array; - dmPolicy?: string; - groupPolicy?: string; - }; -}) { - return { - dmAllowFrom: (account.config?.allowFrom ?? []).map(String), - groupAllowFrom: (account.config?.groupAllowFrom ?? []).map(String), - dmPolicy: account.config?.dmPolicy, - groupPolicy: account.config?.groupPolicy, - }; -} - async function updatePairingStoreAllowlist(params: { action: "add" | "remove"; channelId: ChannelId; @@ -236,7 +211,7 @@ function resolveAccountTarget( target: channel, pathPrefix: `channels.${channelId}`, accountId: DEFAULT_ACCOUNT_ID, - writeTarget: resolveExplicitConfigWriteTarget({ channelId }), + writeTarget: { kind: "channel", scope: { channelId } } as const, }; } const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object"); @@ -246,7 +221,7 @@ function resolveAccountTarget( target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId, - writeTarget: resolveExplicitConfigWriteTarget({ channelId }), + writeTarget: { kind: "channel", scope: { channelId } } as const, }; } const accounts = (channel.accounts ??= {}) as Record; @@ -261,10 +236,10 @@ function resolveAccountTarget( target: account, pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`, accountId: normalizedAccountId, - writeTarget: resolveExplicitConfigWriteTarget({ - channelId, - accountId: normalizedAccountId, - }), + writeTarget: { + kind: "account", + scope: { channelId, accountId: normalizedAccountId }, + } as const, }; } @@ -321,37 +296,6 @@ function deleteNestedValue(root: Record, path: string[]) { delete (parent as Record)[path[path.length - 1]]; } -function resolveChannelAllowFromPaths( - channelId: ChannelId, - scope: AllowlistScope, -): string[] | null { - const supportsGroupAllowlist = - channelId === "telegram" || - channelId === "whatsapp" || - channelId === "signal" || - channelId === "imessage"; - if (scope === "all") { - return null; - } - if (scope === "dm") { - if (channelId === "slack" || channelId === "discord") { - // Canonical DM allowlist location for Slack/Discord. Legacy: dm.allowFrom. - return ["allowFrom"]; - } - if (supportsGroupAllowlist) { - return ["allowFrom"]; - } - return null; - } - if (scope === "group") { - if (supportsGroupAllowlist) { - return ["groupAllowFrom"]; - } - return null; - } - return null; -} - function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map { const map = new Map(); for (const entry of entries) { @@ -362,32 +306,35 @@ function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map(); - } - const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries }); - return mapResolvedAllowlistNames(resolved); + const plugin = getChannelPlugin(params.channelId); + const resolved = await plugin?.allowlist?.resolveNames?.({ + cfg: params.cfg, + accountId: params.accountId, + scope: params.scope, + entries: params.entries, + }); + return mapResolvedAllowlistNames(resolved ?? []); } -async function resolveDiscordNames(params: { +async function readAllowlistConfig(params: { cfg: OpenClawConfig; + channelId: ChannelId; accountId?: string | null; - entries: string[]; }) { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.token?.trim(); - if (!token) { - return new Map(); - } - const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries }); - return mapResolvedAllowlistNames(resolved); + const plugin = getChannelPlugin(params.channelId); + return ( + (await plugin?.allowlist?.readConfig?.({ + cfg: params.cfg, + accountId: params.accountId, + })) ?? {} + ); } export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => { @@ -425,83 +372,31 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId); - const scope = parsed.scope; + const plugin = getChannelPlugin(channelId); if (parsed.action === "list") { - const pairingChannels = listPairingChannels(); - const supportsStore = pairingChannels.includes(channelId); + const supportsStore = listPairingChannels().includes(channelId); + if (!plugin?.allowlist?.readConfig && !supportsStore) { + return { + shouldContinue: false, + reply: { text: `⚠️ ${channelId} does not expose allowlist configuration.` }, + }; + } const storeAllowFrom = supportsStore ? await readChannelAllowFromStore(channelId, process.env, accountId).catch(() => []) : []; + const configState = await readAllowlistConfig({ + cfg: params.cfg, + channelId, + accountId, + }); - let dmAllowFrom: string[] = []; - let groupAllowFrom: string[] = []; - let groupOverrides: Array<{ label: string; entries: string[] }> = []; - let dmPolicy: string | undefined; - let groupPolicy: string | undefined; - - if (channelId === "telegram") { - const account = resolveTelegramAccount({ cfg: params.cfg, accountId }); - ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); - const groups = account.config.groups ?? {}; - for (const [groupId, groupCfg] of Object.entries(groups)) { - const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: groupId, entries }); - } - const topics = groupCfg?.topics ?? {}; - for (const [topicId, topicCfg] of Object.entries(topics)) { - const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (topicEntries.length > 0) { - groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries }); - } - } - } - } else if (channelId === "whatsapp") { - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.allowFrom ?? []).map(String); - groupAllowFrom = (account.groupAllowFrom ?? []).map(String); - dmPolicy = account.dmPolicy; - groupPolicy = account.groupPolicy; - } else if (channelId === "signal") { - const account = resolveSignalAccount({ cfg: params.cfg, accountId }); - ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); - } else if (channelId === "imessage") { - const account = resolveIMessageAccount({ cfg: params.cfg, accountId }); - ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account)); - } else if (channelId === "slack") { - const account = resolveSlackAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String); - groupPolicy = account.groupPolicy; - const channels = account.channels ?? {}; - groupOverrides = Object.entries(channels) - .map(([key, value]) => { - const entries = (value?.users ?? []).map(String).filter(Boolean); - return entries.length > 0 ? { label: key, entries } : null; - }) - .filter(Boolean) as Array<{ label: string; entries: string[] }>; - } else if (channelId === "discord") { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId }); - dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String); - groupPolicy = account.config.groupPolicy; - const guilds = account.config.guilds ?? {}; - for (const [guildKey, guildCfg] of Object.entries(guilds)) { - const entries = (guildCfg?.users ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: `guild ${guildKey}`, entries }); - } - const channels = guildCfg?.channels ?? {}; - for (const [channelKey, channelCfg] of Object.entries(channels)) { - const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean); - if (channelEntries.length > 0) { - groupOverrides.push({ - label: `guild ${guildKey} / channel ${channelKey}`, - entries: channelEntries, - }); - } - } - } - } + const dmAllowFrom = (configState.dmAllowFrom ?? []).map(String); + const groupAllowFrom = (configState.groupAllowFrom ?? []).map(String); + const groupOverrides = (configState.groupOverrides ?? []).map((entry) => ({ + label: entry.label, + entries: entry.entries.map(String).filter(Boolean), + })); const dmDisplay = normalizeAllowFrom({ cfg: params.cfg, @@ -522,38 +417,39 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo accountId, values: groupOverrideEntries, }); + const resolvedDm = - parsed.resolve && dmDisplay.length > 0 && channelId === "slack" - ? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay }) - : parsed.resolve && dmDisplay.length > 0 && channelId === "discord" - ? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay }) - : undefined; - const resolvedGroup = - parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack" - ? await resolveSlackNames({ + parsed.resolve && dmDisplay.length > 0 + ? await resolveAllowlistNames({ cfg: params.cfg, + channelId, accountId, + scope: "dm", + entries: dmDisplay, + }) + : undefined; + const resolvedGroup = + parsed.resolve && groupOverrideDisplay.length > 0 + ? await resolveAllowlistNames({ + cfg: params.cfg, + channelId, + accountId, + scope: "group", entries: groupOverrideDisplay, }) - : parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord" - ? await resolveDiscordNames({ - cfg: params.cfg, - accountId, - entries: groupOverrideDisplay, - }) - : undefined; + : undefined; const lines: string[] = ["🧾 Allowlist"]; lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`); - if (dmPolicy) { - lines.push(`DM policy: ${dmPolicy}`); + if (configState.dmPolicy) { + lines.push(`DM policy: ${configState.dmPolicy}`); } - if (groupPolicy) { - lines.push(`Group policy: ${groupPolicy}`); + if (configState.groupPolicy) { + lines.push(`Group policy: ${configState.groupPolicy}`); } - const showDm = scope === "dm" || scope === "all"; - const showGroup = scope === "group" || scope === "all"; + const showDm = parsed.scope === "dm" || parsed.scope === "all"; + const showGroup = parsed.scope === "group" || parsed.scope === "all"; if (showDm) { lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`); } @@ -568,7 +464,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } if (showGroup) { if (groupAllowFrom.length > 0) { - lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`); + lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay, resolvedGroup)}`); } if (groupOverrides.length > 0) { lines.push("Group overrides:"); @@ -600,12 +496,29 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId); if (shouldUpdateConfig) { - const allowlistPath = resolveChannelAllowFromPaths(channelId, scope); - if (!allowlistPath) { + if (parsed.scope === "all") { + return { + shouldContinue: false, + 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) { return { shouldContinue: false, reply: { - text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`, + text: `⚠️ ${channelId} does not support ${parsed.scope} allowlist edits via /allowlist.`, }, }; } @@ -618,19 +531,14 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }; } const parsedConfig = structuredClone(snapshot.parsed as Record); - const { - target, - pathPrefix, - accountId: normalizedAccountId, - writeTarget, - } = resolveAccountTarget(parsedConfig, channelId, accountId); + const resolvedTarget = resolveAccountTarget(parsedConfig, channelId, accountId); const deniedText = resolveConfigWriteDeniedText({ cfg: params.cfg, channel: params.command.channel, channelId, accountId: params.ctx.AccountId, gatewayClientScopes: params.ctx.GatewayClientScopes, - target: writeTarget, + target: editSpec.writeTarget, }); if (deniedText) { return { @@ -642,13 +550,8 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } const existing: string[] = []; - const existingPaths = - scope === "dm" && (channelId === "slack" || channelId === "discord") - ? // Read both while legacy alias may still exist; write canonical below. - [allowlistPath, ["dm", "allowFrom"]] - : [allowlistPath]; - for (const path of existingPaths) { - const existingRaw = getNestedValue(target, path); + for (const path of editSpec.readPaths) { + const existingRaw = getNestedValue(resolvedTarget.target, path); if (!Array.isArray(existingRaw)) { continue; } @@ -713,13 +616,12 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo if (configChanged) { if (next.length === 0) { - deleteNestedValue(target, allowlistPath); + deleteNestedValue(resolvedTarget.target, editSpec.writePath); } else { - setNestedValue(target, allowlistPath, next); + setNestedValue(resolvedTarget.target, editSpec.writePath, next); } - if (scope === "dm" && (channelId === "slack" || channelId === "discord")) { - // Remove legacy DM allowlist alias to prevent drift. - deleteNestedValue(target, ["dm", "allowFrom"]); + for (const path of editSpec.cleanupPaths ?? []) { + deleteNestedValue(resolvedTarget.target, path); } } @@ -750,10 +652,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo } const actionLabel = parsed.action === "add" ? "added" : "removed"; - const scopeLabel = scope === "dm" ? "DM" : "group"; + const scopeLabel = parsed.scope === "dm" ? "DM" : "group"; const locations: string[] = []; if (configChanged) { - locations.push(`${pathPrefix}.${allowlistPath.join(".")}`); + locations.push(`${resolvedTarget.pathPrefix}.${editSpec.writePath.join(".")}`); } if (shouldTouchStore) { locations.push("pairing store"); @@ -782,7 +684,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }); const actionLabel = parsed.action === "add" ? "added" : "removed"; - const scopeLabel = scope === "dm" ? "DM" : "group"; + const scopeLabel = parsed.scope === "dm" ? "DM" : "group"; return { shouldContinue: false, reply: { text: `✅ ${scopeLabel} allowlist ${actionLabel} in pairing store.` }, diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 2d8e6458933..0f2853aab98 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -8,6 +8,7 @@ import { listSubagentRunsForRequester, resetSubagentRegistryForTests, } from "../../agents/subagent-registry.js"; +import { setDefaultChannelPluginRegistryForTests } from "../../commands/channel-test-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import { updateSessionStore, type SessionEntry } from "../../config/sessions.js"; import * as internalHooks from "../../hooks/internal-hooks.js"; @@ -133,6 +134,32 @@ afterAll(async () => { await fs.rm(testWorkspaceDir, { recursive: true, force: true }); }); +beforeEach(() => { + setDefaultChannelPluginRegistryForTests(); + readConfigFileSnapshotMock.mockImplementation(async () => { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + return { valid: false, parsed: null }; + } + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; + return { valid: true, parsed }; + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + writeConfigFileMock.mockImplementation(async (config: unknown) => { + const configPath = process.env.OPENCLAW_CONFIG_PATH; + if (!configPath) { + return; + } + await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); + }); + readChannelAllowFromStoreMock.mockResolvedValue([]); + addChannelAllowFromStoreEntryMock.mockResolvedValue({ changed: true, allowFrom: [] }); + removeChannelAllowFromStoreEntryMock.mockResolvedValue({ changed: true, allowFrom: [] }); +}); + async function withTempConfigPath( initialConfig: Record, run: (configPath: string) => Promise, @@ -998,6 +1025,7 @@ function buildPolicyParams( describe("handleCommands /allowlist", () => { beforeEach(() => { vi.clearAllMocks(); + setDefaultChannelPluginRegistryForTests(); }); it("lists config + store allowFrom entries", async () => { diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 028192a3f54..9de4e6f0fa7 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -1,31 +1,125 @@ +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 { - createScopedChannelMediaMaxBytesResolver, - createDirectTextMediaOutbound, -} from "./direct-text-media.js"; +import type { ChannelOutboundAdapter } from "../types.js"; +import { createScopedChannelMediaMaxBytesResolver } from "./direct-text-media.js"; function resolveSignalSender(deps: OutboundSendDeps | undefined) { return resolveOutboundSendDep(deps, "signal") ?? sendMessageSignal; } -export const signalOutbound = createDirectTextMediaOutbound({ - channel: "signal", - resolveSender: resolveSignalSender, - resolveMaxBytes: createScopedChannelMediaMaxBytesResolver("signal"), - buildTextOptions: ({ cfg, maxBytes, accountId }) => ({ - cfg, - maxBytes, - accountId: accountId ?? undefined, - }), - buildMediaOptions: ({ cfg, mediaUrl, maxBytes, accountId, mediaLocalRoots }) => ({ +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, - maxBytes, - accountId: accountId ?? undefined, 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/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 084fa653bb8..c66fa0d463e 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -1,11 +1,13 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { AgentAcpBinding } from "../../config/types.js"; import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js"; import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js"; import type { OutboundIdentity } from "../../infra/outbound/identity.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import type { RuntimeEnv } from "../../runtime.js"; +import type { ConfigWriteTarget } from "./config-writes.js"; import type { ChannelAccountSnapshot, ChannelAccountState, @@ -137,12 +139,23 @@ export type ChannelOutboundPayloadContext = ChannelOutboundContext & { payload: ReplyPayload; }; +export type ChannelOutboundFormattedContext = ChannelOutboundContext & { + abortSignal?: AbortSignal; +}; + export type ChannelOutboundAdapter = { deliveryMode: "direct" | "gateway" | "hybrid"; chunker?: ((text: string, limit: number) => string[]) | null; chunkerMode?: "text" | "markdown"; textChunkLimit?: number; pollMaxOptions?: number; + normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null; + shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean; + resolveEffectiveTextChunkLimit?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + fallbackLimit?: number; + }) => number | undefined; resolveTarget?: (params: { cfg?: OpenClawConfig; to?: string; @@ -151,6 +164,10 @@ export type ChannelOutboundAdapter = { mode?: ChannelOutboundTargetMode; }) => { ok: true; to: string } | { ok: false; error: Error }; sendPayload?: (ctx: ChannelOutboundPayloadContext) => Promise; + sendFormattedText?: (ctx: ChannelOutboundFormattedContext) => Promise; + sendFormattedMedia?: ( + ctx: ChannelOutboundFormattedContext & { mediaUrl: string }, + ) => Promise; sendText?: (ctx: ChannelOutboundContext) => Promise; sendMedia?: (ctx: ChannelOutboundContext) => Promise; sendPoll?: (ctx: ChannelPollContext) => Promise; @@ -464,9 +481,63 @@ export type ChannelExecApprovalAdapter = { }; export type ChannelAllowlistAdapter = { + readConfig?: (params: { cfg: OpenClawConfig; accountId?: string | null }) => + | { + dmAllowFrom?: Array; + groupAllowFrom?: Array; + dmPolicy?: string; + groupPolicy?: string; + groupOverrides?: Array<{ label: string; entries: Array }>; + } + | Promise<{ + dmAllowFrom?: Array; + groupAllowFrom?: Array; + dmPolicy?: string; + groupPolicy?: string; + groupOverrides?: Array<{ label: string; entries: Array }>; + }>; + resolveNames?: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + scope: "dm" | "group"; + entries: string[]; + }) => + | Array<{ input: string; resolved: boolean; name?: string | null }> + | Promise>; + 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; }; +export type ChannelAcpBindingAdapter = { + normalizeConfiguredBindingTarget?: (params: { + binding: AgentAcpBinding; + conversationId: string; + }) => { + conversationId: string; + parentConversationId?: string; + } | null; + matchConfiguredBinding?: (params: { + binding: AgentAcpBinding; + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; + }) => { + conversationId: string; + parentConversationId?: string; + matchPriority?: number; + } | null; +}; + export type ChannelSecurityAdapter = { resolveDmPolicy?: ( ctx: ChannelSecurityContext, diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 4d94afe49fd..a43dbb42876 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -345,6 +345,12 @@ export type ChannelThreadingToolContext = { export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; + parseExplicitTarget?: (params: { raw: string }) => { + to: string; + threadId?: string | number; + chatType?: ChatType; + } | null; + inferTargetChatType?: (params: { to: string }) => ChatType | undefined; buildCrossContextComponents?: ChannelCrossContextComponentsFactory; enableInteractiveReplies?: (params: { cfg: OpenClawConfig; diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 713eff20bbe..6798545d22f 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -17,6 +17,7 @@ import type { ChannelSetupAdapter, ChannelStatusAdapter, ChannelAllowlistAdapter, + ChannelAcpBindingAdapter, } from "./types.adapters.js"; import type { ChannelAgentTool, @@ -77,6 +78,7 @@ export type ChannelPlugin { return { channel: "telegram", messageId: res.messageId, chatId: res.chatId }; }, }, + messaging: { + parseExplicitTarget: ({ raw }) => { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; + }, + inferTargetChatType: ({ to }) => { + const target = parseTelegramTarget(to); + return target.chatType === "unknown" ? undefined : target.chatType; + }, + }, }); telegramPlugin.config = { ...telegramPlugin.config, diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 9e10f525cb0..452875d9cff 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,8 +1,3 @@ -import { - markdownToSignalTextChunks, - type SignalTextStyleRange, -} from "../../../extensions/signal/src/format.js"; -import { sendMessageSignal } from "../../../extensions/signal/src/send.js"; import { chunkByParagraph, chunkMarkdownTextWithMode, @@ -10,14 +5,12 @@ import { resolveTextChunkLimit, } from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; -import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js"; import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import type { ChannelOutboundAdapter, ChannelOutboundContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { appendAssistantMessageToSessionTranscript, resolveMirroredTranscriptText, @@ -51,7 +44,6 @@ export { normalizeOutboundPayloads } from "./payloads.js"; export { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js"; const log = createSubsystemLogger("outbound/deliver"); -const TELEGRAM_TEXT_LIMIT = 4096; export type OutboundDeliveryResult = { channel: Exclude; @@ -74,6 +66,9 @@ type ChannelHandler = { chunkerMode?: "text" | "markdown"; textChunkLimit?: number; supportsMedia: boolean; + normalizePayload?: (payload: ReplyPayload) => ReplyPayload | null; + shouldSkipPlainTextSanitization?: (payload: ReplyPayload) => boolean; + resolveEffectiveTextChunkLimit?: (fallbackLimit?: number) => number | undefined; sendPayload?: ( payload: ReplyPayload, overrides?: { @@ -81,6 +76,21 @@ type ChannelHandler = { threadId?: string | number | null; }, ) => Promise; + sendFormattedText?: ( + text: string, + overrides?: { + replyToId?: string | null; + threadId?: string | number | null; + }, + ) => Promise; + sendFormattedMedia?: ( + caption: string, + mediaUrl: string, + overrides?: { + replyToId?: string | null; + threadId?: string | number | null; + }, + ) => Promise; sendText: ( text: string, overrides?: { @@ -155,6 +165,20 @@ function createPluginHandler( chunkerMode, textChunkLimit: outbound.textChunkLimit, supportsMedia: Boolean(sendMedia), + normalizePayload: outbound.normalizePayload + ? (payload) => outbound.normalizePayload!({ payload }) + : undefined, + shouldSkipPlainTextSanitization: outbound.shouldSkipPlainTextSanitization + ? (payload) => outbound.shouldSkipPlainTextSanitization!({ payload }) + : undefined, + resolveEffectiveTextChunkLimit: outbound.resolveEffectiveTextChunkLimit + ? (fallbackLimit) => + outbound.resolveEffectiveTextChunkLimit!({ + cfg: params.cfg, + accountId: params.accountId ?? undefined, + fallbackLimit, + }) + : undefined, sendPayload: outbound.sendPayload ? async (payload, overrides) => outbound.sendPayload!({ @@ -164,6 +188,21 @@ function createPluginHandler( payload, }) : undefined, + sendFormattedText: outbound.sendFormattedText + ? async (text, overrides) => + outbound.sendFormattedText!({ + ...resolveCtx(overrides), + text, + }) + : undefined, + sendFormattedMedia: outbound.sendFormattedMedia + ? async (caption, mediaUrl, overrides) => + outbound.sendFormattedMedia!({ + ...resolveCtx(overrides), + text: caption, + mediaUrl, + }) + : undefined, sendText: async (text, overrides) => sendText({ ...resolveCtx(overrides), @@ -239,18 +278,13 @@ type MessageSentEvent = { messageId?: string; }; -function normalizePayloadForChannelDelivery( - payload: ReplyPayload, - channelId: string, -): ReplyPayload | null { +function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload | null { + const text = typeof payload.text === "string" ? payload.text : ""; const hasChannelData = hasReplyChannelData(payload.channelData); - const rawText = typeof payload.text === "string" ? payload.text : ""; - const normalizedText = - channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText; - if (!normalizedText.trim()) { + if (!text.trim()) { if ( !hasReplyContent({ - text: normalizedText, + text, mediaUrl: payload.mediaUrl, mediaUrls: payload.mediaUrls, interactive: payload.interactive, @@ -259,26 +293,20 @@ function normalizePayloadForChannelDelivery( ) { return null; } - return { - ...payload, - text: "", - }; + if (text) { + return { + ...payload, + text: "", + }; + } } - if (normalizedText === rawText) { - return payload; - } - return { - ...payload, - text: normalizedText, - }; + return payload; } function normalizePayloadsForChannelDelivery( payloads: ReplyPayload[], channel: Exclude, - _cfg: OpenClawConfig, - _to: string, - _accountId?: string, + handler: ChannelHandler, ): ReplyPayload[] { const normalizedPayloads: ReplyPayload[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { @@ -287,15 +315,19 @@ function normalizePayloadsForChannelDelivery( // Models occasionally produce
, , etc. that render as literal text. // See https://github.com/openclaw/openclaw/issues/31884 if (isPlainTextSurface(channel) && sanitizedPayload.text) { - // Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path. - if (!(channel === "telegram" && sanitizedPayload.channelData)) { + if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) { sanitizedPayload = { ...sanitizedPayload, text: sanitizeForPlainText(sanitizedPayload.text), }; } } - const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel); + const normalizedPayload = handler.normalizePayload + ? handler.normalizePayload(sanitizedPayload) + : sanitizedPayload; + const normalized = normalizedPayload + ? normalizeEmptyPayloadForDelivery(normalizedPayload) + : null; if (normalized) { normalizedPayloads.push(normalized); } @@ -513,8 +545,6 @@ async function deliverOutboundPayloadsCore( const accountId = params.accountId; const deps = params.deps; const abortSignal = params.abortSignal; - const sendSignal = - resolveOutboundSendDep(params.deps, "signal") ?? sendMessageSignal; const mediaLocalRoots = getAgentScopedMediaLocalRoots( cfg, params.session?.agentId ?? params.mirror?.agentId, @@ -539,24 +569,10 @@ async function deliverOutboundPayloadsCore( fallbackLimit: handler.textChunkLimit, }) : undefined; - const textLimit = - channel === "telegram" && typeof configuredTextLimit === "number" - ? Math.min(configuredTextLimit, TELEGRAM_TEXT_LIMIT) - : configuredTextLimit; + const textLimit = handler.resolveEffectiveTextChunkLimit + ? handler.resolveEffectiveTextChunkLimit(configuredTextLimit) + : configuredTextLimit; const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length"; - const isSignalChannel = channel === "signal"; - const signalTableMode = isSignalChannel - ? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) - : "code"; - const signalMaxBytes = isSignalChannel - ? resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? - cfg.channels?.signal?.mediaMaxMb, - accountId, - }) - : undefined; const sendTextChunks = async ( text: string, @@ -595,66 +611,7 @@ async function deliverOutboundPayloadsCore( results.push(await handler.sendText(chunk, overrides)); } }; - - const sendSignalText = async (text: string, styles: SignalTextStyleRange[]) => { - throwIfAborted(abortSignal); - return { - channel: "signal" as const, - ...(await sendSignal(to, text, { - cfg, - maxBytes: signalMaxBytes, - accountId: accountId ?? undefined, - textMode: "plain", - textStyles: styles, - })), - }; - }; - - const sendSignalTextChunks = async (text: string) => { - throwIfAborted(abortSignal); - let signalChunks = - textLimit === undefined - ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { - tableMode: signalTableMode, - }) - : markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode }); - if (signalChunks.length === 0 && text) { - signalChunks = [{ text, styles: [] }]; - } - for (const chunk of signalChunks) { - throwIfAborted(abortSignal); - results.push(await sendSignalText(chunk.text, chunk.styles)); - } - }; - - const sendSignalMedia = async (caption: string, mediaUrl: string) => { - throwIfAborted(abortSignal); - const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, { - tableMode: signalTableMode, - })[0] ?? { - text: caption, - styles: [], - }; - return { - channel: "signal" as const, - ...(await sendSignal(to, formatted.text, { - cfg, - mediaUrl, - maxBytes: signalMaxBytes, - accountId: accountId ?? undefined, - textMode: "plain", - textStyles: formatted.styles, - mediaLocalRoots, - })), - }; - }; - const normalizedPayloads = normalizePayloadsForChannelDelivery( - payloads, - channel, - cfg, - to, - accountId, - ); + const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel, handler); const hookRunner = getGlobalHookRunner(); const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key; const mirrorIsGroup = params.mirror?.isGroup; @@ -724,8 +681,8 @@ async function deliverOutboundPayloadsCore( } if (payloadSummary.mediaUrls.length === 0) { const beforeCount = results.length; - if (isSignalChannel) { - await sendSignalTextChunks(payloadSummary.text); + if (handler.sendFormattedText) { + results.push(...(await handler.sendFormattedText(payloadSummary.text, sendOverrides))); } else { await sendTextChunks(payloadSummary.text, sendOverrides); } @@ -770,8 +727,8 @@ async function deliverOutboundPayloadsCore( throwIfAborted(abortSignal); const caption = first ? payloadSummary.text : ""; first = false; - if (isSignalChannel) { - const delivery = await sendSignalMedia(caption, url); + if (handler.sendFormattedMedia) { + const delivery = await handler.sendFormattedMedia(caption, url, sendOverrides); results.push(delivery); lastMessageId = delivery.messageId; } else { diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 4da860d083f..4d9645dc130 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it } from "vitest"; +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 type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { resolveHeartbeatDeliveryTarget, resolveOutboundTarget, @@ -17,6 +21,76 @@ import { runResolveOutboundTargetCoreTests(); +const telegramMessaging = { + parseExplicitTarget: ({ raw }: { raw: string }) => { + const target = parseTelegramTarget(raw); + return { + to: target.chatId, + threadId: target.messageThreadId, + chatType: target.chatType === "unknown" ? undefined : target.chatType, + }; + }, + inferTargetChatType: ({ to }: { to: string }) => { + const target = parseTelegramTarget(to); + return target.chatType === "unknown" ? undefined : target.chatType; + }, +}; + +const whatsappMessaging = { + inferTargetChatType: ({ to }: { to: string }) => { + const normalized = normalizeWhatsAppTarget(to); + if (!normalized) { + return undefined; + } + return isWhatsAppGroupJid(normalized) ? ("group" as const) : ("direct" as const); + }, +}; + +const noopOutbound = (channel: "discord" | "imessage" | "slack"): ChannelOutboundAdapter => ({ + deliveryMode: "direct", + sendText: async () => ({ channel, messageId: `${channel}-msg` }), +}); + +beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + plugin: createOutboundTestPlugin({ id: "discord", outbound: noopOutbound("discord") }), + source: "test", + }, + { + pluginId: "imessage", + plugin: createOutboundTestPlugin({ id: "imessage", outbound: noopOutbound("imessage") }), + source: "test", + }, + { + pluginId: "slack", + plugin: createOutboundTestPlugin({ id: "slack", outbound: noopOutbound("slack") }), + source: "test", + }, + { + pluginId: "telegram", + plugin: createOutboundTestPlugin({ + id: "telegram", + outbound: telegramOutbound, + messaging: telegramMessaging, + }), + source: "test", + }, + { + pluginId: "whatsapp", + plugin: createOutboundTestPlugin({ + id: "whatsapp", + outbound: whatsappOutbound, + messaging: whatsappMessaging, + }), + source: "test", + }, + ]), + ); +}); + describe("resolveOutboundTarget defaultTo config fallback", () => { installResolveOutboundTargetPluginRegistryHooks(); const whatsappDefaultCfg: OpenClawConfig = { @@ -80,7 +154,11 @@ describe("resolveOutboundTarget defaultTo config fallback", () => { registry.channels.push({ pluginId: "telegram", - plugin: createOutboundTestPlugin({ id: "telegram", outbound: telegramOutbound }), + plugin: createOutboundTestPlugin({ + id: "telegram", + outbound: telegramOutbound, + messaging: telegramMessaging, + }), source: "test", }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 9859176abbf..3a584473b8c 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,9 +1,3 @@ -import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js"; -import { parseSlackTarget } from "../../../extensions/slack/src/targets.js"; -import { - parseTelegramTarget, - resolveTelegramTargetChatType, -} from "../../../extensions/telegram/src/targets.js"; import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; @@ -22,7 +16,6 @@ import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; -import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import { normalizeDeliverableOutboundChannel, resolveOutboundChannelPlugin, @@ -65,6 +58,26 @@ export type SessionDeliveryTarget = { lastThreadId?: string | number; }; +function parseExplicitTargetWithPlugin(params: { + channel?: DeliverableMessageChannel; + fallbackChannel?: DeliverableMessageChannel; + raw?: string; +}) { + const raw = params.raw?.trim(); + if (!raw) { + return null; + } + const provider = params.channel ?? params.fallbackChannel; + if (!provider) { + return null; + } + return ( + resolveOutboundChannelPlugin({ channel: provider })?.messaging?.parseExplicitTarget?.({ + raw, + }) ?? null + ); +} + export function resolveSessionDeliveryTarget(params: { entry?: SessionEntry; requestedChannel?: GatewayMessageChannel | "last"; @@ -124,22 +137,19 @@ export function resolveSessionDeliveryTarget(params: { channel = params.fallbackChannel; } - // Parse :topic:NNN from explicitTo (Telegram topic syntax). - // Only applies when we positively know the channel is Telegram. - // When channel is unknown, the downstream send path (resolveTelegramSession) - // handles :topic: parsing independently. - const isTelegramContext = channel === "telegram" || (!channel && lastChannel === "telegram"); let explicitTo = rawExplicitTo; - let parsedThreadId: number | undefined; - if (isTelegramContext && rawExplicitTo && rawExplicitTo.includes(":topic:")) { - const parsed = parseTelegramTarget(rawExplicitTo); - explicitTo = parsed.chatId; - parsedThreadId = parsed.messageThreadId; + const parsedExplicitTarget = parseExplicitTargetWithPlugin({ + channel, + fallbackChannel: !channel ? lastChannel : undefined, + raw: rawExplicitTo, + }); + if (parsedExplicitTarget?.to) { + explicitTo = parsedExplicitTarget.to; } const explicitThreadId = params.explicitThreadId != null && params.explicitThreadId !== "" ? params.explicitThreadId - : parsedThreadId; + : parsedExplicitTarget?.threadId; let to = explicitTo; if (!to && lastTo) { @@ -387,70 +397,6 @@ function buildNoHeartbeatDeliveryTarget(params: { }; } -function inferDiscordTargetChatType(to: string): ChatType | undefined { - try { - const target = parseDiscordTarget(to, { defaultKind: "channel" }); - if (!target) { - return undefined; - } - return target.kind === "user" ? "direct" : "channel"; - } catch { - return undefined; - } -} - -function inferSlackTargetChatType(to: string): ChatType | undefined { - const target = parseSlackTarget(to, { defaultKind: "channel" }); - if (!target) { - return undefined; - } - return target.kind === "user" ? "direct" : "channel"; -} - -function inferTelegramTargetChatType(to: string): ChatType | undefined { - const chatType = resolveTelegramTargetChatType(to); - return chatType === "unknown" ? undefined : chatType; -} - -function inferWhatsAppTargetChatType(to: string): ChatType | undefined { - const normalized = normalizeWhatsAppTarget(to); - if (!normalized) { - return undefined; - } - return isWhatsAppGroupJid(normalized) ? "group" : "direct"; -} - -function inferSignalTargetChatType(rawTo: string): ChatType | undefined { - let to = rawTo.trim(); - if (!to) { - return undefined; - } - if (/^signal:/i.test(to)) { - to = to.replace(/^signal:/i, "").trim(); - } - if (!to) { - return undefined; - } - const lower = to.toLowerCase(); - if (lower.startsWith("group:")) { - return "group"; - } - if (lower.startsWith("username:") || lower.startsWith("u:")) { - return "direct"; - } - return "direct"; -} - -const HEARTBEAT_TARGET_CHAT_TYPE_INFERERS: Partial< - Record ChatType | undefined> -> = { - discord: inferDiscordTargetChatType, - slack: inferSlackTargetChatType, - telegram: inferTelegramTargetChatType, - whatsapp: inferWhatsAppTargetChatType, - signal: inferSignalTargetChatType, -}; - function inferChatTypeFromTarget(params: { channel: DeliverableMessageChannel; to: string; @@ -469,7 +415,9 @@ function inferChatTypeFromTarget(params: { if (/^group:/i.test(to)) { return "group"; } - return HEARTBEAT_TARGET_CHAT_TYPE_INFERERS[params.channel]?.(to); + return resolveOutboundChannelPlugin({ + channel: params.channel, + })?.messaging?.inferTargetChatType?.({ to }); } function resolveHeartbeatDeliveryChatType(params: { diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 2af1191feba..4f52350f8fc 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -1,6 +1,7 @@ import type { ChannelCapabilities, ChannelId, + ChannelMessagingAdapter, ChannelOutboundAdapter, ChannelPlugin, } from "../channels/plugins/types.js"; @@ -96,6 +97,7 @@ export const createMSTeamsTestPlugin = (params?: { export const createOutboundTestPlugin = (params: { id: ChannelId; outbound: ChannelOutboundAdapter; + messaging?: ChannelMessagingAdapter; label?: string; docsPath?: string; capabilities?: ChannelCapabilities; @@ -108,4 +110,5 @@ export const createOutboundTestPlugin = (params: { config: { listAccountIds: () => [] }, }), outbound: params.outbound, + ...(params.messaging ? { messaging: params.messaging } : {}), });