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
88 changes: 88 additions & 0 deletions tests/tools/test_discord_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ def test_missing_multiple_params(self, monkeypatch):
assert "user_id" in result["error"]
assert "role_id" in result["error"]

@patch("tools.discord_tool._discord_request")
def test_missing_required_content(self, mock_req, 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"]
mock_req.assert_not_called()


# ---------------------------------------------------------------------------
# Action: list_guilds
Expand Down Expand Up @@ -427,6 +437,50 @@ def test_unpin_message(self, mock_req, monkeypatch):
assert result["success"] is True


# ---------------------------------------------------------------------------
# Actions: edit_message / delete_message
# ---------------------------------------------------------------------------

class TestEditDeleteMessage:
@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": "2024-01-01T00:01:00Z",
}
result = json.loads(discord_admin_handler(
action="edit_message",
channel_id="11",
message_id="500",
content="Updated announcement",
))
assert result["success"] is True
assert result["message_id"] == "500"
assert result["content"] == "Updated announcement"
assert result["edited_timestamp"] == "2024-01-01T00:01:00Z"
mock_req.assert_called_once_with(
"PATCH",
"/channels/11/messages/500",
"test-token",
body={"content": "Updated announcement"},
)

@patch("tools.discord_tool._discord_request")
def test_delete_message(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = None
result = json.loads(discord_admin_handler(
action="delete_message", channel_id="11", message_id="500",
))
assert result["success"] is True
assert result["message_id"] == "500"
mock_req.assert_called_once_with(
"DELETE", "/channels/11/messages/500", "test-token",
)


# ---------------------------------------------------------------------------
# Action: create_thread
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -539,12 +593,34 @@ def test_admin_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_registry_handler_passes_content(self, mock_req, monkeypatch):
from tools.registry import registry
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {"id": "500", "content": "Updated via registry"}
result = json.loads(registry._tools["discord_admin"].handler({
"action": "edit_message",
"channel_id": "11",
"message_id": "500",
"content": "Updated via registry",
}))
assert result["success"] is True
assert result["content"] == "Updated via registry"
mock_req.assert_called_once_with(
"PATCH",
"/channels/11/messages/500",
"test-token",
body={"content": "Updated via registry"},
)

def test_core_schema_actions(self):
"""Core static schema should list only core actions."""
from tools.registry import registry
entry = registry._tools["discord"]
actions = set(entry.schema["parameters"]["properties"]["action"]["enum"])
assert actions == {"fetch_messages", "search_members", "create_thread"}
assert "edit_message" not in actions
assert "delete_message" not in actions

def test_admin_schema_actions(self):
"""Admin static schema should list only admin actions."""
Expand All @@ -553,6 +629,8 @@ def test_admin_schema_actions(self):
actions = set(entry.schema["parameters"]["properties"]["action"]["enum"])
expected_admin = set(_ACTIONS.keys()) - {"fetch_messages", "search_members", "create_thread"}
assert actions == expected_admin
assert "edit_message" in actions
assert "delete_message" in actions

def test_all_actions_covered(self):
"""Core + admin actions should cover all known actions."""
Expand All @@ -566,6 +644,8 @@ def test_schema_parameter_bounds(self):
assert props["limit"]["minimum"] == 1
assert props["limit"]["maximum"] == 100
assert props["auto_archive_duration"]["enum"] == [60, 1440, 4320, 10080]
assert props["content"]["type"] == "string"
assert "edit_message" in props["content"]["description"]

def test_core_schema_description(self):
"""Core schema description should mention core actions."""
Expand All @@ -586,6 +666,8 @@ def test_admin_schema_description(self):
desc = entry.schema["description"]
assert "list_guilds()" in desc
assert "add_role(guild_id, user_id, role_id)" in desc
assert "edit_message(channel_id, message_id, content)" in desc
assert "delete_message(channel_id, message_id)" in desc
# Core actions should NOT be in admin description
assert "fetch_messages(" not in desc
assert "create_thread(" not in desc
Expand Down Expand Up @@ -1001,6 +1083,12 @@ def test_enrich_known_action(self):
assert "MANAGE_ROLES" in msg
assert "Missing Permissions" in msg # Raw body preserved

def test_enrich_message_admin_actions(self):
edit_msg = _enrich_403("edit_message", '{"message":"Missing Access"}')
delete_msg = _enrich_403("delete_message", '{"message":"Missing Permissions"}')
assert "only edit messages it authored" in edit_msg
assert "MANAGE_MESSAGES" in delete_msg

def test_enrich_unknown_action_includes_body(self):
msg = _enrich_403("some_new_action", '{"message":"weird"}')
assert "some_new_action" in msg
Expand Down
53 changes: 52 additions & 1 deletion tools/discord_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,36 @@ def _unpin_message(token: str, channel_id: str, message_id: str, **_kwargs: Any)
return json.dumps({"success": True, "message": f"Message {message_id} unpinned."})


def _edit_message(
token: str,
channel_id: str,
message_id: str,
content: str,
**_kwargs: Any,
) -> str:
"""Edit a message in a channel."""
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.get("edited_timestamp")
return json.dumps(result)


def _delete_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str:
"""Delete a message from a channel."""
_discord_request("DELETE", f"/channels/{channel_id}/messages/{message_id}", token)
return json.dumps({"success": True, "message_id": message_id})


def _create_thread(
token: str, channel_id: str, name: str,
message_id: Optional[str] = None,
Expand Down Expand Up @@ -468,6 +498,8 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
"list_pins": _list_pins,
"pin_message": _pin_message,
"unpin_message": _unpin_message,
"edit_message": _edit_message,
"delete_message": _delete_message,
"create_thread": _create_thread,
"add_role": _add_role,
"remove_role": _remove_role,
Expand All @@ -494,6 +526,8 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
("list_pins", "(channel_id)", "pinned messages in a channel"),
("pin_message", "(channel_id, message_id)", "pin a message"),
("unpin_message", "(channel_id, message_id)", "unpin a message"),
("edit_message", "(channel_id, message_id, content)", "edit a message's content"),
("delete_message", "(channel_id, message_id)", "delete a message"),
("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 @@ -514,6 +548,8 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
"list_pins": ["channel_id"],
"pin_message": ["channel_id", "message_id"],
"unpin_message": ["channel_id", "message_id"],
"edit_message": ["channel_id", "message_id", "content"],
"delete_message": ["channel_id", "message_id"],
"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 @@ -668,6 +704,10 @@ def _build_schema(
"type": "string",
"description": "Discord message ID.",
},
"content": {
"type": "string",
"description": "New message content (edit_message).",
},
"query": {
"type": "string",
"description": "Member name prefix to search for (search_members).",
Expand Down Expand Up @@ -750,6 +790,14 @@ def get_dynamic_schema() -> Optional[Dict[str, Any]]:
"unpin_message": (
"Bot lacks MANAGE_MESSAGES permission in this channel."
),
"edit_message": (
"Bot can only edit messages it authored, and must be able to view and send "
"messages in this channel."
),
"delete_message": (
"Bot lacks MANAGE_MESSAGES permission in this channel, or cannot delete "
"that specific message."
),
"create_thread": (
"Bot lacks CREATE_PUBLIC_THREADS in this channel, or cannot view it."
),
Expand Down Expand Up @@ -813,6 +861,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 @@ -850,6 +899,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 @@ -868,6 +918,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 @@ -901,7 +952,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