| """Discord server introspection and management tool. |
| |
| Provides the agent with the ability to interact with Discord servers |
| when running on the Discord gateway. Uses Discord REST API directly |
| with the bot token — no dependency on the gateway adapter's client. |
| |
| Only included in the hermes-discord toolset, so it has zero cost |
| for users on other platforms. |
| |
| The schema exposed to the model is filtered by two gates: |
| |
| 1. Privileged intents detected from GET /applications/@me at schema |
| build time. Actions that require an intent the bot doesn't have |
| (search_members / member_info → GUILD_MEMBERS intent) are hidden. |
| fetch_messages is kept regardless of MESSAGE_CONTENT intent, but |
| its description is annotated when the intent is missing. |
| |
| 2. User config allowlist at ``discord.server_actions``. If the user |
| sets a comma-separated list (or YAML list) of action names, only |
| those appear in the schema. Empty/unset means all intent-available |
| actions are exposed. |
| |
| Per-guild permissions (MANAGE_ROLES etc.) are NOT pre-checked — Discord |
| returns a 403 at call time and :func:`_enrich_403` maps it to |
| actionable guidance the model can relay to the user. |
| """ |
|
|
| import json |
| import logging |
| import os |
| import urllib.error |
| import urllib.parse |
| import urllib.request |
| from typing import Any, Dict, List, Optional, Tuple |
|
|
| from tools.registry import registry |
|
|
| logger = logging.getLogger(__name__) |
|
|
| DISCORD_API_BASE = "https://discord.com/api/v10" |
|
|
| |
| |
| _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 |
|
|
| |
| |
| |
|
|
| def _get_bot_token() -> Optional[str]: |
| """Resolve the Discord bot token from environment.""" |
| return os.getenv("DISCORD_BOT_TOKEN", "").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) |
|
|
| data = None |
| if body is not None: |
| data = json.dumps(body).encode("utf-8") |
|
|
| req = urllib.request.Request( |
| url, |
| data=data, |
| 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 |
| return json.loads(resp.read().decode("utf-8")) |
| except urllib.error.HTTPError as e: |
| error_body = "" |
| try: |
| error_body = e.read().decode("utf-8", errors="replace") |
| except Exception: |
| pass |
| raise DiscordAPIError(e.code, error_body) from e |
|
|
|
|
| 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}") |
|
|
|
|
| |
| |
| |
|
|
| _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_cache: Optional[Dict[str, Any]] = None |
|
|
|
|
| def _detect_capabilities(token: str, *, force: bool = False) -> Dict[str, Any]: |
| """Detect the bot's app-wide capabilities via GET /applications/@me. |
| |
| Returns a dict with keys: |
| |
| - ``has_members_intent``: GUILD_MEMBERS intent is enabled |
| - ``has_message_content``: MESSAGE_CONTENT intent is enabled |
| - ``detected``: detection succeeded (False means exposing everything |
| and letting runtime errors handle it) |
| |
| Cached in a module-global. Pass ``force=True`` to re-fetch. |
| """ |
| global _capability_cache |
| if _capability_cache is not None and not force: |
| return _capability_cache |
|
|
| caps: Dict[str, Any] = { |
| "has_members_intent": True, |
| "has_message_content": True, |
| "detected": False, |
| } |
|
|
| 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: |
| logger.info( |
| "Discord capability detection failed (%s); exposing all actions.", exc, |
| ) |
|
|
| _capability_cache = caps |
| return caps |
|
|
|
|
| def _reset_capability_cache() -> None: |
| """Test hook: clear the detection cache.""" |
| global _capability_cache |
| _capability_cache = None |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| result = [] |
| for g in guilds: |
| result.append({ |
| "id": g["id"], |
| "name": g["name"], |
| "icon": g.get("icon"), |
| "owner": g.get("owner", False), |
| "permissions": g.get("permissions"), |
| }) |
| return json.dumps({"guilds": result, "count": len(result)}) |
|
|
|
|
| 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]] = {} |
| uncategorized: List[Dict[str, Any]] = [] |
|
|
| |
| for ch in channels: |
| if ch["type"] == 4: |
| categories[ch["id"]] = { |
| "id": ch["id"], |
| "name": ch["name"], |
| "position": ch.get("position", 0), |
| "channels": [], |
| } |
|
|
| |
| 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}) |
| for cat in sorted_cats: |
| result.append({ |
| "category": {"id": cat["id"], "name": cat["name"]}, |
| "channels": cat["channels"], |
| }) |
|
|
| 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) |
| result = [] |
| for r in sorted(roles, key=lambda r: r.get("position", 0), reverse=True): |
| result.append({ |
| "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), |
| }) |
| return json.dumps({"roles": result, "count": len(result)}) |
|
|
|
|
| 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) |
| user = m.get("user", {}) |
| return json.dumps({ |
| "user_id": user.get("id"), |
| "username": user.get("username"), |
| "display_name": user.get("global_name"), |
| "nickname": m.get("nick"), |
| "avatar": user.get("avatar"), |
| "bot": user.get("bot", False), |
| "roles": m.get("roles", []), |
| "joined_at": m.get("joined_at"), |
| "premium_since": m.get("premium_since"), |
| }) |
|
|
|
|
| def _search_members(token: str, guild_id: str, query: str, limit: int = 20, **_kwargs: Any) -> str: |
| """Search for guild members by name.""" |
| params = {"query": query, "limit": str(min(limit, 100))} |
| members = _discord_request("GET", f"/guilds/{guild_id}/members/search", token, params=params) |
| result = [] |
| for m in members: |
| user = m.get("user", {}) |
| result.append({ |
| "user_id": user.get("id"), |
| "username": user.get("username"), |
| "display_name": user.get("global_name"), |
| "nickname": m.get("nick"), |
| "bot": user.get("bot", False), |
| "roles": m.get("roles", []), |
| }) |
| return json.dumps({"members": result, "count": len(result)}) |
|
|
|
|
| def _fetch_messages( |
| token: str, channel_id: str, limit: int = 50, |
| before: Optional[str] = None, after: Optional[str] = None, |
| **_kwargs: Any, |
| ) -> str: |
| """Fetch recent messages from a channel.""" |
| params: Dict[str, str] = {"limit": str(min(limit, 100))} |
| if before: |
| params["before"] = before |
| if after: |
| params["after"] = after |
| messages = _discord_request("GET", f"/channels/{channel_id}/messages", token, params=params) |
| result = [] |
| for msg in messages: |
| author = msg.get("author", {}) |
| result.append({ |
| "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), |
| }) |
| return json.dumps({"messages": result, "count": len(result)}) |
|
|
|
|
| def _list_pins(token: str, channel_id: str, **_kwargs: Any) -> str: |
| """List pinned messages in a channel.""" |
| messages = _discord_request("GET", f"/channels/{channel_id}/pins", token) |
| result = [] |
| for msg in messages: |
| author = msg.get("author", {}) |
| result.append({ |
| "id": msg["id"], |
| "content": msg.get("content", "")[:200], |
| "author": author.get("username"), |
| "timestamp": msg.get("timestamp"), |
| }) |
| return json.dumps({"pinned_messages": result, "count": len(result)}) |
|
|
|
|
| def _pin_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str: |
| """Pin a message in a channel.""" |
| _discord_request("PUT", f"/channels/{channel_id}/pins/{message_id}", token) |
| return json.dumps({"success": True, "message": f"Message {message_id} pinned."}) |
|
|
|
|
| def _unpin_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str: |
| """Unpin a message from a channel.""" |
| _discord_request("DELETE", f"/channels/{channel_id}/pins/{message_id}", token) |
| return json.dumps({"success": True, "message": f"Message {message_id} unpinned."}) |
|
|
|
|
| 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 in a channel.""" |
| if message_id: |
| |
| path = f"/channels/{channel_id}/messages/{message_id}/threads" |
| body: Dict[str, Any] = { |
| "name": name, |
| "auto_archive_duration": auto_archive_duration, |
| } |
| else: |
| |
| path = f"/channels/{channel_id}/threads" |
| body = { |
| "name": name, |
| "auto_archive_duration": auto_archive_duration, |
| "type": 11, |
| } |
| 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: |
| """Add a role to a guild member.""" |
| _discord_request("PUT", f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}", token) |
| return json.dumps({"success": True, "message": 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: |
| """Remove a role from a guild member.""" |
| _discord_request("DELETE", f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}", token) |
| return json.dumps({"success": True, "message": f"Role {role_id} removed from user {user_id}."}) |
|
|
|
|
| |
| |
| |
|
|
| _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, |
| "create_thread": _create_thread, |
| "add_role": _add_role, |
| "remove_role": _remove_role, |
| } |
|
|
| |
| |
| |
| _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"), |
| ("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"), |
| ] |
|
|
| |
| _INTENT_GATED_MEMBERS = frozenset({"member_info", "search_members"}) |
|
|
| |
| _REQUIRED_PARAMS: Dict[str, List[str]] = { |
| "server_info": ["guild_id"], |
| "list_channels": ["guild_id"], |
| "list_roles": ["guild_id"], |
| "member_info": ["guild_id", "user_id"], |
| "search_members": ["guild_id", "query"], |
| "channel_info": ["channel_id"], |
| "fetch_messages": ["channel_id"], |
| "list_pins": ["channel_id"], |
| "pin_message": ["channel_id", "message_id"], |
| "unpin_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"], |
| } |
|
|
|
|
| |
| |
| |
|
|
| def _load_allowed_actions_config() -> Optional[List[str]]: |
| """Read ``discord.server_actions`` from user config. |
| |
| Returns a list of allowed action names, or ``None`` if the user |
| hasn't restricted the set (default: all actions allowed). |
| |
| Accepts either a comma-separated string or a YAML list. |
| Unknown action names are dropped with a log warning. |
| """ |
| try: |
| from hermes_cli.config import load_config |
| cfg = load_config() |
| except Exception as exc: |
| logger.debug("discord_server: 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): |
| names = [n.strip() for n in raw.split(",") if n.strip()] |
| elif isinstance(raw, (list, tuple)): |
| names = [str(n).strip() for n in raw if str(n).strip()] |
| else: |
| logger.warning( |
| "discord.server_actions: unexpected type %s; ignoring.", type(raw).__name__, |
| ) |
| return None |
|
|
| valid = [n for n in names if n in _ACTIONS] |
| 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 valid |
|
|
|
|
| def _available_actions( |
| caps: Dict[str, Any], |
| allowlist: Optional[List[str]], |
| ) -> List[str]: |
| """Compute the visible action list from intents + config allowlist. |
| |
| Preserves the canonical order from :data:`_ACTIONS`. |
| """ |
| actions: List[str] = [] |
| for name in _ACTIONS: |
| |
| if not caps.get("has_members_intent", True) and name in _INTENT_GATED_MEMBERS: |
| continue |
| |
| if allowlist is not None and name not in allowlist: |
| continue |
| actions.append(name) |
| return actions |
|
|
|
|
| |
| |
| |
|
|
| def _build_schema( |
| actions: List[str], |
| caps: Optional[Dict[str, Any]] = None, |
| ) -> Dict[str, Any]: |
| """Build the tool schema for the given filtered action list.""" |
| caps = caps or {} |
| if not actions: |
| |
| actions = list(_ACTIONS.keys()) |
|
|
| |
| manifest_lines = [ |
| f" {name}{sig} — {desc}" |
| for name, sig, desc in _ACTION_MANIFEST |
| if name in actions |
| ] |
| manifest_block = "\n".join(manifest_lines) |
|
|
| content_note = "" |
| if caps.get("detected") and caps.get("has_message_content") is False: |
| content_note = ( |
| "\n\nNOTE: Bot does NOT have the MESSAGE_CONTENT privileged intent. " |
| "fetch_messages and list_pins 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." |
| ) |
|
|
| description = ( |
| "Query and manage a Discord server via the REST API.\n\n" |
| "Available actions:\n" |
| f"{manifest_block}\n\n" |
| "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)." |
| f"{content_note}" |
| ) |
|
|
| properties: Dict[str, Any] = { |
| "action": { |
| "type": "string", |
| "enum": actions, |
| }, |
| "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).", |
| }, |
| } |
|
|
| return { |
| "name": "discord_server", |
| "description": description, |
| "parameters": { |
| "type": "object", |
| "properties": properties, |
| "required": ["action"], |
| }, |
| } |
|
|
|
|
| def get_dynamic_schema() -> Optional[Dict[str, Any]]: |
| """Return a schema filtered by current intents + config allowlist. |
| |
| Called by ``model_tools.get_tool_definitions`` as a post-processing |
| step so the schema the model sees always reflects reality. Returns |
| ``None`` when no actions are available (tool should be removed from |
| the schema list entirely). |
| """ |
| token = _get_bot_token() |
| if not token: |
| return None |
|
|
| caps = _detect_capabilities(token) |
| allowlist = _load_allowed_actions_config() |
| actions = _available_actions(caps, allowlist) |
| if not actions: |
| logger.warning( |
| "discord_server: config allowlist/intents left zero available actions; " |
| "hiding tool from this session." |
| ) |
| return None |
| return _build_schema(actions, caps) |
|
|
|
|
| |
| |
| |
|
|
| _ACTION_403_HINT = { |
| "pin_message": ( |
| "Bot lacks MANAGE_MESSAGES permission in this channel. " |
| "Ask the server admin to grant the bot a role that has MANAGE_MESSAGES, " |
| "or a per-channel overwrite." |
| ), |
| "unpin_message": ( |
| "Bot lacks MANAGE_MESSAGES permission in this channel." |
| ), |
| "create_thread": ( |
| "Bot lacks CREATE_PUBLIC_THREADS in this channel, or cannot view it." |
| ), |
| "add_role": ( |
| "Either the bot lacks MANAGE_ROLES, or the target role sits higher " |
| "than the bot's highest role. Roles can only be assigned below the " |
| "bot's own position in the role hierarchy." |
| ), |
| "remove_role": ( |
| "Either the bot lacks MANAGE_ROLES, or the target role sits higher " |
| "than the bot's highest role." |
| ), |
| "fetch_messages": ( |
| "Bot cannot view this channel (missing VIEW_CHANNEL or READ_MESSAGE_HISTORY)." |
| ), |
| "list_pins": ( |
| "Bot cannot view this channel (missing VIEW_CHANNEL or READ_MESSAGE_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}'." |
| if hint: |
| return f"{base} {hint} (Raw: {body})" |
| return 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()) |
|
|
|
|
| |
| |
| |
|
|
| def discord_server( |
| action: str, |
| guild_id: str = "", |
| channel_id: str = "", |
| user_id: str = "", |
| role_id: str = "", |
| message_id: str = "", |
| query: str = "", |
| name: str = "", |
| limit: int = 50, |
| before: str = "", |
| after: str = "", |
| auto_archive_duration: int = 1440, |
| task_id: str = None, |
| ) -> str: |
| """Execute a Discord server action.""" |
| token = _get_bot_token() |
| if not token: |
| return json.dumps({"error": "DISCORD_BOT_TOKEN not configured."}) |
|
|
| action_fn = _ACTIONS.get(action) |
| if not action_fn: |
| return json.dumps({ |
| "error": f"Unknown action: {action}", |
| "available_actions": list(_ACTIONS.keys()), |
| }) |
|
|
| |
| |
| |
| allowlist = _load_allowed_actions_config() |
| if allowlist is not None and action not in allowlist: |
| return json.dumps({ |
| "error": ( |
| f"Action '{action}' is disabled by config (discord.server_actions). " |
| f"Allowed: {', '.join(allowlist) if allowlist else '<none>'}" |
| ), |
| }) |
|
|
| local_vars = { |
| "guild_id": guild_id, |
| "channel_id": channel_id, |
| "user_id": user_id, |
| "role_id": role_id, |
| "message_id": message_id, |
| "query": query, |
| "name": name, |
| } |
|
|
| missing = [p for p in _REQUIRED_PARAMS.get(action, []) if not local_vars.get(p)] |
| if missing: |
| return json.dumps({ |
| "error": f"Missing required parameters for '{action}': {', '.join(missing)}", |
| }) |
|
|
| try: |
| return action_fn( |
| token=token, |
| guild_id=guild_id, |
| channel_id=channel_id, |
| user_id=user_id, |
| role_id=role_id, |
| message_id=message_id, |
| query=query, |
| name=name, |
| limit=limit, |
| before=before, |
| after=after, |
| auto_archive_duration=auto_archive_duration, |
| ) |
| except DiscordAPIError as e: |
| logger.warning("Discord API error in action '%s': %s", action, e) |
| if e.status == 403: |
| return json.dumps({"error": _enrich_403(action, e.body)}) |
| return json.dumps({"error": str(e)}) |
| except Exception as e: |
| logger.exception("Unexpected error in discord_server action '%s'", action) |
| return json.dumps({"error": f"Unexpected error: {e}"}) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| _STATIC_SCHEMA = _build_schema(list(_ACTIONS.keys()), caps={"detected": False}) |
|
|
| registry.register( |
| name="discord_server", |
| toolset="discord", |
| schema=_STATIC_SCHEMA, |
| handler=lambda args, **kw: discord_server( |
| action=args.get("action", ""), |
| guild_id=args.get("guild_id", ""), |
| channel_id=args.get("channel_id", ""), |
| user_id=args.get("user_id", ""), |
| role_id=args.get("role_id", ""), |
| message_id=args.get("message_id", ""), |
| query=args.get("query", ""), |
| name=args.get("name", ""), |
| limit=args.get("limit", 50), |
| before=args.get("before", ""), |
| after=args.get("after", ""), |
| auto_archive_duration=args.get("auto_archive_duration", 1440), |
| task_id=kw.get("task_id"), |
| ), |
| check_fn=check_discord_tool_requirements, |
| requires_env=["DISCORD_BOT_TOKEN"], |
| ) |
|
|