import type { ChannelId, ChannelMessageActionName, ChannelThreadingToolContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getChannelMessageAdapter, type CrossContextComponentsBuilder, } from "./channel-adapters.js"; import { normalizeTargetForProvider } from "./target-normalization.js"; import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js"; export type CrossContextDecoration = { prefix: string; suffix: string; componentsBuilder?: CrossContextComponentsBuilder; }; const CONTEXT_GUARDED_ACTIONS = new Set([ "send", "poll", "reply", "sendWithEffect", "sendAttachment", "thread-create", "thread-reply", "sticker", ]); const CONTEXT_MARKER_ACTIONS = new Set([ "send", "poll", "reply", "sendWithEffect", "sendAttachment", "thread-reply", "sticker", ]); function resolveContextGuardTarget( action: ChannelMessageActionName, params: Record, ): string | undefined { if (!CONTEXT_GUARDED_ACTIONS.has(action)) { return undefined; } if (action === "thread-reply" || action === "thread-create") { if (typeof params.channelId === "string") { return params.channelId; } if (typeof params.to === "string") { return params.to; } return undefined; } if (typeof params.to === "string") { return params.to; } if (typeof params.channelId === "string") { return params.channelId; } return undefined; } function normalizeTarget(channel: ChannelId, raw: string): string | undefined { return normalizeTargetForProvider(channel, raw) ?? raw.trim(); } function isCrossContextTarget(params: { channel: ChannelId; target: string; toolContext?: ChannelThreadingToolContext; }): boolean { const currentTarget = params.toolContext?.currentChannelId?.trim(); if (!currentTarget) { return false; } const normalizedTarget = normalizeTarget(params.channel, params.target); const normalizedCurrent = normalizeTarget(params.channel, currentTarget); if (!normalizedTarget || !normalizedCurrent) { return false; } return normalizedTarget !== normalizedCurrent; } export function enforceCrossContextPolicy(params: { channel: ChannelId; action: ChannelMessageActionName; args: Record; toolContext?: ChannelThreadingToolContext; cfg: OpenClawConfig; }): void { const currentTarget = params.toolContext?.currentChannelId?.trim(); if (!currentTarget) { return; } if (!CONTEXT_GUARDED_ACTIONS.has(params.action)) { return; } if (params.cfg.tools?.message?.allowCrossContextSend) { return; } const currentProvider = params.toolContext?.currentChannelProvider; const allowWithinProvider = params.cfg.tools?.message?.crossContext?.allowWithinProvider !== false; const allowAcrossProviders = params.cfg.tools?.message?.crossContext?.allowAcrossProviders === true; if (currentProvider && currentProvider !== params.channel) { if (!allowAcrossProviders) { throw new Error( `Cross-context messaging denied: action=${params.action} target provider "${params.channel}" while bound to "${currentProvider}".`, ); } return; } if (allowWithinProvider) { return; } const target = resolveContextGuardTarget(params.action, params.args); if (!target) { return; } if (!isCrossContextTarget({ channel: params.channel, target, toolContext: params.toolContext })) { return; } throw new Error( `Cross-context messaging denied: action=${params.action} target="${target}" while bound to "${currentTarget}" (channel=${params.channel}).`, ); } export async function buildCrossContextDecoration(params: { cfg: OpenClawConfig; channel: ChannelId; target: string; toolContext?: ChannelThreadingToolContext; accountId?: string | null; }): Promise { if (!params.toolContext?.currentChannelId) { return null; } // Skip decoration for direct tool sends (agent composing, not forwarding) if (params.toolContext.skipCrossContextDecoration) { return null; } if (!isCrossContextTarget(params)) { return null; } const markerConfig = params.cfg.tools?.message?.crossContext?.marker; if (markerConfig?.enabled === false) { return null; } const currentName = (await lookupDirectoryDisplay({ cfg: params.cfg, channel: params.channel, targetId: params.toolContext.currentChannelId, accountId: params.accountId ?? undefined, })) ?? params.toolContext.currentChannelId; // Don't force group formatting here; currentChannelId can be a DM or a group. const originLabel = formatTargetDisplay({ channel: params.channel, target: params.toolContext.currentChannelId, display: currentName, }); const prefixTemplate = markerConfig?.prefix ?? "[from {channel}] "; const suffixTemplate = markerConfig?.suffix ?? ""; const prefix = prefixTemplate.replaceAll("{channel}", originLabel); const suffix = suffixTemplate.replaceAll("{channel}", originLabel); const adapter = getChannelMessageAdapter(params.channel); const componentsBuilder = adapter.supportsComponentsV2 ? adapter.buildCrossContextComponents ? (message: string) => adapter.buildCrossContextComponents!({ originLabel, message, cfg: params.cfg, accountId: params.accountId ?? undefined, }) : undefined : undefined; return { prefix, suffix, componentsBuilder }; } export function shouldApplyCrossContextMarker(action: ChannelMessageActionName): boolean { return CONTEXT_MARKER_ACTIONS.has(action); } export function applyCrossContextDecoration(params: { message: string; decoration: CrossContextDecoration; preferComponents: boolean; }): { message: string; componentsBuilder?: CrossContextComponentsBuilder; usedComponents: boolean; } { const useComponents = params.preferComponents && params.decoration.componentsBuilder; if (useComponents) { return { message: params.message, componentsBuilder: params.decoration.componentsBuilder, usedComponents: true, }; } const message = `${params.decoration.prefix}${params.message}${params.decoration.suffix}`; return { message, usedComponents: false }; }