hermes-agent/tests/tools/test_discord_tool.py
Teknium ef73367fc5
feat: add Discord server introspection and management tool (#4753)
* feat: add Discord server introspection and management tool

Add a discord_server tool that gives the agent the ability to interact
with Discord servers when running on the Discord gateway. Uses Discord
REST API directly with the bot token — no dependency on the gateway
adapter's discord.py client.

The tool is only included in the hermes-discord toolset (zero cost for
users on other platforms) and gated on DISCORD_BOT_TOKEN via check_fn.

Actions (14):
- Introspection: list_guilds, server_info, list_channels, channel_info,
  list_roles, member_info, search_members
- Messages: fetch_messages, list_pins, pin_message, unpin_message
- Management: create_thread, add_role, remove_role

This addresses a gap where users on Discord could not ask Hermes to
review server structure, channels, roles, or members — a task competing
agents (OpenClaw) handle out of the box.

Files changed:
- tools/discord_tool.py (new): Tool implementation + registration
- model_tools.py: Add to discovery list
- toolsets.py: Add to hermes-discord toolset only
- tests/tools/test_discord_tool.py (new): 43 tests covering all actions,
  validation, error handling, registration, and toolset scoping

* feat(discord): intent-aware schema filtering + config allowlist + schema cleanup

- _detect_capabilities() hits GET /applications/@me once per process
  to read GUILD_MEMBERS / MESSAGE_CONTENT privileged intent bits.
- Schema is rebuilt per-session in model_tools.get_tool_definitions:
  hides search_members / member_info when GUILD_MEMBERS intent is off,
  annotates fetch_messages description when MESSAGE_CONTENT is off.
- New config key discord.server_actions (comma-separated or YAML list)
  lets users restrict which actions the agent can call, intersected
  with intent availability. Unknown names are warned and dropped.
- Defense-in-depth: runtime handler re-checks the allowlist so a stale
  cached schema cannot bypass a tightened config.
- Schema description rewritten as an action-first manifest (signature
  per action) instead of per-parameter 'required for X, Y, Z' cross-refs.
  ~25% shorter; model can see each action's required params at a glance.
- Added bounds: limit gets minimum=1 maximum=100, auto_archive_duration
  becomes an enum of the 4 valid Discord values.
- 403 enrichment: runtime 403 errors are mapped to actionable guidance
  (which permission is missing and what to do about it) instead of the
  raw Discord error body.
- 36 new tests: capability detection with caching and force refresh,
  config allowlist parsing (string/list/invalid/unknown), intent+allowlist
  intersection, dynamic schema build, runtime allowlist enforcement,
  403 enrichment, and model_tools integration wiring.
2026-04-19 11:52:19 -07:00

979 lines
42 KiB
Python

"""Tests for the Discord server introspection and management tool."""
import json
import os
import urllib.error
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
from tools.discord_tool import (
DiscordAPIError,
_ACTIONS,
_available_actions,
_build_schema,
_channel_type_name,
_detect_capabilities,
_discord_request,
_enrich_403,
_get_bot_token,
_load_allowed_actions_config,
_reset_capability_cache,
check_discord_tool_requirements,
discord_server,
get_dynamic_schema,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_urlopen(response_data, status=200):
"""Create a mock for urllib.request.urlopen."""
mock_resp = MagicMock()
mock_resp.status = status
mock_resp.read.return_value = json.dumps(response_data).encode("utf-8")
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp
# ---------------------------------------------------------------------------
# Token / check_fn
# ---------------------------------------------------------------------------
class TestCheckRequirements:
def test_no_token(self, monkeypatch):
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
assert check_discord_tool_requirements() is False
def test_empty_token(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "")
assert check_discord_tool_requirements() is False
def test_valid_token(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token-123")
assert check_discord_tool_requirements() is True
def test_get_bot_token(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", " my-token ")
assert _get_bot_token() == "my-token"
def test_get_bot_token_missing(self, monkeypatch):
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
assert _get_bot_token() is None
# ---------------------------------------------------------------------------
# Channel type names
# ---------------------------------------------------------------------------
class TestChannelTypeNames:
def test_known_types(self):
assert _channel_type_name(0) == "text"
assert _channel_type_name(2) == "voice"
assert _channel_type_name(4) == "category"
assert _channel_type_name(5) == "announcement"
assert _channel_type_name(13) == "stage"
assert _channel_type_name(15) == "forum"
def test_unknown_type(self):
assert _channel_type_name(99) == "unknown(99)"
# ---------------------------------------------------------------------------
# Discord API request helper
# ---------------------------------------------------------------------------
class TestDiscordRequest:
@patch("tools.discord_tool.urllib.request.urlopen")
def test_get_request(self, mock_urlopen_fn):
mock_urlopen_fn.return_value = _mock_urlopen({"ok": True})
result = _discord_request("GET", "/test", "token123")
assert result == {"ok": True}
# Verify the request was constructed correctly
call_args = mock_urlopen_fn.call_args
req = call_args[0][0]
assert "https://discord.com/api/v10/test" in req.full_url
assert req.get_header("Authorization") == "Bot token123"
assert req.get_method() == "GET"
@patch("tools.discord_tool.urllib.request.urlopen")
def test_get_with_params(self, mock_urlopen_fn):
mock_urlopen_fn.return_value = _mock_urlopen({"ok": True})
_discord_request("GET", "/test", "tok", params={"foo": "bar"})
req = mock_urlopen_fn.call_args[0][0]
assert "foo=bar" in req.full_url
@patch("tools.discord_tool.urllib.request.urlopen")
def test_post_with_body(self, mock_urlopen_fn):
mock_urlopen_fn.return_value = _mock_urlopen({"id": "123"})
result = _discord_request("POST", "/channels", "tok", body={"name": "test"})
assert result == {"id": "123"}
req = mock_urlopen_fn.call_args[0][0]
assert req.data == json.dumps({"name": "test"}).encode("utf-8")
@patch("tools.discord_tool.urllib.request.urlopen")
def test_204_returns_none(self, mock_urlopen_fn):
mock_resp = _mock_urlopen({}, status=204)
mock_urlopen_fn.return_value = mock_resp
result = _discord_request("PUT", "/pins/1", "tok")
assert result is None
@patch("tools.discord_tool.urllib.request.urlopen")
def test_http_error(self, mock_urlopen_fn):
error_body = json.dumps({"message": "Missing Access"}).encode()
http_error = urllib.error.HTTPError(
url="https://discord.com/api/v10/test",
code=403,
msg="Forbidden",
hdrs={},
fp=BytesIO(error_body),
)
mock_urlopen_fn.side_effect = http_error
with pytest.raises(DiscordAPIError) as exc_info:
_discord_request("GET", "/test", "tok")
assert exc_info.value.status == 403
assert "Missing Access" in exc_info.value.body
# ---------------------------------------------------------------------------
# Main handler: validation
# ---------------------------------------------------------------------------
class TestDiscordServerValidation:
def test_no_token(self, monkeypatch):
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
result = json.loads(discord_server(action="list_guilds"))
assert "error" in result
assert "DISCORD_BOT_TOKEN" in result["error"]
def test_unknown_action(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_server(action="bad_action"))
assert "error" in result
assert "Unknown action" in result["error"]
assert "available_actions" in result
def test_missing_required_guild_id(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_server(action="list_channels"))
assert "error" in result
assert "guild_id" in result["error"]
def test_missing_required_channel_id(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_server(action="fetch_messages"))
assert "error" in result
assert "channel_id" in result["error"]
def test_missing_multiple_params(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_server(action="add_role"))
assert "error" in result
assert "guild_id" in result["error"]
assert "user_id" in result["error"]
assert "role_id" in result["error"]
# ---------------------------------------------------------------------------
# Action: list_guilds
# ---------------------------------------------------------------------------
class TestListGuilds:
@patch("tools.discord_tool._discord_request")
def test_list_guilds(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [
{"id": "111", "name": "Test Server", "icon": "abc", "owner": True, "permissions": "123"},
{"id": "222", "name": "Other Server", "icon": None, "owner": False, "permissions": "456"},
]
result = json.loads(discord_server(action="list_guilds"))
assert result["count"] == 2
assert result["guilds"][0]["name"] == "Test Server"
assert result["guilds"][1]["id"] == "222"
mock_req.assert_called_once_with("GET", "/users/@me/guilds", "test-token")
# ---------------------------------------------------------------------------
# Action: server_info
# ---------------------------------------------------------------------------
class TestServerInfo:
@patch("tools.discord_tool._discord_request")
def test_server_info(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {
"id": "111",
"name": "My Server",
"description": "A cool server",
"icon": "icon_hash",
"owner_id": "999",
"approximate_member_count": 42,
"approximate_presence_count": 10,
"features": ["COMMUNITY"],
"premium_tier": 2,
"premium_subscription_count": 5,
"verification_level": 1,
}
result = json.loads(discord_server(action="server_info", guild_id="111"))
assert result["name"] == "My Server"
assert result["member_count"] == 42
assert result["online_count"] == 10
mock_req.assert_called_once_with(
"GET", "/guilds/111", "test-token", params={"with_counts": "true"}
)
# ---------------------------------------------------------------------------
# Action: list_channels
# ---------------------------------------------------------------------------
class TestListChannels:
@patch("tools.discord_tool._discord_request")
def test_list_channels_organized(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [
{"id": "10", "name": "General", "type": 4, "position": 0, "parent_id": None},
{"id": "11", "name": "chat", "type": 0, "position": 0, "parent_id": "10", "topic": "Main chat", "nsfw": False},
{"id": "12", "name": "voice", "type": 2, "position": 1, "parent_id": "10", "topic": None, "nsfw": False},
{"id": "13", "name": "no-category", "type": 0, "position": 0, "parent_id": None, "topic": None, "nsfw": False},
]
result = json.loads(discord_server(action="list_channels", guild_id="111"))
assert result["total_channels"] == 3 # excludes the category itself
groups = result["channel_groups"]
# Uncategorized first
assert groups[0]["category"] is None
assert len(groups[0]["channels"]) == 1
assert groups[0]["channels"][0]["name"] == "no-category"
# Then the category
assert groups[1]["category"]["name"] == "General"
assert len(groups[1]["channels"]) == 2
@patch("tools.discord_tool._discord_request")
def test_empty_guild(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = []
result = json.loads(discord_server(action="list_channels", guild_id="111"))
assert result["total_channels"] == 0
# ---------------------------------------------------------------------------
# Action: channel_info
# ---------------------------------------------------------------------------
class TestChannelInfo:
@patch("tools.discord_tool._discord_request")
def test_channel_info(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {
"id": "11", "name": "general", "type": 0, "guild_id": "111",
"topic": "Welcome!", "nsfw": False, "position": 0,
"parent_id": "10", "rate_limit_per_user": 0, "last_message_id": "999",
}
result = json.loads(discord_server(action="channel_info", channel_id="11"))
assert result["name"] == "general"
assert result["type"] == "text"
assert result["guild_id"] == "111"
# ---------------------------------------------------------------------------
# Action: list_roles
# ---------------------------------------------------------------------------
class TestListRoles:
@patch("tools.discord_tool._discord_request")
def test_list_roles_sorted(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [
{"id": "1", "name": "@everyone", "position": 0, "color": 0, "mentionable": False, "managed": False, "hoist": False},
{"id": "2", "name": "Admin", "position": 2, "color": 16711680, "mentionable": True, "managed": False, "hoist": True},
{"id": "3", "name": "Mod", "position": 1, "color": 255, "mentionable": True, "managed": False, "hoist": True},
]
result = json.loads(discord_server(action="list_roles", guild_id="111"))
assert result["count"] == 3
# Should be sorted by position descending
assert result["roles"][0]["name"] == "Admin"
assert result["roles"][0]["color"] == "#ff0000"
assert result["roles"][1]["name"] == "Mod"
assert result["roles"][2]["name"] == "@everyone"
# ---------------------------------------------------------------------------
# Action: member_info
# ---------------------------------------------------------------------------
class TestMemberInfo:
@patch("tools.discord_tool._discord_request")
def test_member_info(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {
"user": {"id": "42", "username": "testuser", "global_name": "Test User", "avatar": "abc", "bot": False},
"nick": "Testy",
"roles": ["2", "3"],
"joined_at": "2024-01-01T00:00:00Z",
"premium_since": None,
}
result = json.loads(discord_server(action="member_info", guild_id="111", user_id="42"))
assert result["username"] == "testuser"
assert result["nickname"] == "Testy"
assert result["roles"] == ["2", "3"]
# ---------------------------------------------------------------------------
# Action: search_members
# ---------------------------------------------------------------------------
class TestSearchMembers:
@patch("tools.discord_tool._discord_request")
def test_search_members(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [
{"user": {"id": "42", "username": "testuser", "global_name": "Test", "bot": False}, "nick": None, "roles": []},
]
result = json.loads(discord_server(action="search_members", guild_id="111", query="test"))
assert result["count"] == 1
assert result["members"][0]["username"] == "testuser"
mock_req.assert_called_once_with(
"GET", "/guilds/111/members/search", "test-token",
params={"query": "test", "limit": "50"},
)
@patch("tools.discord_tool._discord_request")
def test_search_members_limit_capped(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = []
discord_server(action="search_members", guild_id="111", query="x", limit=200)
call_params = mock_req.call_args[1]["params"]
assert call_params["limit"] == "100" # Capped at 100
# ---------------------------------------------------------------------------
# Action: fetch_messages
# ---------------------------------------------------------------------------
class TestFetchMessages:
@patch("tools.discord_tool._discord_request")
def test_fetch_messages(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [
{
"id": "1001",
"content": "Hello world",
"author": {"id": "42", "username": "user1", "global_name": "User One", "bot": False},
"timestamp": "2024-01-01T12:00:00Z",
"edited_timestamp": None,
"attachments": [],
"pinned": False,
},
]
result = json.loads(discord_server(action="fetch_messages", channel_id="11"))
assert result["count"] == 1
assert result["messages"][0]["content"] == "Hello world"
assert result["messages"][0]["author"]["username"] == "user1"
@patch("tools.discord_tool._discord_request")
def test_fetch_messages_with_pagination(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = []
discord_server(action="fetch_messages", channel_id="11", before="999", limit=10)
call_params = mock_req.call_args[1]["params"]
assert call_params["before"] == "999"
assert call_params["limit"] == "10"
# ---------------------------------------------------------------------------
# Action: list_pins
# ---------------------------------------------------------------------------
class TestListPins:
@patch("tools.discord_tool._discord_request")
def test_list_pins(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = [
{"id": "500", "content": "Important announcement", "author": {"username": "admin"}, "timestamp": "2024-01-01T00:00:00Z"},
]
result = json.loads(discord_server(action="list_pins", channel_id="11"))
assert result["count"] == 1
assert result["pinned_messages"][0]["content"] == "Important announcement"
# ---------------------------------------------------------------------------
# Actions: pin_message / unpin_message
# ---------------------------------------------------------------------------
class TestPinUnpin:
@patch("tools.discord_tool._discord_request")
def test_pin_message(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None # 204
result = json.loads(discord_server(action="pin_message", channel_id="11", message_id="500"))
assert result["success"] is True
mock_req.assert_called_once_with("PUT", "/channels/11/pins/500", "test-token")
@patch("tools.discord_tool._discord_request")
def test_unpin_message(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None
result = json.loads(discord_server(action="unpin_message", channel_id="11", message_id="500"))
assert result["success"] is True
# ---------------------------------------------------------------------------
# Action: create_thread
# ---------------------------------------------------------------------------
class TestCreateThread:
@patch("tools.discord_tool._discord_request")
def test_create_standalone_thread(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {"id": "800", "name": "New Thread"}
result = json.loads(discord_server(action="create_thread", channel_id="11", name="New Thread"))
assert result["success"] is True
assert result["thread_id"] == "800"
# Verify the API call
mock_req.assert_called_once_with(
"POST", "/channels/11/threads", "test-token",
body={"name": "New Thread", "auto_archive_duration": 1440, "type": 11},
)
@patch("tools.discord_tool._discord_request")
def test_create_thread_from_message(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {"id": "801", "name": "Discussion"}
result = json.loads(discord_server(
action="create_thread", channel_id="11", name="Discussion", message_id="1001",
))
assert result["success"] is True
mock_req.assert_called_once_with(
"POST", "/channels/11/messages/1001/threads", "test-token",
body={"name": "Discussion", "auto_archive_duration": 1440},
)
# ---------------------------------------------------------------------------
# Actions: add_role / remove_role
# ---------------------------------------------------------------------------
class TestRoleManagement:
@patch("tools.discord_tool._discord_request")
def test_add_role(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None
result = json.loads(discord_server(
action="add_role", guild_id="111", user_id="42", role_id="2",
))
assert result["success"] is True
mock_req.assert_called_once_with(
"PUT", "/guilds/111/members/42/roles/2", "test-token",
)
@patch("tools.discord_tool._discord_request")
def test_remove_role(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None
result = json.loads(discord_server(
action="remove_role", guild_id="111", user_id="42", role_id="2",
))
assert result["success"] is True
# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------
class TestErrorHandling:
@patch("tools.discord_tool._discord_request")
def test_api_error_handled(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.side_effect = DiscordAPIError(403, '{"message": "Missing Access"}')
result = json.loads(discord_server(action="list_guilds"))
assert "error" in result
assert "403" in result["error"]
@patch("tools.discord_tool._discord_request")
def test_unexpected_error_handled(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.side_effect = RuntimeError("something broke")
result = json.loads(discord_server(action="list_guilds"))
assert "error" in result
assert "something broke" in result["error"]
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
class TestRegistration:
def test_tool_registered(self):
from tools.registry import registry
entry = registry._tools.get("discord_server")
assert entry is not None
assert entry.schema["name"] == "discord_server"
assert entry.toolset == "discord"
assert entry.check_fn is not None
assert entry.requires_env == ["DISCORD_BOT_TOKEN"]
def test_schema_actions(self):
"""Static schema should list all actions (the model_tools post-processing
narrows this per-session; static registration is the superset)."""
from tools.registry import registry
entry = registry._tools["discord_server"]
actions = entry.schema["parameters"]["properties"]["action"]["enum"]
expected = [
"list_guilds", "server_info", "list_channels", "channel_info",
"list_roles", "member_info", "search_members", "fetch_messages",
"list_pins", "pin_message", "unpin_message", "create_thread",
"add_role", "remove_role",
]
assert set(actions) == set(expected)
assert set(_ACTIONS.keys()) == set(expected)
def test_schema_parameter_bounds(self):
from tools.registry import registry
entry = registry._tools["discord_server"]
props = entry.schema["parameters"]["properties"]
assert props["limit"]["minimum"] == 1
assert props["limit"]["maximum"] == 100
assert props["auto_archive_duration"]["enum"] == [60, 1440, 4320, 10080]
def test_schema_description_is_action_manifest(self):
"""The top-level description should include the action manifest
(one-line signatures per action) so the model can find required
params without re-reading every parameter description."""
from tools.registry import registry
entry = registry._tools["discord_server"]
desc = entry.schema["description"]
# Spot-check a few entries
assert "list_guilds()" in desc
assert "fetch_messages(channel_id)" in desc
assert "add_role(guild_id, user_id, role_id)" in desc
def test_handler_callable(self):
from tools.registry import registry
entry = registry._tools["discord_server"]
assert callable(entry.handler)
# ---------------------------------------------------------------------------
# Toolset: discord_server only in hermes-discord
# ---------------------------------------------------------------------------
class TestToolsetInclusion:
def test_discord_server_in_hermes_discord_toolset(self):
from toolsets import TOOLSETS
assert "discord_server" in TOOLSETS["hermes-discord"]["tools"]
def test_discord_server_not_in_core_tools(self):
from toolsets import _HERMES_CORE_TOOLS
assert "discord_server" not in _HERMES_CORE_TOOLS
def test_discord_server_not_in_other_toolsets(self):
from toolsets import TOOLSETS
for name, ts in TOOLSETS.items():
if name == "hermes-discord":
continue
# The gateway toolset might include it if it unions all platform tools
if name == "hermes-gateway":
continue
assert "discord_server" not in ts.get("tools", []), (
f"discord_server should not be in toolset '{name}'"
)
# ---------------------------------------------------------------------------
# Capability detection (privileged intents)
# ---------------------------------------------------------------------------
class TestCapabilityDetection:
def setup_method(self):
_reset_capability_cache()
def teardown_method(self):
_reset_capability_cache()
@patch("tools.discord_tool._discord_request")
def test_both_intents_enabled(self, mock_req):
# flags: GUILD_MEMBERS (1<<14) + MESSAGE_CONTENT (1<<18) = 278528
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
caps = _detect_capabilities("tok")
assert caps["has_members_intent"] is True
assert caps["has_message_content"] is True
assert caps["detected"] is True
@patch("tools.discord_tool._discord_request")
def test_no_intents(self, mock_req):
mock_req.return_value = {"flags": 0}
caps = _detect_capabilities("tok")
assert caps["has_members_intent"] is False
assert caps["has_message_content"] is False
assert caps["detected"] is True
@patch("tools.discord_tool._discord_request")
def test_limited_intent_variants_counted(self, mock_req):
# GUILD_MEMBERS_LIMITED (1<<15), MESSAGE_CONTENT_LIMITED (1<<19)
mock_req.return_value = {"flags": (1 << 15) | (1 << 19)}
caps = _detect_capabilities("tok")
assert caps["has_members_intent"] is True
assert caps["has_message_content"] is True
@patch("tools.discord_tool._discord_request")
def test_only_members_intent(self, mock_req):
mock_req.return_value = {"flags": 1 << 14}
caps = _detect_capabilities("tok")
assert caps["has_members_intent"] is True
assert caps["has_message_content"] is False
@patch("tools.discord_tool._discord_request")
def test_detection_failure_is_permissive(self, mock_req):
"""If detection fails (network/401/revoked token), expose everything
and let runtime errors surface. Silent failure should never hide
actions the bot actually has."""
mock_req.side_effect = DiscordAPIError(401, "unauthorized")
caps = _detect_capabilities("tok")
assert caps["detected"] is False
assert caps["has_members_intent"] is True
assert caps["has_message_content"] is True
@patch("tools.discord_tool._discord_request")
def test_detection_is_cached(self, mock_req):
mock_req.return_value = {"flags": 0}
_detect_capabilities("tok")
_detect_capabilities("tok")
_detect_capabilities("tok")
assert mock_req.call_count == 1
@patch("tools.discord_tool._discord_request")
def test_force_refresh(self, mock_req):
mock_req.return_value = {"flags": 0}
_detect_capabilities("tok")
_detect_capabilities("tok", force=True)
assert mock_req.call_count == 2
# ---------------------------------------------------------------------------
# Config allowlist
# ---------------------------------------------------------------------------
class TestConfigAllowlist:
def test_empty_string_returns_none(self, monkeypatch):
"""Empty config means no allowlist — all actions visible."""
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
assert _load_allowed_actions_config() is None
def test_missing_key_returns_none(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {}},
)
assert _load_allowed_actions_config() is None
def test_comma_separated_string(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "list_guilds,list_channels,fetch_messages"}},
)
result = _load_allowed_actions_config()
assert result == ["list_guilds", "list_channels", "fetch_messages"]
def test_yaml_list(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ["list_guilds", "server_info"]}},
)
result = _load_allowed_actions_config()
assert result == ["list_guilds", "server_info"]
def test_unknown_names_dropped(self, monkeypatch, caplog):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "list_guilds,bogus_action,fetch_messages"}},
)
with caplog.at_level("WARNING"):
result = _load_allowed_actions_config()
assert result == ["list_guilds", "fetch_messages"]
assert "bogus_action" in caplog.text
def test_config_load_failure_is_permissive(self, monkeypatch):
"""If config can't be loaded at all, fall back to None (all allowed)."""
def bad_load():
raise RuntimeError("disk gone")
monkeypatch.setattr("hermes_cli.config.load_config", bad_load)
assert _load_allowed_actions_config() is None
def test_unexpected_type_ignored(self, monkeypatch, caplog):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": {"unexpected": "dict"}}},
)
with caplog.at_level("WARNING"):
result = _load_allowed_actions_config()
assert result is None
assert "unexpected type" in caplog.text
# ---------------------------------------------------------------------------
# Action filtering combines intents + allowlist
# ---------------------------------------------------------------------------
class TestAvailableActions:
def test_all_available_when_unrestricted(self):
caps = {"detected": True, "has_members_intent": True, "has_message_content": True}
assert _available_actions(caps, None) == list(_ACTIONS.keys())
def test_no_members_intent_hides_member_actions(self):
caps = {"detected": True, "has_members_intent": False, "has_message_content": True}
actions = _available_actions(caps, None)
assert "search_members" not in actions
assert "member_info" not in actions
# fetch_messages stays — MESSAGE_CONTENT affects content field but action works
assert "fetch_messages" in actions
def test_no_message_content_keeps_fetch_messages(self):
"""MESSAGE_CONTENT affects the content field, not the action.
Hiding fetch_messages would lose author/timestamp/attachments access."""
caps = {"detected": True, "has_members_intent": True, "has_message_content": False}
actions = _available_actions(caps, None)
assert "fetch_messages" in actions
assert "list_pins" in actions
def test_allowlist_intersects_with_intents(self):
"""Allowlist can only narrow — not re-enable intent-gated actions."""
caps = {"detected": True, "has_members_intent": False, "has_message_content": True}
allowlist = ["list_guilds", "search_members", "fetch_messages"]
actions = _available_actions(caps, allowlist)
# search_members gated by intent → stripped even though allowlisted
assert actions == ["list_guilds", "fetch_messages"]
def test_empty_allowlist_yields_empty(self):
caps = {"detected": True, "has_members_intent": True, "has_message_content": True}
assert _available_actions(caps, []) == []
def test_allowlist_preserves_canonical_order(self):
caps = {"detected": True, "has_members_intent": True, "has_message_content": True}
# Pass allowlist out of canonical order
allowlist = ["fetch_messages", "list_guilds", "server_info"]
assert _available_actions(caps, allowlist) == ["list_guilds", "server_info", "fetch_messages"]
# ---------------------------------------------------------------------------
# Dynamic schema build (integration of intents + config)
# ---------------------------------------------------------------------------
class TestDynamicSchema:
def setup_method(self):
_reset_capability_cache()
def teardown_method(self):
_reset_capability_cache()
@patch("tools.discord_tool._discord_request")
def test_no_token_returns_none(self, mock_req, monkeypatch):
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
assert get_dynamic_schema() is None
mock_req.assert_not_called()
@patch("tools.discord_tool._discord_request")
def test_full_intents_full_schema(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
schema = get_dynamic_schema()
actions = schema["parameters"]["properties"]["action"]["enum"]
assert set(actions) == set(_ACTIONS.keys())
# No content warning
assert "MESSAGE_CONTENT" not in schema["description"]
@patch("tools.discord_tool._discord_request")
def test_no_members_intent_removes_member_actions_from_schema(
self, mock_req, monkeypatch,
):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
mock_req.return_value = {"flags": 1 << 18} # only MESSAGE_CONTENT
schema = get_dynamic_schema()
actions = schema["parameters"]["properties"]["action"]["enum"]
assert "search_members" not in actions
assert "member_info" not in actions
# Manifest description should also not advertise them
assert "search_members" not in schema["description"]
assert "member_info" not in schema["description"]
@patch("tools.discord_tool._discord_request")
def test_no_message_content_adds_warning_note(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
mock_req.return_value = {"flags": 1 << 14} # only GUILD_MEMBERS
schema = get_dynamic_schema()
assert "MESSAGE_CONTENT" in schema["description"]
# But fetch_messages is still available
actions = schema["parameters"]["properties"]["action"]["enum"]
assert "fetch_messages" in actions
@patch("tools.discord_tool._discord_request")
def test_config_allowlist_narrows_schema(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "list_guilds,list_channels"}},
)
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
schema = get_dynamic_schema()
actions = schema["parameters"]["properties"]["action"]["enum"]
assert actions == ["list_guilds", "list_channels"]
# Manifest description should only show allowed ones (check for
# the signature marker, which is specific to manifest lines)
assert "list_guilds()" in schema["description"]
assert "add_role(" not in schema["description"]
assert "create_thread(" not in schema["description"]
@patch("tools.discord_tool._discord_request")
def test_empty_allowlist_with_valid_values_hides_tool(self, mock_req, monkeypatch):
"""If the allowlist resolves to zero valid actions (e.g. all names
were typos), get_dynamic_schema returns None so the tool is dropped
entirely rather than showing an empty enum."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "typo_one,typo_two"}},
)
mock_req.return_value = {"flags": (1 << 14) | (1 << 18)}
assert get_dynamic_schema() is None
# ---------------------------------------------------------------------------
# Runtime allowlist enforcement (defense in depth — schema already filtered)
# ---------------------------------------------------------------------------
class TestRuntimeAllowlistEnforcement:
@patch("tools.discord_tool._discord_request")
def test_denied_action_blocked_at_runtime(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "list_guilds"}},
)
result = json.loads(discord_server(action="add_role", guild_id="1", user_id="2", role_id="3"))
assert "error" in result
assert "disabled by config" in result["error"]
mock_req.assert_not_called()
@patch("tools.discord_tool._discord_request")
def test_allowed_action_proceeds(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "list_guilds"}},
)
mock_req.return_value = []
result = json.loads(discord_server(action="list_guilds"))
assert "guilds" in result
# ---------------------------------------------------------------------------
# 403 enrichment
# ---------------------------------------------------------------------------
class Test403Enrichment:
def test_enrich_known_action(self):
msg = _enrich_403("add_role", '{"message":"Missing Permissions"}')
assert "MANAGE_ROLES" in msg
assert "Missing Permissions" in msg # Raw body preserved
def test_enrich_unknown_action_includes_body(self):
msg = _enrich_403("some_new_action", '{"message":"weird"}')
assert "some_new_action" in msg
assert "weird" in msg
@patch("tools.discord_tool._discord_request")
def test_403_in_runtime_is_enriched(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
mock_req.side_effect = DiscordAPIError(403, '{"message":"Missing Permissions"}')
result = json.loads(discord_server(
action="add_role", guild_id="1", user_id="2", role_id="3",
))
assert "error" in result
assert "MANAGE_ROLES" in result["error"]
@patch("tools.discord_tool._discord_request")
def test_non_403_errors_are_not_enriched(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": ""}},
)
mock_req.side_effect = DiscordAPIError(500, "server error")
result = json.loads(discord_server(action="list_guilds"))
assert "500" in result["error"]
assert "MANAGE_ROLES" not in result["error"]
# ---------------------------------------------------------------------------
# model_tools integration — dynamic schema replaces static
# ---------------------------------------------------------------------------
class TestModelToolsIntegration:
def setup_method(self):
_reset_capability_cache()
def teardown_method(self):
_reset_capability_cache()
@patch("tools.discord_tool._discord_request")
def test_discord_server_schema_rebuilt_by_get_tool_definitions(
self, mock_req, monkeypatch,
):
"""When model_tools.get_tool_definitions runs with discord_server
available, it should replace the static schema with the dynamic one."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "list_guilds,server_info"}},
)
# Bot without GUILD_MEMBERS intent
mock_req.return_value = {"flags": 0}
from model_tools import get_tool_definitions
tools = get_tool_definitions(enabled_toolsets=["hermes-discord"], quiet_mode=True)
discord_tool = next(
(t for t in tools if t.get("function", {}).get("name") == "discord_server"),
None,
)
assert discord_tool is not None, "discord_server should be in the schema"
actions = discord_tool["function"]["parameters"]["properties"]["action"]["enum"]
assert actions == ["list_guilds", "server_info"]
@patch("tools.discord_tool._discord_request")
def test_discord_server_dropped_when_allowlist_empties_it(
self, mock_req, monkeypatch,
):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"discord": {"server_actions": "all_bogus_names"}},
)
mock_req.return_value = {"flags": 0}
from model_tools import get_tool_definitions
tools = get_tool_definitions(enabled_toolsets=["hermes-discord"], quiet_mode=True)
names = [t.get("function", {}).get("name") for t in tools]
assert "discord_server" not in names