feat(approvals): auto-enable native chat approvals

This commit is contained in:
Peter Steinberger
2026-04-02 17:28:32 +01:00
parent 721cab2b8d
commit 17f6626ffe
26 changed files with 197 additions and 113 deletions

View File

@@ -31477,7 +31477,7 @@
"network"
],
"label": "Slack Exec Approvals",
"help": "Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.",
"help": "Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.",
"hasChildren": true
},
{
@@ -31545,7 +31545,7 @@
"network"
],
"label": "Slack Exec Approvals Enabled",
"help": "Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.",
"help": "Controls Slack native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.",
"hasChildren": false
},
{
@@ -35444,7 +35444,7 @@
"network"
],
"label": "Telegram Exec Approvals",
"help": "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.",
"help": "Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.",
"hasChildren": true
},
{
@@ -35512,7 +35512,7 @@
"network"
],
"label": "Telegram Exec Approvals Enabled",
"help": "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.",
"help": "Controls Telegram native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.",
"hasChildren": false
},
{

View File

@@ -2830,12 +2830,12 @@
{"recordType":"path","path":"channels.slack.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"channels.slack.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.","hasChildren":true}
{"recordType":"path","path":"channels.slack.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.","hasChildren":true}
{"recordType":"path","path":"channels.slack.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack.","hasChildren":true}
{"recordType":"path","path":"channels.slack.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible.","hasChildren":true}
{"recordType":"path","path":"channels.slack.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals Enabled","help":"Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.","hasChildren":false}
{"recordType":"path","path":"channels.slack.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.","hasChildren":false}
{"recordType":"path","path":"channels.slack.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","storage"],"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions.","hasChildren":true}
{"recordType":"path","path":"channels.slack.execApprovals.sessionFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.slack.execApprovals.target","kind":"channel","type":"string","required":false,"enumValues":["dm","channel","both"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Slack chat/thread, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels.","hasChildren":false}
@@ -3182,12 +3182,12 @@
{"recordType":"path","path":"channels.telegram.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.errorCooldownMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.errorPolicy","kind":"channel","type":"string","required":false,"enumValues":["always","once","silent"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.","hasChildren":true}
{"recordType":"path","path":"channels.telegram.execApprovals","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.","hasChildren":true}
{"recordType":"path","path":"channels.telegram.execApprovals.agentFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\"main\", \"ops-agent\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram.","hasChildren":true}
{"recordType":"path","path":"channels.telegram.execApprovals.agentFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.execApprovals.approvers","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from channels.telegram.allowFrom and direct-message defaultTo when possible.","hasChildren":true}
{"recordType":"path","path":"channels.telegram.execApprovals.approvers.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals Enabled","help":"Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.","hasChildren":false}
{"recordType":"path","path":"channels.telegram.execApprovals.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \"auto\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.","hasChildren":false}
{"recordType":"path","path":"channels.telegram.execApprovals.sessionFilter","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","storage"],"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions.","hasChildren":true}
{"recordType":"path","path":"channels.telegram.execApprovals.sessionFilter.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"channels.telegram.execApprovals.target","kind":"channel","type":"string","required":false,"enumValues":["dm","channel","both"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \"dm\" sends to approver DMs (default), \"channel\" sends to the originating Telegram chat/topic, and \"both\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics.","hasChildren":false}

View File

@@ -4,6 +4,7 @@ import {
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
doesApprovalRequestMatchChannelAccount,
isChannelExecApprovalClientEnabledFromConfig,
matchesApprovalRequestFilters,
} from "openclaw/plugin-sdk/approval-runtime";
import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
@@ -75,16 +76,18 @@ export function shouldHandleDiscordApprovalRequest(params: {
) {
return false;
}
if (!config) {
return true;
}
if (!config.enabled || approvers.length === 0) {
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: approvers.length,
})
) {
return false;
}
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config.agentFilter,
sessionFilter: config.sessionFilter,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
}

View File

@@ -22,34 +22,35 @@ function buildConfig(
}
describe("discord exec approvals", () => {
it("requires enablement and explicit or owner approvers", () => {
it("auto-enables when owner approvers resolve and disables only when forced off", () => {
expect(isDiscordExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(isDiscordExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false);
expect(
isDiscordExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }, { allowFrom: ["123"] }),
cfg: buildConfig({ enabled: true }),
}),
).toBe(false);
expect(
isDiscordExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
cfg: buildConfig({ approvers: ["123"] }),
}),
).toBe(true);
expect(
isDiscordExecApprovalClientEnabled({
cfg: {
...buildConfig({ enabled: true }),
...buildConfig(),
commands: { ownerAllowFrom: ["discord:789"] },
} as OpenClawConfig,
}),
).toBe(true);
expect(
isDiscordExecApprovalClientEnabled({
cfg: buildConfig({ enabled: false, approvers: ["123"] }),
}),
).toBe(false);
});
it("prefers explicit approvers when configured", () => {
const cfg = buildConfig(
{ enabled: true, approvers: ["456"] },
{ allowFrom: ["123"], defaultTo: "user:789" },
);
const cfg = buildConfig({ approvers: ["456"] }, { allowFrom: ["123"], defaultTo: "user:789" });
expect(getDiscordExecApprovalApprovers({ cfg })).toEqual(["456"]);
expect(isDiscordExecApprovalApprover({ cfg, senderId: "456" })).toBe(true);
@@ -72,7 +73,7 @@ describe("discord exec approvals", () => {
it("falls back to commands.ownerAllowFrom for exec approvers", () => {
const cfg = {
...buildConfig({ enabled: true }),
...buildConfig(),
commands: { ownerAllowFrom: ["discord:123", "user:456", "789"] },
} as OpenClawConfig;

View File

@@ -1,4 +1,5 @@
import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime";
import { isChannelExecApprovalClientEnabledFromConfig } from "openclaw/plugin-sdk/approval-runtime";
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime";
@@ -53,14 +54,14 @@ export function isDiscordExecApprovalClientEnabled(params: {
configOverride?: DiscordExecApprovalConfig | null;
}): boolean {
const config = params.configOverride ?? resolveDiscordAccount(params).config.execApprovals;
return Boolean(
config?.enabled &&
getDiscordExecApprovalApprovers({
return isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getDiscordExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
configOverride: params.configOverride,
}).length > 0,
);
}).length,
});
}
export function isDiscordExecApprovalApprover(params: {

View File

@@ -34,7 +34,10 @@ import {
createDiscordApprovalCapability,
shouldHandleDiscordApprovalRequest,
} from "../approval-native.js";
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";
import {
getDiscordExecApprovalApprovers,
isDiscordExecApprovalClientEnabled,
} from "../exec-approvals.js";
import { createDiscordClient, stripUndefinedFields } from "../send.shared.js";
import { DiscordUiContainer } from "../ui.js";
@@ -484,7 +487,12 @@ export class DiscordExecApprovalHandler {
gatewayUrl: this.opts.gatewayUrl,
eventKinds: ["exec", "plugin"],
nativeAdapter: createDiscordApprovalCapability(this.opts.config).native,
isConfigured: () => Boolean(this.opts.config.enabled && this.getApprovers().length > 0),
isConfigured: () =>
isDiscordExecApprovalClientEnabled({
cfg: this.opts.cfg,
accountId: this.opts.accountId,
configOverride: this.opts.config,
}),
shouldHandle: (request) => this.shouldHandle(request),
buildPendingContent: ({ request }) => {
const actionRow = createApprovalActionRow(request);

View File

@@ -20,12 +20,6 @@ import {
} from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
GROUP_POLICY_BLOCKED_LABEL,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
@@ -38,9 +32,16 @@ import {
} from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
GROUP_POLICY_BLOCKED_LABEL,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { summarizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordAccount } from "../accounts.js";
import { isDiscordExecApprovalClientEnabled } from "../exec-approvals.js";
import { fetchDiscordApplicationId } from "../probe.js";
import { normalizeDiscordToken } from "../token.js";
import { createDiscordVoiceCommand } from "../voice/command.js";
@@ -824,7 +825,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
// Initialize exec approvals handler if enabled
const execApprovalsConfig = discordCfg.execApprovals ?? {};
const execApprovalsHandler = execApprovalsConfig.enabled
const execApprovalsHandler = isDiscordExecApprovalClientEnabled({
cfg,
accountId: account.accountId,
configOverride: execApprovalsConfig,
})
? new DiscordExecApprovalHandler({
token,
accountId: account.accountId,

View File

@@ -51,11 +51,11 @@ export const slackChannelConfigUiHints = {
},
execApprovals: {
label: "Slack Exec Approvals",
help: "Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.",
help: "Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.",
},
"execApprovals.enabled": {
label: "Slack Exec Approvals Enabled",
help: "Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.",
help: 'Controls Slack native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.',
},
"execApprovals.approvers": {
label: "Slack Exec Approval Approvers",

View File

@@ -29,32 +29,36 @@ function buildConfig(
}
describe("slack exec approvals", () => {
it("requires enablement and explicit or owner approvers", () => {
it("auto-enables when owner approvers resolve and disables only when forced off", () => {
expect(isSlackExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(isSlackExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }, { allowFrom: ["U123"] }),
cfg: buildConfig({ enabled: true }),
}),
).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["U123"] }),
cfg: buildConfig({ approvers: ["U123"] }),
}),
).toBe(true);
expect(
isSlackExecApprovalClientEnabled({
cfg: {
...buildConfig({ enabled: true }),
...buildConfig(),
commands: { ownerAllowFrom: ["slack:U123OWNER"] },
} as OpenClawConfig,
}),
).toBe(true);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: false, approvers: ["U123"] }),
}),
).toBe(false);
});
it("prefers explicit approvers when configured", () => {
const cfg = buildConfig(
{ enabled: true, approvers: ["U456"] },
{ approvers: ["U456"] },
{ allowFrom: ["U123"], defaultTo: "user:U789" },
);

View File

@@ -16,6 +16,7 @@ import { logError } from "openclaw/plugin-sdk/text-runtime";
import { slackNativeApprovalAdapter } from "../approval-native.js";
import {
getSlackExecApprovalApprovers,
isSlackExecApprovalClientEnabled,
normalizeSlackApproverId,
shouldHandleSlackExecApprovalRequest,
} from "../exec-approvals.js";
@@ -239,13 +240,10 @@ export class SlackExecApprovalHandler {
gatewayUrl: opts.gatewayUrl,
nativeAdapter: slackNativeApprovalAdapter.native,
isConfigured: () =>
Boolean(
opts.config.enabled &&
getSlackExecApprovalApprovers({
cfg: opts.cfg,
accountId: opts.accountId,
}).length > 0,
),
isSlackExecApprovalClientEnabled({
cfg: opts.cfg,
accountId: opts.accountId,
}),
shouldHandle: (request) => this.shouldHandle(request),
buildPendingContent: ({ request }) => ({
text: buildSlackPendingApprovalText(request),

View File

@@ -31,6 +31,7 @@ import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/webhook-request-guards";
import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackWebClientOptions } from "../client.js";
import { isSlackExecApprovalClientEnabled } from "../exec-approvals.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { SLACK_TEXT_LIMIT } from "../limits.js";
import { resolveSlackChannelAllowlist, type SlackChannelResolution } from "../resolve-channels.js";
@@ -406,11 +407,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
: undefined;
const handleSlackMessage = createSlackMessageHandler({ ctx, account, trackEvent });
const execApprovalsHandler = slackCfg.execApprovals?.enabled
const execApprovalsHandler = isSlackExecApprovalClientEnabled({
cfg,
accountId: account.accountId,
})
? new SlackExecApprovalHandler({
app,
accountId: account.accountId,
config: slackCfg.execApprovals,
config: slackCfg.execApprovals ?? {},
cfg,
})
: null;

View File

@@ -83,11 +83,11 @@ export const telegramChannelConfigUiHints = {
},
execApprovals: {
label: "Telegram Exec Approvals",
help: "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.",
help: "Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.",
},
"execApprovals.enabled": {
label: "Telegram Exec Approvals Enabled",
help: "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.",
help: 'Controls Telegram native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.',
},
"execApprovals.approvers": {
label: "Telegram Exec Approval Approvers",

View File

@@ -21,8 +21,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { telegramNativeApprovalAdapter } from "./approval-native.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import {
getTelegramExecApprovalApprovers,
resolveTelegramExecApprovalConfig,
isTelegramExecApprovalHandlerConfigured,
shouldHandleTelegramExecApprovalRequest,
} from "./exec-approvals.js";
import { editMessageReplyMarkupTelegram, sendMessageTelegram, sendTypingTelegram } from "./send.js";
@@ -56,19 +55,7 @@ export type TelegramExecApprovalHandlerDeps = {
};
function isHandlerConfigured(params: { cfg: OpenClawConfig; accountId: string }): boolean {
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (!config?.enabled) {
return false;
}
return (
getTelegramExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
}).length > 0
);
return isTelegramExecApprovalHandlerConfigured(params);
}
export class TelegramExecApprovalHandler {

View File

@@ -28,7 +28,7 @@ function buildConfig(
}
describe("telegram exec approvals", () => {
it("requires enablement and an explicit or inferred approver", () => {
it("auto-enables when approvers resolve and disables only when forced off", () => {
expect(isTelegramExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
@@ -37,18 +37,23 @@ describe("telegram exec approvals", () => {
).toBe(false);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }, { allowFrom: ["123"] }),
cfg: buildConfig(undefined, { allowFrom: ["123"] }),
}),
).toBe(true);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true, approvers: ["123"] }),
cfg: buildConfig({ approvers: ["123"] }),
}),
).toBe(true);
expect(
isTelegramExecApprovalClientEnabled({
cfg: buildConfig({ enabled: false, approvers: ["123"] }),
}),
).toBe(false);
});
it("matches approvers by normalized sender id", () => {
const cfg = buildConfig({ enabled: true, approvers: [123, "456"] });
const cfg = buildConfig({ approvers: [123, "456"] });
expect(isTelegramExecApprovalApprover({ cfg, senderId: "123" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "456" })).toBe(true);
expect(isTelegramExecApprovalApprover({ cfg, senderId: "789" })).toBe(false);

View File

@@ -1,5 +1,6 @@
import {
createChannelExecApprovalProfile,
isChannelExecApprovalClientEnabledFromConfig,
isChannelExecApprovalTargetRecipient,
resolveApprovalRequestAccountId,
resolveApprovalApprovers,
@@ -138,3 +139,13 @@ export function shouldSuppressLocalTelegramExecApprovalPrompt(params: {
}): boolean {
return telegramExecApprovalProfile.shouldSuppressLocalPrompt(params);
}
export function isTelegramExecApprovalHandlerConfigured(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return isChannelExecApprovalClientEnabledFromConfig({
enabled: resolveTelegramExecApprovalConfig(params)?.enabled,
approverCount: getTelegramExecApprovalApprovers(params).length,
});
}

View File

@@ -8,6 +8,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { isTelegramExecApprovalHandlerConfigured } from "./exec-approvals.js";
import { resolveTelegramTransport } from "./fetch.js";
import {
isRecoverableTelegramNetworkError,
@@ -145,13 +146,15 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
if (opts.useWebhook) {
const { TelegramExecApprovalHandler, startTelegramWebhook } =
await loadTelegramMonitorWebhookRuntime();
execApprovalsHandler = new TelegramExecApprovalHandler({
token,
accountId: account.accountId,
cfg,
runtime: opts.runtime,
});
await execApprovalsHandler.start();
if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) {
execApprovalsHandler = new TelegramExecApprovalHandler({
token,
accountId: account.accountId,
cfg,
runtime: opts.runtime,
});
await execApprovalsHandler.start();
}
await startTelegramWebhook({
token,
accountId: account.accountId,
@@ -177,13 +180,15 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
writeTelegramUpdateOffset,
} = await loadTelegramMonitorPollingRuntime();
execApprovalsHandler = new TelegramExecApprovalHandler({
token,
accountId: account.accountId,
cfg,
runtime: opts.runtime,
});
await execApprovalsHandler.start();
if (isTelegramExecApprovalHandlerConfigured({ cfg, accountId: account.accountId })) {
execApprovalsHandler = new TelegramExecApprovalHandler({
token,
accountId: account.accountId,
cfg,
runtime: opts.runtime,
});
await execApprovalsHandler.start();
}
const persistedOffsetRaw = await readTelegramUpdateOffset({
accountId: account.accountId,

View File

@@ -11566,11 +11566,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
execApprovals: {
label: "Slack Exec Approvals",
help: "Slack-native exec approval routing and approver authorization. Enable this only when Slack should act as an explicit exec-approval client for the selected workspace account.",
help: "Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.",
},
"execApprovals.enabled": {
label: "Slack Exec Approvals Enabled",
help: "Enable Slack exec approvals for this account. When false or unset, Slack messages/buttons cannot approve exec requests.",
help: 'Controls Slack native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.',
},
"execApprovals.approvers": {
label: "Slack Exec Approval Approvers",
@@ -13721,11 +13721,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
},
execApprovals: {
label: "Telegram Exec Approvals",
help: "Telegram-native exec approval routing and approver authorization. Enable this only when Telegram should act as an explicit exec-approval client for the selected bot account.",
help: "Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account.",
},
"execApprovals.enabled": {
label: "Telegram Exec Approvals Enabled",
help: "Enable Telegram exec approvals for this account. When false or unset, Telegram messages/buttons cannot approve exec requests.",
help: 'Controls Telegram native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.',
},
"execApprovals.approvers": {
label: "Telegram Exec Approval Approvers",

View File

@@ -1,3 +1,5 @@
export type NativeExecApprovalEnableMode = boolean | "auto";
export type ExecApprovalForwardingMode = "session" | "targets" | "both";
export type ExecApprovalForwardTarget = {

View File

@@ -140,8 +140,8 @@ export type DiscordVoiceConfig = {
};
export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
/** Enable mode for Discord exec approvals on this account. Default: auto when approvers can be resolved; false disables. */
enabled?: import("./types.approvals.js").NativeExecApprovalEnableMode;
/** Discord user IDs to receive approval prompts. Optional: falls back to commands.ownerAllowFrom when possible. */
approvers?: string[];
/** Only forward approvals for these agent IDs. Omit = all agents. */

View File

@@ -52,8 +52,8 @@ export type SlackStreamingMode = "off" | "partial" | "block" | "progress";
export type SlackLegacyStreamMode = "replace" | "status_final" | "append";
export type SlackExecApprovalTarget = "dm" | "channel" | "both";
export type SlackExecApprovalConfig = {
/** Enable Slack exec approvals for this account. Default: false. */
enabled?: boolean;
/** Enable mode for Slack exec approvals on this account. Default: auto when approvers can be resolved; false disables. */
enabled?: import("./types.approvals.js").NativeExecApprovalEnableMode;
/** Slack user IDs allowed to approve exec requests. Optional: falls back to commands.ownerAllowFrom when possible. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */

View File

@@ -59,8 +59,8 @@ export type TelegramStreamingMode = "off" | "partial" | "block" | "progress";
export type TelegramExecApprovalTarget = "dm" | "channel" | "both";
export type TelegramExecApprovalConfig = {
/** Enable Telegram exec approvals for this account. Default: false. */
enabled?: boolean;
/** Enable mode for Telegram exec approvals on this account. Default: auto when approvers can be resolved; false disables. */
enabled?: import("./types.approvals.js").NativeExecApprovalEnableMode;
/** Telegram user IDs allowed to approve exec requests. Optional: falls back to numeric owner IDs inferred from allowFrom/defaultTo when possible. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */

View File

@@ -54,12 +54,12 @@ describe("exec approval reply helpers", () => {
);
});
it("mentions Slack in the fallback approval-client guidance", () => {
it("mentions native chat approval clients in the fallback guidance", () => {
expect(
buildExecApprovalUnavailableReplyPayload({
reason: "no-approval-route",
}).text,
).toContain("Discord, Slack, or Telegram exec approvals");
).toContain("native chat approval client such as Discord, Slack, or Telegram");
});
it.each(invalidReplyMetadataCases)(

View File

@@ -324,21 +324,21 @@ export function buildExecApprovalUnavailableReplyPayload(
`Exec approval is required, but chat exec approvals are not enabled on ${params.channelLabel ?? "this platform"}.`,
);
lines.push(
"Approve it from the Web UI or terminal UI, or enable Discord, Slack, or Telegram exec approvals. If those accounts already know your owner ID via allowFrom, OpenClaw can infer approvers automatically.",
"Approve it from the Web UI or terminal UI, or enable a native chat approval client such as Discord, Slack, or Telegram. If those accounts already know your owner ID via allowFrom or owner config, OpenClaw can often infer approvers automatically.",
);
} else if (params.reason === "initiating-platform-unsupported") {
lines.push(
`Exec approval is required, but ${params.channelLabel ?? "this platform"} does not support chat exec approvals.`,
);
lines.push(
"Approve it from the Web UI or terminal UI, or enable Discord, Slack, or Telegram exec approvals. If those accounts already know your owner ID via allowFrom, OpenClaw can infer approvers automatically.",
"Approve it from the Web UI or terminal UI, or enable a native chat approval client such as Discord, Slack, or Telegram. If those accounts already know your owner ID via allowFrom or owner config, OpenClaw can often infer approvers automatically.",
);
} else {
lines.push(
"Exec approval is required, but no interactive approval client is currently available.",
);
lines.push(
"Open the Web UI or terminal UI, or enable Discord, Slack, or Telegram exec approvals, then retry the command. If those accounts already know your owner ID via allowFrom, you can usually leave execApprovals.approvers unset.",
"Open the Web UI or terminal UI, or enable a native chat approval client such as Discord, Slack, or Telegram, then retry the command. If those accounts already know your owner ID via allowFrom or owner config, you can usually leave execApprovals.approvers unset.",
);
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
createChannelExecApprovalProfile,
isChannelExecApprovalClientEnabledFromConfig,
isChannelExecApprovalTargetRecipient,
} from "./approval-client-helpers.js";
import type { OpenClawConfig } from "./config-runtime.js";
@@ -76,6 +77,37 @@ describe("createChannelExecApprovalProfile", () => {
matchesRequestAccount: ({ accountId }) => accountId !== "other",
});
it("treats unset enabled as auto and false as disabled", () => {
expect(
isChannelExecApprovalClientEnabledFromConfig({
approverCount: 1,
}),
).toBe(true);
expect(
isChannelExecApprovalClientEnabledFromConfig({
enabled: "auto",
approverCount: 1,
}),
).toBe(true);
expect(
isChannelExecApprovalClientEnabledFromConfig({
enabled: true,
approverCount: 1,
}),
).toBe(true);
expect(
isChannelExecApprovalClientEnabledFromConfig({
enabled: false,
approverCount: 1,
}),
).toBe(false);
expect(
isChannelExecApprovalClientEnabledFromConfig({
approverCount: 0,
}),
).toBe(false);
});
it("reuses shared client, auth, and request-filter logic", () => {
expect(profile.isClientEnabled({ cfg: {} })).toBe(true);
expect(profile.isApprover({ cfg: {}, senderId: "owner" })).toBe(true);

View File

@@ -9,9 +9,10 @@ import { normalizeAccountId } from "./routing.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type ApprovalTarget = "dm" | "channel" | "both";
type ChannelExecApprovalEnableMode = boolean | "auto";
type ChannelApprovalConfig = {
enabled?: boolean;
enabled?: ChannelExecApprovalEnableMode;
target?: ApprovalTarget;
agentFilter?: string[];
sessionFilter?: string[];
@@ -35,6 +36,16 @@ function isApprovalTargetsMode(cfg: OpenClawConfig): boolean {
return execApprovals.mode === "targets" || execApprovals.mode === "both";
}
export function isChannelExecApprovalClientEnabledFromConfig(params: {
enabled?: ChannelExecApprovalEnableMode;
approverCount: number;
}): boolean {
if (params.approverCount <= 0) {
return false;
}
return params.enabled !== false;
}
export function isChannelExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
@@ -91,7 +102,10 @@ export function createChannelExecApprovalProfile(params: {
const isClientEnabled = (input: ApprovalProfileParams): boolean => {
const config = params.resolveConfig(input);
return Boolean(config?.enabled && params.resolveApprovers(input).length > 0);
return isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: params.resolveApprovers(input).length,
});
};
const isApprover = (input: ApprovalProfileParams & { senderId?: string | null }): boolean => {
@@ -119,16 +133,19 @@ export function createChannelExecApprovalProfile(params: {
return false;
}
const config = params.resolveConfig(input);
if (!config?.enabled) {
return false;
}
if (params.resolveApprovers(input).length === 0) {
const approverCount = params.resolveApprovers(input).length;
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount,
})
) {
return false;
}
return matchesApprovalRequestFilters({
request: input.request.request,
agentFilter: config.agentFilter,
sessionFilter: config.sessionFilter,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
fallbackAgentIdFromSessionKey: params.fallbackAgentIdFromSessionKey === true,
});
};

View File

@@ -40,6 +40,7 @@ export {
export { createResolvedApproverActionAuthAdapter } from "./approval-auth-helpers.js";
export {
createChannelExecApprovalProfile,
isChannelExecApprovalClientEnabledFromConfig,
isChannelExecApprovalTargetRecipient,
} from "./approval-client-helpers.js";
export {