diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7f2945de5f80..184350ae3a47 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2384,6 +2384,11 @@ def _ensure_hermes_home_managed(home: Path): # real memory cost. Default 32 MiB matches the historical hardcoded # cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES. "max_attachment_bytes": 33554432, + # When True, Discord approval prompts mention numeric allowed users so + # owners notice approval requests in shared channels/threads. Env + # override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise + # pings. + "approval_mentions": False, # Voice-channel audio effects (the continuous mixer). OFF by default. # When enabled, the bot installs a software mixer on the outgoing voice # stream so a low ambient "thinking" bed, verbal acknowledgements, and diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 47e93a2836cc..8a83f8805ed9 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -754,6 +754,13 @@ def _read_dm_role_auth_guild() -> Optional[int]: _DISCORD_PROMPT_TIMEOUT_MAX = 900 +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name, "").strip().lower() + if not raw: + return default + return raw in {"true", "1", "yes", "on"} + + def _read_discord_prompt_timeout() -> int: """Return the timeout (in seconds) for Discord button views. @@ -5502,6 +5509,20 @@ def _self_contained_prompt_content( body = body[: max(0, budget - len(truncated_suffix))] + truncated_suffix return f"{prefix}{body}{suffix}" + def _approval_mention_content(self) -> Optional[str]: + """Return user mentions for approval prompts when explicitly enabled. + + Gated on ``discord.approval_mentions`` in config.yaml (bridged to the + ``DISCORD_APPROVAL_MENTIONS`` env var). Only numeric allowlist entries + can be mentioned; default off avoids surprise pings. + """ + if not _env_bool("DISCORD_APPROVAL_MENTIONS", False): + return None + user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit()) + if not user_ids: + return None + return " ".join(f"<@{uid}>" for uid in user_ids) + async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", @@ -5541,6 +5562,9 @@ async def send_exec_approval( "Do you want Hermes to run this command?\n\n" "**Requested command:**\n```bash\n" ) + mention_content = self._approval_mention_content() + if mention_content: + prompt_prefix = f"{mention_content}\n{prompt_prefix}" prompt_tail = f"\n```\n**Reason:** {reason_display}" truncated_suffix = "\n... [truncated]" command_budget = max(0, self.MAX_MESSAGE_LENGTH - len(prompt_prefix) - len(prompt_tail)) @@ -5576,7 +5600,17 @@ async def send_exec_approval( admin_user_ids=admin_user_ids, ) - msg = await channel.send(content=content, embed=embed, view=view) + send_kwargs: Dict[str, Any] = {"content": content, "embed": embed, "view": view} + if mention_content: + allowed_mentions_cls = getattr(discord, "AllowedMentions", None) + if allowed_mentions_cls is not None: + send_kwargs["allowed_mentions"] = allowed_mentions_cls( + users=True, + roles=False, + everyone=False, + replied_user=False, + ) + msg = await channel.send(**send_kwargs) view._message = msg # store for on_timeout expiration editing return SendResult(success=True, message_id=str(msg.id)) @@ -8161,6 +8195,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: if isinstance(allowed_users_cfg, list): allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg) os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg) + approval_mentions_cfg = ( + discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg + else platform_extra_cfg.get("approval_mentions") + ) + if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"): + os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower() frc = discord_cfg.get("free_response_channels") if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): if isinstance(frc, list): diff --git a/scripts/release.py b/scripts/release.py index 65001e564fb4..8e19363706a1 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -291,6 +291,7 @@ "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "30668368+alex107ivanov@users.noreply.github.com": "alex107ivanov", "210088133+rungmc357@users.noreply.github.com": "rungmc357", "florian.rutishauser@outlook.com": "flo1t", "fanyang@microsoft.com": "fanyangCS", diff --git a/tests/gateway/test_discord_approval_mentions.py b/tests/gateway/test_discord_approval_mentions.py new file mode 100644 index 000000000000..786ecace3429 --- /dev/null +++ b/tests/gateway/test_discord_approval_mentions.py @@ -0,0 +1,87 @@ +"""Discord approval prompts can opt into owner mentions.""" + +import os +from types import SimpleNamespace + +import pytest + +from plugins.platforms.discord.adapter import ( + DiscordAdapter, + _apply_yaml_config, +) + + +class _FakeChannel: + def __init__(self): + self.sent_kwargs = None + + async def send(self, **kwargs): + self.sent_kwargs = kwargs + return SimpleNamespace(id=12345) + + +class _FakeClient: + def __init__(self, channel): + self.channel = channel + + def get_channel(self, channel_id): + return self.channel + + +@pytest.mark.asyncio +async def test_exec_approval_mentions_allowed_users_when_enabled(monkeypatch): + monkeypatch.setenv("DISCORD_APPROVAL_MENTIONS", "true") + channel = _FakeChannel() + adapter = object.__new__(DiscordAdapter) + adapter._client = _FakeClient(channel) + adapter._allowed_user_ids = {"222", "111", "alice"} + adapter._allowed_role_ids = set() + adapter.config = SimpleNamespace(extra=None) + + result = await adapter.send_exec_approval( + chat_id="99", + command="make check", + session_key="session-1", + description="dangerous command", + ) + + assert result.success is True + # Mentions are prepended to the (always present) content mirror. + assert channel.sent_kwargs["content"].startswith("<@111> <@222>\n") + assert "make check" in channel.sent_kwargs["content"] + assert "allowed_mentions" in channel.sent_kwargs + assert channel.sent_kwargs["embed"].title.endswith("Command Approval Required") + + +@pytest.mark.asyncio +async def test_exec_approval_does_not_mention_by_default(monkeypatch): + monkeypatch.delenv("DISCORD_APPROVAL_MENTIONS", raising=False) + channel = _FakeChannel() + adapter = object.__new__(DiscordAdapter) + adapter._client = _FakeClient(channel) + adapter._allowed_user_ids = {"111"} + adapter._allowed_role_ids = set() + adapter.config = SimpleNamespace(extra=None) + + result = await adapter.send_exec_approval( + chat_id="99", + command="make check", + session_key="session-1", + ) + + assert result.success is True + # Content mirror is always present (embed-invisibility fix), but no + # mention markup and no allowed_mentions override. + assert "<@" not in channel.sent_kwargs["content"] + assert "allowed_mentions" not in channel.sent_kwargs + + +def test_yaml_config_bridges_approval_mentions_to_env(monkeypatch): + monkeypatch.delenv("DISCORD_APPROVAL_MENTIONS", raising=False) + + _apply_yaml_config( + {"discord": {"approval_mentions": True}}, + {"approval_mentions": True}, + ) + + assert os.environ["DISCORD_APPROVAL_MENTIONS"] == "true"