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
3 changes: 2 additions & 1 deletion hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -1883,7 +1883,8 @@
# or YAML list. Unknown names are dropped with a warning at load time.
# Actions: 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.
# pin_message, unpin_message, delete_message, edit_message, create_thread,
# add_role, remove_role.
"server_actions": "",
# DEPRECATED / no-op. Any uploaded file is now always cached and
# surfaced to the agent regardless of file type — authorization to
Expand Down
72 changes: 72 additions & 0 deletions tests/tools/test_discord_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ def test_missing_multiple_params(self, monkeypatch):
assert "user_id" in result["error"]
assert "role_id" in result["error"]

def test_edit_message_requires_content(self, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
result = json.loads(discord_admin_handler(
action="edit_message", channel_id="11", message_id="500",
))
assert "error" in result
assert "content" in result["error"]


# ---------------------------------------------------------------------------
# Action: list_channels
Expand Down Expand Up @@ -217,6 +225,41 @@ def test_fetch_messages(self, mock_req, monkeypatch):
assert result["messages"][0]["author"]["username"] == "user1"


# ---------------------------------------------------------------------------
# Action: edit_message
# ---------------------------------------------------------------------------

class TestEditMessage:
@patch("tools.discord_tool._discord_request")
def test_edit_message(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {
"id": "500",
"content": "Updated announcement",
"edited_timestamp": "2026-08-02T00:01:00Z",
}

result = json.loads(discord_admin_handler(
action="edit_message",
channel_id="11",
message_id="500",
content="Updated announcement",
))

assert result == {
"success": True,
"message_id": "500",
"content": "Updated announcement",
"edited_timestamp": "2026-08-02T00:01:00Z",
}
mock_req.assert_called_once_with(
"PATCH",
"/channels/11/messages/500",
"test-token",
body={"content": "Updated announcement"},
)


# ---------------------------------------------------------------------------
# Action: create_thread
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -285,6 +328,28 @@ def test_core_tool_registered(self):
assert entry.check_fn is not None
assert entry.requires_env == ["DISCORD_BOT_TOKEN"]

@patch("tools.discord_tool._discord_request")
def test_admin_registry_forwards_edit_content(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {"content": "Updated announcement"}

from tools.registry import registry
entry = registry._tools["discord_admin"]
result = json.loads(entry.handler({
"action": "edit_message",
"channel_id": "11",
"message_id": "500",
"content": "Updated announcement",
}))

assert result["content"] == "Updated announcement"
mock_req.assert_called_once_with(
"PATCH",
"/channels/11/messages/500",
"test-token",
body={"content": "Updated announcement"},
)

def test_all_actions_covered(self):
"""Core + admin actions should cover all known actions."""
assert set(_CORE_ACTIONS.keys()) | set(_ADMIN_ACTIONS.keys()) == set(_ACTIONS.keys())
Expand Down Expand Up @@ -622,6 +687,13 @@ def test_allowed_action_proceeds(self, mock_req, monkeypatch):
# ---------------------------------------------------------------------------

class Test403Enrichment:
def test_edit_message_hint_explains_authorship(self):
from tools.discord_tool import _enrich_403

assert "only edit messages it authored" in _enrich_403(
"edit_message", '{"message":"Missing Access"}',
)

@patch("tools.discord_tool._discord_request")
def test_403_in_runtime_is_enriched(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "tok")
Expand Down
40 changes: 39 additions & 1 deletion tools/discord_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,30 @@ def _delete_message(token: str, channel_id: str, message_id: str, **_kwargs: Any
return json.dumps({"success": True, "message": f"Message {message_id} deleted."})


def _edit_message(
token: str,
channel_id: str,
message_id: str,
content: str,
**_kwargs: Any,
) -> str:
"""Edit the bot-authored content of a message in a channel or thread."""
message = _discord_request(
"PATCH",
f"/channels/{channel_id}/messages/{message_id}",
token,
body={"content": content},
)
result = {
"success": True,
"message_id": message_id,
"content": message.get("content", content) if isinstance(message, dict) else content,
}
if isinstance(message, dict) and message.get("edited_timestamp") is not None:
result["edited_timestamp"] = message["edited_timestamp"]
return json.dumps(result)


def _create_thread(
token: str, channel_id: str, name: str,
message_id: Optional[str] = None,
Expand Down Expand Up @@ -642,6 +666,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
"pin_message": _pin_message,
"unpin_message": _unpin_message,
"delete_message": _delete_message,
"edit_message": _edit_message,
"create_thread": _create_thread,
"add_role": _add_role,
"remove_role": _remove_role,
Expand Down Expand Up @@ -669,6 +694,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
("pin_message", "(channel_id, message_id)", "pin a message"),
("unpin_message", "(channel_id, message_id)", "unpin a message"),
("delete_message", "(channel_id, message_id)", "delete a message"),
("edit_message", "(channel_id, message_id, content)", "edit a bot-authored message's content"),
("create_thread", "(channel_id, name)", "create a public thread; optional message_id anchor"),
("add_role", "(guild_id, user_id, role_id)", "assign a role"),
("remove_role", "(guild_id, user_id, role_id)", "remove a role"),
Expand All @@ -690,6 +716,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
"pin_message": ["channel_id", "message_id"],
"unpin_message": ["channel_id", "message_id"],
"delete_message": ["channel_id", "message_id"],
"edit_message": ["channel_id", "message_id", "content"],
"create_thread": ["channel_id", "name"],
"add_role": ["guild_id", "user_id", "role_id"],
"remove_role": ["guild_id", "user_id", "role_id"],
Expand Down Expand Up @@ -844,6 +871,10 @@ def _build_schema(
"type": "string",
"description": "Discord message ID.",
},
"content": {
"type": "string",
"description": "Replacement message content (edit_message).",
},
"query": {
"type": "string",
"description": "Member name prefix to search for (search_members).",
Expand Down Expand Up @@ -929,6 +960,10 @@ def get_dynamic_schema() -> Optional[Dict[str, Any]]:
"delete_message": (
"Bot lacks MANAGE_MESSAGES permission in this channel, or cannot view the channel/message."
),
"edit_message": (
"Bot can only edit messages it authored, and must be able to view and send "
"messages in this channel."
),
"create_thread": (
"Bot lacks CREATE_PUBLIC_THREADS in this channel, or cannot view it."
),
Expand Down Expand Up @@ -992,6 +1027,7 @@ def _run_discord_action(
user_id: str = "",
role_id: str = "",
message_id: str = "",
content: str = "",
query: str = "",
name: str = "",
limit: int = 50,
Expand Down Expand Up @@ -1027,6 +1063,7 @@ def _run_discord_action(
"user_id": user_id,
"role_id": role_id,
"message_id": message_id,
"content": content,
"query": query,
"name": name,
}
Expand All @@ -1045,6 +1082,7 @@ def _run_discord_action(
user_id=user_id,
role_id=role_id,
message_id=message_id,
content=content,
query=query,
name=name,
limit=limit,
Expand Down Expand Up @@ -1078,7 +1116,7 @@ def discord_admin_handler(action: str, **kwargs) -> str:

_HANDLER_DEFAULTS = {
"action": "", "guild_id": "", "channel_id": "", "user_id": "",
"role_id": "", "message_id": "", "query": "", "name": "",
"role_id": "", "message_id": "", "content": "", "query": "", "name": "",
"limit": 50, "before": "", "after": "", "auto_archive_duration": 1440,
}

Expand Down