refactor: move channel messaging hooks into plugins

This commit is contained in:
Peter Steinberger
2026-03-15 22:38:49 -07:00
parent 680eff63fb
commit ad97c581e2
22 changed files with 658 additions and 399 deletions

View File

@@ -212,3 +212,25 @@ Notes:
- External plugins can be developed and updated without core source access.
Related docs: [Plugins](/tools/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration).
## Implemented channel-owned seams
Recent refactor work widened the channel plugin contract so core can stop owning
channel-specific UX and routing behavior:
- `messaging.buildCrossContextComponents`: channel-owned cross-context UI markers
(for example Discord components v2 containers)
- `messaging.enableInteractiveReplies`: channel-owned reply normalization toggles
(for example Slack interactive replies)
- `messaging.resolveOutboundSessionRoute`: channel-owned outbound session routing
- `threading.resolveAutoThreadId`: channel-owned same-conversation auto-threading
- `threading.resolveReplyTransport`: channel-owned reply-vs-thread delivery mapping
- `actions.requiresTrustedRequesterSender`: channel-owned privileged action trust gates
- `execApprovals.*`: channel-owned exec approval surface state, forwarding suppression,
pending payload UX, and pre-delivery hooks
- `lifecycle.onAccountConfigChanged` / `lifecycle.onAccountRemoved`: channel-owned cleanup on
config mutation/removal
- `allowlist.supportsScope`: channel-owned allowlist scope advertisement
These hooks should be preferred over new `channel === "discord"` / `telegram`
branches in shared core flows.

View File

@@ -1,3 +1,4 @@
import { Separator, TextDisplay } from "@buape/carbon";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
@@ -31,11 +32,15 @@ import {
resolveDiscordGroupToolPolicy,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type OpenClawConfig,
type ResolvedDiscordAccount,
} from "openclaw/plugin-sdk/discord";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { normalizeMessageChannel } from "../../../src/utils/message-channel.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import { getDiscordRuntime } from "./runtime.js";
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
import { DiscordUiContainer } from "./ui.js";
type DiscordSendFn = ReturnType<
typeof getDiscordRuntime
@@ -59,8 +64,37 @@ const discordMessageActions: ChannelMessageActionAdapter = {
}
return ma.handleAction(ctx);
},
requiresTrustedRequesterSender: ({ action, toolContext }) =>
Boolean(toolContext && (action === "timeout" || action === "kick" || action === "ban")),
};
function buildDiscordCrossContextComponents(params: {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
}) {
const trimmed = params.message.trim();
const components: Array<TextDisplay | Separator> = [];
if (trimmed) {
components.push(new TextDisplay(params.message));
components.push(new Separator({ divider: true, spacing: "small" }));
}
components.push(new TextDisplay(`*From ${params.originLabel}*`));
return [new DiscordUiContainer({ cfg: params.cfg, accountId: params.accountId, components })];
}
function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
return listDiscordAccountIds(cfg).some((accountId) => {
const execApprovals = resolveDiscordAccount({ cfg, accountId }).config.execApprovals;
if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) {
return false;
}
const target = execApprovals.target ?? "dm";
return target === "dm" || target === "both";
});
}
const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
@@ -183,11 +217,22 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
buildCrossContextComponents: buildDiscordCrossContextComponents,
targetResolver: {
looksLikeId: looksLikeDiscordTargetId,
hint: "<channelId|user:ID|channel:ID>",
},
},
execApprovals: {
getInitiatingSurfaceState: ({ cfg, accountId }) =>
isDiscordExecApprovalClientEnabled({ cfg, accountId })
? { kind: "enabled" }
: { kind: "disabled" },
hasConfiguredDmRoute: ({ cfg }) => hasDiscordExecApprovalDmRoute(cfg),
shouldSuppressForwardingFallback: ({ cfg, target }) =>
(normalizeMessageChannel(target.channel) ?? target.channel) === "discord" &&
isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }),
},
directory: {
self: async () => null,
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),

View File

@@ -38,6 +38,7 @@ import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { getSlackRuntime } from "./runtime.js";
import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js";
import { parseSlackTarget } from "./targets.js";
const meta = getChatChannelMeta("slack");
@@ -94,6 +95,37 @@ function resolveSlackSendContext(params: {
return { send, threadTsValue, tokenOverride };
}
function resolveSlackAutoThreadId(params: {
cfg: Parameters<typeof resolveSlackAccount>[0]["cfg"];
accountId?: string | null;
to: string;
toolContext?: {
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean };
};
}): string | undefined {
const context = params.toolContext;
if (!context?.currentThreadTs || !context.currentChannelId) {
return undefined;
}
if (context.replyToMode !== "all" && context.replyToMode !== "first") {
return undefined;
}
const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" });
if (!parsedTarget || parsedTarget.kind !== "channel") {
return undefined;
}
if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) {
return undefined;
}
if (context.replyToMode === "first" && context.hasRepliedRef?.value) {
return undefined;
}
return context.currentThreadTs;
}
const slackConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom,
@@ -235,9 +267,24 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
allowExplicitReplyTagsWhenOff: false,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
resolveAutoThreadId: ({ cfg, accountId, to, toolContext, replyToId }) =>
replyToId
? undefined
: resolveSlackAutoThreadId({
cfg,
accountId,
to,
toolContext,
}),
resolveReplyTransport: ({ threadId, replyToId }) => ({
replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined),
threadId: null,
}),
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
enableInteractiveReplies: ({ cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId }),
targetResolver: {
looksLikeId: looksLikeSlackTargetId,
hint: "<channelId|user:ID|channel:ID>",

View File

@@ -37,13 +37,24 @@ import {
type ResolvedTelegramAccount,
type TelegramProbe,
} from "openclaw/plugin-sdk/telegram";
import { resolveExecApprovalCommandDisplay } from "../../../src/infra/exec-approval-command-display.js";
import { buildExecApprovalPendingReplyPayload } from "../../../src/infra/exec-approval-reply.js";
import {
type OutboundSendDeps,
resolveOutboundSendDep,
} from "../../../src/infra/outbound/send-deps.js";
import { normalizeMessageChannel } from "../../../src/utils/message-channel.js";
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
import {
isTelegramExecApprovalClientEnabled,
resolveTelegramExecApprovalTarget,
} from "./exec-approvals.js";
import { getTelegramRuntime } from "./runtime.js";
import { sendTypingTelegram } from "./send.js";
import { telegramSetupAdapter } from "./setup-core.js";
import { telegramSetupWizard } from "./setup-surface.js";
import { parseTelegramTarget } from "./targets.js";
import { deleteTelegramUpdateOffset } from "./update-offset-store.js";
type TelegramSendFn = ReturnType<
typeof getTelegramRuntime
@@ -140,6 +151,32 @@ async function sendTelegramOutbound(params: {
);
}
function resolveTelegramAutoThreadId(params: {
to: string;
toolContext?: { currentThreadTs?: string; currentChannelId?: string };
}): string | undefined {
const context = params.toolContext;
if (!context?.currentThreadTs || !context.currentChannelId) {
return undefined;
}
const parsedTo = parseTelegramTarget(params.to);
const parsedChannel = parseTelegramTarget(context.currentChannelId);
if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) {
return undefined;
}
return context.currentThreadTs;
}
function hasTelegramExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
return listTelegramAccountIds(cfg).some((accountId) => {
if (!isTelegramExecApprovalClientEnabled({ cfg, accountId })) {
return false;
}
const target = resolveTelegramExecApprovalTarget({ cfg, accountId });
return target === "dm" || target === "both";
});
}
const telegramMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) =>
getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [],
@@ -282,6 +319,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off",
resolveAutoThreadId: ({ to, toolContext, replyToId }) =>
replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }),
},
messaging: {
normalizeTarget: normalizeTelegramMessagingTarget,
@@ -290,6 +329,84 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
hint: "<chatId>",
},
},
lifecycle: {
onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => {
const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim();
const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim();
if (previousToken !== nextToken) {
await deleteTelegramUpdateOffset({ accountId });
}
},
onAccountRemoved: async ({ accountId }) => {
await deleteTelegramUpdateOffset({ accountId });
},
},
execApprovals: {
getInitiatingSurfaceState: ({ cfg, accountId }) =>
isTelegramExecApprovalClientEnabled({ cfg, accountId })
? { kind: "enabled" }
: { kind: "disabled" },
hasConfiguredDmRoute: ({ cfg }) => hasTelegramExecApprovalDmRoute(cfg),
shouldSuppressForwardingFallback: ({ cfg, target, request }) => {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (channel !== "telegram") {
return false;
}
const requestChannel = normalizeMessageChannel(request.request.turnSourceChannel ?? "");
if (requestChannel !== "telegram") {
return false;
}
const accountId = target.accountId?.trim() || request.request.turnSourceAccountId?.trim();
return isTelegramExecApprovalClientEnabled({ cfg, accountId });
},
buildPendingPayload: ({ request, nowMs }) => {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: resolveExecApprovalCommandDisplay(request.request).commandText,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs,
});
const buttons = buildTelegramExecApprovalButtons(request.id);
if (!buttons) {
return payload;
}
return {
...payload,
channelData: {
...payload.channelData,
telegram: {
buttons,
},
},
};
},
beforeDeliverPending: async ({ cfg, target, payload }) => {
const hasExecApprovalData =
payload.channelData &&
typeof payload.channelData === "object" &&
!Array.isArray(payload.channelData) &&
payload.channelData.execApproval;
if (!hasExecApprovalData) {
return;
}
const threadId =
typeof target.threadId === "number"
? target.threadId
: typeof target.threadId === "string"
? Number.parseInt(target.threadId, 10)
: undefined;
await sendTypingTelegram(target.to, {
cfg,
accountId: target.accountId ?? undefined,
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
}).catch(() => {});
},
},
directory: {
self: async () => null,
listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params),

View File

@@ -11,7 +11,7 @@ import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-inpu
import { isSlackInteractiveRepliesEnabled } from "../../../extensions/slack/src/interactive-replies.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/config.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
@@ -80,6 +80,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
return { ok: true };
}
const normalizedChannel = normalizeMessageChannel(channel);
const channelId = normalizeChannelId(channel) ?? null;
const plugin = channelId ? getChannelPlugin(channelId) : undefined;
const resolvedAgentId = params.sessionKey
? resolveSessionAgentId({
sessionKey: params.sessionKey,
@@ -100,7 +102,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
const normalized = normalizeReplyPayload(payload, {
responsePrefix,
enableSlackInteractiveReplies:
channel === "slack" ? isSlackInteractiveRepliesEnabled({ cfg, accountId }) : false,
plugin?.messaging?.enableInteractiveReplies?.({ cfg, accountId }) ??
(channel === "slack" ? isSlackInteractiveRepliesEnabled({ cfg, accountId }) : false),
});
if (!normalized) {
return { ok: true };
@@ -118,7 +121,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
: [];
const replyToId = externalPayload.replyToId;
const hasInteractive = (externalPayload.interactive?.blocks.length ?? 0) > 0;
let hasSlackBlocks = false;
let hasChannelData =
externalPayload.channelData != null && Object.keys(externalPayload.channelData).length > 0;
if (
channel === "slack" &&
externalPayload.channelData?.slack &&
@@ -126,17 +130,17 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
!Array.isArray(externalPayload.channelData.slack)
) {
try {
hasSlackBlocks = Boolean(
hasChannelData = Boolean(
parseSlackBlocksInput((externalPayload.channelData.slack as { blocks?: unknown }).blocks)
?.length,
);
} catch {
hasSlackBlocks = false;
hasChannelData = false;
}
}
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasSlackBlocks) {
if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) {
return { ok: true };
}
@@ -147,7 +151,6 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
};
}
const channelId = normalizeChannelId(channel) ?? null;
if (!channelId) {
return { ok: false, error: `Unknown channel: ${String(channel)}` };
}
@@ -155,12 +158,21 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
return { ok: false, error: "Reply routing aborted" };
}
const replyTransport =
plugin?.threading?.resolveReplyTransport?.({
cfg,
accountId,
threadId,
replyToId,
}) ?? null;
const resolvedReplyToId =
replyTransport?.replyToId ??
replyToId ??
((channelId === "slack" || channelId === "mattermost") && threadId != null && threadId !== ""
? String(threadId)
: undefined);
const resolvedThreadId = channelId === "slack" ? null : (threadId ?? null);
const resolvedThreadId =
replyTransport?.threadId ?? (channelId === "slack" ? null : (threadId ?? null));
try {
// Provider docking: this is an execution boundary (we're about to send).

View File

@@ -4,17 +4,26 @@ import { getChannelPlugin, listChannelPlugins } from "./index.js";
import type { ChannelMessageCapability } from "./message-capabilities.js";
import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js";
type ChannelActions = NonNullable<NonNullable<ReturnType<typeof getChannelPlugin>>["actions"]>;
const trustedRequesterRequiredByChannel: Readonly<
Partial<Record<string, ReadonlySet<ChannelMessageActionName>>>
> = {
discord: new Set<ChannelMessageActionName>(["timeout", "kick", "ban"]),
};
type ChannelActions = NonNullable<NonNullable<ReturnType<typeof getChannelPlugin>>["actions"]>;
function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean {
const actions = trustedRequesterRequiredByChannel[ctx.channel];
return Boolean(actions?.has(ctx.action) && ctx.toolContext);
const plugin = getChannelPlugin(ctx.channel);
const fromPlugin = plugin?.actions?.requiresTrustedRequesterSender?.({
action: ctx.action,
toolContext: ctx.toolContext,
});
if (fromPlugin != null) {
return fromPlugin;
}
return Boolean(
trustedRequesterRequiredByChannel[ctx.channel]?.has(ctx.action) && ctx.toolContext,
);
}
export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] {

View File

@@ -1,6 +1,7 @@
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/config.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";
@@ -21,6 +22,19 @@ import type {
ChannelStatusIssue,
} from "./types.core.js";
export type ChannelExecApprovalInitiatingSurfaceState =
| { kind: "enabled" }
| { kind: "disabled" }
| { kind: "unsupported" };
export type ChannelExecApprovalForwardTarget = {
channel: string;
to: string;
accountId?: string | null;
threadId?: string | number | null;
source?: "session" | "target";
};
export type ChannelSetupAdapter = {
resolveAccountId?: (params: {
cfg: OpenClawConfig;
@@ -377,6 +391,53 @@ export type ChannelCommandAdapter = {
skipWhenConfigEmpty?: boolean;
};
export type ChannelLifecycleAdapter = {
onAccountConfigChanged?: (params: {
prevCfg: OpenClawConfig;
nextCfg: OpenClawConfig;
accountId: string;
runtime: RuntimeEnv;
}) => Promise<void> | void;
onAccountRemoved?: (params: {
prevCfg: OpenClawConfig;
accountId: string;
runtime: RuntimeEnv;
}) => Promise<void> | void;
};
export type ChannelExecApprovalAdapter = {
getInitiatingSurfaceState?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => ChannelExecApprovalInitiatingSurfaceState;
hasConfiguredDmRoute?: (params: { cfg: OpenClawConfig }) => boolean;
shouldSuppressForwardingFallback?: (params: {
cfg: OpenClawConfig;
target: ChannelExecApprovalForwardTarget;
request: ExecApprovalRequest;
}) => boolean;
buildPendingPayload?: (params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
target: ChannelExecApprovalForwardTarget;
nowMs: number;
}) => ReplyPayload | null;
buildResolvedPayload?: (params: {
cfg: OpenClawConfig;
resolved: ExecApprovalResolved;
target: ChannelExecApprovalForwardTarget;
}) => ReplyPayload | null;
beforeDeliverPending?: (params: {
cfg: OpenClawConfig;
target: ChannelExecApprovalForwardTarget;
payload: ReplyPayload;
}) => Promise<void> | void;
};
export type ChannelAllowlistAdapter = {
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
};
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
resolveDmPolicy?: (
ctx: ChannelSecurityContext<ResolvedAccount>,

View File

@@ -1,3 +1,4 @@
import type { TopLevelComponents } from "@buape/carbon";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox";
import type { MsgContext } from "../../auto-reply/templating.js";
@@ -237,6 +238,38 @@ export type ChannelStreamingAdapter = {
};
};
export type ChannelCrossContextComponentsFactory = (params: {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
}) => TopLevelComponents[];
export type ChannelReplyTransport = {
replyToId?: string | null;
threadId?: string | number | null;
};
export type ChannelFocusedBindingContext = {
conversationId: string;
parentConversationId?: string;
placement: "current" | "child";
labelNoun: string;
};
export type ChannelOutboundSessionRoute = {
sessionKey: string;
baseSessionKey: string;
peer: {
kind: ChatType;
id: string;
};
chatType: "direct" | "group" | "channel";
from: string;
to: string;
threadId?: string | number;
};
export type ChannelThreadingAdapter = {
resolveReplyToMode?: (params: {
cfg: OpenClawConfig;
@@ -260,6 +293,24 @@ export type ChannelThreadingAdapter = {
context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean };
}) => ChannelThreadingToolContext | undefined;
resolveAutoThreadId?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
to: string;
toolContext?: ChannelThreadingToolContext;
replyToId?: string | null;
}) => string | undefined;
resolveReplyTransport?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
threadId?: string | number | null;
replyToId?: string | null;
}) => ChannelReplyTransport | null;
resolveFocusedBinding?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
context: ChannelThreadingContext;
}) => ChannelFocusedBindingContext | null;
};
export type ChannelThreadingContext = {
@@ -293,6 +344,11 @@ export type ChannelThreadingToolContext = {
export type ChannelMessagingAdapter = {
normalizeTarget?: (raw: string) => string | undefined;
buildCrossContextComponents?: ChannelCrossContextComponentsFactory;
enableInteractiveReplies?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) => boolean;
targetResolver?: {
looksLikeId?: (raw: string, normalized?: string) => boolean;
hint?: string;
@@ -314,6 +370,20 @@ export type ChannelMessagingAdapter = {
display?: string;
kind?: ChannelDirectoryEntryKind;
}) => string;
resolveOutboundSessionRoute?: (params: {
cfg: OpenClawConfig;
agentId: string;
accountId?: string | null;
target: string;
resolvedTarget?: {
to: string;
kind: ChannelDirectoryEntryKind | "channel";
display?: string;
source: "normalized" | "directory";
};
replyToId?: string | null;
threadId?: string | number | null;
}) => ChannelOutboundSessionRoute | Promise<ChannelOutboundSessionRoute | null> | null;
};
export type ChannelAgentPromptAdapter = {
@@ -374,6 +444,10 @@ export type ChannelMessageActionAdapter = {
listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
getCapabilities?: (params: { cfg: OpenClawConfig }) => readonly ChannelMessageCapability[];
requiresTrustedRequesterSender?: (params: {
action: ChannelMessageActionName;
toolContext?: ChannelThreadingToolContext;
}) => boolean;
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
handleAction?: (ctx: ChannelMessageActionContext) => Promise<AgentToolResult<unknown>>;
};

View File

@@ -4,16 +4,19 @@ import type {
ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelDirectoryAdapter,
ChannelExecApprovalAdapter,
ChannelResolverAdapter,
ChannelElevatedAdapter,
ChannelGatewayAdapter,
ChannelGroupAdapter,
ChannelHeartbeatAdapter,
ChannelLifecycleAdapter,
ChannelOutboundAdapter,
ChannelPairingAdapter,
ChannelSecurityAdapter,
ChannelSetupAdapter,
ChannelStatusAdapter,
ChannelAllowlistAdapter,
} from "./types.adapters.js";
import type {
ChannelAgentTool,
@@ -71,6 +74,9 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
auth?: ChannelAuthAdapter;
elevated?: ChannelElevatedAdapter;
commands?: ChannelCommandAdapter;
lifecycle?: ChannelLifecycleAdapter;
execApprovals?: ChannelExecApprovalAdapter;
allowlist?: ChannelAllowlistAdapter;
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;

View File

@@ -11,6 +11,9 @@ export type {
ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelDirectoryAdapter,
ChannelExecApprovalAdapter,
ChannelExecApprovalForwardTarget,
ChannelExecApprovalInitiatingSurfaceState,
ChannelResolveKind,
ChannelResolveResult,
ChannelResolverAdapter,
@@ -19,12 +22,14 @@ export type {
ChannelGatewayContext,
ChannelGroupAdapter,
ChannelHeartbeatAdapter,
ChannelLifecycleAdapter,
ChannelLoginWithQrStartResult,
ChannelLoginWithQrWaitResult,
ChannelLogoutContext,
ChannelLogoutResult,
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelAllowlistAdapter,
ChannelPairingAdapter,
ChannelSecurityAdapter,
ChannelSetupAdapter,

View File

@@ -5,6 +5,7 @@ import {
type ResponsePrefixContext,
} from "../auto-reply/reply/response-prefix-template.js";
import type { GetReplyOptions } from "../auto-reply/types.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import type { OpenClawConfig } from "../config/config.js";
type ModelSelectionContext = Parameters<NonNullable<GetReplyOptions["onModelSelected"]>>[0];
@@ -50,10 +51,16 @@ export function createReplyPrefixContext(params: {
channel: params.channel,
accountId: params.accountId,
}).responsePrefix,
enableSlackInteractiveReplies:
params.channel === "slack"
? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId })
: undefined,
enableSlackInteractiveReplies: params.channel
? (getChannelPlugin(params.channel)?.messaging?.enableInteractiveReplies?.({
cfg,
accountId: params.accountId,
}) ??
(params.channel === "slack"
? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId })
: undefined) ??
undefined)
: undefined,
responsePrefixContextProvider: () => prefixContext,
onModelSelected,
};

View File

@@ -42,8 +42,8 @@ export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext
merged.currentThreadTs = String(opts.threadId);
}
// Populate currentChannelId from the outbound target so that
// resolveTelegramAutoThreadId can match the originating chat.
// Populate currentChannelId from the outbound target so channel threading
// adapters can detect same-conversation auto-threading.
if (!merged.currentChannelId && opts.to) {
const trimmedTo = opts.to.trim();
if (trimmedTo) {

View File

@@ -312,20 +312,7 @@ export async function channelsAddCommand(
return;
}
let previousTelegramToken = "";
let resolveTelegramAccount:
| ((
params: Parameters<
typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount
>[0],
) => ReturnType<
typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount
>)
| undefined;
if (channel === "telegram") {
({ resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js"));
previousTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
}
const prevConfig = nextConfig;
if (accountId !== DEFAULT_ACCOUNT_ID) {
nextConfig = moveSingleAccountChannelSectionToDefaultAccount({
@@ -334,6 +321,12 @@ export async function channelsAddCommand(
});
}
let previousTelegramToken = "";
if (channel === "telegram") {
const { resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js");
previousTelegramToken = resolveTelegramAccount({ cfg: prevConfig, accountId }).token.trim();
}
nextConfig = applyChannelAccountConfig({
cfg: nextConfig,
channel,
@@ -341,13 +334,19 @@ export async function channelsAddCommand(
input,
plugin,
});
if (channel === "telegram" && resolveTelegramAccount) {
const { deleteTelegramUpdateOffset } =
await import("../../../extensions/telegram/src/update-offset-store.js");
await plugin.lifecycle?.onAccountConfigChanged?.({
prevCfg: prevConfig,
nextCfg: nextConfig,
accountId,
runtime,
});
if (channel === "telegram") {
const [{ resolveTelegramAccount }, { deleteTelegramUpdateOffset }] = await Promise.all([
import("../../../extensions/telegram/src/accounts.js"),
import("../../../extensions/telegram/src/update-offset-store.js"),
]);
const nextTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
if (previousTelegramToken !== nextTelegramToken) {
// Clear stale polling offsets after Telegram token rotation.
await deleteTelegramUpdateOffset({ accountId });
}
}

View File

@@ -1,4 +1,3 @@
import { deleteTelegramUpdateOffset } from "../../../extensions/telegram/src/update-offset-store.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import {
getChannelPlugin,
@@ -103,6 +102,7 @@ export async function channelsRemoveCommand(
const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID;
let next = { ...cfg };
const prevCfg = cfg;
if (deleteConfig) {
if (!plugin.config.deleteAccount) {
runtime.error(`Channel ${channel} does not support delete.`);
@@ -113,9 +113,14 @@ export async function channelsRemoveCommand(
cfg: next,
accountId: resolvedAccountId,
});
// Clean up Telegram polling offset to prevent stale offset on bot token change (#18233)
await plugin.lifecycle?.onAccountRemoved?.({
prevCfg,
accountId: resolvedAccountId,
runtime,
});
if (channel === "telegram") {
const { deleteTelegramUpdateOffset } =
await import("../../../extensions/telegram/src/update-offset-store.js");
await deleteTelegramUpdateOffset({ accountId: resolvedAccountId });
}
} else {
@@ -129,6 +134,12 @@ export async function channelsRemoveCommand(
accountId: resolvedAccountId,
enabled: false,
});
await plugin.lifecycle?.onAccountConfigChanged?.({
prevCfg,
nextCfg: next,
accountId: resolvedAccountId,
runtime,
});
}
await writeConfigFile(next);

View File

@@ -1,6 +1,5 @@
import { buildTelegramExecApprovalButtons } from "../../extensions/telegram/src/approval-buttons.js";
import { sendTypingTelegram } from "../../extensions/telegram/src/send.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import type {
@@ -8,7 +7,7 @@ import type {
ExecApprovalForwardTarget,
} from "../config/types.approvals.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { compileConfigRegex } from "../security/config-regex.js";
import { testRegexWithBoundedInput } from "../security/safe-regex.js";
import {
@@ -17,7 +16,6 @@ import {
type DeliverableMessageChannel,
} from "../utils/message-channel.js";
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js";
import { resolveExecApprovalSessionTarget } from "./exec-approval-session-target.js";
import type {
ExecApprovalDecision,
@@ -111,91 +109,22 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string {
return [channel, target.to, accountId, threadId].join(":");
}
function resolveChannelAccountConfig<T>(
accounts: Record<string, T> | undefined,
accountId?: string,
): T | undefined {
if (!accounts || !accountId?.trim()) {
return undefined;
}
const normalized = normalizeAccountId(accountId);
const direct = accounts[normalized];
if (direct) {
return direct;
}
const fallbackKey = Object.keys(accounts).find(
(key) => key.toLowerCase() === normalized.toLowerCase(),
);
return fallbackKey ? accounts[fallbackKey] : undefined;
}
// Discord has component-based exec approvals; skip text fallback only when the
// Discord-specific handler is enabled for the same target account.
function shouldSkipDiscordForwarding(
target: ExecApprovalForwardTarget,
cfg: OpenClawConfig,
): boolean {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (channel !== "discord") {
return false;
}
const discord = cfg.channels?.discord as
| {
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
accounts?: Record<
string,
{ execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
>;
}
| undefined;
if (!discord) {
return false;
}
const account = resolveChannelAccountConfig(discord.accounts, target.accountId);
const execApprovals = account?.execApprovals ?? discord.execApprovals;
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
}
function shouldSkipTelegramForwarding(params: {
function shouldSkipForwardingFallback(params: {
target: ExecApprovalForwardTarget;
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): boolean {
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
if (channel !== "telegram") {
if (!channel) {
return false;
}
const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? "");
if (requestChannel !== "telegram") {
return false;
}
const telegram = params.cfg.channels?.telegram;
if (!telegram) {
return false;
}
const telegramConfig = telegram as
| {
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
accounts?: Record<
string,
{ execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
>;
}
| undefined;
if (!telegramConfig) {
return false;
}
const accountId =
params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim();
const account = accountId
? (resolveChannelAccountConfig<{
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
}>(telegramConfig.accounts, accountId) as
| { execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
| undefined)
: undefined;
const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals;
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
return (
getChannelPlugin(channel)?.execApprovals?.shouldSuppressForwardingFallback?.({
cfg: params.cfg,
target: params.target,
request: params.request,
}) ?? false
);
}
function formatApprovalCommand(command: string): { inline: boolean; text: string } {
@@ -309,6 +238,7 @@ async function deliverToTargets(params: {
targets: ForwardTarget[];
buildPayload: (target: ForwardTarget) => ReplyPayload;
deliver: typeof deliverOutboundPayloads;
beforeDeliver?: (target: ForwardTarget, payload: ReplyPayload) => Promise<void> | void;
shouldSend?: () => boolean;
}) {
const deliveries = params.targets.map(async (target) => {
@@ -321,25 +251,7 @@ async function deliverToTargets(params: {
}
try {
const payload = params.buildPayload(target);
if (
channel === "telegram" &&
payload.channelData &&
typeof payload.channelData === "object" &&
!Array.isArray(payload.channelData) &&
payload.channelData.execApproval
) {
const threadId =
typeof target.threadId === "number"
? target.threadId
: typeof target.threadId === "string"
? Number.parseInt(target.threadId, 10)
: undefined;
await sendTypingTelegram(target.to, {
cfg: params.cfg,
accountId: target.accountId,
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
}).catch(() => {});
}
await params.beforeDeliver?.(target, payload);
await params.deliver({
cfg: params.cfg,
channel,
@@ -356,41 +268,45 @@ async function deliverToTargets(params: {
}
function buildRequestPayloadForTarget(
_cfg: OpenClawConfig,
cfg: OpenClawConfig,
request: ExecApprovalRequest,
nowMsValue: number,
target: ForwardTarget,
): ReplyPayload {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (channel === "telegram") {
const payload = buildExecApprovalPendingReplyPayload({
approvalId: request.id,
approvalSlug: request.id.slice(0, 8),
approvalCommandId: request.id,
command: resolveExecApprovalCommandDisplay(request.request).commandText,
cwd: request.request.cwd ?? undefined,
host: request.request.host === "node" ? "node" : "gateway",
nodeId: request.request.nodeId ?? undefined,
expiresAtMs: request.expiresAtMs,
nowMs: nowMsValue,
});
const buttons = buildTelegramExecApprovalButtons(request.id);
if (!buttons) {
return payload;
}
return {
...payload,
channelData: {
...payload.channelData,
telegram: {
buttons,
},
},
};
const pluginPayload = channel
? getChannelPlugin(channel)?.execApprovals?.buildPendingPayload?.({
cfg,
request,
target,
nowMs: nowMsValue,
})
: null;
if (pluginPayload) {
return pluginPayload;
}
return { text: buildRequestMessage(request, nowMsValue) };
}
function buildResolvedPayloadForTarget(
cfg: OpenClawConfig,
resolved: ExecApprovalResolved,
target: ForwardTarget,
): ReplyPayload {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
const pluginPayload = channel
? getChannelPlugin(channel)?.execApprovals?.buildResolvedPayload?.({
cfg,
resolved,
target,
})
: null;
if (pluginPayload) {
return pluginPayload;
}
return { text: buildResolvedMessage(resolved) };
}
function resolveForwardTargets(params: {
cfg: OpenClawConfig;
config?: ExecApprovalForwardingConfig;
@@ -454,11 +370,7 @@ export function createExecApprovalForwarder(
resolveSessionTarget,
})
: []),
].filter(
(target) =>
!shouldSkipDiscordForwarding(target, cfg) &&
!shouldSkipTelegramForwarding({ target, cfg, request }),
);
].filter((target) => !shouldSkipForwardingFallback({ target, cfg, request }));
if (filteredTargets.length === 0) {
return false;
@@ -493,6 +405,17 @@ export function createExecApprovalForwarder(
cfg,
targets: filteredTargets,
buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target),
beforeDeliver: async (target, payload) => {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (!channel) {
return;
}
await getChannelPlugin(channel)?.execApprovals?.beforeDeliverPending?.({
cfg,
target,
payload,
});
},
deliver,
shouldSend: () => pending.get(request.id) === pendingEntry,
}).catch((err) => {
@@ -529,17 +452,17 @@ export function createExecApprovalForwarder(
resolveSessionTarget,
})
: []),
].filter(
(target) =>
!shouldSkipDiscordForwarding(target, cfg) &&
!shouldSkipTelegramForwarding({ target, cfg, request }),
);
].filter((target) => !shouldSkipForwardingFallback({ target, cfg, request }));
}
if (!targets || targets.length === 0) {
return;
}
const text = buildResolvedMessage(resolved);
await deliverToTargets({ cfg, targets, buildPayload: () => ({ text }), deliver });
await deliverToTargets({
cfg,
targets,
buildPayload: (target) => buildResolvedPayloadForTarget(cfg, resolved, target),
deliver,
});
};
const stop = () => {

View File

@@ -1,32 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.hoisted(() => vi.fn());
const listEnabledDiscordAccountsMock = vi.hoisted(() => vi.fn());
const isDiscordExecApprovalClientEnabledMock = vi.hoisted(() => vi.fn());
const listEnabledTelegramAccountsMock = vi.hoisted(() => vi.fn());
const isTelegramExecApprovalClientEnabledMock = vi.hoisted(() => vi.fn());
const getChannelPluginMock = vi.hoisted(() => vi.fn());
const listChannelPluginsMock = vi.hoisted(() => vi.fn());
const normalizeMessageChannelMock = vi.hoisted(() => vi.fn());
vi.mock("../config/config.js", () => ({
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
}));
vi.mock("../../extensions/discord/src/accounts.js", () => ({
listEnabledDiscordAccounts: (...args: unknown[]) => listEnabledDiscordAccountsMock(...args),
vi.mock("../channels/plugins/index.js", () => ({
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
listChannelPlugins: (...args: unknown[]) => listChannelPluginsMock(...args),
}));
vi.mock("../../extensions/discord/src/exec-approvals.js", () => ({
isDiscordExecApprovalClientEnabled: (...args: unknown[]) =>
isDiscordExecApprovalClientEnabledMock(...args),
vi.mock("../../extensions/discord/src/channel.js", () => ({
discordPlugin: {},
}));
vi.mock("../../extensions/telegram/src/accounts.js", () => ({
listEnabledTelegramAccounts: (...args: unknown[]) => listEnabledTelegramAccountsMock(...args),
vi.mock("../../extensions/telegram/src/channel.js", () => ({
telegramPlugin: {},
}));
vi.mock("../../extensions/telegram/src/exec-approvals.js", () => ({
isTelegramExecApprovalClientEnabled: (...args: unknown[]) =>
isTelegramExecApprovalClientEnabledMock(...args),
vi.mock("../../extensions/slack/src/channel.js", () => ({
slackPlugin: {},
}));
vi.mock("../../extensions/whatsapp/src/channel.js", () => ({
whatsappPlugin: {},
}));
vi.mock("../../extensions/signal/src/channel.js", () => ({
signalPlugin: {},
}));
vi.mock("../../extensions/imessage/src/channel.js", () => ({
imessagePlugin: {},
}));
vi.mock("../utils/message-channel.js", () => ({
@@ -42,10 +51,8 @@ import {
describe("resolveExecApprovalInitiatingSurfaceState", () => {
beforeEach(() => {
loadConfigMock.mockReset();
listEnabledDiscordAccountsMock.mockReset();
isDiscordExecApprovalClientEnabledMock.mockReset();
listEnabledTelegramAccountsMock.mockReset();
isTelegramExecApprovalClientEnabledMock.mockReset();
getChannelPluginMock.mockReset();
listChannelPluginsMock.mockReset();
normalizeMessageChannelMock.mockReset();
normalizeMessageChannelMock.mockImplementation((value?: string | null) =>
typeof value === "string" ? value.trim().toLowerCase() : undefined,
@@ -71,8 +78,21 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
});
it("uses the provided cfg for telegram and discord client enablement", () => {
isTelegramExecApprovalClientEnabledMock.mockReturnValueOnce(true);
isDiscordExecApprovalClientEnabledMock.mockReturnValueOnce(false);
getChannelPluginMock.mockImplementation((channel: string) =>
channel === "telegram"
? {
execApprovals: {
getInitiatingSurfaceState: () => ({ kind: "enabled" }),
},
}
: channel === "discord"
? {
execApprovals: {
getInitiatingSurfaceState: () => ({ kind: "disabled" }),
},
}
: undefined,
);
const cfg = { channels: {} };
expect(
@@ -103,7 +123,15 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
it("loads config lazily when cfg is omitted and marks unsupported channels", () => {
loadConfigMock.mockReturnValueOnce({ loaded: true });
isTelegramExecApprovalClientEnabledMock.mockReturnValueOnce(false);
getChannelPluginMock.mockImplementation((channel: string) =>
channel === "telegram"
? {
execApprovals: {
getInitiatingSurfaceState: () => ({ kind: "disabled" }),
},
}
: undefined,
);
expect(
resolveExecApprovalInitiatingSurfaceState({
@@ -127,30 +155,19 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
describe("hasConfiguredExecApprovalDmRoute", () => {
beforeEach(() => {
listEnabledDiscordAccountsMock.mockReset();
listEnabledTelegramAccountsMock.mockReset();
listChannelPluginsMock.mockReset();
});
it("returns true when any enabled account routes approvals to DM or both", () => {
listEnabledDiscordAccountsMock.mockReturnValueOnce([
listChannelPluginsMock.mockReturnValueOnce([
{
config: {
execApprovals: {
enabled: true,
approvers: ["a"],
target: "channel",
},
execApprovals: {
hasConfiguredDmRoute: () => false,
},
},
]);
listEnabledTelegramAccountsMock.mockReturnValueOnce([
{
config: {
execApprovals: {
enabled: true,
approvers: ["a"],
target: "both",
},
execApprovals: {
hasConfiguredDmRoute: () => true,
},
},
]);
@@ -158,37 +175,21 @@ describe("hasConfiguredExecApprovalDmRoute", () => {
expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(true);
});
it("returns false when exec approvals are disabled or have no DM route", () => {
listEnabledDiscordAccountsMock.mockReturnValueOnce([
it("returns false when no plugin reports a DM route", () => {
listChannelPluginsMock.mockReturnValueOnce([
{
config: {
execApprovals: {
enabled: false,
approvers: ["a"],
target: "dm",
},
},
},
]);
listEnabledTelegramAccountsMock.mockReturnValueOnce([
{
config: {
execApprovals: {
enabled: true,
approvers: [],
target: "dm",
},
execApprovals: {
hasConfiguredDmRoute: () => false,
},
},
{
config: {
execApprovals: {
enabled: true,
approvers: ["a"],
target: "channel",
},
execApprovals: {
hasConfiguredDmRoute: () => false,
},
},
{
execApprovals: undefined,
},
]);
expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(false);

View File

@@ -1,7 +1,4 @@
import { listEnabledDiscordAccounts } from "../../extensions/discord/src/accounts.js";
import { isDiscordExecApprovalClientEnabled } from "../../extensions/discord/src/exec-approvals.js";
import { listEnabledTelegramAccounts } from "../../extensions/telegram/src/accounts.js";
import { isTelegramExecApprovalClientEnabled } from "../../extensions/telegram/src/exec-approvals.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js";
@@ -37,46 +34,18 @@ export function resolveExecApprovalInitiatingSurfaceState(params: {
}
const cfg = params.cfg ?? loadConfig();
if (channel === "telegram") {
return isTelegramExecApprovalClientEnabled({ cfg, accountId: params.accountId })
? { kind: "enabled", channel, channelLabel }
: { kind: "disabled", channel, channelLabel };
}
if (channel === "discord") {
return isDiscordExecApprovalClientEnabled({ cfg, accountId: params.accountId })
? { kind: "enabled", channel, channelLabel }
: { kind: "disabled", channel, channelLabel };
const state = getChannelPlugin(channel)?.execApprovals?.getInitiatingSurfaceState?.({
cfg,
accountId: params.accountId,
});
if (state) {
return { ...state, channel, channelLabel };
}
return { kind: "unsupported", channel, channelLabel };
}
function hasExecApprovalDmRoute(
accounts: Array<{
config: {
execApprovals?: {
enabled?: boolean;
approvers?: unknown[];
target?: string;
};
};
}>,
): boolean {
for (const account of accounts) {
const execApprovals = account.config.execApprovals;
if (!execApprovals?.enabled || (execApprovals.approvers?.length ?? 0) === 0) {
continue;
}
const target = execApprovals.target ?? "dm";
if (target === "dm" || target === "both") {
return true;
}
}
return false;
}
export function hasConfiguredExecApprovalDmRoute(cfg: OpenClawConfig): boolean {
return (
hasExecApprovalDmRoute(listEnabledDiscordAccounts(cfg)) ||
hasExecApprovalDmRoute(listEnabledTelegramAccounts(cfg))
return listChannelPlugins().some(
(plugin) => plugin.execApprovals?.hasConfiguredDmRoute?.({ cfg }) ?? false,
);
}

View File

@@ -1,5 +1,5 @@
import { Separator, TextDisplay, type TopLevelComponents } from "@buape/carbon";
import { DiscordUiContainer } from "../../../extensions/discord/src/ui.js";
import type { TopLevelComponents } from "@buape/carbon";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -17,40 +17,17 @@ export type ChannelMessageAdapter = {
buildCrossContextComponents?: CrossContextComponentsFactory;
};
type CrossContextContainerParams = {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
};
class CrossContextContainer extends DiscordUiContainer {
constructor({ originLabel, message, cfg, accountId }: CrossContextContainerParams) {
const trimmed = message.trim();
const components = [] as Array<TextDisplay | Separator>;
if (trimmed) {
components.push(new TextDisplay(message));
components.push(new Separator({ divider: true, spacing: "small" }));
}
components.push(new TextDisplay(`*From ${originLabel}*`));
super({ cfg, accountId, components });
}
}
const DEFAULT_ADAPTER: ChannelMessageAdapter = {
supportsComponentsV2: false,
};
const DISCORD_ADAPTER: ChannelMessageAdapter = {
supportsComponentsV2: true,
buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => [
new CrossContextContainer({ originLabel, message, cfg, accountId }),
],
};
export function getChannelMessageAdapter(channel: ChannelId): ChannelMessageAdapter {
if (channel === "discord") {
return DISCORD_ADAPTER;
const adapter = getChannelPlugin(channel)?.messaging?.buildCrossContextComponents;
if (adapter) {
return {
supportsComponentsV2: true,
buildCrossContextComponents: adapter,
};
}
return DEFAULT_ADAPTER;
}

View File

@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
@@ -9,8 +11,6 @@ import {
normalizeSandboxMediaList,
normalizeSandboxMediaParams,
resolveAttachmentMediaPolicy,
resolveSlackAutoThreadId,
resolveTelegramAutoThreadId,
} from "./message-action-params.js";
const cfg = {} as OpenClawConfig;
@@ -30,19 +30,25 @@ function createToolContext(
describe("message action threading helpers", () => {
it("resolves Slack auto-thread ids only for matching active channels", () => {
expect(
resolveSlackAutoThreadId({
slackPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "#c123",
toolContext: createToolContext(),
}),
).toBe("thread-1");
expect(
resolveSlackAutoThreadId({
slackPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "channel:C999",
toolContext: createToolContext(),
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
slackPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "user:U123",
toolContext: createToolContext(),
}),
@@ -51,7 +57,9 @@ describe("message action threading helpers", () => {
it("skips Slack auto-thread ids when reply mode or context blocks them", () => {
expect(
resolveSlackAutoThreadId({
slackPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "C123",
toolContext: createToolContext({
replyToMode: "first",
@@ -60,13 +68,17 @@ describe("message action threading helpers", () => {
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
slackPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "C123",
toolContext: createToolContext({ replyToMode: "off" }),
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
slackPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "C123",
toolContext: createToolContext({ currentThreadTs: undefined }),
}),
@@ -75,7 +87,9 @@ describe("message action threading helpers", () => {
it("resolves Telegram auto-thread ids for matching chats across target formats", () => {
expect(
resolveTelegramAutoThreadId({
telegramPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "telegram:group:-100123:topic:77",
toolContext: createToolContext({
currentChannelId: "tg:group:-100123",
@@ -83,7 +97,9 @@ describe("message action threading helpers", () => {
}),
).toBe("thread-1");
expect(
resolveTelegramAutoThreadId({
telegramPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "-100999:77",
toolContext: createToolContext({
currentChannelId: "-100123",
@@ -91,7 +107,9 @@ describe("message action threading helpers", () => {
}),
).toBeUndefined();
expect(
resolveTelegramAutoThreadId({
telegramPlugin?.threading?.resolveAutoThreadId?.({
cfg,
accountId: undefined,
to: "-100123",
toolContext: createToolContext({ currentChannelId: undefined }),
}),

View File

@@ -1,15 +1,9 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseSlackTarget } from "../../../extensions/slack/src/targets.js";
import { parseTelegramTarget } from "../../../extensions/telegram/src/targets.js";
import { loadWebMedia } from "../../../extensions/whatsapp/src/media.js";
import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js";
import { readStringParam } from "../../agents/tools/common.js";
import type {
ChannelId,
ChannelMessageActionName,
ChannelThreadingToolContext,
} from "../../channels/plugins/types.js";
import type { ChannelId, ChannelMessageActionName } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createRootScopedReadFile } from "../../infra/fs-safe.js";
import { extensionForMime } from "../../media/mime.js";
@@ -17,60 +11,6 @@ import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boo
export const readBooleanParam = readBooleanParamShared;
export function resolveSlackAutoThreadId(params: {
to: string;
toolContext?: ChannelThreadingToolContext;
}): string | undefined {
const context = params.toolContext;
if (!context?.currentThreadTs || !context.currentChannelId) {
return undefined;
}
// Only mirror auto-threading when Slack would reply in the active thread for this channel.
if (context.replyToMode !== "all" && context.replyToMode !== "first") {
return undefined;
}
const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" });
if (!parsedTarget || parsedTarget.kind !== "channel") {
return undefined;
}
if (parsedTarget.id.toLowerCase() !== context.currentChannelId.toLowerCase()) {
return undefined;
}
if (context.replyToMode === "first" && context.hasRepliedRef?.value) {
return undefined;
}
return context.currentThreadTs;
}
/**
* Auto-inject Telegram forum topic thread ID when the message tool targets
* the same chat the session originated from. Mirrors the Slack auto-threading
* pattern so media, buttons, and other tool-sent messages land in the correct
* topic instead of the General Topic.
*
* Unlike Slack, we do not gate on `replyToMode` here: Telegram forum topics
* are persistent sub-channels (not ephemeral reply threads), so auto-injection
* should always apply when the target chat matches.
*/
export function resolveTelegramAutoThreadId(params: {
to: string;
toolContext?: ChannelThreadingToolContext;
}): string | undefined {
const context = params.toolContext;
if (!context?.currentThreadTs || !context.currentChannelId) {
return undefined;
}
// Use parseTelegramTarget to extract canonical chatId from both sides,
// mirroring how Slack uses parseSlackTarget. This handles format variations
// like `telegram:group:123:topic:456` vs `telegram:123`.
const parsedTo = parseTelegramTarget(params.to);
const parsedChannel = parseTelegramTarget(context.currentChannelId);
if (parsedTo.chatId.toLowerCase() !== parsedChannel.chatId.toLowerCase()) {
return undefined;
}
return context.currentThreadTs;
}
function resolveAttachmentMaxBytes(params: {
cfg: OpenClawConfig;
channel: ChannelId;

View File

@@ -6,6 +6,7 @@ import {
readStringParam,
} from "../../agents/tools/common.js";
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
import type {
ChannelId,
@@ -37,8 +38,6 @@ import {
parseInteractiveParam,
readBooleanParam,
resolveAttachmentMediaPolicy,
resolveSlackAutoThreadId,
resolveTelegramAutoThreadId,
} from "./message-action-params.js";
import type { MessagePollResult, MessageSendResult } from "./message.js";
import {
@@ -65,22 +64,23 @@ export type MessageActionRunnerGateway = {
function resolveAndApplyOutboundThreadId(
params: Record<string, unknown>,
ctx: {
cfg: OpenClawConfig;
channel: ChannelId;
to: string;
accountId?: string | null;
toolContext?: ChannelThreadingToolContext;
allowSlackAutoThread: boolean;
},
): string | undefined {
const threadId = readStringParam(params, "threadId");
const slackAutoThreadId =
ctx.allowSlackAutoThread && ctx.channel === "slack" && !threadId
? resolveSlackAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
: undefined;
const telegramAutoThreadId =
ctx.channel === "telegram" && !threadId
? resolveTelegramAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext })
: undefined;
const resolved = threadId ?? slackAutoThreadId ?? telegramAutoThreadId;
const resolved =
threadId ??
getChannelPlugin(ctx.channel)?.threading?.resolveAutoThreadId?.({
cfg: ctx.cfg,
accountId: ctx.accountId,
to: ctx.to,
toolContext: ctx.toolContext,
replyToId: readStringParam(params, "replyTo"),
});
// Write auto-resolved threadId back into params so downstream dispatch
// (plugin `readStringParam(params, "threadId")`) picks it up.
if (resolved && !params.threadId) {
@@ -501,10 +501,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const replyToId = readStringParam(params, "replyTo");
const resolvedThreadId = resolveAndApplyOutboundThreadId(params, {
cfg,
channel,
to,
accountId,
toolContext: input.toolContext,
allowSlackAutoThread: channel === "slack" && !replyToId,
});
const outboundRoute =
agentId && !dryRun
@@ -619,10 +620,11 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
}
const resolvedThreadId = resolveAndApplyOutboundThreadId(params, {
cfg,
channel,
to,
accountId,
toolContext: input.toolContext,
allowSlackAutoThread: channel === "slack",
});
const base = typeof params.message === "string" ? params.message : "";

View File

@@ -970,6 +970,20 @@ export async function resolveOutboundSessionRoute(
return null;
}
const nextParams = { ...params, target };
const pluginRoute = await getChannelPlugin(
params.channel,
)?.messaging?.resolveOutboundSessionRoute?.({
cfg: nextParams.cfg,
agentId: nextParams.agentId,
accountId: nextParams.accountId,
target,
resolvedTarget: nextParams.resolvedTarget,
replyToId: nextParams.replyToId,
threadId: nextParams.threadId,
});
if (pluginRoute) {
return pluginRoute;
}
const resolver = OUTBOUND_SESSION_RESOLVERS[params.channel];
if (!resolver) {
return resolveFallbackSession(nextParams);