hermes-agent/tools/discord_tool.py
Teknium ef73367fc5
feat: add Discord server introspection and management tool (#4753)
* feat: add Discord server introspection and management tool

Add a discord_server tool that gives the agent 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 discord.py client.

The tool is only included in the hermes-discord toolset (zero cost for
users on other platforms) and gated on DISCORD_BOT_TOKEN via check_fn.

Actions (14):
- Introspection: list_guilds, server_info, list_channels, channel_info,
  list_roles, member_info, search_members
- Messages: fetch_messages, list_pins, pin_message, unpin_message
- Management: create_thread, add_role, remove_role

This addresses a gap where users on Discord could not ask Hermes to
review server structure, channels, roles, or members — a task competing
agents (OpenClaw) handle out of the box.

Files changed:
- tools/discord_tool.py (new): Tool implementation + registration
- model_tools.py: Add to discovery list
- toolsets.py: Add to hermes-discord toolset only
- tests/tools/test_discord_tool.py (new): 43 tests covering all actions,
  validation, error handling, registration, and toolset scoping

* feat(discord): intent-aware schema filtering + config allowlist + schema cleanup

- _detect_capabilities() hits GET /applications/@me once per process
  to read GUILD_MEMBERS / MESSAGE_CONTENT privileged intent bits.
- Schema is rebuilt per-session in model_tools.get_tool_definitions:
  hides search_members / member_info when GUILD_MEMBERS intent is off,
  annotates fetch_messages description when MESSAGE_CONTENT is off.
- New config key discord.server_actions (comma-separated or YAML list)
  lets users restrict which actions the agent can call, intersected
  with intent availability. Unknown names are warned and dropped.
- Defense-in-depth: runtime handler re-checks the allowlist so a stale
  cached schema cannot bypass a tightened config.
- Schema description rewritten as an action-first manifest (signature
  per action) instead of per-parameter 'required for X, Y, Z' cross-refs.
  ~25% shorter; model can see each action's required params at a glance.
- Added bounds: limit gets minimum=1 maximum=100, auto_archive_duration
  becomes an enum of the 4 valid Discord values.
- 403 enrichment: runtime 403 errors are mapped to actionable guidance
  (which permission is missing and what to do about it) instead of the
  raw Discord error body.
- 36 new tests: capability detection with caching and force refresh,
  config allowlist parsing (string/list/invalid/unknown), intent+allowlist
  intersection, dynamic schema build, runtime allowlist enforcement,
  403 enrichment, and model_tools integration wiring.
2026-04-19 11:52:19 -07:00

891 lines
31 KiB
Python

"""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"
# 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
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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 mapping
# ---------------------------------------------------------------------------
_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)
# ---------------------------------------------------------------------------
# Module-level cache so the app/me endpoint is hit at most once per process.
_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: # nosec — detection is best-effort
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
# ---------------------------------------------------------------------------
# Action implementations
# ---------------------------------------------------------------------------
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)
# Organize: categories first, then channels under each
categories: Dict[Optional[str], Dict[str, Any]] = {}
uncategorized: List[Dict[str, Any]] = []
# First pass: collect categories
for ch in channels:
if ch["type"] == 4: # category
categories[ch["id"]] = {
"id": ch["id"],
"name": ch["name"],
"position": ch.get("position", 0),
"channels": [],
}
# Second pass: assign channels to categories
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)
# Sort
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], # Truncate for overview
"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:
# Create thread from an existing message
path = f"/channels/{channel_id}/messages/{message_id}/threads"
body: Dict[str, Any] = {
"name": name,
"auto_archive_duration": auto_archive_duration,
}
else:
# Create a standalone thread
path = f"/channels/{channel_id}/threads"
body = {
"name": name,
"auto_archive_duration": auto_archive_duration,
"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:
"""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}."})
# ---------------------------------------------------------------------------
# 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,
"create_thread": _create_thread,
"add_role": _add_role,
"remove_role": _remove_role,
}
# Single-source-of-truth manifest: action → (signature, one-line description).
# Consumed by :func:`_build_schema` so the schema's top-level description
# always matches the registered action set.
_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"),
]
# Actions that require the GUILD_MEMBERS privileged intent.
_INTENT_GATED_MEMBERS = frozenset({"member_info", "search_members"})
# Per-action required params for runtime validation.
_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"],
}
# ---------------------------------------------------------------------------
# Config-based action allowlist
# ---------------------------------------------------------------------------
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:
# Intent filter
if not caps.get("has_members_intent", True) and name in _INTENT_GATED_MEMBERS:
continue
# Config allowlist filter
if allowlist is not None and name not in allowlist:
continue
actions.append(name)
return actions
# ---------------------------------------------------------------------------
# Schema construction
# ---------------------------------------------------------------------------
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:
# Tool shouldn't be registered when empty, but guard anyway.
actions = list(_ACTIONS.keys())
# Action manifest lines (action-first, parameter-scoped).
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)
# ---------------------------------------------------------------------------
# 403 error enrichment
# ---------------------------------------------------------------------------
_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})"
# ---------------------------------------------------------------------------
# Check function
# ---------------------------------------------------------------------------
def check_discord_tool_requirements() -> bool:
"""Tool is available only when a Discord bot token is configured."""
return bool(_get_bot_token())
# ---------------------------------------------------------------------------
# Main handler
# ---------------------------------------------------------------------------
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()),
})
# Config-level allowlist gate (defense in depth — schema already filtered,
# but a stale cached schema from a prior config should not let denied
# actions through).
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}"})
# ---------------------------------------------------------------------------
# Tool registration
# ---------------------------------------------------------------------------
# Register with the full unfiltered schema. ``model_tools.get_tool_definitions``
# rebuilds this per-session via ``get_dynamic_schema`` so the model only ever
# sees intent-available, config-allowed actions. The static registration is a
# safe baseline for tools that inspect the registry directly.
_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"],
)