From b301af2fb2038312398f71040fb54a7508e03735 Mon Sep 17 00:00:00 2001 From: pwnqgljxs Date: Sun, 14 Jun 2026 21:51:37 -0400 Subject: [PATCH] feat(discord): add rename_thread action to discord_tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discord_tool surface exposed 15 server-management actions but no way to rename a thread. The REST primitive is the same `PATCH /channels/{id}` that renames a regular channel, so the action validates that the target is a thread type (10/11/12) before issuing the PATCH — preventing an agent asked to "rename this thread" from accidentally renaming a top- level text/voice/forum channel sharing a similar-looking ID. Action shape mirrors create_thread: classified core (read/participate), not admin, since renaming is metadata-only and bot accounts already need MANAGE_THREADS or thread ownership for it to succeed. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- tests/tools/test_discord_tool.py | 88 +++++++++++++++++++++++++++++++- tools/discord_tool.py | 39 +++++++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_discord_tool.py b/tests/tools/test_discord_tool.py index ac94ce5e75168..fd8d7eb9ad008 100644 --- a/tests/tools/test_discord_tool.py +++ b/tests/tools/test_discord_tool.py @@ -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 # --------------------------------------------------------------------------- @@ -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): diff --git a/tools/discord_tool.py b/tools/discord_tool.py index 1da43ac9140e2..86888785e1486 100644 --- a/tools/discord_tool.py +++ b/tools/discord_tool.py @@ -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) @@ -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} @@ -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"), ] @@ -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"], }