Skip to content
Open
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
39 changes: 39 additions & 0 deletions docs/plans/2026-04-02-discord-read-tools.md
Original file line number Diff line number Diff line change
@@ -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.
130 changes: 109 additions & 21 deletions gateway/channel_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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 = []
Expand Down Expand Up @@ -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"]

Expand Down
15 changes: 15 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,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()
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()

Expand Down
9 changes: 5 additions & 4 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,27 @@ def ensure_hermes_home():
"password": False,
"category": "messaging",
},
"DISCORD_READ_ALLOWED_GUILDS": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are non-secret behavioral allowlist settings. Please retain discord.read.* in config.yaml as the user-facing interface and remove these entries from .env setup; AGENTS.md requires behavioral settings to be documented in config rather than .env.

"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, "
Expand Down
1 change: 1 addition & 0 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ def _discover_tools():
"tools.delegate_tool",
"tools.process_registry",
"tools.send_message_tool",
"tools.discord_read_tool",
# "tools.honcho_tools", # Removed — Honcho is now a memory provider plugin
"tools.homeassistant_tool",
]
Expand Down
1 change: 1 addition & 0 deletions tests/gateway/test_api_server_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
43 changes: 43 additions & 0 deletions tests/gateway/test_channel_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import json
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch

from gateway.channel_directory import (
resolve_channel_name,
format_directory_for_display,
load_directory,
_build_from_sessions,
_build_discord,
DIRECTORY_PATH,
)

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