refactor: move channel delivery and ACP seams into plugins

This commit is contained in:
Peter Steinberger 2026-03-15 23:24:18 -07:00
parent d5b12f505c
commit d163278e9c
No known key found for this signature in database
24 changed files with 1177 additions and 646 deletions

View File

@ -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<typeof resolveDiscordAccount>[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<ResolvedDiscordAccount> = {
}),
...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<ResolvedDiscordAccount> = {
},
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<ResolvedDiscordAccount> = {
silent: silent ?? undefined,
}),
},
acpBindings: {
normalizeConfiguredBindingTarget: ({ conversationId }) =>
normalizeDiscordAcpConversationId(conversationId),
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
matchDiscordAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,

View File

@ -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<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
@ -393,6 +465,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
});
},
},
acpBindings: {
normalizeConfiguredBindingTarget: ({ conversationId }) =>
normalizeFeishuAcpConversationId(conversationId),
matchConfiguredBinding: ({ bindingConversationId, conversationId, parentConversationId }) =>
matchFeishuAcpConversation({ bindingConversationId, conversationId, parentConversationId }),
},
setup: feishuSetupAdapter,
setupWizard: feishuSetupWizard,
messaging: {

View File

@ -124,6 +124,24 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
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({

View File

@ -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<typeof getSignalRuntime>["channel"]["signal"]["sendMessageSignal"];
async function sendSignalOutbound(params: {
function resolveSignalSendContext(params: {
cfg: Parameters<typeof resolveSignalAccount>[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<typeof resolveSignalAccount>[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<typeof resolveSignalAccount>[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<typeof resolveSignalAccount>[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<ResolvedSignalAccount> = {
id: "signal",
meta: {
@ -146,6 +272,24 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
}),
...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<ResolvedSignalAccount> = {
},
messaging: {
normalizeTarget: normalizeSignalMessagingTarget,
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
inferTargetChatType: ({ to }) => inferSignalTargetChatType(to),
targetResolver: {
looksLikeId: looksLikeSignalTargetId,
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
@ -185,6 +331,35 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
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,

View File

@ -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<ReturnType<typeof fetchSlackScopes>>;
@ -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<typeof resolveSlackAccount>[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<ResolvedSlackAccount> = {
}),
...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<ResolvedSlackAccount> = {
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType,
enableInteractiveReplies: ({ cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId }),
hasStructuredReplyPayload: ({ payload }) => {

View File

@ -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<ResolvedTelegramA
normalizeEntry: (raw) => 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<ResolvedTelegramAccount, TelegramProbe> = {
id: "telegram",
meta: {
@ -284,6 +354,23 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
}),
...telegramConfigAccessors,
},
allowlist: {
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"],
}),
},
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<ResolvedTelegramAccount, TelegramProb
},
messaging: {
normalizeTarget: normalizeTelegramMessagingTarget,
parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType,
targetResolver: {
looksLikeId: looksLikeTelegramTargetId,
hint: "<chatId>",
@ -423,6 +512,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 10,
shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData),
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
sendPayload: async ({
cfg,
to,

View File

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

View File

@ -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<ResolvedWhatsAppAccount> = {
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<ResolvedWhatsAppAccount> = {
},
messaging: {
normalizeTarget: normalizeWhatsAppMessagingTarget,
parseExplicitTarget: ({ raw }) => parseWhatsAppExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseWhatsAppExplicitTarget(to)?.chatType,
targetResolver: {
looksLikeId: looksLikeWhatsAppTargetId,
hint: "<E.164|group JID>",
@ -288,16 +324,22 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
);
},
},
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);

View File

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

View File

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

View File

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

View File

@ -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 | number>;
}): 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<string, string>): str
.join(", ");
}
function extractConfigAllowlist(account: {
config?: {
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
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<string, unknown>;
@ -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<string, unknown>, path: string[]) {
delete (parent as Record<string, unknown>)[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<string, string> {
const map = new Map<string, string>();
for (const entry of entries) {
@ -362,32 +306,35 @@ function mapResolvedAllowlistNames(entries: ResolvedAllowlistName[]): Map<string
return map;
}
async function resolveSlackNames(params: {
async function resolveAllowlistNames(params: {
cfg: OpenClawConfig;
channelId: ChannelId;
accountId?: string | null;
scope: "dm" | "group";
entries: string[];
}) {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.userToken || account.botToken?.trim();
if (!token) {
return new Map<string, string>();
}
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<string, string>();
}
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<string, unknown>);
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.` },

View File

@ -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<string, unknown>;
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<T>(
initialConfig: Record<string, unknown>,
run: (configPath: string) => Promise<T>,
@ -998,6 +1025,7 @@ function buildPolicyParams(
describe("handleCommands /allowlist", () => {
beforeEach(() => {
vi.clearAllMocks();
setDefaultChannelPluginRegistryForTests();
});
it("lists config + store allowFrom entries", async () => {

View File

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

View File

@ -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<OutboundDeliveryResult>;
sendFormattedText?: (ctx: ChannelOutboundFormattedContext) => Promise<OutboundDeliveryResult[]>;
sendFormattedMedia?: (
ctx: ChannelOutboundFormattedContext & { mediaUrl: string },
) => Promise<OutboundDeliveryResult>;
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
sendPoll?: (ctx: ChannelPollContext) => Promise<ChannelPollResult>;
@ -464,9 +481,63 @@ export type ChannelExecApprovalAdapter = {
};
export type ChannelAllowlistAdapter = {
readConfig?: (params: { cfg: OpenClawConfig; accountId?: string | null }) =>
| {
dmAllowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: string;
groupPolicy?: string;
groupOverrides?: Array<{ label: string; entries: Array<string | number> }>;
}
| Promise<{
dmAllowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
dmPolicy?: string;
groupPolicy?: string;
groupOverrides?: Array<{ label: string; entries: Array<string | number> }>;
}>;
resolveNames?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
scope: "dm" | "group";
entries: string[];
}) =>
| 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;
};
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<ResolvedAccount = unknown> = {
resolveDmPolicy?: (
ctx: ChannelSecurityContext<ResolvedAccount>,

View File

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

View File

@ -17,6 +17,7 @@ import type {
ChannelSetupAdapter,
ChannelStatusAdapter,
ChannelAllowlistAdapter,
ChannelAcpBindingAdapter,
} from "./types.adapters.js";
import type {
ChannelAgentTool,
@ -77,6 +78,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
lifecycle?: ChannelLifecycleAdapter;
execApprovals?: ChannelExecApprovalAdapter;
allowlist?: ChannelAllowlistAdapter;
acpBindings?: ChannelAcpBindingAdapter;
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;

View File

@ -33,6 +33,7 @@ export type {
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelAllowlistAdapter,
ChannelAcpBindingAdapter,
ChannelPairingAdapter,
ChannelSecurityAdapter,
ChannelSetupAdapter,

View File

@ -1,4 +1,5 @@
import { discordPlugin } from "../../extensions/discord/src/channel.js";
import { feishuPlugin } from "../../extensions/feishu/src/channel.js";
import { imessagePlugin } from "../../extensions/imessage/src/channel.js";
import { signalPlugin } from "../../extensions/signal/src/channel.js";
import { slackPlugin } from "../../extensions/slack/src/channel.js";
@ -27,6 +28,7 @@ type PatchedSetupAdapterFields = {
export function setDefaultChannelPluginRegistryForTests(): void {
const channels = [
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
{ pluginId: "feishu", plugin: feishuPlugin, source: "test" },
{ pluginId: "slack", plugin: slackPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
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 { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
import * as replyModule from "../auto-reply/reply.js";
import { whatsappOutbound } from "../channels/plugins/outbound/whatsapp.js";
@ -80,6 +81,20 @@ beforeAll(async () => {
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,

View File

@ -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<OutboundChannel, "none">;
@ -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<OutboundDeliveryResult>;
sendFormattedText?: (
text: string,
overrides?: {
replyToId?: string | null;
threadId?: string | number | null;
},
) => Promise<OutboundDeliveryResult[]>;
sendFormattedMedia?: (
caption: string,
mediaUrl: string,
overrides?: {
replyToId?: string | null;
threadId?: string | number | null;
},
) => Promise<OutboundDeliveryResult>;
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<OutboundChannel, "none">,
_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 <br>, <b>, 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<typeof sendMessageSignal>(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 {

View File

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

View File

@ -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<DeliverableMessageChannel, (to: string) => 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: {

View File

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