diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e7e41e0528..1d88b18fe44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/tools/discord-actions.e2e.test.ts b/src/agents/tools/discord-actions.e2e.test.ts index e266135cbe2..b95e5e85b33 100644 --- a/src/agents/tools/discord-actions.e2e.test.ts +++ b/src/agents/tools/discord-actions.e2e.test.ts @@ -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" }, + ); + }); }); diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index bf5a7a2989d..8325d559498 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -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> { 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); diff --git a/src/agents/tools/telegram-actions.e2e.test.ts b/src/agents/tools/telegram-actions.e2e.test.ts index 3ffaa4730b2..f4fb510ef52 100644 --- a/src/agents/tools/telegram-actions.e2e.test.ts +++ b/src/agents/tools/telegram-actions.e2e.test.ts @@ -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" }), + ); + }); }); diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index a81e7c96fbb..6dd624f5d74 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -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> { 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 diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 2593f15a091..dd5081e339e 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -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; diff --git a/src/channels/plugins/actions/discord.ts b/src/channels/plugins/actions/discord.ts index 9674192e5a6..4f8b06e7c32 100644 --- a/src/channels/plugins/actions/discord.ts +++ b/src/channels/plugins/actions/discord.ts @@ -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(["send"]); diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 60808c35ef8..db0a51b4deb 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -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(["send"]); diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts index 4762c90f50d..81b8d114818 100644 --- a/src/discord/accounts.ts +++ b/src/discord/accounts.ts @@ -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; diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index e985e67c614..ce7f2d1bf61 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -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;