"""Discord server introspection and management tool (REST API + bot token). Talks to the Discord REST API directly with the bot token — no dependency on the gateway adapter's client. Only in the hermes-discord toolset, so it costs nothing on other platforms. The model-visible schema is filtered by two gates: (1) privileged intents from GET /applications/@me — actions needing an intent the bot lacks (search_members / member_info → GUILD_MEMBERS) are hidden, and fetch_messages/list_pins are annotated when MESSAGE_CONTENT is missing; (2) the ``discord.server_actions`` config allowlist (comma string or YAML list; empty/unset = all). Per-guild permissions are NOT pre-checked — a call-time 403 is mapped to actionable guidance by :func:`_enrich_403`. """ import hashlib import json import logging import threading import time import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from agent.secret_scope import get_secret from tools.registry import registry, tool_error logger = logging.getLogger(__name__) DISCORD_API_BASE = "https://discord.com/api/v10" _DISCORD_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024 _DISCORD_ERROR_BODY_MAX_BYTES = 64 * 1024 # Application flag bits (from GET /applications/@me → "flags"). # Source: https://discord.com/developers/docs/resources/application#application-object-application-flags _FLAG_GATEWAY_GUILD_MEMBERS = 1 << 14 _FLAG_GATEWAY_GUILD_MEMBERS_LIMITED = 1 << 15 _FLAG_GATEWAY_MESSAGE_CONTENT = 1 << 18 _FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19 class DiscordAPIError(Exception): """Raised when a Discord API call fails.""" def __init__(self, status: int, body: str): self.status = status self.body = body super().__init__(f"Discord API error {status}: {body}") def _read_limited_response_body(source: Any, limit: int, *, label: str) -> bytes: body = source.read(limit + 1) if len(body) > limit: raise DiscordAPIError(502, f"Discord API {label} exceeded {limit} bytes.") return body def _get_bot_token() -> Optional[str]: """Resolve the Discord bot token under the active profile secret scope.""" return (get_secret("DISCORD_BOT_TOKEN", "") or "").strip() or None def _discord_request( method: str, path: str, token: str, params: Optional[Dict[str, str]] = None, body: Optional[Dict[str, Any]] = None, timeout: int = 15, ) -> Any: """Make a request to the Discord REST API.""" url = f"{DISCORD_API_BASE}{path}" if params: url += "?" + urllib.parse.urlencode(params) req = urllib.request.Request( url, data=None if body is None else json.dumps(body).encode("utf-8"), method=method, headers={ "Authorization": f"Bot {token}", "Content-Type": "application/json", "User-Agent": "Hermes-Agent (https://github.com/NousResearch/hermes-agent)", }, ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status == 204: return None response_body = _read_limited_response_body( resp, _DISCORD_RESPONSE_BODY_MAX_BYTES, label="response body", ) return json.loads(response_body.decode("utf-8")) except urllib.error.HTTPError as e: error_body = "" try: error_body = _read_limited_response_body( e, _DISCORD_ERROR_BODY_MAX_BYTES, label="error body" ).decode("utf-8", errors="replace") except DiscordAPIError as too_large: error_body = too_large.body except Exception: pass raise DiscordAPIError(e.code, error_body) from e _CHANNEL_TYPE_NAMES = { 0: "text", 2: "voice", 4: "category", 5: "announcement", 10: "announcement_thread", 11: "public_thread", 12: "private_thread", 13: "stage", 15: "forum", 16: "media", } def _channel_type_name(type_id: int) -> str: return _CHANNEL_TYPE_NAMES.get(type_id, f"unknown({type_id})") # ── capability detection (application intents) ────────────────────────────── # Per-token in-process cache: the app/me endpoint is hit at most once per process. _capability_cache: Dict[str, Dict[str, Any]] = {} # Privileged intents change only when the user flips them in the Developer # Portal, so 24h disk staleness is harmless: a hidden action re-appears on the # next refresh; an exposed action the bot lost fails at call time with an # enriched 403. _CAPABILITY_DISK_TTL_SECONDS = 24 * 3600 # One background detection per (process, token) at most. _capability_bg_started: set = set() _capability_bg_lock = threading.Lock() # Permissive default (``detected`` False = detection failed/pending): all actions # exposed, call-time 403s mapped to guidance by ``_enrich_403``. _PERMISSIVE_CAPS = {"has_members_intent": True, "has_message_content": True, "detected": False} def _capability_disk_cache_path() -> Path: from hermes_constants import get_hermes_home return get_hermes_home() / "cache" / "discord_capabilities.json" def _token_cache_key(token: str) -> str: """Stable non-reversible cache key for a bot token.""" return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16] def _load_caps_from_disk(token: str) -> Optional[Dict[str, Any]]: """Return fresh disk-cached capabilities for *token*, or None.""" try: path = _capability_disk_cache_path() with path.open("r", encoding="utf-8") as f: data = json.load(f) entry = data.get(_token_cache_key(token)) if not isinstance(entry, dict): return None if time.time() - float(entry.get("ts", 0)) > _CAPABILITY_DISK_TTL_SECONDS: return None caps = entry.get("caps") if isinstance(caps, dict) and "has_members_intent" in caps: return caps except Exception: pass return None def _save_caps_to_disk(token: str, caps: Dict[str, Any]) -> None: try: path = _capability_disk_cache_path() path.parent.mkdir(parents=True, exist_ok=True) try: with path.open("r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): data = {} except Exception: data = {} data[_token_cache_key(token)] = {"caps": caps, "ts": time.time()} tmp = path.with_suffix(".json.tmp") with tmp.open("w", encoding="utf-8") as f: json.dump(data, f) tmp.replace(path) except Exception: logger.debug("discord capability disk-cache write failed", exc_info=True) def _detect_capabilities_nonblocking(token: str) -> Dict[str, Any]: """Non-blocking capability lookup for schema builds. Order: in-process cache → fresh disk cache → permissive default plus a fire-and-forget background detection that fills the disk cache for the NEXT process. The blocking HTTPS call (~2-5s) used to sit on the first-token critical path of every cold process with DISCORD_BOT_TOKEN set. """ cached = _capability_cache.get(token) if cached is not None: return cached disk = _load_caps_from_disk(token) if disk is not None: _capability_cache[token] = disk return disk # Cold start — pin the permissive default for THIS process: schemas must not # change between agent inits within a live process or the per-conversation # prompt cache breaks. caps_default = dict(_PERMISSIVE_CAPS) _capability_cache[token] = caps_default with _capability_bg_lock: if token not in _capability_bg_started: _capability_bg_started.add(token) def _bg_detect() -> None: try: caps = _fetch_capabilities(token) if caps.get("detected"): _save_caps_to_disk(token, caps) except Exception: logger.debug("background discord capability detection failed", exc_info=True) threading.Thread( target=_bg_detect, name="discord-caps-detect", daemon=True ).start() return caps_default def _fetch_capabilities(token: str) -> Dict[str, Any]: """Fetch capabilities from GET /applications/@me. Pure network fetch — never touches the in-process cache (background detection must not mutate schemas mid-process). Detection failure is permissive.""" caps: Dict[str, Any] = dict(_PERMISSIVE_CAPS) try: app = _discord_request("GET", "/applications/@me", token, timeout=5) flags = int(app.get("flags", 0) or 0) caps["has_members_intent"] = bool( flags & (_FLAG_GATEWAY_GUILD_MEMBERS | _FLAG_GATEWAY_GUILD_MEMBERS_LIMITED) ) caps["has_message_content"] = bool( flags & (_FLAG_GATEWAY_MESSAGE_CONTENT | _FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED) ) caps["detected"] = True except Exception as exc: # nosec — detection is best-effort logger.info( "Discord capability detection failed (%s); exposing all actions.", exc, ) return caps def _detect_capabilities(token: str, *, force: bool = False) -> Dict[str, Any]: """Blocking detection via GET /applications/@me, cached per token (the warm-up path; schema builds use the non-blocking variant). ``force`` re-fetches.""" if token in _capability_cache and not force: return _capability_cache[token] caps = _fetch_capabilities(token) _capability_cache[token] = caps return caps def _reset_capability_cache() -> None: """Test hook: clear the detection cache.""" global _capability_cache, _capability_bg_started _capability_cache = {} with _capability_bg_lock: _capability_bg_started = set() # ── action implementations ─────────────────────────────────────────────────── def _listing(key: str, items: List[Dict[str, Any]]) -> str: return json.dumps({key: items, "count": len(items)}) def _ok(message: str) -> str: return json.dumps({"success": True, "message": message}) def _member_summary(m: Dict[str, Any], *, full: bool) -> Dict[str, Any]: """Member row; ``full`` adds the avatar/join fields member_info exposes (key order is part of the result text, so the two shapes stay explicit).""" user = m.get("user", {}) base = { "user_id": user.get("id"), "username": user.get("username"), "display_name": user.get("global_name"), "nickname": m.get("nick"), } tail = {"bot": user.get("bot", False), "roles": m.get("roles", [])} if not full: return {**base, **tail} return { **base, "avatar": user.get("avatar"), **tail, "joined_at": m.get("joined_at"), "premium_since": m.get("premium_since"), } def _message_summary(msg: Dict[str, Any]) -> Dict[str, Any]: author = msg.get("author", {}) return { "id": msg["id"], "content": msg.get("content", ""), "author": { "id": author.get("id"), "username": author.get("username"), "display_name": author.get("global_name"), "bot": author.get("bot", False), }, "timestamp": msg.get("timestamp"), "edited_timestamp": msg.get("edited_timestamp"), "attachments": [ {"filename": a.get("filename"), "url": a.get("url"), "size": a.get("size")} for a in msg.get("attachments", []) ], "reactions": [ {"emoji": r.get("emoji", {}).get("name"), "count": r.get("count", 0)} for r in msg.get("reactions", []) ] if msg.get("reactions") else [], "pinned": msg.get("pinned", False), } def _int_or(value: Any, default: int) -> int: try: return int(value) except (TypeError, ValueError): return default def _limit_param(limit: Any, default: int) -> str: """Discord caps list endpoints at 100 per page.""" return str(min(_int_or(limit, default), 100)) def _list_guilds(token: str, **_kwargs: Any) -> str: """List all guilds the bot is a member of.""" guilds = _discord_request("GET", "/users/@me/guilds", token) return _listing("guilds", [ { "id": g["id"], "name": g["name"], "icon": g.get("icon"), "owner": g.get("owner", False), "permissions": g.get("permissions"), } for g in guilds ]) def _server_info(token: str, guild_id: str, **_kwargs: Any) -> str: """Get detailed information about a guild.""" g = _discord_request("GET", f"/guilds/{guild_id}", token, params={"with_counts": "true"}) return json.dumps({ "id": g["id"], "name": g["name"], "description": g.get("description"), "icon": g.get("icon"), "owner_id": g.get("owner_id"), "member_count": g.get("approximate_member_count"), "online_count": g.get("approximate_presence_count"), "features": g.get("features", []), "premium_tier": g.get("premium_tier"), "premium_subscription_count": g.get("premium_subscription_count"), "verification_level": g.get("verification_level"), }) def _list_channels(token: str, guild_id: str, **_kwargs: Any) -> str: """List all channels in a guild, organized by category.""" channels = _discord_request("GET", f"/guilds/{guild_id}/channels", token) categories: Dict[Optional[str], Dict[str, Any]] = { ch["id"]: {"id": ch["id"], "name": ch["name"], "position": ch.get("position", 0), "channels": []} for ch in channels if ch["type"] == 4 # category } uncategorized: List[Dict[str, Any]] = [] for ch in channels: if ch["type"] == 4: continue entry = { "id": ch["id"], "name": ch.get("name", ""), "type": _channel_type_name(ch["type"]), "position": ch.get("position", 0), "topic": ch.get("topic"), "nsfw": ch.get("nsfw", False), } parent = ch.get("parent_id") if parent and parent in categories: categories[parent]["channels"].append(entry) else: uncategorized.append(entry) sorted_cats = sorted(categories.values(), key=lambda c: c["position"]) for cat in sorted_cats: cat["channels"].sort(key=lambda c: c["position"]) uncategorized.sort(key=lambda c: c["position"]) result: List[Dict[str, Any]] = [] if uncategorized: result.append({"category": None, "channels": uncategorized}) result.extend( {"category": {"id": cat["id"], "name": cat["name"]}, "channels": cat["channels"]} for cat in sorted_cats ) total = sum(len(group["channels"]) for group in result) return json.dumps({"channel_groups": result, "total_channels": total}) def _channel_info(token: str, channel_id: str, **_kwargs: Any) -> str: """Get detailed info about a specific channel.""" ch = _discord_request("GET", f"/channels/{channel_id}", token) return json.dumps({ "id": ch["id"], "name": ch.get("name"), "type": _channel_type_name(ch["type"]), "guild_id": ch.get("guild_id"), "topic": ch.get("topic"), "nsfw": ch.get("nsfw", False), "position": ch.get("position"), "parent_id": ch.get("parent_id"), "rate_limit_per_user": ch.get("rate_limit_per_user", 0), "last_message_id": ch.get("last_message_id"), }) def _list_roles(token: str, guild_id: str, **_kwargs: Any) -> str: """List all roles in a guild.""" roles = _discord_request("GET", f"/guilds/{guild_id}/roles", token) return _listing("roles", [ { "id": r["id"], "name": r["name"], "color": f"#{r.get('color', 0):06x}" if r.get("color") else None, "position": r.get("position", 0), "mentionable": r.get("mentionable", False), "managed": r.get("managed", False), "member_count": r.get("member_count"), "hoist": r.get("hoist", False), } for r in sorted(roles, key=lambda r: r.get("position", 0), reverse=True) ]) def _member_info(token: str, guild_id: str, user_id: str, **_kwargs: Any) -> str: """Get info about a specific guild member.""" m = _discord_request("GET", f"/guilds/{guild_id}/members/{user_id}", token) return json.dumps(_member_summary(m, full=True)) def _search_members(token: str, guild_id: str, query: str, limit: int = 20, **_kwargs: Any) -> str: """Search for guild members by name prefix (requires the GUILD_MEMBERS intent).""" params = {"query": query, "limit": _limit_param(limit, 20)} members = _discord_request("GET", f"/guilds/{guild_id}/members/search", token, params=params) return _listing("members", [_member_summary(m, full=False) for m in members]) def _fetch_messages( token: str, channel_id: str, limit: int = 50, before: Optional[str] = None, after: Optional[str] = None, **_kwargs: Any, ) -> str: """Recent messages from a channel or thread; ``before``/``after`` are message snowflakes for reverse/forward pagination.""" params: Dict[str, str] = {"limit": _limit_param(limit, 50)} if before: params["before"] = before if after: params["after"] = after messages = _discord_request("GET", f"/channels/{channel_id}/messages", token, params=params) return _listing("messages", [_message_summary(msg) for msg in messages]) def _list_pins(token: str, channel_id: str, **_kwargs: Any) -> str: """List pinned messages in a channel (content truncated for overview).""" messages = _discord_request("GET", f"/channels/{channel_id}/pins", token) return _listing("pinned_messages", [ { "id": msg["id"], "content": msg.get("content", "")[:200], "author": msg.get("author", {}).get("username"), "timestamp": msg.get("timestamp"), } for msg in messages ]) def _pin_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str: _discord_request("PUT", f"/channels/{channel_id}/pins/{message_id}", token) return _ok(f"Message {message_id} pinned.") def _unpin_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str: _discord_request("DELETE", f"/channels/{channel_id}/pins/{message_id}", token) return _ok(f"Message {message_id} unpinned.") def _delete_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str: _discord_request("DELETE", f"/channels/{channel_id}/messages/{message_id}", token) return _ok(f"Message {message_id} deleted.") def _create_thread( token: str, channel_id: str, name: str, message_id: Optional[str] = None, auto_archive_duration: int = 1440, **_kwargs: Any, ) -> str: """Create a thread — anchored to ``message_id`` when given, else standalone public.""" body: Dict[str, Any] = {"name": name, "auto_archive_duration": auto_archive_duration} if message_id: path = f"/channels/{channel_id}/messages/{message_id}/threads" else: path = f"/channels/{channel_id}/threads" body["type"] = 11 # PUBLIC_THREAD thread = _discord_request("POST", path, token, body=body) return json.dumps({"success": True, "thread_id": thread["id"], "name": thread.get("name")}) def _add_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwargs: Any) -> str: _discord_request("PUT", f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}", token) return _ok(f"Role {role_id} added to user {user_id}.") def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwargs: Any) -> str: _discord_request("DELETE", f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}", token) return _ok(f"Role {role_id} removed from user {user_id}.") # ── action dispatch + metadata ─────────────────────────────────────────────── _ACTIONS = { "list_guilds": _list_guilds, "server_info": _server_info, "list_channels": _list_channels, "channel_info": _channel_info, "list_roles": _list_roles, "member_info": _member_info, "search_members": _search_members, "fetch_messages": _fetch_messages, "list_pins": _list_pins, "pin_message": _pin_message, "unpin_message": _unpin_message, "delete_message": _delete_message, "create_thread": _create_thread, "add_role": _add_role, "remove_role": _remove_role, } # Two tools share one action table: ``discord`` (core, the participation trio # every bot user wants) and ``discord_admin`` (everything else). _CORE_ACTION_NAMES = frozenset({"fetch_messages", "search_members", "create_thread"}) _CORE_ACTIONS = {k: v for k, v in _ACTIONS.items() if k in _CORE_ACTION_NAMES} _ADMIN_ACTIONS = {k: v for k, v in _ACTIONS.items() if k not in _CORE_ACTION_NAMES} # Single source of truth: action → (required-param signature, description). # Drives the schema description AND runtime required-param validation. _ACTION_MANIFEST: List[Tuple[str, str, str]] = [ ("list_guilds", "()", "list servers the bot is in"), ("server_info", "(guild_id)", "server details + member counts"), ("list_channels", "(guild_id)", "all channels grouped by category"), ("channel_info", "(channel_id)", "single channel details"), ("list_roles", "(guild_id)", "roles sorted by position"), ("member_info", "(guild_id, user_id)", "lookup a specific member"), ("search_members", "(guild_id, query)", "find members by name prefix"), ("fetch_messages", "(channel_id)", "recent messages; optional before/after snowflakes"), ("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"), ("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"), ] # Actions that require the GUILD_MEMBERS privileged intent. _INTENT_GATED_MEMBERS = frozenset({"member_info", "search_members"}) # Per-action required params for runtime validation, parsed from the manifest. _REQUIRED_PARAMS: Dict[str, List[str]] = { name: [p.strip() for p in sig.strip("()").split(",") if p.strip()] for name, sig, _desc in _ACTION_MANIFEST } def _load_allowed_actions_config() -> Optional[List[str]]: """``discord.server_actions`` allowlist (comma string or YAML list), or ``None`` when unrestricted. Unknown names are dropped with a warning.""" try: from hermes_cli.config import load_config cfg = load_config() except Exception as exc: logger.debug("discord: could not load config (%s); allowing all actions.", exc) return None raw = (cfg.get("discord") or {}).get("server_actions") if raw is None or raw == "": return None if isinstance(raw, str): raw = raw.split(",") elif not isinstance(raw, (list, tuple)): logger.warning( "discord.server_actions: unexpected type %s; ignoring.", type(raw).__name__, ) return None names = [str(n).strip() for n in raw if str(n).strip()] invalid = [n for n in names if n not in _ACTIONS] if invalid: logger.warning( "discord.server_actions: unknown action(s) ignored: %s. " "Known: %s", ", ".join(invalid), ", ".join(_ACTIONS.keys()), ) return [n for n in names if n in _ACTIONS] def _available_actions( caps: Dict[str, Any], allowlist: Optional[List[str]], ) -> List[str]: """Visible actions from intents + config allowlist, in :data:`_ACTIONS` order.""" members_ok = caps.get("has_members_intent", True) return [ name for name in _ACTIONS if (members_ok or name not in _INTENT_GATED_MEMBERS) and (allowlist is None or name in allowlist) ] # ── schema construction ────────────────────────────────────────────────────── _TOOL_DESCRIPTIONS = { "discord_admin": ( "Manage a Discord server via the REST API.", "Call list_guilds first to discover guild_ids, then list_channels for " "channel_ids. Runtime errors will tell you if the bot lacks a specific " "per-guild permission (e.g. MANAGE_ROLES for add_role).", ), "discord": ( "Read and participate in a Discord server.", "Use the channel_id from the current conversation context. " "Use search_members to look up user IDs by name prefix.", ), } _SCHEMA_PROPERTIES: Dict[str, Any] = { "guild_id": {"type": "string", "description": "Discord server (guild) ID."}, "channel_id": {"type": "string", "description": "Discord channel ID."}, "user_id": {"type": "string", "description": "Discord user ID."}, "role_id": {"type": "string", "description": "Discord role ID."}, "message_id": {"type": "string", "description": "Discord message ID."}, "query": {"type": "string", "description": "Member name prefix to search for (search_members)."}, "name": {"type": "string", "description": "New thread name (create_thread)."}, "limit": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Max results (default 50). Applies to fetch_messages, search_members.", }, "before": {"type": "string", "description": "Snowflake ID for reverse pagination (fetch_messages)."}, "after": {"type": "string", "description": "Snowflake ID for forward pagination (fetch_messages)."}, "auto_archive_duration": { "type": "integer", "enum": [60, 1440, 4320, 10080], "description": "Thread archive duration in minutes (create_thread, default 1440).", }, } def _build_schema( actions: List[str], caps: Optional[Dict[str, Any]] = None, tool_name: str = "discord", ) -> Optional[Dict[str, Any]]: """Tool schema for the filtered action list; ``None`` when empty (drop the tool).""" caps = caps or {} if not actions: return None manifest_block = "\n".join( f" {name}{sig} — {desc}" for name, sig, desc in _ACTION_MANIFEST if name in actions ) content_note = "" affected_actions = {"fetch_messages", "list_pins"} & set(actions) if affected_actions and caps.get("detected") and caps.get("has_message_content") is False: names = " and ".join(sorted(affected_actions)) content_note = ( f"\n\nNOTE: Bot does NOT have the MESSAGE_CONTENT privileged intent. " f"{names} will return message metadata (author, " "timestamps, attachments, reactions, pin state) but `content` will be " "empty for messages not sent as a direct mention to the bot or in DMs. " "Enable the intent in the Discord Developer Portal to see all content." ) lead, guidance = _TOOL_DESCRIPTIONS["discord_admin" if tool_name == "discord_admin" else "discord"] description = f"{lead}\n\nAvailable actions:\n{manifest_block}\n\n{guidance}{content_note}" properties: Dict[str, Any] = {"action": {"type": "string", "enum": actions}, **_SCHEMA_PROPERTIES} return { "name": tool_name, "description": description, "parameters": {"type": "object", "properties": properties, "required": ["action"]}, } def _get_dynamic_schema( action_subset: Dict[str, Any], tool_name: str, ) -> Optional[Dict[str, Any]]: """Build a dynamic schema for *action_subset* filtered by intents + config.""" token = _get_bot_token() if not token: return None caps = _detect_capabilities_nonblocking(token) allowlist = _load_allowed_actions_config() actions = [a for a in _available_actions(caps, allowlist) if a in action_subset] if not actions: return None return _build_schema(actions, caps, tool_name=tool_name) def get_dynamic_schema_core() -> Optional[Dict[str, Any]]: return _get_dynamic_schema(_CORE_ACTIONS, "discord") def get_dynamic_schema_admin() -> Optional[Dict[str, Any]]: return _get_dynamic_schema(_ADMIN_ACTIONS, "discord_admin") # ── 403 error enrichment ───────────────────────────────────────────────────── _NO_MANAGE_MESSAGES = "Bot lacks MANAGE_MESSAGES permission in this channel" _VIEW_HISTORY = "Bot cannot view this channel (missing VIEW_CHANNEL or READ_MESSAGE_HISTORY)." _ROLE_HIERARCHY = ( "Either the bot lacks MANAGE_ROLES, or the target role sits higher " "than the bot's highest role." ) # Per-action guidance for a call-time 403 (per-guild permissions are never pre-checked). _ACTION_403_HINT = { "pin_message": ( f"{_NO_MANAGE_MESSAGES}. " "Ask the server admin to grant the bot a role that has MANAGE_MESSAGES, " "or a per-channel overwrite." ), "unpin_message": f"{_NO_MANAGE_MESSAGES}.", "delete_message": f"{_NO_MANAGE_MESSAGES}, or cannot view the channel/message.", "create_thread": "Bot lacks CREATE_PUBLIC_THREADS in this channel, or cannot view it.", "add_role": ( f"{_ROLE_HIERARCHY} Roles can only be assigned below the " "bot's own position in the role hierarchy." ), "remove_role": _ROLE_HIERARCHY, "fetch_messages": _VIEW_HISTORY, "list_pins": _VIEW_HISTORY, "channel_info": "Bot cannot view this channel (missing VIEW_CHANNEL).", "search_members": ( "Likely missing the Server Members privileged intent — enable it in the " "Discord Developer Portal under your bot's settings." ), "member_info": ( "Bot cannot see this guild member (missing Server Members intent or " "insufficient permissions)." ), } def _enrich_403(action: str, body: str) -> str: """Return a user-friendly guidance string for a 403 on ``action``.""" hint = _ACTION_403_HINT.get(action) base = f"Discord API 403 (forbidden) on '{action}'." return f"{base} {hint} (Raw: {body})" if hint else f"{base} (Raw: {body})" def check_discord_tool_requirements() -> bool: """Tool is available only when a Discord bot token is configured.""" return bool(_get_bot_token()) # ── handlers ───────────────────────────────────────────────────────────────── _HANDLER_DEFAULTS = { "action": "", "guild_id": "", "channel_id": "", "user_id": "", "role_id": "", "message_id": "", "query": "", "name": "", "limit": 50, "before": "", "after": "", "auto_archive_duration": 1440, } def _run_discord_action( action: str, valid_actions: Dict[str, Any], tool_label: str, **params: Any, ) -> str: """Shared handler logic for both discord tools (``params`` default per :data:`_HANDLER_DEFAULTS`).""" token = _get_bot_token() if not token: return tool_error("DISCORD_BOT_TOKEN not configured.") action_fn = valid_actions.get(action) if not action_fn: return tool_error( f"Unknown action: {action}", available_actions=list(valid_actions.keys()), ) # Config-level allowlist gate (defense in depth): a stale cached schema from # a prior config must not let denied actions through. allowlist = _load_allowed_actions_config() if allowlist is not None and action not in allowlist: return tool_error( f"Action '{action}' is disabled by config (discord.server_actions). " f"Allowed: {', '.join(allowlist) if allowlist else ''}" ) kwargs = {k: params.get(k, v) for k, v in _HANDLER_DEFAULTS.items() if k != "action"} missing = [p for p in _REQUIRED_PARAMS.get(action, []) if not kwargs.get(p)] if missing: return tool_error( f"Missing required parameters for '{action}': {', '.join(missing)}" ) try: return action_fn(token=token, **kwargs) except DiscordAPIError as e: logger.warning("Discord API error in %s action '%s': %s", tool_label, action, e) if e.status == 403: return tool_error(_enrich_403(action, e.body)) return tool_error(str(e)) except Exception as e: logger.exception("Unexpected error in %s action '%s'", tool_label, action) return tool_error(f"Unexpected error: {e}") def discord_core(action: str, **kwargs) -> str: """Execute a core Discord action (fetch_messages, search_members, create_thread).""" return _run_discord_action(action, _CORE_ACTIONS, "discord", **kwargs) def discord_admin_handler(action: str, **kwargs) -> str: """Execute a Discord admin action (server management).""" return _run_discord_action(action, _ADMIN_ACTIONS, "discord_admin", **kwargs) # ── tool registration ──────────────────────────────────────────────────────── def _make_handler(handler_fn): """Registry-compatible handler: fills every schema param from ``_HANDLER_DEFAULTS``.""" return lambda args, **kw: handler_fn( **{k: args.get(k, v) for k, v in _HANDLER_DEFAULTS.items()}, ) # Static (un-detected) schemas at import; the intent/config-filtered ones come # from get_dynamic_schema_core/admin via model_tools' dynamic schema overrides. registry.register( name="discord", toolset="discord", schema=_build_schema(list(_CORE_ACTIONS), caps={"detected": False}, tool_name="discord"), handler=_make_handler(discord_core), check_fn=check_discord_tool_requirements, requires_env=["DISCORD_BOT_TOKEN"], ) registry.register( name="discord_admin", toolset="discord_admin", schema=_build_schema(list(_ADMIN_ACTIONS), caps={"detected": False}, tool_name="discord_admin"), handler=_make_handler(discord_admin_handler), check_fn=check_discord_tool_requirements, requires_env=["DISCORD_BOT_TOKEN"], )