Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions tests/gateway/test_discord_approval_mentions.py
Original file line number Diff line number Diff line change
@@ -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"
Loading