| """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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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)" |
|
|
|
|
| |
| |
| |
|
|
| 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} |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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"] |
|
|
|
|
| |
| |
| |
|
|
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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"} |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| groups = result["channel_groups"] |
| |
| assert groups[0]["category"] is None |
| assert len(groups[0]["channels"]) == 1 |
| assert groups[0]["channels"][0]["name"] == "no-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 |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| assert result["roles"][0]["name"] == "Admin" |
| assert result["roles"][0]["color"] == "#ff0000" |
| assert result["roles"][1]["name"] == "Mod" |
| assert result["roles"][2]["name"] == "@everyone" |
|
|
|
|
| |
| |
| |
|
|
| 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"] |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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" |
| |
| 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}, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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"] |
|
|
|
|
| |
| |
| |
|
|
| 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"] |
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| if name == "hermes-gateway": |
| continue |
| assert "discord_server" not in ts.get("tools", []), ( |
| f"discord_server should not be in toolset '{name}'" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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): |
| |
| 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): |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| class TestConfigAllowlist: |
| @pytest.fixture(autouse=True) |
| def _reset_tools_logger(self): |
| """Restore the ``tools`` logger level after cross-test pollution. |
| |
| ``AIAgent(quiet_mode=True)`` globally sets ``tools`` and |
| ``tools.*`` children to ``ERROR`` (see run_agent.py quiet_mode |
| block). xdist workers are persistent, so a streaming test on the |
| same worker will silence WARNING-level logs from |
| ``tools.discord_tool`` for every test that follows. Reset here so |
| ``caplog`` can capture warnings regardless of worker history. |
| """ |
| import logging as _logging |
| _prev_tools = _logging.getLogger("tools").level |
| _prev_dt = _logging.getLogger("tools.discord_tool").level |
| _logging.getLogger("tools").setLevel(_logging.NOTSET) |
| _logging.getLogger("tools.discord_tool").setLevel(_logging.NOTSET) |
| try: |
| yield |
| finally: |
| _logging.getLogger("tools").setLevel(_prev_tools) |
| _logging.getLogger("tools.discord_tool").setLevel(_prev_dt) |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| 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) |
| |
| 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} |
| |
| allowlist = ["fetch_messages", "list_guilds", "server_info"] |
| assert _available_actions(caps, allowlist) == ["list_guilds", "server_info", "fetch_messages"] |
|
|
|
|
| |
| |
| |
|
|
| 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()) |
| |
| 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} |
| schema = get_dynamic_schema() |
| actions = schema["parameters"]["properties"]["action"]["enum"] |
| assert "search_members" not in actions |
| assert "member_info" not in actions |
| |
| 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} |
| schema = get_dynamic_schema() |
| assert "MESSAGE_CONTENT" in schema["description"] |
| |
| 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"] |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
| 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"] |
|
|
|
|
| |
| |
| |
|
|
| 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"}}, |
| ) |
| |
| 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 |
|
|