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.
This commit is contained in:
891
tools/discord_tool.py
Normal file
891
tools/discord_tool.py
Normal file
@@ -0,0 +1,891 @@
|
||||
"""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"],
|
||||
)
|
||||
Reference in New Issue
Block a user