fix(actions): layer per-account gate fallback

This commit is contained in:
Sebastian
2026-02-16 20:59:23 -05:00
parent 616c0bd4c7
commit 2b3ecee7c5
10 changed files with 203 additions and 25 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky.
- iOS/Location: restore the significant location monitor implementation (service hooks + protocol surface + ATS key alignment) after merge drift so iOS builds compile again. (#18260) Thanks @ngutman.
- Discord/Telegram: make per-account message action gates effective for both action listing and execution, and preserve top-level gate restrictions when account overrides only specify a subset of `actions` keys (account key -> base key -> default fallback). (#18494)
- Telegram: keep DM-topic replies and draft previews in the originating private-chat topic by preserving positive `message_thread_id` values for DM threads. (#18586) Thanks @sebslight.
- Discord: prevent duplicate media delivery when the model uses the `message send` tool with media, by skipping media extraction from messaging tool results since the tool already sent the message directly. (#18270)
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.

View File

@@ -660,4 +660,50 @@ describe("handleDiscordAction per-account gating", () => {
);
expect(kickMemberDiscord).toHaveBeenCalled();
});
it("inherits top-level channel gate when account overrides moderation only", async () => {
const cfg = {
channels: {
discord: {
actions: { channels: false },
accounts: {
ops: { token: "tok-ops", actions: { moderation: true } },
},
},
},
} as OpenClawConfig;
await expect(
handleDiscordAction(
{ action: "channelCreate", guildId: "G1", name: "alerts", accountId: "ops" },
cfg,
),
).rejects.toThrow(/channel management is disabled/i);
});
it("allows account to explicitly re-enable top-level disabled channel gate", async () => {
const cfg = {
channels: {
discord: {
actions: { channels: false },
accounts: {
ops: {
token: "tok-ops",
actions: { moderation: true, channels: true },
},
},
},
},
} as OpenClawConfig;
await handleDiscordAction(
{ action: "channelCreate", guildId: "G1", name: "alerts", accountId: "ops" },
cfg,
);
expect(createChannelDiscord).toHaveBeenCalledWith(
expect.objectContaining({ guildId: "G1", name: "alerts" }),
{ accountId: "ops" },
);
});
});

View File

@@ -1,7 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveDiscordAccount } from "../../discord/accounts.js";
import { createActionGate, readStringParam } from "./common.js";
import { createDiscordActionGate } from "../../discord/accounts.js";
import { readStringParam } from "./common.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
@@ -61,8 +61,7 @@ export async function handleDiscordAction(
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const account = resolveDiscordAccount({ cfg, accountId });
const isActionEnabled = createActionGate(account.config.actions);
const isActionEnabled = createDiscordActionGate({ cfg, accountId });
if (messagingActions.has(action)) {
return await handleDiscordMessagingAction(action, params, isActionEnabled);

View File

@@ -686,4 +686,61 @@ describe("handleTelegramAction per-account gating", () => {
expect.objectContaining({ token: "tok-media" }),
);
});
it("inherits top-level reaction gate when account overrides sticker only", async () => {
const cfg = {
channels: {
telegram: {
actions: { reactions: false },
accounts: {
media: { botToken: "tok-media", actions: { sticker: true } },
},
},
},
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: 1,
emoji: "👀",
accountId: "media",
},
cfg,
),
).rejects.toThrow(/reactions are disabled via actions.reactions/i);
});
it("allows account to explicitly re-enable top-level disabled reaction gate", async () => {
const cfg = {
channels: {
telegram: {
actions: { reactions: false },
accounts: {
media: { botToken: "tok-media", actions: { sticker: true, reactions: true } },
},
},
},
} as OpenClawConfig;
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: 1,
emoji: "👀",
accountId: "media",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
1,
"👀",
expect.objectContaining({ token: "tok-media", accountId: "media" }),
);
});
});

View File

@@ -1,7 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js";
import { createTelegramActionGate } from "../../telegram/accounts.js";
import {
resolveTelegramInlineButtonsScope,
resolveTelegramTargetChatType,
@@ -18,7 +18,6 @@ import {
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
@@ -89,8 +88,7 @@ export async function handleTelegramAction(
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const account = resolveTelegramAccount({ cfg, accountId });
const isActionEnabled = createActionGate(account.config.actions);
const isActionEnabled = createTelegramActionGate({ cfg, accountId });
if (action === "react") {
// Check reaction level first

View File

@@ -107,13 +107,11 @@ describe("discord message actions", () => {
expect(actions).not.toContain("ban");
});
it("shallow merge: account actions object replaces base entirely", () => {
// Base has reactions: false, account has actions: { moderation: true }
// Shallow merge replaces the whole actions object, so reactions defaults to true
it("inherits top-level channel gate when account overrides moderation only", () => {
const cfg = {
channels: {
discord: {
actions: { reactions: false },
actions: { channels: false },
accounts: {
vime: { token: "d1", actions: { moderation: true } },
},
@@ -122,9 +120,25 @@ describe("discord message actions", () => {
} as OpenClawConfig;
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
// vime's actions override replaces entire actions object; reactions defaults to true
expect(actions).toContain("react");
expect(actions).toContain("timeout");
expect(actions).not.toContain("channel-create");
});
it("allows account to explicitly re-enable top-level disabled channels", () => {
const cfg = {
channels: {
discord: {
actions: { channels: false },
accounts: {
vime: { token: "d1", actions: { moderation: true, channels: true } },
},
},
},
} as OpenClawConfig;
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("timeout");
expect(actions).toContain("channel-create");
});
});
@@ -473,6 +487,24 @@ describe("telegramMessageActions", () => {
expect(actions).not.toContain("sticker-search");
});
it("inherits top-level reaction gate when account overrides sticker only", () => {
const cfg = {
channels: {
telegram: {
actions: { reactions: false },
accounts: {
media: { botToken: "tok", actions: { sticker: true } },
},
},
},
} as OpenClawConfig;
const actions = telegramMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("sticker");
expect(actions).toContain("sticker-search");
expect(actions).not.toContain("react");
});
it("accepts numeric messageId and channelId for reactions", async () => {
const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig;

View File

@@ -1,7 +1,6 @@
import { createActionGate } from "../../../agents/tools/common.js";
import type { DiscordActionConfig } from "../../../config/types.discord.js";
import { listEnabledDiscordAccounts } from "../../../discord/accounts.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
import { createDiscordActionGate, listEnabledDiscordAccounts } from "../../../discord/accounts.js";
import { handleDiscordMessageAction } from "./discord/handle-action.js";
export const discordMessageActions: ChannelMessageActionAdapter = {
@@ -13,7 +12,9 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
return [];
}
// Union of all accounts' action gates (any account enabling an action makes it available)
const gates = accounts.map((a) => createActionGate(a.config.actions));
const gates = accounts.map((account) =>
createDiscordActionGate({ cfg, accountId: account.accountId }),
);
const gate = (key: keyof DiscordActionConfig, defaultValue = true) =>
gates.some((g) => g(key, defaultValue));
const actions = new Set<ChannelMessageActionName>(["send"]);

View File

@@ -1,16 +1,18 @@
import type { TelegramActionConfig } from "../../../config/types.telegram.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
import {
createActionGate,
readNumberParam,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
} from "../../../agents/tools/common.js";
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
import type { TelegramActionConfig } from "../../../config/types.telegram.js";
import { extractToolSend } from "../../../plugin-sdk/tool-send.js";
import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js";
import {
createTelegramActionGate,
listEnabledTelegramAccounts,
} from "../../../telegram/accounts.js";
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
const providerId = "telegram";
@@ -48,7 +50,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
return [];
}
// Union of all accounts' action gates (any account enabling an action makes it available)
const gates = accounts.map((a) => createActionGate(a.config.actions));
const gates = accounts.map((account) =>
createTelegramActionGate({ cfg, accountId: account.accountId }),
);
const gate = (key: keyof TelegramActionConfig, defaultValue = true) =>
gates.some((g) => g(key, defaultValue));
const actions = new Set<ChannelMessageActionName>(["send"]);

View File

@@ -1,6 +1,6 @@
import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
import type { OpenClawConfig } from "../config/config.js";
import type { DiscordAccountConfig } from "../config/types.js";
import type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js";
import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { resolveDiscordToken } from "./token.js";
@@ -36,6 +36,26 @@ function mergeDiscordAccountConfig(cfg: OpenClawConfig, accountId: string): Disc
return { ...base, ...account };
}
export function createDiscordActionGate(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean {
const accountId = normalizeAccountId(params.accountId);
const baseActions = params.cfg.channels?.discord?.actions;
const accountActions = resolveAccountConfig(params.cfg, accountId)?.actions;
return (key, defaultValue = true) => {
const accountValue = accountActions?.[key];
if (accountValue !== undefined) {
return accountValue;
}
const baseValue = baseActions?.[key];
if (baseValue !== undefined) {
return baseValue;
}
return defaultValue;
};
}
export function resolveDiscordAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import type { TelegramAccountConfig } from "../config/types.js";
import type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
@@ -82,6 +82,26 @@ function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): Tel
return { ...base, ...account };
}
export function createTelegramActionGate(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean {
const accountId = normalizeAccountId(params.accountId);
const baseActions = params.cfg.channels?.telegram?.actions;
const accountActions = resolveAccountConfig(params.cfg, accountId)?.actions;
return (key, defaultValue = true) => {
const accountValue = accountActions?.[key];
if (accountValue !== undefined) {
return accountValue;
}
const baseValue = baseActions?.[key];
if (baseValue !== undefined) {
return baseValue;
}
return defaultValue;
};
}
export function resolveTelegramAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;