diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 31406a7de..c12c844c4 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -80,6 +80,13 @@ def _build_discord(adapter) -> List[Dict[str, str]]: "guild": guild.name, "type": "channel", }) + for thread in getattr(guild, "threads", []) or []: + channels.append({ + "id": str(thread.id), + "name": thread.name, + "guild": guild.name, + "type": "thread", + }) # Also include DM-capable users we've interacted with is not # feasible via guild enumeration; those come from sessions. diff --git a/model_tools.py b/model_tools.py index a2fd68c4d..ff987480f 100644 --- a/model_tools.py +++ b/model_tools.py @@ -93,6 +93,7 @@ def _discover_tools(): "tools.delegate_tool", "tools.process_registry", "tools.send_message_tool", + "tools.discord_tool", "tools.honcho_tools", "tools.homeassistant_tool", ] @@ -276,9 +277,9 @@ def handle_function_call( function_args: Arguments for the function. task_id: Unique identifier for terminal/browser session isolation. user_task: The user's original task (for browser_snapshot context). - enabled_tools: Tool names enabled for this session. When provided, + enabled_tools: Tool names enabled for this session. When provided, execute_code uses this list to determine which sandbox - tools to generate. Falls back to the process-global + tools to generate. Falls back to the process-global ``_last_resolved_tool_names`` for backward compat. Returns: diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index d7562977d..c7ca2a65b 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -1,6 +1,7 @@ """Tests for gateway/channel_directory.py — channel resolution and display.""" import json +import sys from pathlib import Path from unittest.mock import patch @@ -9,6 +10,7 @@ format_directory_for_display, load_directory, _build_from_sessions, + _build_discord, DIRECTORY_PATH, ) @@ -170,6 +172,26 @@ def test_deduplication_by_chat_id(self, tmp_path): assert len(entries) == 1 +class TestBuildDiscord: + def test_includes_active_threads(self, monkeypatch): + monkeypatch.setitem(sys.modules, "discord", object()) + guild = type( + "Guild", + (), + { + "name": "Hermes", + "text_channels": [type("TextChannel", (), {"id": 1, "name": "general"})()], + "threads": [type("Thread", (), {"id": 2, "name": "planning-thread"})()], + }, + )() + adapter = type("Adapter", (), {"_client": type("Client", (), {"guilds": [guild]})()})() + + entries = _build_discord(adapter) + + assert {"id": "1", "name": "general", "guild": "Hermes", "type": "channel"} in entries + assert {"id": "2", "name": "planning-thread", "guild": "Hermes", "type": "thread"} in entries + + class TestFormatDirectoryForDisplay: def test_empty_directory(self, tmp_path): with patch("gateway.channel_directory.DIRECTORY_PATH", tmp_path / "nope.json"): diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index 8c2f8e6f7..009a1ccdb 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -38,6 +38,28 @@ def test_exception_returns_json_error(self): assert len(parsed["error"]) > 0 assert "error" in parsed["error"].lower() or "failed" in parsed["error"].lower() + def test_execute_code_prefers_explicit_enabled_tools(self, monkeypatch): + captured = {} + + def fake_dispatch(function_name, function_args, **kwargs): + captured["function_name"] = function_name + captured["function_args"] = function_args + captured["kwargs"] = kwargs + return json.dumps({"success": True}) + + monkeypatch.setattr("model_tools._last_resolved_tool_names", ["terminal", "browser_snapshot"]) + monkeypatch.setattr("model_tools.registry.dispatch", fake_dispatch) + + result = json.loads(handle_function_call( + "execute_code", + {"code": "print('hi')"}, + enabled_tools=["web_search"], + )) + + assert result == {"success": True} + assert captured["function_name"] == "execute_code" + assert captured["kwargs"]["enabled_tools"] == ["web_search"] + # ========================================================================= # Agent loop tools diff --git a/tests/tools/test_discord_tool.py b/tests/tools/test_discord_tool.py new file mode 100644 index 000000000..d1ecf3e4a --- /dev/null +++ b/tests/tools/test_discord_tool.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import json +import sys +from types import SimpleNamespace + +import pytest + +import tools.discord_tool as discord_tool + + +class FakeResponse: + def __init__(self, status: int, payload): + self.status = status + self._payload = payload + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def text(self): + if isinstance(self._payload, str): + return self._payload + return json.dumps(self._payload) + + +class FakeClientSession: + def __init__(self, responses, recorder, headers=None): + self._responses = list(responses) + self._recorder = recorder + self.headers = headers or {} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def request(self, method, url, json=None): + self._recorder.append({"method": method, "url": url, "json": json, "headers": dict(self.headers)}) + if not self._responses: + raise AssertionError(f"No fake response left for {method} {url}") + status, payload = self._responses.pop(0) + return FakeResponse(status, payload) + + +class FakeAioHttpModule: + def __init__(self, responses, recorder): + self._responses = responses + self._recorder = recorder + + def ClientSession(self, headers=None): + return FakeClientSession(self._responses, self._recorder, headers=headers) + + +@pytest.fixture +def fake_runner(monkeypatch): + monkeypatch.setattr(discord_tool, "_load_discord_token", lambda: "token-123") + + def _run(coro): + import asyncio + return asyncio.run(coro) + + monkeypatch.setitem(sys.modules, "model_tools", SimpleNamespace(_run_async=_run)) + + +def test_resolve_target_uses_current_discord_chat(monkeypatch): + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "123456") + + target_id, note = discord_tool._resolve_target_chat_id(None) + + assert target_id == "123456" + assert "current Discord chat" in note + + +@pytest.mark.asyncio +async def test_create_thread_uses_parent_channel_when_origin_is_thread(monkeypatch): + calls = [] + fake_aiohttp = FakeAioHttpModule( + responses=[ + (200, {"id": "777", "type": 11, "parent_id": "555"}), + (201, {"id": "999", "name": "Spec review"}), + ], + recorder=calls, + ) + monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp) + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "777") + monkeypatch.setattr(discord_tool, "_load_discord_token", lambda: "token-123") + + result = await discord_tool._create_thread({"name": "Spec review"}) + + assert result["success"] is True + assert result["thread_id"] == "999" + assert result["parent_channel_id"] == "555" + assert "creation_mode" not in result + assert "note" not in result + assert calls[1]["url"].endswith("/channels/555/threads") + + +@pytest.mark.asyncio +async def test_create_thread_falls_back_to_message_seed_when_direct_create_fails(monkeypatch): + calls = [] + fake_aiohttp = FakeAioHttpModule( + responses=[ + (200, {"id": "123", "type": 0}), + (400, {"message": "Cannot create thread that way"}), + (200, {"id": "seed-1"}), + (201, {"id": "thread-2", "name": "Implementation"}), + ], + recorder=calls, + ) + monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp) + monkeypatch.setattr(discord_tool, "_load_discord_token", lambda: "token-123") + monkeypatch.setattr(discord_tool, "_resolve_target_chat_id", lambda target: ("123", "Used Discord target 123.")) + + result = await discord_tool._create_thread({"name": "Implementation"}) + + assert result["success"] is True + assert result["starter_message_id"] == "seed-1" + assert "creation_mode" not in result + assert "note" not in result + assert calls[2]["url"].endswith("/channels/123/messages") + assert calls[3]["url"].endswith("/channels/123/messages/seed-1/threads") + + +@pytest.mark.asyncio +async def test_create_thread_posts_opening_message_when_direct_create_succeeds(monkeypatch): + calls = [] + fake_aiohttp = FakeAioHttpModule( + responses=[ + (200, {"id": "123", "type": 0}), + (201, {"id": "thread-9", "name": "Bugs"}), + (200, {"id": "msg-9"}), + ], + recorder=calls, + ) + monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp) + monkeypatch.setattr(discord_tool, "_load_discord_token", lambda: "token-123") + monkeypatch.setattr(discord_tool, "_resolve_target_chat_id", lambda target: ("123", "Used Discord target 123.")) + + result = await discord_tool._create_thread({"name": "Bugs", "message": "Track rough edges here."}) + + assert result["success"] is True + assert result["starter_message_id"] == "msg-9" + assert "creation_mode" not in result + assert "note" not in result + assert calls[2]["url"].endswith("/channels/thread-9/messages") + assert calls[2]["json"] == {"content": "Track rough edges here."} + + +@pytest.mark.asyncio +async def test_create_channel_uses_same_category_as_current_channel(monkeypatch): + calls = [] + fake_aiohttp = FakeAioHttpModule( + responses=[ + (200, {"id": "123", "type": 0, "guild_id": "guild-1", "parent_id": "cat-9"}), + (201, {"id": "chan-2", "name": "planning-room", "topic": "Roadmap", "nsfw": False}), + ], + recorder=calls, + ) + monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp) + monkeypatch.setattr(discord_tool, "_load_discord_token", lambda: "token-123") + monkeypatch.setattr(discord_tool, "_resolve_target_chat_id", lambda target: ("123", "Used Discord target 123.")) + + result = await discord_tool._create_channel({"name": "planning-room", "topic": "Roadmap"}) + + assert result["success"] is True + assert result["channel_id"] == "chan-2" + assert result["guild_id"] == "guild-1" + assert result["parent_category_id"] == "cat-9" + assert "note" not in result + assert calls[1]["url"].endswith("/guilds/guild-1/channels") + assert calls[1]["json"] == {"name": "planning-room", "type": 0, "nsfw": False, "parent_id": "cat-9", "topic": "Roadmap"} + + +@pytest.mark.asyncio +async def test_create_channel_from_thread_uses_parent_channel_category(monkeypatch): + calls = [] + fake_aiohttp = FakeAioHttpModule( + responses=[ + (200, {"id": "thread-7", "type": 11, "parent_id": "123", "guild_id": "guild-1"}), + (200, {"id": "123", "type": 0, "guild_id": "guild-1", "parent_id": "cat-9"}), + (201, {"id": "chan-8", "name": "bugs"}), + ], + recorder=calls, + ) + monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp) + monkeypatch.setattr(discord_tool, "_load_discord_token", lambda: "token-123") + monkeypatch.setattr(discord_tool, "_resolve_target_chat_id", lambda target: ("thread-7", "Used Discord target thread-7.")) + + result = await discord_tool._create_channel({"name": "bugs"}) + + assert result["success"] is True + assert result["channel_id"] == "chan-8" + assert result["parent_category_id"] == "cat-9" + assert "note" not in result + assert calls[1]["url"].endswith("/channels/123") + assert calls[2]["url"].endswith("/guilds/guild-1/channels") + + +@pytest.mark.asyncio +async def test_create_channel_rejects_dm_targets(monkeypatch): + calls = [] + fake_aiohttp = FakeAioHttpModule( + responses=[ + (200, {"id": "dm-1", "type": 1}), + ], + recorder=calls, + ) + monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp) + monkeypatch.setattr(discord_tool, "_load_discord_token", lambda: "token-123") + monkeypatch.setattr(discord_tool, "_resolve_target_chat_id", lambda target: ("dm-1", "Used Discord target dm-1.")) + + result = await discord_tool._create_channel({"name": "private-lair"}) + + assert result["error"] == "Discord channels can only be created inside servers, not DMs." + + +def test_discord_manage_tool_serializes_create_thread(fake_runner, monkeypatch): + monkeypatch.setattr( + discord_tool, + "_create_thread", + lambda args: _immediate_result({"success": True, "thread_id": "42", "thread_name": args["name"]}), + ) + + result = json.loads(discord_tool.discord_manage_tool({"action": "create_thread", "name": "Planning"})) + + assert result["success"] is True + assert result["thread_id"] == "42" + assert result["thread_name"] == "Planning" + + +def test_discord_manage_tool_serializes_create_channel(fake_runner, monkeypatch): + monkeypatch.setattr( + discord_tool, + "_create_channel", + lambda args: _immediate_result({"success": True, "channel_id": "84", "channel_name": args["name"]}), + ) + + result = json.loads(discord_tool.discord_manage_tool({"action": "create_channel", "name": "planning-room"})) + + assert result["success"] is True + assert result["channel_id"] == "84" + assert result["channel_name"] == "planning-room" + + +async def _immediate_result(value): + return value diff --git a/tools/discord_tool.py b/tools/discord_tool.py new file mode 100644 index 000000000..5cd1aed70 --- /dev/null +++ b/tools/discord_tool.py @@ -0,0 +1,409 @@ +"""Primitive Discord management tool for creating threads and channels.""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Dict, Optional, Tuple +from urllib.parse import quote + +logger = logging.getLogger(__name__) + + +DISCORD_MANAGE_SCHEMA = { + "name": "discord_manage", + "description": ( + "Create Discord threads or text channels when Discord is connected. " + "This is a low-level primitive for explicit workspace creation requests." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create_thread", "create_channel"], + "description": "Discord management action to perform." + }, + "name": { + "type": "string", + "description": "Name to create. Required for create_thread and create_channel." + }, + "target": { + "type": "string", + "description": ( + "Where to create the Discord space. Defaults to the current Discord chat when invoked from Discord, " + "otherwise falls back to the configured Discord home channel. Accepts 'origin', 'discord:chat_id', " + "or 'discord:#channel-name'. For threads, if the target resolves to an existing thread, Hermes " + "creates the new thread under that thread's parent channel. For channels, Hermes creates the new " + "channel in the same guild and category context as the target channel when possible." + ) + }, + "message": { + "type": "string", + "description": "Optional starter message to seed the new thread." + }, + "topic": { + "type": "string", + "description": "Optional channel topic. Used for create_channel." + }, + "channel_type": { + "type": "string", + "enum": ["text"], + "description": "Discord channel type to create. Currently only 'text' is supported." + }, + "nsfw": { + "type": "boolean", + "description": "Whether the new channel should be marked NSFW. Used for create_channel." + }, + "auto_archive_duration": { + "type": "integer", + "enum": [60, 1440, 4320, 10080], + "description": "Discord thread auto-archive duration in minutes. Default: 1440 (24 hours)." + }, + "reason": { + "type": "string", + "description": "Optional audit-log reason for Discord." + }, + }, + "required": ["action", "name"], + }, +} + + +THREAD_CHANNEL_TYPES = {10, 11, 12} +DM_CHANNEL_TYPES = {1, 3} +DEFAULT_AUTO_ARCHIVE_DURATION = 1440 + + +def discord_manage_tool(args, **kwargs): + action = (args.get("action") or "").strip().lower() + if action == "create_thread": + return _handle_create_thread(args) + if action == "create_channel": + return _handle_create_channel(args) + return json.dumps({"error": f"Unsupported discord_manage action: {action}"}) + + +def _run_discord_manage_action(action_name: str, coro) -> str: + try: + from model_tools import _run_async + + result = _run_async(coro) + return json.dumps(result) + except Exception as e: + logger.exception("discord_manage %s failed", action_name) + label = action_name.replace("_", " ") + return json.dumps({"error": f"Discord {label} failed: {e}"}) + + +def _handle_create_thread(args: Dict[str, Any]) -> str: + return _run_discord_manage_action("create_thread", _create_thread(args)) + + +def _handle_create_channel(args: Dict[str, Any]) -> str: + return _run_discord_manage_action("create_channel", _create_channel(args)) + + +async def _create_thread(args: Dict[str, Any]) -> Dict[str, Any]: + token = _load_discord_token() + if not token: + return {"error": "Discord is not configured. Add a Discord bot token in gateway config first."} + + try: + import aiohttp + except ImportError: + return {"error": "aiohttp not installed. Run: pip install aiohttp"} + + name = (args.get("name") or "").strip() + if not name: + return {"error": "'name' is required for action='create_thread'"} + + target_id, target_note = _resolve_target_chat_id(args.get("target")) + if not target_id: + return {"error": target_note or "Could not resolve Discord target."} + + auto_archive_duration = int(args.get("auto_archive_duration") or DEFAULT_AUTO_ARCHIVE_DURATION) + starter_message = (args.get("message") or "").strip() + reason = (args.get("reason") or "").strip() or None + + headers = { + "Authorization": f"Bot {token}", + "Content-Type": "application/json", + } + if reason: + headers["X-Audit-Log-Reason"] = quote(reason, safe="") + + async with aiohttp.ClientSession(headers=headers) as session: + channel_data = await _discord_api_json(session, "GET", f"/channels/{target_id}") + if "error" in channel_data: + return channel_data + + channel_type = channel_data.get("type") + if channel_type in DM_CHANNEL_TYPES: + return {"error": "Discord threads can only be created inside server text channels, not DMs."} + + parent_channel_id = channel_data.get("parent_id") if channel_type in THREAD_CHANNEL_TYPES else str(target_id) + if not parent_channel_id: + return {"error": "Could not determine a parent text channel for the new thread."} + + direct_payload = { + "name": name, + "auto_archive_duration": auto_archive_duration, + "type": 11, + } + direct_result = await _discord_api_json( + session, + "POST", + f"/channels/{parent_channel_id}/threads", + payload=direct_payload, + allowed_statuses={200, 201}, + include_status=True, + ) + + starter_message_id = None + thread_data: Optional[Dict[str, Any]] = None + + if "error" not in direct_result: + thread_data = direct_result + if starter_message: + post_result = await _discord_api_json( + session, + "POST", + f"/channels/{thread_data['id']}/messages", + payload={"content": starter_message}, + ) + if "error" in post_result: + return post_result + starter_message_id = post_result.get("id") + else: + seed_content = starter_message or f"🧵 Thread created by Hermes: **{name}**" + seed_result = await _discord_api_json( + session, + "POST", + f"/channels/{parent_channel_id}/messages", + payload={"content": seed_content}, + ) + if "error" in seed_result: + return { + "error": ( + "Discord rejected direct thread creation and Hermes could not create a starter message either. " + f"Direct error: {direct_result['error']}" + ) + } + starter_message_id = seed_result.get("id") + thread_result = await _discord_api_json( + session, + "POST", + f"/channels/{parent_channel_id}/messages/{starter_message_id}/threads", + payload={ + "name": name, + "auto_archive_duration": auto_archive_duration, + }, + ) + if "error" in thread_result: + return thread_result + thread_data = thread_result + + return { + "success": True, + "platform": "discord", + "action": "create_thread", + "thread_id": str(thread_data.get("id")), + "thread_name": thread_data.get("name") or name, + "parent_channel_id": str(parent_channel_id), + "starter_message_id": str(starter_message_id) if starter_message_id else None, + } + + +async def _create_channel(args: Dict[str, Any]) -> Dict[str, Any]: + token = _load_discord_token() + if not token: + return {"error": "Discord is not configured. Add a Discord bot token in gateway config first."} + + try: + import aiohttp + except ImportError: + return {"error": "aiohttp not installed. Run: pip install aiohttp"} + + name = (args.get("name") or "").strip() + if not name: + return {"error": "'name' is required for action='create_channel'"} + + channel_type = (args.get("channel_type") or "text").strip().lower() + if channel_type != "text": + return {"error": f"Unsupported channel_type '{channel_type}'. Only 'text' is supported right now."} + + target_id, target_note = _resolve_target_chat_id(args.get("target")) + if not target_id: + return {"error": target_note or "Could not resolve Discord target."} + + reason = (args.get("reason") or "").strip() or None + topic = (args.get("topic") or "").strip() or None + nsfw = bool(args.get("nsfw", False)) + + headers = { + "Authorization": f"Bot {token}", + "Content-Type": "application/json", + } + if reason: + headers["X-Audit-Log-Reason"] = quote(reason, safe="") + + async with aiohttp.ClientSession(headers=headers) as session: + channel_data = await _discord_api_json(session, "GET", f"/channels/{target_id}") + if "error" in channel_data: + return channel_data + + if channel_data.get("type") in DM_CHANNEL_TYPES: + return {"error": "Discord channels can only be created inside servers, not DMs."} + + base_channel_data = channel_data + if channel_data.get("type") in THREAD_CHANNEL_TYPES: + parent_channel_id = channel_data.get("parent_id") + if not parent_channel_id: + return {"error": "Could not determine the parent channel for the current thread."} + base_channel_data = await _discord_api_json(session, "GET", f"/channels/{parent_channel_id}") + if "error" in base_channel_data: + return base_channel_data + + guild_id = base_channel_data.get("guild_id") or channel_data.get("guild_id") + if not guild_id: + return {"error": "Could not determine which Discord server should own the new channel."} + + payload = { + "name": name, + "type": 0, + "nsfw": nsfw, + } + parent_category_id = base_channel_data.get("parent_id") + if parent_category_id: + payload["parent_id"] = parent_category_id + if topic: + payload["topic"] = topic + + created = await _discord_api_json( + session, + "POST", + f"/guilds/{guild_id}/channels", + payload=payload, + ) + if "error" in created: + return created + + return { + "success": True, + "platform": "discord", + "action": "create_channel", + "channel_id": str(created.get("id")), + "channel_name": created.get("name") or name, + "guild_id": str(guild_id), + "parent_category_id": str(parent_category_id) if parent_category_id else None, + "topic": created.get("topic") if created.get("topic") is not None else topic, + "nsfw": bool(created.get("nsfw", nsfw)), + } + + +async def _discord_api_json( + session, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + allowed_statuses: Optional[set[int]] = None, + include_status: bool = False, +) -> Dict[str, Any]: + allowed_statuses = allowed_statuses or {200, 201} + url = f"https://discord.com/api/v10{path}" + async with session.request(method, url, json=payload) as resp: + text = await resp.text() + if resp.status not in allowed_statuses: + body = text.strip() or "" + return {"error": f"Discord API error ({resp.status}): {body}", "status": resp.status} + if not text.strip(): + data: Dict[str, Any] = {} + else: + try: + data = json.loads(text) + except json.JSONDecodeError: + data = {"raw": text} + if include_status: + data["_status"] = resp.status + return data + + +def _resolve_target_chat_id(target: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + target = (target or "").strip() + + if not target or target.lower() == "origin": + session_platform = os.getenv("HERMES_SESSION_PLATFORM", "").lower() + session_chat_id = os.getenv("HERMES_SESSION_CHAT_ID", "").strip() + if session_platform == "discord" and session_chat_id: + return session_chat_id, "Used the current Discord chat as the thread target." + + home_channel = _load_discord_home_channel() + if home_channel: + return home_channel, "Used the configured Discord home channel as the thread target." + + return None, "No Discord target provided and no current Discord chat/home channel was available." + + if target.isdigit(): + return target, f"Used Discord target {target}." + + if target.lower().startswith("discord:"): + chat_ref = target.split(":", 1)[1].strip() + if chat_ref.isdigit(): + return chat_ref, f"Used Discord target {chat_ref}." + try: + from gateway.channel_directory import resolve_channel_name + + resolved = resolve_channel_name("discord", chat_ref) + except Exception: + resolved = None + if resolved: + return resolved, f"Resolved {target} to Discord target {resolved}." + return None, f"Could not resolve Discord target '{target}'. Use send_message(action='list') to inspect known channels." + + return None, "Discord targets must be 'origin', a numeric channel ID, or 'discord:CHANNEL'." + + +def _load_discord_token() -> Optional[str]: + try: + from gateway.config import Platform, load_gateway_config + + config = load_gateway_config() + pconfig = config.platforms.get(Platform.DISCORD) + if pconfig and pconfig.enabled and pconfig.token: + return pconfig.token + except Exception: + return None + return None + + +def _load_discord_home_channel() -> Optional[str]: + try: + from gateway.config import Platform, load_gateway_config + + config = load_gateway_config() + home = config.get_home_channel(Platform.DISCORD) + if home and home.chat_id: + return str(home.chat_id) + except Exception: + return None + return None + + +def _check_discord_manage() -> bool: + session_platform = os.getenv("HERMES_SESSION_PLATFORM", "").lower() + if session_platform == "discord": + return True + return bool(_load_discord_token()) + + +from tools.registry import registry + +registry.register( + name="discord_manage", + toolset="messaging", + schema=DISCORD_MANAGE_SCHEMA, + handler=discord_manage_tool, + check_fn=_check_discord_manage, +) diff --git a/toolsets.py b/toolsets.py index 87b48c7ec..cb4a9744d 100644 --- a/toolsets.py +++ b/toolsets.py @@ -58,8 +58,8 @@ "execute_code", "delegate_task", # Cronjob management "schedule_cronjob", "list_cronjobs", "remove_cronjob", - # Cross-platform messaging (gated on gateway running via check_fn) - "send_message", + # Cross-platform messaging and Discord workspace management + "send_message", "discord_manage", # Honcho user context (gated on honcho being active via check_fn) "query_user_context", # Home Assistant smart home control (gated on HASS_TOKEN via check_fn)