From f71f88fb35a5e6161695dc0c081ffdcd2d6423b2 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 2 Apr 2026 20:32:55 -0400 Subject: [PATCH] Add Discord read tools --- docs/plans/2026-04-02-discord-read-tools.md | 39 + gateway/channel_directory.py | 130 ++- gateway/config.py | 16 +- gateway/session.py | 9 +- hermes_cli/config.py | 21 + model_tools.py | 1 + tests/gateway/test_api_server_toolset.py | 1 + tests/gateway/test_channel_directory.py | 43 + tests/gateway/test_config.py | 27 + tests/gateway/test_session.py | 3 +- tests/test_toolsets.py | 8 + tests/tools/test_discord_read_tool.py | 282 ++++++ tools/discord_read_tool.py | 832 ++++++++++++++++++ toolsets.py | 10 + .../docs/reference/environment-variables.md | 3 + website/docs/user-guide/configuration.md | 21 + website/docs/user-guide/messaging/discord.md | 38 +- 17 files changed, 1456 insertions(+), 28 deletions(-) create mode 100644 docs/plans/2026-04-02-discord-read-tools.md create mode 100644 tests/tools/test_discord_read_tool.py create mode 100644 tools/discord_read_tool.py diff --git a/docs/plans/2026-04-02-discord-read-tools.md b/docs/plans/2026-04-02-discord-read-tools.md new file mode 100644 index 000000000000..c60d53f6446c --- /dev/null +++ b/docs/plans/2026-04-02-discord-read-tools.md @@ -0,0 +1,39 @@ +# Discord Read Tools Plan + +Date: 2026-04-02 + +## Scope + +Add three read-only Discord tools: + +- `discord_list_channels` +- `discord_read_history` +- `discord_search_messages` + +## Design Constraints + +- Use Discord HTTP API v10 only. +- Keep the feature read-only. +- Enforce explicit allowlists: + - `DISCORD_READ_ALLOWED_GUILDS` + - `DISCORD_READ_ALLOWED_CHANNELS` + - `DISCORD_READ_INCLUDE_DMS` +- Auto-allow the current Discord session target so Hermes can inspect the conversation it is already in. +- Include thread discovery and thread-aware name resolution. +- Hard-cap history and search sizes. +- Return message permalinks when possible. + +## Implementation Notes + +- Guild allowlists expose readable text channels plus active threads in those guilds. +- Channel allowlists expose specific channels/threads directly; allowed parent text channels also expose active child threads. +- DM discovery is limited to channels Hermes already knows from prior sessions. +- Search scans a bounded recent-message window instead of pretending to be a full-server index. + +## Verification Targets + +- Tool registration and `get_tool_definitions()` exposure. +- Allowlist denial outside scope. +- Current-session access for guild and DM contexts. +- Thread discovery and deterministic qualified-name resolution. +- Config bridging from `config.yaml` into Discord read env vars. diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 235f11f59fde..1d8633492bef 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -2,7 +2,7 @@ Channel directory -- cached map of reachable channels/contacts per platform. Built on gateway startup, refreshed periodically (every 5 min), and saved to -~/.hermes/channel_directory.json. The send_message tool reads this file for +{HERMES_HOME}/channel_directory.json. The send_message tool reads this file for action="list" and for resolving human-friendly channel names to numeric IDs. """ @@ -18,6 +18,14 @@ DIRECTORY_PATH = get_hermes_home() / "channel_directory.json" +def _normalize_query(value: str) -> str: + normalized = str(value or "").strip().lower().replace("\\", "/") + while " " in normalized: + normalized = normalized.replace(" ", " ") + normalized = normalized.replace(" /", "/").replace("/ ", "/") + return normalized.lstrip("#") + + def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]: chat_id = origin.get("chat_id") if not chat_id: @@ -82,7 +90,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: def _build_discord(adapter) -> List[Dict[str, str]]: - """Enumerate all text channels the Discord bot can see.""" + """Enumerate all text channels and active threads the Discord bot can see.""" channels = [] client = getattr(adapter, "_client", None) if not client: @@ -93,22 +101,79 @@ def _build_discord(adapter) -> List[Dict[str, str]]: except ImportError: return channels + seen_ids = set() for guild in client.guilds: for ch in guild.text_channels: + channel_id = str(ch.id) + if channel_id in seen_ids: + continue + seen_ids.add(channel_id) channels.append({ - "id": str(ch.id), + "id": channel_id, "name": ch.name, + "qualified_name": f"{guild.name} / #{ch.name}", "guild": guild.name, "type": "channel", }) - # Also include DM-capable users we've interacted with is not - # feasible via guild enumeration; those come from sessions. + for thread in _iter_discord_threads(guild): + thread_id = str(getattr(thread, "id", "")) + if not thread_id or thread_id in seen_ids: + continue + seen_ids.add(thread_id) + parent = getattr(thread, "parent", None) + parent_id = str(getattr(parent, "id", "")) or None + parent_name = getattr(parent, "name", None) + name = _discord_thread_name(thread, guild.name) + channels.append({ + "id": thread_id, + "name": name, + "qualified_name": name, + "guild": guild.name, + "parent_id": parent_id, + "parent_name": parent_name, + "type": "thread", + }) + # Also include DM-capable users we've interacted with; those come from sessions. # Merge any DMs from session history channels.extend(_build_from_sessions("discord")) return channels +def _iter_discord_threads(guild) -> List[Any]: + """Collect active thread objects from a discord.py guild object.""" + threads = [] + seen_ids = set() + + for candidate in list(getattr(guild, "threads", []) or []): + thread_id = getattr(candidate, "id", None) + if thread_id is None or thread_id in seen_ids: + continue + seen_ids.add(thread_id) + threads.append(candidate) + + for channel in list(getattr(guild, "text_channels", []) or []): + for candidate in list(getattr(channel, "threads", []) or []): + thread_id = getattr(candidate, "id", None) + if thread_id is None or thread_id in seen_ids: + continue + seen_ids.add(thread_id) + threads.append(candidate) + + return threads + + +def _discord_thread_name(thread, guild_name: Optional[str]) -> str: + thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread")) + parent = getattr(thread, "parent", None) + parent_name = getattr(parent, "name", None) + if guild_name and parent_name: + return f"{guild_name} / #{parent_name} / {thread_name}" + if parent_name: + return f"{parent_name} / {thread_name}" + return thread_name + + def _build_slack(adapter) -> List[Dict[str, str]]: """List Slack channels the bot has joined.""" channels = [] @@ -188,23 +253,46 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: if not channels: return None - query = name.lstrip("#").lower() - - # 1. Exact name match - for ch in channels: - if ch["name"].lower() == query: - return ch["id"] - - # 2. Guild-qualified match for Discord ("GuildName/channel") - if "/" in query: - guild_part, ch_part = query.rsplit("/", 1) - for ch in channels: - guild = ch.get("guild", "").lower() - if guild == guild_part and ch["name"].lower() == ch_part: - return ch["id"] + query = _normalize_query(name) + + def _aliases(channel: Dict[str, Any]) -> set[str]: + aliases = { + _normalize_query(channel.get("name", "")), + _normalize_query(channel.get("qualified_name") or channel.get("name", "")), + } + + raw_name = channel.get("name", "") + guild = channel.get("guild", "") + parent_name = channel.get("parent_name", "") + channel_type = channel.get("type") + + if channel_type == "channel" and raw_name: + aliases.add(_normalize_query(f"#{raw_name}")) + if guild: + aliases.add(_normalize_query(f"{guild}/{raw_name}")) + aliases.add(_normalize_query(f"{guild}/#{raw_name}")) + if channel_type == "thread" and raw_name: + if guild and parent_name: + thread_name = raw_name.split("/")[-1].strip() + aliases.add(_normalize_query(f"{guild}/{parent_name}/{thread_name}")) + aliases.add(_normalize_query(f"{guild}/#{parent_name}/{thread_name}")) + aliases.add(_normalize_query(f"{parent_name}/{thread_name}")) + aliases.add(_normalize_query(f"#{parent_name}/{thread_name}")) + + return {alias for alias in aliases if alias} + + # 1. Exact alias match + exact_matches = [ch for ch in channels if query in _aliases(ch)] + if len(exact_matches) == 1: + return exact_matches[0]["id"] + if len(exact_matches) > 1: + return None - # 3. Partial prefix match (only if unambiguous) - matches = [ch for ch in channels if ch["name"].lower().startswith(query)] + # 2. Partial prefix match (only if unambiguous) + matches = [ + ch for ch in channels + if any(alias.startswith(query) for alias in _aliases(ch)) + ] if len(matches) == 1: return matches[0]["id"] diff --git a/gateway/config.py b/gateway/config.py index c8ce89a7d637..02a2d1c79850 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -543,6 +543,21 @@ def load_gateway_config() -> GatewayConfig: os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() + read_cfg = discord_cfg.get("read") + if isinstance(read_cfg, dict): + allowed_guilds = read_cfg.get("allowed_guilds") + if allowed_guilds is not None and not os.getenv("DISCORD_READ_ALLOWED_GUILDS"): + if isinstance(allowed_guilds, list): + allowed_guilds = ",".join(str(v) for v in allowed_guilds) + os.environ["DISCORD_READ_ALLOWED_GUILDS"] = str(allowed_guilds) + allowed_channels = read_cfg.get("allowed_channels") + if allowed_channels is not None and not os.getenv("DISCORD_READ_ALLOWED_CHANNELS"): + if isinstance(allowed_channels, list): + allowed_channels = ",".join(str(v) for v in allowed_channels) + os.environ["DISCORD_READ_ALLOWED_CHANNELS"] = str(allowed_channels) + include_dms = read_cfg.get("include_dms") + if include_dms is not None and not os.getenv("DISCORD_READ_INCLUDE_DMS"): + os.environ["DISCORD_READ_INCLUDE_DMS"] = str(include_dms).lower() # Telegram settings → env vars (env vars take precedence) telegram_cfg = yaml_cfg.get("telegram", {}) @@ -900,4 +915,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None: except ValueError: pass - diff --git a/gateway/session.py b/gateway/session.py index 5aefb6c01293..4e8ef52ac8bb 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -277,10 +277,11 @@ def build_session_context_prompt( lines.append("") lines.append( "**Platform notes:** You are running inside Discord. " - "You do NOT have access to Discord-specific APIs — you cannot search " - "channel history, pin messages, manage roles, or list server members. " - "Do not promise to perform these actions. If the user asks, explain " - "that you can only read messages sent directly to you and respond." + "You do NOT have unrestricted Discord API access — you cannot manage " + "roles, pin messages, or browse the whole server. If Discord read tools " + "are available, use them only for the explicitly scoped channels or the " + "current Discord session target. Otherwise, explain that you can only " + "read messages sent directly to you and respond." ) # Connected platforms diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e2503ebec260..f7eadc10047b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -797,6 +797,27 @@ def ensure_hermes_home(): "password": False, "category": "messaging", }, + "DISCORD_READ_ALLOWED_GUILDS": { + "description": "Comma-separated Discord guild IDs whose readable channels may be listed and read", + "prompt": "Readable Discord guild IDs (comma-separated)", + "url": None, + "password": False, + "category": "messaging", + }, + "DISCORD_READ_ALLOWED_CHANNELS": { + "description": "Comma-separated Discord channel or thread IDs allowed for read-only history and search", + "prompt": "Readable Discord channel IDs (comma-separated)", + "url": None, + "password": False, + "category": "messaging", + }, + "DISCORD_READ_INCLUDE_DMS": { + "description": "Allow Discord read tools to include DM channels already known to Hermes", + "prompt": "Include Discord DMs in read tools? (true/false)", + "url": None, + "password": False, + "category": "messaging", + }, "SLACK_BOT_TOKEN": { "description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. " "Required scopes: chat:write, app_mentions:read, channels:history, groups:history, " diff --git a/model_tools.py b/model_tools.py index c651d93ed73d..0df10e6c2590 100644 --- a/model_tools.py +++ b/model_tools.py @@ -156,6 +156,7 @@ def _discover_tools(): "tools.delegate_tool", "tools.process_registry", "tools.send_message_tool", + "tools.discord_read_tool", "tools.honcho_tools", "tools.homeassistant_tool", ] diff --git a/tests/gateway/test_api_server_toolset.py b/tests/gateway/test_api_server_toolset.py index 3b4ff254d8e7..39c2dba85880 100644 --- a/tests/gateway/test_api_server_toolset.py +++ b/tests/gateway/test_api_server_toolset.py @@ -31,6 +31,7 @@ def test_toolset_includes_core_tools(self): "vision_analyze", "image_generate", "execute_code", "delegate_task", "todo", "memory", "session_search", "cronjob", + "discord_list_channels", "discord_read_history", "discord_search_messages", ] for tool in expected: assert tool in tools, f"Missing expected tool: {tool}" diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 2ecacc457db0..0bca4566c0f1 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -3,6 +3,7 @@ import json import os from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch from gateway.channel_directory import ( @@ -10,6 +11,7 @@ format_directory_for_display, load_directory, _build_from_sessions, + _build_discord, DIRECTORY_PATH, ) @@ -119,6 +121,23 @@ def test_topic_name_resolves_to_composite_id(self, tmp_path): with self._setup(tmp_path, platforms): assert resolve_channel_name("telegram", "Coaching Chat / topic 17585") == "-1001:17585" + def test_discord_thread_name_resolves_with_parent_path(self, tmp_path): + platforms = { + "discord": [ + { + "id": "210", + "name": "ServerA / #deploys / incident-7", + "qualified_name": "ServerA / #deploys / incident-7", + "guild": "ServerA", + "parent_name": "deploys", + "type": "thread", + } + ] + } + with self._setup(tmp_path, platforms): + assert resolve_channel_name("discord", "ServerA/#deploys/incident-7") == "210" + assert resolve_channel_name("discord", "deploys/incident-7") == "210" + class TestBuildFromSessions: def _write_sessions(self, tmp_path, sessions_data): @@ -214,6 +233,30 @@ def test_keeps_distinct_topics_with_same_chat_id(self, tmp_path): assert "Coaching Chat / topic 17587" in names +class TestBuildDiscord: + def test_builds_threads_with_parent_context(self): + thread = SimpleNamespace( + id=210, + name="incident-7", + parent=SimpleNamespace(id=201, name="deploys"), + ) + guild = SimpleNamespace( + name="Ops", + text_channels=[SimpleNamespace(id=201, name="deploys", threads=[thread])], + threads=[thread], + ) + adapter = SimpleNamespace(_client=SimpleNamespace(guilds=[guild])) + + with patch.dict("sys.modules", {"discord": SimpleNamespace()}): + entries = _build_discord(adapter) + + by_id = {entry["id"]: entry for entry in entries} + assert by_id["201"]["qualified_name"] == "Ops / #deploys" + assert by_id["210"]["name"] == "Ops / #deploys / incident-7" + assert by_id["210"]["type"] == "thread" + assert by_id["210"]["parent_name"] == "deploys" + + class TestFormatDirectoryForDisplay: def test_empty_directory(self, tmp_path): with patch("gateway.channel_directory.DIRECTORY_PATH", tmp_path / "nope.json"): diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 8f24faa99591..76553d1fecb0 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -197,6 +197,33 @@ def test_bridges_unauthorized_dm_behavior_from_config_yaml(self, tmp_path, monke assert config.unauthorized_dm_behavior == "ignore" assert config.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" + def test_bridges_discord_read_scope_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " read:\n" + " allowed_guilds:\n" + " - 100\n" + " allowed_channels:\n" + " - 200\n" + " - 210\n" + " include_dms: true\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_READ_ALLOWED_GUILDS", raising=False) + monkeypatch.delenv("DISCORD_READ_ALLOWED_CHANNELS", raising=False) + monkeypatch.delenv("DISCORD_READ_INCLUDE_DMS", raising=False) + + load_gateway_config() + + assert os.getenv("DISCORD_READ_ALLOWED_GUILDS") == "100" + assert os.getenv("DISCORD_READ_ALLOWED_CHANNELS") == "200,210" + assert os.getenv("DISCORD_READ_INCLUDE_DMS") == "true" + class TestHomeChannelEnvOverrides: """Home channel env vars should apply even when the platform was already diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 82281acc2eba..2fde56728ac9 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -197,7 +197,8 @@ def test_discord_prompt(self): prompt = build_session_context_prompt(ctx) assert "Discord" in prompt - assert "cannot search" in prompt.lower() or "do not have access" in prompt.lower() + assert "unrestricted discord api access" in prompt.lower() + assert "current discord session target" in prompt.lower() def test_slack_prompt_includes_platform_notes(self): config = GatewayConfig( diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 13c345070289..6603b26bd524 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -30,6 +30,14 @@ def test_leaf_toolset(self): tools = resolve_toolset("web") assert set(tools) == {"web_search", "web_extract"} + def test_discord_read_toolset(self): + tools = resolve_toolset("discord_read") + assert set(tools) == { + "discord_list_channels", + "discord_read_history", + "discord_search_messages", + } + def test_composite_toolset(self): tools = resolve_toolset("debugging") assert "terminal" in tools diff --git a/tests/tools/test_discord_read_tool.py b/tests/tools/test_discord_read_tool.py new file mode 100644 index 000000000000..0fd4fff4575e --- /dev/null +++ b/tests/tools/test_discord_read_tool.py @@ -0,0 +1,282 @@ +"""Tests for the Discord read-only tools.""" + +import json +import sys +from types import ModuleType + +import pytest + +from model_tools import get_tool_definitions +import tools.discord_read_tool # noqa: F401 - ensure tool registration +from tools.registry import registry + + +class _FakeHTTPError(Exception): + pass + + +class _FakeResponse: + def __init__(self, status_code, data): + self.status_code = status_code + self._data = data + self.text = json.dumps(data) + + def json(self): + return self._data + + +class _FakeAsyncClient: + def __init__(self, responder, **_kwargs): + self._responder = responder + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def request(self, method, url, params=None): + status_code, payload = self._responder(method, url, params or {}) + return _FakeResponse(status_code, payload) + + +def _install_fake_httpx(monkeypatch, responder): + httpx_mod = ModuleType("httpx") + httpx_mod.AsyncClient = lambda **kwargs: _FakeAsyncClient(responder, **kwargs) + httpx_mod.Timeout = lambda *args, **kwargs: None + httpx_mod.HTTPError = _FakeHTTPError + monkeypatch.setitem(sys.modules, "httpx", httpx_mod) + + +@pytest.fixture(autouse=True) +def _clear_discord_env(monkeypatch): + for key in ( + "DISCORD_BOT_TOKEN", + "DISCORD_READ_ALLOWED_GUILDS", + "DISCORD_READ_ALLOWED_CHANNELS", + "DISCORD_READ_INCLUDE_DMS", + "HERMES_SESSION_PLATFORM", + "HERMES_SESSION_CHAT_ID", + "HERMES_SESSION_CHAT_NAME", + "HERMES_SESSION_THREAD_ID", + "HERMES_HOME", + ): + monkeypatch.delenv(key, raising=False) + + +def test_history_current_guild_session_is_auto_allowed(monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "token") + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "200") + + def responder(method, url, params): + if url.endswith("/channels/200"): + return 200, {"id": "200", "type": 0, "name": "general", "guild_id": "100"} + if url.endswith("/guilds/100"): + return 200, {"id": "100", "name": "Ops"} + if url.endswith("/channels/200/messages"): + assert params["limit"] == 2 + return 200, [ + { + "id": "m2", + "timestamp": "2026-04-02T10:00:00+00:00", + "content": "second", + "author": {"id": "u2", "username": "sam"}, + "attachments": [], + }, + { + "id": "m1", + "timestamp": "2026-04-02T09:00:00+00:00", + "content": "first", + "author": {"id": "u1", "username": "pat"}, + "attachments": [], + }, + ] + raise AssertionError(f"Unexpected request: {method} {url} {params}") + + _install_fake_httpx(monkeypatch, responder) + + result = json.loads(registry.dispatch("discord_read_history", {"limit": 2})) + + assert result["channel"]["id"] == "200" + assert result["channel"]["qualified_name"] == "Ops / #general" + assert result["messages"][0]["permalink"] == "https://discord.com/channels/100/200/m2" + assert len(result["messages"]) == 2 + + +def test_history_current_dm_session_is_auto_allowed(monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "token") + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "900") + + def responder(method, url, params): + if url.endswith("/channels/900"): + return 200, { + "id": "900", + "type": 1, + "recipients": [{"id": "42", "username": "avery"}], + } + if url.endswith("/channels/900/messages"): + return 200, [ + { + "id": "dm1", + "timestamp": "2026-04-02T11:00:00+00:00", + "content": "hello from DM", + "author": {"id": "42", "username": "avery"}, + "attachments": [], + } + ] + raise AssertionError(f"Unexpected request: {method} {url} {params}") + + _install_fake_httpx(monkeypatch, responder) + + result = json.loads(registry.dispatch("discord_read_history", {})) + + assert result["channel"]["id"] == "900" + assert result["channel"]["qualified_name"] == "avery" + assert result["messages"][0]["permalink"] == "https://discord.com/channels/@me/900/dm1" + + +def test_history_denies_channel_outside_scope(monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "token") + monkeypatch.setenv("DISCORD_READ_ALLOWED_CHANNELS", "200") + + def responder(method, url, params): + if url.endswith("/channels/200"): + return 200, {"id": "200", "type": 0, "name": "general", "guild_id": "100"} + if url.endswith("/guilds/100"): + return 200, {"id": "100", "name": "Ops"} + if url.endswith("/guilds/100/threads/active"): + return 200, {"threads": []} + raise AssertionError(f"Unexpected request: {method} {url} {params}") + + _install_fake_httpx(monkeypatch, responder) + + result = json.loads(registry.dispatch("discord_read_history", {"channel": "400"})) + + assert "not accessible" in result["error"] + + +def test_list_channels_includes_active_threads_for_allowed_parent(monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "token") + monkeypatch.setenv("DISCORD_READ_ALLOWED_CHANNELS", "201") + + def responder(method, url, params): + if url.endswith("/channels/201"): + return 200, {"id": "201", "type": 0, "name": "deploys", "guild_id": "100"} + if url.endswith("/guilds/100"): + return 200, {"id": "100", "name": "Ops"} + if url.endswith("/guilds/100/threads/active"): + return 200, { + "threads": [ + { + "id": "210", + "type": 11, + "name": "incident-7", + "guild_id": "100", + "parent_id": "201", + } + ] + } + raise AssertionError(f"Unexpected request: {method} {url} {params}") + + _install_fake_httpx(monkeypatch, responder) + + result = json.loads(registry.dispatch("discord_list_channels", {})) + + by_id = {entry["id"]: entry for entry in result["channels"]} + assert by_id["201"]["qualified_name"] == "Ops / #deploys" + assert by_id["210"]["qualified_name"] == "Ops / #deploys / incident-7" + assert by_id["210"]["allow_reason"] == "allowed_parent_channel" + + +def test_search_respects_result_and_scan_limits(monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "token") + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "200") + + def responder(method, url, params): + if url.endswith("/channels/200"): + return 200, {"id": "200", "type": 0, "name": "general", "guild_id": "100"} + if url.endswith("/guilds/100"): + return 200, {"id": "100", "name": "Ops"} + if url.endswith("/channels/200/messages"): + assert params["limit"] == 3 + return 200, [ + { + "id": "m3", + "timestamp": "2026-04-02T12:00:00+00:00", + "content": "deploy is green", + "author": {"id": "u1", "username": "sam"}, + "attachments": [], + }, + { + "id": "m2", + "timestamp": "2026-04-02T11:00:00+00:00", + "content": "deploy failed once", + "author": {"id": "u2", "username": "pat"}, + "attachments": [], + }, + { + "id": "m1", + "timestamp": "2026-04-02T10:00:00+00:00", + "content": "unrelated chatter", + "author": {"id": "u3", "username": "lee"}, + "attachments": [], + }, + ] + raise AssertionError(f"Unexpected request: {method} {url} {params}") + + _install_fake_httpx(monkeypatch, responder) + + result = json.loads( + registry.dispatch( + "discord_search_messages", + {"query": "deploy", "limit": 2, "scan_limit": 3}, + ) + ) + + assert result["returned"] == 2 + assert result["scanned_messages"] == 3 + assert [match["id"] for match in result["matches"]] == ["m3", "m2"] + + +def test_search_requires_qualified_name_when_raw_name_is_ambiguous(monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "token") + monkeypatch.setenv("DISCORD_READ_ALLOWED_GUILDS", "100,300") + + def responder(method, url, params): + if url.endswith("/guilds/100"): + return 200, {"id": "100", "name": "Ops"} + if url.endswith("/guilds/100/channels"): + return 200, [{"id": "200", "type": 0, "name": "general", "guild_id": "100"}] + if url.endswith("/guilds/100/threads/active"): + return 200, {"threads": []} + if url.endswith("/guilds/300"): + return 200, {"id": "300", "name": "Eng"} + if url.endswith("/guilds/300/channels"): + return 200, [{"id": "400", "type": 0, "name": "general", "guild_id": "300"}] + if url.endswith("/guilds/300/threads/active"): + return 200, {"threads": []} + raise AssertionError(f"Unexpected request: {method} {url} {params}") + + _install_fake_httpx(monkeypatch, responder) + + result = json.loads( + registry.dispatch("discord_search_messages", {"channel": "general", "query": "foo"}) + ) + + assert "ambiguous" in result["error"] + + +def test_tool_definitions_expose_discord_read_toolset(monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "token") + + tool_defs = get_tool_definitions(enabled_toolsets=["discord_read"], quiet_mode=True) + tool_names = {tool["function"]["name"] for tool in tool_defs} + + assert tool_names == { + "discord_list_channels", + "discord_read_history", + "discord_search_messages", + } diff --git a/tools/discord_read_tool.py b/tools/discord_read_tool.py new file mode 100644 index 000000000000..8bfcf41c7634 --- /dev/null +++ b/tools/discord_read_tool.py @@ -0,0 +1,832 @@ +"""Read-only Discord tools backed by the Discord HTTP API v10.""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional, Set + +from tools.registry import registry + +logger = logging.getLogger(__name__) + +DISCORD_API_BASE = "https://discord.com/api/v10" + +LIST_CHANNELS_MAX = 100 +HISTORY_MAX = 50 +SEARCH_MAX = 25 +SEARCH_SCAN_MAX = 250 +MESSAGE_PAGE_SIZE = 100 + +GUILD_TEXT_TYPES = {0, 5} +THREAD_TYPES = {10, 11, 12} +DM_TYPES = {1, 3} +READABLE_TYPES = GUILD_TEXT_TYPES | THREAD_TYPES | DM_TYPES + + +def _json(data: Dict[str, Any]) -> str: + return json.dumps(data, ensure_ascii=False) + + +def _coerce_limit(raw: Any, *, default: int, minimum: int, maximum: int) -> int: + try: + value = int(raw) + except (TypeError, ValueError): + return default + return max(minimum, min(maximum, value)) + + +def _parse_csv_env(name: str) -> Set[str]: + value = os.getenv(name, "") + return {part.strip() for part in value.split(",") if part.strip()} + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _normalize_ref(value: str) -> str: + normalized = str(value or "").strip().lower().replace("\\", "/") + while " " in normalized: + normalized = normalized.replace(" ", " ") + normalized = normalized.replace(" /", "/").replace("/ ", "/") + return normalized + + +@dataclass(frozen=True) +class CurrentDiscordContext: + chat_id: Optional[str] + chat_name: Optional[str] + thread_id: Optional[str] + + @property + def allowed_ids(self) -> Set[str]: + return {value for value in (self.chat_id, self.thread_id) if value} + + +class DiscordReadError(RuntimeError): + """Raised when a Discord read operation cannot be completed safely.""" + + +def _get_current_discord_context() -> CurrentDiscordContext: + platform = os.getenv("HERMES_SESSION_PLATFORM", "").strip().lower() + if platform != "discord": + return CurrentDiscordContext(chat_id=None, chat_name=None, thread_id=None) + return CurrentDiscordContext( + chat_id=os.getenv("HERMES_SESSION_CHAT_ID", "").strip() or None, + chat_name=os.getenv("HERMES_SESSION_CHAT_NAME", "").strip() or None, + thread_id=os.getenv("HERMES_SESSION_THREAD_ID", "").strip() or None, + ) + + +def _load_discord_token() -> Optional[str]: + token = os.getenv("DISCORD_BOT_TOKEN", "").strip() + if token: + return token + + try: + from gateway.config import Platform, load_gateway_config + + config = load_gateway_config() + platform_cfg = config.platforms.get(Platform.DISCORD) + if platform_cfg and platform_cfg.enabled and platform_cfg.token: + return platform_cfg.token.strip() + except Exception as exc: # pragma: no cover - defensive logging + logger.debug("Discord read tool could not load gateway config: %s", exc) + + return None + + +def _check_discord_read_requirements() -> bool: + return bool(_load_discord_token()) + + +def _load_dm_session_candidates() -> List[Dict[str, Any]]: + try: + from gateway.channel_directory import _build_from_sessions + + entries = _build_from_sessions("discord") + return [entry for entry in entries if entry.get("type") == "dm"] + except Exception as exc: # pragma: no cover - defensive logging + logger.debug("Discord read tool could not load DM session candidates: %s", exc) + return [] + + +def _format_channel_name( + *, + kind: str, + name: str, + guild_name: Optional[str], + parent_name: Optional[str], +) -> str: + if kind == "dm": + return name + if kind == "thread": + if guild_name and parent_name: + return f"{guild_name} / #{parent_name} / {name}" + if parent_name: + return f"{parent_name} / {name}" + return name + if guild_name: + return f"{guild_name} / #{name}" + return f"#{name}" + + +def _channel_aliases(entry: Dict[str, Any]) -> Set[str]: + aliases = { + _normalize_ref(entry["id"]), + _normalize_ref(entry["qualified_name"]), + } + + name = entry.get("name") + if name: + aliases.add(_normalize_ref(name)) + if entry.get("type") == "channel": + aliases.add(_normalize_ref(f"#{name}")) + + guild_name = entry.get("guild_name") + parent_name = entry.get("parent_name") + if guild_name and entry.get("type") == "channel" and name: + aliases.add(_normalize_ref(f"{guild_name}/{name}")) + aliases.add(_normalize_ref(f"{guild_name}/#{name}")) + if entry.get("type") == "thread" and name: + if parent_name: + aliases.add(_normalize_ref(f"{parent_name}/{name}")) + aliases.add(_normalize_ref(f"#{parent_name}/{name}")) + if guild_name and parent_name: + aliases.add(_normalize_ref(f"{guild_name}/{parent_name}/{name}")) + aliases.add(_normalize_ref(f"{guild_name}/#{parent_name}/{name}")) + + return {alias for alias in aliases if alias} + + +def _truncate_text(value: str, *, limit: int = 500) -> str: + text = (value or "").strip() + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "…" + + +def _message_permalink(message: Dict[str, Any], channel: Dict[str, Any]) -> Optional[str]: + message_id = str(message.get("id") or "").strip() + channel_id = str(channel.get("id") or "").strip() + if not message_id or not channel_id: + return None + + guild_id = channel.get("guild_id") + if guild_id: + return f"https://discord.com/channels/{guild_id}/{channel_id}/{message_id}" + if channel.get("type") == "dm": + return f"https://discord.com/channels/@me/{channel_id}/{message_id}" + return None + + +def _format_message(message: Dict[str, Any], channel: Dict[str, Any]) -> Dict[str, Any]: + author = message.get("author") or {} + content = (message.get("content") or "").strip() + attachments = [ + { + "filename": attachment.get("filename"), + "url": attachment.get("url"), + "content_type": attachment.get("content_type"), + } + for attachment in message.get("attachments", []) + if attachment.get("filename") or attachment.get("url") + ] + if attachments: + attachment_names = ", ".join( + attachment["filename"] or "attachment" for attachment in attachments + ) + if content: + content = f"{content}\nAttachments: {attachment_names}" + else: + content = f"[Attachment only] {attachment_names}" + + return { + "id": str(message.get("id")), + "timestamp": message.get("timestamp"), + "author_id": str(author.get("id")) if author.get("id") is not None else None, + "author_name": author.get("global_name") or author.get("username"), + "content": _truncate_text(content), + "permalink": _message_permalink(message, channel), + "attachments": attachments, + } + + +async def _request_json( + client: Any, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, +) -> Any: + import httpx + + url = f"{DISCORD_API_BASE}{path}" + try: + response = await client.request(method, url, params=params) + except httpx.HTTPError as exc: + raise DiscordReadError(f"Discord API request failed: {exc}") from exc + + if response.status_code >= 400: + try: + body = response.json() + except ValueError: + body = response.text + raise DiscordReadError(f"Discord API error ({response.status_code}) for {path}: {body}") + + try: + return response.json() + except ValueError as exc: + raise DiscordReadError(f"Discord API returned invalid JSON for {path}") from exc + + +async def _fetch_guild_name( + client: Any, + guild_id: Optional[str], + guild_names: Dict[str, Optional[str]], +) -> Optional[str]: + if not guild_id: + return None + if guild_id in guild_names: + return guild_names[guild_id] + + data = await _request_json(client, "GET", f"/guilds/{guild_id}") + guild_name = data.get("name") or guild_id + guild_names[guild_id] = guild_name + return guild_name + + +async def _build_channel_entry( + client: Any, + channel_data: Dict[str, Any], + guild_names: Dict[str, Optional[str]], + parent_cache: Dict[str, Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + channel_type = channel_data.get("type") + channel_id = str(channel_data.get("id") or "").strip() + if not channel_id or channel_type not in READABLE_TYPES: + return None + + guild_id = ( + str(channel_data.get("guild_id")) if channel_data.get("guild_id") is not None else None + ) + parent_id = ( + str(channel_data.get("parent_id")) if channel_data.get("parent_id") is not None else None + ) + guild_name = await _fetch_guild_name(client, guild_id, guild_names) if guild_id else None + + parent_name = None + if parent_id: + parent = parent_cache.get(parent_id) + if parent is None: + try: + parent = await _request_json(client, "GET", f"/channels/{parent_id}") + parent_cache[parent_id] = parent + except DiscordReadError: + parent = None + if parent is not None: + parent_name = parent.get("name") + + if channel_type in DM_TYPES: + recipients = channel_data.get("recipients") or [] + recipient = recipients[0] if recipients else {} + name = ( + recipient.get("global_name") + or recipient.get("username") + or recipient.get("id") + or channel_id + ) + kind = "dm" + else: + name = channel_data.get("name") or channel_id + kind = "thread" if channel_type in THREAD_TYPES else "channel" + + qualified_name = _format_channel_name( + kind=kind, + name=name, + guild_name=guild_name, + parent_name=parent_name, + ) + + return { + "id": channel_id, + "name": name, + "qualified_name": qualified_name, + "type": kind, + "discord_type": channel_type, + "guild_id": guild_id, + "guild_name": guild_name, + "parent_id": parent_id, + "parent_name": parent_name, + } + + +async def _fetch_channel( + client: Any, + channel_id: str, + guild_names: Dict[str, Optional[str]], + parent_cache: Dict[str, Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + data = await _request_json(client, "GET", f"/channels/{channel_id}") + return await _build_channel_entry(client, data, guild_names, parent_cache) + + +async def _discover_guild_scope( + client: Any, + guild_id: str, + *, + guild_names: Dict[str, Optional[str]], + parent_cache: Dict[str, Dict[str, Any]], +) -> Dict[str, Dict[str, Any]]: + entries: Dict[str, Dict[str, Any]] = {} + guild_name = await _fetch_guild_name(client, guild_id, guild_names) + channel_payloads = await _request_json(client, "GET", f"/guilds/{guild_id}/channels") + + for payload in channel_payloads: + entry = await _build_channel_entry(client, payload, guild_names, parent_cache) + if entry is None: + continue + entry["guild_name"] = guild_name + parent_cache.setdefault(entry["id"], payload) + entries[entry["id"]] = entry + + try: + active_threads = await _request_json(client, "GET", f"/guilds/{guild_id}/threads/active") + except DiscordReadError: + active_threads = {} + + for payload in active_threads.get("threads", []) or []: + entry = await _build_channel_entry(client, payload, guild_names, parent_cache) + if entry is None: + continue + entry["guild_name"] = guild_name + entries[entry["id"]] = entry + + return entries + + +async def _discover_accessible_channels() -> List[Dict[str, Any]]: + import httpx + + token = _load_discord_token() + if not token: + raise DiscordReadError("DISCORD_BOT_TOKEN is not configured.") + + allowed_guilds = _parse_csv_env("DISCORD_READ_ALLOWED_GUILDS") + allowed_channels = _parse_csv_env("DISCORD_READ_ALLOWED_CHANNELS") + include_dms = _env_bool("DISCORD_READ_INCLUDE_DMS", default=False) + current = _get_current_discord_context() + + dm_candidates: Set[str] = set() + if include_dms: + for entry in _load_dm_session_candidates(): + channel_id = str(entry.get("id") or "").split(":", 1)[0].strip() + if channel_id: + dm_candidates.add(channel_id) + if current.chat_id: + dm_candidates.add(current.chat_id) + + allowed_direct_ids = set(allowed_channels) | current.allowed_ids + + if not allowed_guilds and not allowed_channels and not current.allowed_ids: + raise DiscordReadError( + "Discord read scope is empty. Set DISCORD_READ_ALLOWED_GUILDS or " + "DISCORD_READ_ALLOWED_CHANNELS, or use the tool from an active Discord session." + ) + + headers = {"Authorization": f"Bot {token}"} + timeout = httpx.Timeout(30.0) + + entries: Dict[str, Dict[str, Any]] = {} + guild_names: Dict[str, Optional[str]] = {} + parent_cache: Dict[str, Dict[str, Any]] = {} + guilds_with_thread_scan: Set[str] = set() + + async with httpx.AsyncClient(headers=headers, timeout=timeout, follow_redirects=True) as client: + for guild_id in sorted(allowed_guilds): + for channel_id, entry in ( + await _discover_guild_scope( + client, + guild_id, + guild_names=guild_names, + parent_cache=parent_cache, + ) + ).items(): + entries[channel_id] = entry + guilds_with_thread_scan.add(guild_id) + + direct_ids_to_fetch = set(allowed_direct_ids) + if include_dms or (current.chat_id and current.chat_id in current.allowed_ids): + direct_ids_to_fetch.update(dm_candidates) + + channel_entries_from_ids: List[Dict[str, Any]] = [] + for channel_id in sorted(direct_ids_to_fetch): + try: + entry = await _fetch_channel(client, channel_id, guild_names, parent_cache) + except DiscordReadError as exc: + logger.debug("Discord read tool could not fetch channel %s: %s", channel_id, exc) + continue + if entry is None: + continue + channel_entries_from_ids.append(entry) + entries[entry["id"]] = entry + + for entry in channel_entries_from_ids: + guild_id = entry.get("guild_id") + if not guild_id or guild_id in guilds_with_thread_scan: + continue + if entry["id"] not in allowed_channels: + continue + try: + active_threads = await _request_json(client, "GET", f"/guilds/{guild_id}/threads/active") + except DiscordReadError: + continue + guilds_with_thread_scan.add(guild_id) + for payload in active_threads.get("threads", []) or []: + if str(payload.get("parent_id")) != entry["id"]: + continue + thread_entry = await _build_channel_entry(client, payload, guild_names, parent_cache) + if thread_entry is None: + continue + entries[thread_entry["id"]] = thread_entry + + accessible: List[Dict[str, Any]] = [] + current_ids = current.allowed_ids + for entry in entries.values(): + channel_id = entry["id"] + guild_id = entry.get("guild_id") + parent_id = entry.get("parent_id") + is_dm = entry["type"] == "dm" + + if channel_id in current_ids: + allow_reason = "current_session" + elif guild_id and guild_id in allowed_guilds: + allow_reason = "allowed_guild" + elif channel_id in allowed_channels: + allow_reason = "allowed_channel" + elif parent_id and parent_id in allowed_channels and entry["type"] == "thread": + allow_reason = "allowed_parent_channel" + elif is_dm and include_dms: + allow_reason = "allowed_dm" + else: + continue + + accessible.append( + { + **entry, + "allow_reason": allow_reason, + "is_current": channel_id in current_ids, + } + ) + + accessible.sort( + key=lambda entry: ( + 0 if entry.get("is_current") else 1, + entry.get("guild_name") or "", + entry.get("parent_name") or "", + entry.get("name") or "", + entry["id"], + ) + ) + return accessible + + +def _resolve_channel_ref( + ref: Optional[str], + channels: Iterable[Dict[str, Any]], +) -> Dict[str, Any]: + current = _get_current_discord_context() + if not ref: + if not current.chat_id: + raise DiscordReadError( + "No Discord channel specified and there is no active Discord session to infer one from." + ) + ref = current.chat_id + + query = _normalize_ref(ref) + if not query: + raise DiscordReadError("Channel reference cannot be empty.") + + channels = list(channels) + by_id = {entry["id"]: entry for entry in channels} + if query in by_id: + return by_id[query] + + matches = [entry for entry in channels if query in _channel_aliases(entry)] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + candidates = ", ".join(entry["qualified_name"] for entry in matches[:5]) + raise DiscordReadError(f"Channel reference '{ref}' is ambiguous. Use one of: {candidates}") + + raise DiscordReadError( + f"Channel reference '{ref}' is not accessible. Use discord_list_channels to inspect the scoped channels." + ) + + +async def _fetch_recent_messages( + channel_id: str, + *, + limit: int, +) -> List[Dict[str, Any]]: + import httpx + + token = _load_discord_token() + if not token: + raise DiscordReadError("DISCORD_BOT_TOKEN is not configured.") + + headers = {"Authorization": f"Bot {token}"} + timeout = httpx.Timeout(30.0) + async with httpx.AsyncClient(headers=headers, timeout=timeout, follow_redirects=True) as client: + return await _request_json( + client, + "GET", + f"/channels/{channel_id}/messages", + params={"limit": limit}, + ) + + +async def _search_recent_messages( + channel_id: str, + *, + query: str, + result_limit: int, + scan_limit: int, +) -> Dict[str, Any]: + import httpx + + token = _load_discord_token() + if not token: + raise DiscordReadError("DISCORD_BOT_TOKEN is not configured.") + + headers = {"Authorization": f"Bot {token}"} + timeout = httpx.Timeout(30.0) + normalized_query = query.lower() + matches: List[Dict[str, Any]] = [] + scanned = 0 + before: Optional[str] = None + + async with httpx.AsyncClient(headers=headers, timeout=timeout, follow_redirects=True) as client: + while scanned < scan_limit and len(matches) < result_limit: + batch_size = min(MESSAGE_PAGE_SIZE, scan_limit - scanned) + params: Dict[str, Any] = {"limit": batch_size} + if before: + params["before"] = before + page = await _request_json( + client, + "GET", + f"/channels/{channel_id}/messages", + params=params, + ) + if not page: + break + + scanned += len(page) + for message in page: + author = message.get("author") or {} + haystack_parts = [ + message.get("content") or "", + author.get("global_name") or "", + author.get("username") or "", + ] + for attachment in message.get("attachments", []) or []: + if attachment.get("filename"): + haystack_parts.append(attachment["filename"]) + haystack = "\n".join(haystack_parts).lower() + if normalized_query in haystack: + matches.append(message) + if len(matches) >= result_limit: + break + + before = str(page[-1].get("id")) if page else None + if len(page) < batch_size or not before: + break + + return {"matches": matches, "scanned": scanned} + + +async def _discord_list_channels_impl(args: Dict[str, Any], **_kwargs) -> str: + limit = _coerce_limit(args.get("limit"), default=25, minimum=1, maximum=LIST_CHANNELS_MAX) + query = str(args.get("query") or "").strip().lower() + + channels = await _discover_accessible_channels() + if query: + channels = [ + entry + for entry in channels + if query in entry["qualified_name"].lower() or query in (entry.get("name") or "").lower() + ] + + visible = channels[:limit] + return _json( + { + "channels": [ + { + "id": entry["id"], + "name": entry["name"], + "qualified_name": entry["qualified_name"], + "type": entry["type"], + "guild_id": entry.get("guild_id"), + "guild_name": entry.get("guild_name"), + "parent_id": entry.get("parent_id"), + "parent_name": entry.get("parent_name"), + "is_current": entry.get("is_current", False), + "allow_reason": entry.get("allow_reason"), + } + for entry in visible + ], + "returned": len(visible), + "total_accessible": len(channels), + "limit": limit, + } + ) + + +async def _discord_read_history_impl(args: Dict[str, Any], **_kwargs) -> str: + limit = _coerce_limit(args.get("limit"), default=20, minimum=1, maximum=HISTORY_MAX) + channels = await _discover_accessible_channels() + channel = _resolve_channel_ref(args.get("channel"), channels) + messages = await _fetch_recent_messages(channel["id"], limit=limit) + + return _json( + { + "channel": { + "id": channel["id"], + "qualified_name": channel["qualified_name"], + "type": channel["type"], + "guild_id": channel.get("guild_id"), + "guild_name": channel.get("guild_name"), + }, + "messages": [_format_message(message, channel) for message in messages[:limit]], + "returned": min(len(messages), limit), + "limit": limit, + } + ) + + +async def _discord_search_messages_impl(args: Dict[str, Any], **_kwargs) -> str: + query = str(args.get("query") or "").strip() + if len(query) < 2: + return _json({"error": "Query must be at least 2 characters long."}) + + result_limit = _coerce_limit(args.get("limit"), default=10, minimum=1, maximum=SEARCH_MAX) + scan_limit = _coerce_limit( + args.get("scan_limit"), + default=SEARCH_SCAN_MAX, + minimum=1, + maximum=SEARCH_SCAN_MAX, + ) + + channels = await _discover_accessible_channels() + channel = _resolve_channel_ref(args.get("channel"), channels) + search_result = await _search_recent_messages( + channel["id"], + query=query, + result_limit=result_limit, + scan_limit=scan_limit, + ) + + return _json( + { + "channel": { + "id": channel["id"], + "qualified_name": channel["qualified_name"], + "type": channel["type"], + "guild_id": channel.get("guild_id"), + "guild_name": channel.get("guild_name"), + }, + "query": query, + "matches": [_format_message(message, channel) for message in search_result["matches"]], + "returned": len(search_result["matches"]), + "limit": result_limit, + "scanned_messages": search_result["scanned"], + "scan_limit": scan_limit, + } + ) + + +async def _wrap_tool(handler, args: Dict[str, Any]) -> str: + try: + return await handler(args) + except DiscordReadError as exc: + return _json({"error": str(exc)}) + except Exception as exc: # pragma: no cover - defensive fallback + logger.exception("Discord read tool failed: %s", exc) + return _json({"error": f"Discord read tool failed: {type(exc).__name__}: {exc}"}) + + +DISCORD_LIST_CHANNELS_SCHEMA = { + "name": "discord_list_channels", + "description": ( + "List the Discord channels and threads that are readable within the configured allowlist scope. " + "Results are bounded and include the current Discord session target when one is active." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional case-insensitive substring filter applied to readable channel names.", + }, + "limit": { + "type": "integer", + "description": f"Maximum number of channels to return (1-{LIST_CHANNELS_MAX}).", + }, + }, + "required": [], + }, +} + + +DISCORD_READ_HISTORY_SCHEMA = { + "name": "discord_read_history", + "description": ( + "Read the most recent messages from one readable Discord channel or thread. " + "If no channel is provided, the current Discord session target is used." + ), + "parameters": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Readable Discord channel reference or numeric channel ID within the configured scope.", + }, + "limit": { + "type": "integer", + "description": f"Maximum number of recent messages to return (1-{HISTORY_MAX}).", + }, + }, + "required": [], + }, +} + + +DISCORD_SEARCH_MESSAGES_SCHEMA = { + "name": "discord_search_messages", + "description": ( + "Search a bounded recent window of messages in one readable Discord channel or thread. " + "If no channel is provided, the current Discord session target is used." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Case-insensitive text query to search for.", + }, + "channel": { + "type": "string", + "description": "Readable Discord channel reference or numeric channel ID within the configured scope.", + }, + "limit": { + "type": "integer", + "description": f"Maximum number of matching messages to return (1-{SEARCH_MAX}).", + }, + "scan_limit": { + "type": "integer", + "description": ( + f"Maximum number of recent messages to scan while searching (1-{SEARCH_SCAN_MAX}). " + "Higher values increase coverage but stay within a hard safety bound." + ), + }, + }, + "required": ["query"], + }, +} + + +registry.register( + name="discord_list_channels", + toolset="discord_read", + schema=DISCORD_LIST_CHANNELS_SCHEMA, + handler=lambda args, **kwargs: _wrap_tool(_discord_list_channels_impl, args), + check_fn=_check_discord_read_requirements, + is_async=True, + emoji="💬", +) + +registry.register( + name="discord_read_history", + toolset="discord_read", + schema=DISCORD_READ_HISTORY_SCHEMA, + handler=lambda args, **kwargs: _wrap_tool(_discord_read_history_impl, args), + check_fn=_check_discord_read_requirements, + is_async=True, + emoji="📜", +) + +registry.register( + name="discord_search_messages", + toolset="discord_read", + schema=DISCORD_SEARCH_MESSAGES_SCHEMA, + handler=lambda args, **kwargs: _wrap_tool(_discord_search_messages_impl, args), + check_fn=_check_discord_read_requirements, + is_async=True, + emoji="🔎", +) diff --git a/toolsets.py b/toolsets.py index ad762555bdb0..4f6f713a2c8b 100644 --- a/toolsets.py +++ b/toolsets.py @@ -60,6 +60,8 @@ "cronjob", # Cross-platform messaging (gated on gateway running via check_fn) "send_message", + # Discord read-only access (gated on Discord bot config) + "discord_list_channels", "discord_read_history", "discord_search_messages", # Honcho memory tools (gated on honcho being active via check_fn) "honcho_context", "honcho_profile", "honcho_search", "honcho_conclude", # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) @@ -135,6 +137,12 @@ "tools": ["send_message"], "includes": [] }, + + "discord_read": { + "description": "Read-only Discord channel listing, recent history, and bounded message search", + "tools": ["discord_list_channels", "discord_read_history", "discord_search_messages"], + "includes": [] + }, "rl": { "description": "RL training tools for running reinforcement learning on Tinker-Atropos", @@ -277,6 +285,8 @@ "execute_code", "delegate_task", # Cronjob management "cronjob", + # Read-only Discord access (gated on Discord bot config) + "discord_list_channels", "discord_read_history", "discord_search_messages", # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", # Honcho memory tools (gated on honcho being active via check_fn) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 715c9fbc1507..39577a578405 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -159,6 +159,9 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels | | `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required | | `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported | +| `DISCORD_READ_ALLOWED_GUILDS` | Comma-separated Discord guild IDs whose channels/threads are readable via the Discord read tools | +| `DISCORD_READ_ALLOWED_CHANNELS` | Comma-separated Discord channel or thread IDs readable via the Discord read tools | +| `DISCORD_READ_INCLUDE_DMS` | Include already-known Discord DM channels in the Discord read tools (`true`/`false`) | | `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) | | `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) | | `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 48d76dd80b27..480e988c66f7 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1365,6 +1365,27 @@ group_sessions_per_user: true # true = per-user isolation in groups/channels, f For the behavior details and examples, see [Sessions](/docs/user-guide/sessions) and the [Discord guide](/docs/user-guide/messaging/discord). +## Discord Read Scope + +If you want Hermes to use the read-only Discord tools for bounded channel listing, recent history, and recent-message search, scope them explicitly: + +```yaml +discord: + read: + allowed_guilds: + - "123456789012345678" + allowed_channels: + - "234567890123456789" + - "345678901234567890" + include_dms: false +``` + +- `allowed_guilds` grants read-only access to readable text channels and active threads in those guilds. +- `allowed_channels` grants read-only access to specific channels or threads; if you allow a parent text channel, active child threads are included. +- `include_dms: true` lets the read tools surface Discord DM channels Hermes already knows about from prior sessions. +- Even without these allowlists, the current Discord session target is allowed automatically so the agent can inspect the conversation it is already in. +- Search is intentionally bounded to a recent message window; it is not a full-server index. + ## Unauthorized DM Behavior Control what Hermes does when an unknown user sends a direct message: diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index df97930a6753..d75269782572 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -17,6 +17,7 @@ Before setup, here's the part most people want to know: how Hermes behaves once | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. | | **Server channels** | By default, Hermes only responds when you `@mention` it. If you post in a channel without mentioning it, Hermes ignores the message. | | **Free-response channels** | You can make specific channels mention-free with `DISCORD_FREE_RESPONSE_CHANNELS`, or disable mentions globally with `DISCORD_REQUIRE_MENTION=false`. | +| **Read-only Discord tools** | Optional Discord read tools can list scoped channels, read recent history, and search a bounded recent message window in allowlisted channels or the current session target. | | **Threads** | Hermes replies in the same thread. Mention rules still apply unless that thread or its parent channel is configured as free-response. Threads stay isolated from the parent channel for session history. | | **Shared channels with multiple users** | By default, Hermes isolates session history per user inside the channel for safety and clarity. Two people talking in the same channel do not share one transcript unless you explicitly disable that. | @@ -79,6 +80,24 @@ With `group_sessions_per_user: false`: - the whole room shares one running-agent slot for that channel/thread - follow-up messages from different people can interrupt or queue behind each other +## Read-only Discord Tools + +Hermes can optionally expose three read-only Discord tools: + +- `discord_list_channels` +- `discord_read_history` +- `discord_search_messages` + +They are intentionally limited: + +- they use Discord HTTP API v10 in read-only mode +- they only work for explicitly allowlisted guilds/channels, plus the current Discord session target +- DM access is opt-in with `DISCORD_READ_INCLUDE_DMS` +- message search is bounded to a recent message window, not the full server history +- they do not grant moderation, role management, or unrestricted member listing + +This keeps the feature useful without turning the bot into a broad server crawler. + This guide walks you through the full setup process — from creating your bot on Discord's Developer Portal to sending your first message. ## Step 1: Create a Discord Application @@ -253,6 +272,11 @@ DISCORD_ALLOWED_USERS=284102345871466496 # Optional: channels where bot responds without @mention (comma-separated channel IDs) # DISCORD_FREE_RESPONSE_CHANNELS=1234567890,9876543210 + +# Optional: read-only Discord tools scope +# DISCORD_READ_ALLOWED_GUILDS=123456789012345678 +# DISCORD_READ_ALLOWED_CHANNELS=234567890123456789,345678901234567890 +# DISCORD_READ_INCLUDE_DMS=true ``` Optional behavior settings in `~/.hermes/config.yaml`: @@ -260,11 +284,18 @@ Optional behavior settings in `~/.hermes/config.yaml`: ```yaml discord: require_mention: true + read: + allowed_guilds: + - "123456789012345678" + allowed_channels: + - "234567890123456789" + include_dms: false group_sessions_per_user: true ``` - `discord.require_mention: true` keeps Hermes quiet in normal server traffic unless mentioned +- `discord.read.*` scopes the optional read-only Discord tools to specific guilds/channels and known DMs - `group_sessions_per_user: true` keeps each participant's context isolated inside shared channels and threads ### Start the Gateway @@ -338,6 +369,12 @@ For the full setup and operational guide, see: **Fix**: Re-invite the bot with the correct permissions using the URL from Step 5, or manually adjust the bot's role permissions in Server Settings → Roles. +### Discord read tools say a channel is not accessible + +**Cause**: The channel, thread, or guild is outside the configured read scope. + +**Fix**: Add the relevant ID to `DISCORD_READ_ALLOWED_GUILDS` or `DISCORD_READ_ALLOWED_CHANNELS`, or set it in `discord.read` inside `~/.hermes/config.yaml`. The current Discord session target is auto-allowed, but nothing else is. + ### Bot is offline **Cause**: The Hermes gateway isn't running, or the token is incorrect. @@ -371,4 +408,3 @@ Always set `DISCORD_ALLOWED_USERS` to restrict who can interact with the bot. Wi For more information on securing your Hermes Agent deployment, see the [Security Guide](../security.md). -