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: 86 additions & 2 deletions tests/tools/test_discord_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,86 @@ def test_create_thread_from_message(self, mock_req, monkeypatch):
)


# ---------------------------------------------------------------------------
# Actions: rename_thread
# ---------------------------------------------------------------------------

class TestRenameThread:
@patch("tools.discord_tool._discord_request")
def test_rename_public_thread(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
# Two sequential _discord_request calls: GET (probe) then PATCH (edit).
mock_req.side_effect = [
{"id": "800", "type": 11, "name": "old name"},
{"id": "800", "type": 11, "name": "new name"},
]
result = json.loads(discord_core(
action="rename_thread", channel_id="800", name="new name",
))
assert result == {"success": True, "thread_id": "800", "name": "new name"}
assert mock_req.call_args_list[0].args[:2] == ("GET", "/channels/800")
assert mock_req.call_args_list[1].args[:2] == ("PATCH", "/channels/800")
assert mock_req.call_args_list[1].kwargs.get("body") == {"name": "new name"}

@patch("tools.discord_tool._discord_request")
def test_rename_private_thread(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.side_effect = [
{"id": "801", "type": 12, "name": "old"},
{"id": "801", "type": 12, "name": "renamed"},
]
result = json.loads(discord_core(
action="rename_thread", channel_id="801", name="renamed",
))
assert result["success"] is True

@patch("tools.discord_tool._discord_request")
def test_rename_news_thread(self, mock_req, monkeypatch):
"""Announcement-channel threads (type 10) are valid targets too."""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.side_effect = [
{"id": "802", "type": 10, "name": "old"},
{"id": "802", "type": 10, "name": "v2"},
]
result = json.loads(discord_core(
action="rename_thread", channel_id="802", name="v2",
))
assert result["success"] is True

@patch("tools.discord_tool._discord_request")
def test_rename_refuses_text_channel(self, mock_req, monkeypatch):
"""Defense-in-depth: PATCH /channels/{id} would happily rename a
top-level text channel if we let it. Refuse anything that isn't a
thread type (10/11/12) so an agent asked to "rename this thread"
can't accidentally rename a server channel.
"""
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {"id": "900", "type": 0, "name": "general"}
result = json.loads(discord_core(
action="rename_thread", channel_id="900", name="new",
))
assert result == {
"success": False,
"error": (
"channel 900 is not a thread (type=0 → text). "
"Use a different action to rename non-thread channels."
),
}
# GET only — no PATCH must fire.
assert mock_req.call_count == 1

@patch("tools.discord_tool._discord_request")
def test_rename_refuses_forum_channel(self, mock_req, monkeypatch):
monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token")
mock_req.return_value = {"id": "950", "type": 15, "name": "support"}
result = json.loads(discord_core(
action="rename_thread", channel_id="950", name="new",
))
assert result["success"] is False
assert "forum" in result["error"]
assert mock_req.call_count == 1


# ---------------------------------------------------------------------------
# Actions: add_role / remove_role
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -558,14 +638,18 @@ def test_core_schema_actions(self):
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 actions == {
"fetch_messages", "search_members", "create_thread", "rename_thread",
}

def test_admin_schema_actions(self):
"""Admin static schema should list only admin actions."""
from tools.registry import registry
entry = registry._tools["discord_admin"]
actions = set(entry.schema["parameters"]["properties"]["action"]["enum"])
expected_admin = set(_ACTIONS.keys()) - {"fetch_messages", "search_members", "create_thread"}
expected_admin = set(_ACTIONS.keys()) - {
"fetch_messages", "search_members", "create_thread", "rename_thread",
}
assert actions == expected_admin

def test_all_actions_covered(self):
Expand Down
39 changes: 38 additions & 1 deletion tools/discord_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,40 @@ def _create_thread(
})


def _rename_thread(
token: str, channel_id: str, name: str, **_kwargs: Any,
) -> str:
"""Rename a Discord thread.

Threads are channels under the API, so ``PATCH /channels/{id}`` with
``{"name": ...}`` is the same call that would rename a regular channel.
To avoid surprises (the agent renaming an actual server channel when
asked to rename a thread), this action probes the target's ``type``
first and refuses on non-thread channels.
"""
info = _discord_request("GET", f"/channels/{channel_id}", token)
channel_type = info.get("type")
# 10 = NEWS_THREAD, 11 = PUBLIC_THREAD, 12 = PRIVATE_THREAD
if channel_type not in (10, 11, 12):
return json.dumps({
"success": False,
"error": (
f"channel {channel_id} is not a thread "
f"(type={channel_type} → "
f"{_channel_type_name(channel_type if channel_type is not None else -1)}). "
"Use a different action to rename non-thread channels."
),
})
updated = _discord_request(
"PATCH", f"/channels/{channel_id}", token, body={"name": name},
)
return json.dumps({
"success": True,
"thread_id": updated["id"],
"name": updated.get("name"),
})


def _add_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwargs: Any) -> str:
"""Add a role to a guild member."""
_discord_request("PUT", f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}", token)
Expand Down Expand Up @@ -484,11 +518,12 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
"unpin_message": _unpin_message,
"delete_message": _delete_message,
"create_thread": _create_thread,
"rename_thread": _rename_thread,
"add_role": _add_role,
"remove_role": _remove_role,
}

_CORE_ACTION_NAMES = frozenset({"fetch_messages", "search_members", "create_thread"})
_CORE_ACTION_NAMES = frozenset({"fetch_messages", "search_members", "create_thread", "rename_thread"})
_ADMIN_ACTION_NAMES = frozenset(_ACTIONS.keys()) - _CORE_ACTION_NAMES

_CORE_ACTIONS = {k: v for k, v in _ACTIONS.items() if k in _CORE_ACTION_NAMES}
Expand All @@ -511,6 +546,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
("unpin_message", "(channel_id, message_id)", "unpin a message"),
("delete_message", "(channel_id, message_id)", "delete a message"),
("create_thread", "(channel_id, name)", "create a public thread; optional message_id anchor"),
("rename_thread", "(channel_id, name)", "rename an existing thread (channel_id = thread id)"),
("add_role", "(guild_id, user_id, role_id)", "assign a role"),
("remove_role", "(guild_id, user_id, role_id)", "remove a role"),
]
Expand All @@ -532,6 +568,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg
"unpin_message": ["channel_id", "message_id"],
"delete_message": ["channel_id", "message_id"],
"create_thread": ["channel_id", "name"],
"rename_thread": ["channel_id", "name"],
"add_role": ["guild_id", "user_id", "role_id"],
"remove_role": ["guild_id", "user_id", "role_id"],
}
Expand Down