Files
hermes-agent/tools/discord_tool.py
ethernet e8fcb007b9 Merge remote-tracking branch 'upstream/main' into ethie/pm-clean
# Conflicts:
#	AGENTS.md
#	acp_adapter/edit_approval.py
#	acp_adapter/server.py
#	agent/agent_init.py
#	agent/anthropic_adapter.py
#	agent/anthropic_credentials.py
#	agent/auxiliary_client.py
#	agent/azure_identity_adapter.py
#	agent/bedrock_adapter.py
#	agent/browser_registry.py
#	agent/chat_completion_helpers.py
#	agent/coding_context.py
#	agent/context_references.py
#	agent/conversation_loop.py
#	agent/copilot_acp_client.py
#	agent/credits_tracker.py
#	agent/curator.py
#	agent/curator_backup.py
#	agent/deadline.py
#	agent/display.py
#	agent/errors.py
#	agent/estop.py
#	agent/i18n.py
#	agent/image_gen_registry.py
#	agent/image_routing.py
#	agent/learning_graph.py
#	agent/learning_mutations.py
#	agent/lsp/servers.py
#	agent/model_metadata.py
#	agent/models_dev.py
#	agent/monitoring/gateway_health_export.py
#	agent/monitoring/otlp_exporter.py
#	agent/pet/store.py
#	agent/process_bootstrap.py
#	agent/prompt_builder.py
#	agent/proxy_sources/iron_proxy.py
#	agent/secret_sources/_cache.py
#	agent/secret_sources/bitwarden.py
#	agent/secret_sources/registry.py
#	agent/shell_hooks.py
#	agent/skill_bundles.py
#	agent/skill_commands.py
#	agent/skill_utils.py
#	agent/ssl_guard.py
#	agent/ssl_verify.py
#	agent/system_prompt.py
#	agent/terminal_env_registry.py
#	agent/trace_upload.py
#	agent/transcription_registry.py
#	agent/tts_registry.py
#	agent/verify/environment.py
#	agent/vertex_adapter.py
#	agent/video_gen_registry.py
#	agent/web_search_registry.py
#	cli.py
#	cron/jobs.py
#	cron/scheduler.py
#	gateway/agent_cache_pressure.py
#	gateway/cgroup_cleanup.py
#	gateway/channel_directory.py
#	gateway/config.py
#	gateway/control_socket.py
#	gateway/dead_targets.py
#	gateway/drain_control.py
#	gateway/hooks.py
#	gateway/kanban_watchers.py
#	gateway/lifecycle_ledger.py
#	gateway/mirror.py
#	gateway/pairing.py
#	gateway/platform_registry.py
#	gateway/platforms/helpers.py
#	gateway/platforms/weixin.py
#	gateway/readiness.py
#	gateway/restart_loop_guard.py
#	gateway/rich_sent_store.py
#	gateway/run.py
#	gateway/session.py
#	gateway/shutdown_flush.py
#	gateway/shutdown_forensics.py
#	gateway/slash_commands.py
#	gateway/status.py
#	gateway/sticker_cache.py
#	gateway/whatsapp_identity.py
#	hermes_bootstrap.py
#	hermes_cli/_early_recovery.py
#	hermes_cli/_install_repair.py
#	hermes_cli/_startup_fast.py
#	hermes_cli/_subprocess_compat.py
#	hermes_cli/agent_plugins.py
#	hermes_cli/auth.py
#	hermes_cli/backup.py
#	hermes_cli/banner.py
#	hermes_cli/browser_connect.py
#	hermes_cli/build_info.py
#	hermes_cli/cli_agent_setup_mixin.py
#	hermes_cli/cli_commands_mixin.py
#	hermes_cli/codex_models.py
#	hermes_cli/config.py
#	hermes_cli/config_defaults.py
#	hermes_cli/config_migrations.py
#	hermes_cli/container_boot.py
#	hermes_cli/dashboard_auth/registry.py
#	hermes_cli/debug.py
#	hermes_cli/dep_ensure.py
#	hermes_cli/doctor.py
#	hermes_cli/doctor_live.py
#	hermes_cli/dump.py
#	hermes_cli/env_loader.py
#	hermes_cli/foreign_sessions.py
#	hermes_cli/gateway.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/gui_uninstall.py
#	hermes_cli/image_provenance.py
#	hermes_cli/install_identity.py
#	hermes_cli/kanban.py
#	hermes_cli/kanban_db.py
#	hermes_cli/linux_desktop_entry.py
#	hermes_cli/local_runtime/binaries.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/local_runtime/growth.py
#	hermes_cli/local_runtime/supervisor.py
#	hermes_cli/logs.py
#	hermes_cli/macos_tcc_anchor.py
#	hermes_cli/main.py
#	hermes_cli/memory_setup.py
#	hermes_cli/model_catalog.py
#	hermes_cli/models.py
#	hermes_cli/nous_subscription.py
#	hermes_cli/npm_engine.py
#	hermes_cli/plugin_index.py
#	hermes_cli/plugins.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/profile_distribution.py
#	hermes_cli/profiles.py
#	hermes_cli/prompt_size.py
#	hermes_cli/psutil_android.py
#	hermes_cli/runtime_repair.py
#	hermes_cli/security_advisories.py
#	hermes_cli/security_audit.py
#	hermes_cli/security_audit_startup.py
#	hermes_cli/service_manager.py
#	hermes_cli/session_export_md.py
#	hermes_cli/setup.py
#	hermes_cli/skills_hub.py
#	hermes_cli/slack_cli.py
#	hermes_cli/status.py
#	hermes_cli/subcommands/gateway.py
#	hermes_cli/subcommands/uninstall.py
#	hermes_cli/tools_config.py
#	hermes_cli/uninstall.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_contract.py
#	hermes_cli/update_inventory.py
#	hermes_cli/update_lock.py
#	hermes_cli/update_receipt.py
#	hermes_cli/urllib_security.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_routers/profiles.py
#	hermes_cli/web_routers/skills.py
#	hermes_cli/web_server.py
#	hermes_constants.py
#	hermes_state.py
#	plugins/disk-cleanup/__init__.py
#	plugins/disk-cleanup/disk_cleanup.py
#	plugins/google_meet/node/registry.py
#	plugins/google_meet/node/server.py
#	plugins/google_meet/process_manager.py
#	plugins/google_meet/realtime/openai_client.py
#	plugins/hermes-achievements/dashboard/plugin_api.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/honcho/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/honcho/client.py
#	plugins/memory/honcho/oauth.py
#	plugins/memory/honcho/session.py
#	plugins/memory/mem0/__init__.py
#	plugins/memory/mem0/_setup.py
#	plugins/memory/openviking/__init__.py
#	plugins/memory/retaindb/__init__.py
#	plugins/memory/supermemory/__init__.py
#	plugins/platforms/a2a/protocol.py
#	plugins/platforms/dingtalk/adapter.py
#	plugins/platforms/discord/adapter.py
#	plugins/platforms/feishu/adapter.py
#	plugins/platforms/google_chat/adapter.py
#	plugins/platforms/matrix/adapter.py
#	plugins/platforms/photon/adapter.py
#	plugins/platforms/photon/auth.py
#	plugins/platforms/photon/cli.py
#	plugins/platforms/slack/adapter.py
#	plugins/platforms/teams/adapter.py
#	plugins/platforms/telegram/adapter.py
#	plugins/platforms/wecom/callback_adapter.py
#	plugins/platforms/whatsapp/adapter.py
#	plugins/teams_pipeline/store.py
#	plugins/video_gen/fal/__init__.py
#	plugins/web/ddgs/provider.py
#	plugins/web/exa/provider.py
#	plugins/web/firecrawl/provider.py
#	plugins/web/parallel/provider.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_cmd_update_apt.py
#	tests/hermes_cli/test_dashboard_unified_launch.py
#	tests/hermes_cli/test_dep_ensure.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_live.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_kanban_boards.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_memory_setup_provider_arg.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_pip_install_detection.py
#	tests/hermes_cli/test_profile_export_credentials.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_tui_npm_install.py
#	tests/hermes_cli/test_update_fleet_restart_pending.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/tools/test_browser_chromium_autoinstall.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_lightpanda.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_open_timeout.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_suspect_recycle.py
#	tests/tools/test_find_shell.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_macos_protected_search.py
#	tests/tui_gateway/test_compute_host.py
#	tools/approval.py
#	tools/blueprints.py
#	tools/bot_mode_dm.py
#	tools/bot_mode_probe.py
#	tools/bot_relay.py
#	tools/browser_tool.py
#	tools/browser_use_cli.py
#	tools/checkpoint_manager.py
#	tools/code_execution_tool.py
#	tools/code_kernel.py
#	tools/computer_use/cua_backend.py
#	tools/cronjob_tools.py
#	tools/discord_tool.py
#	tools/environments/base.py
#	tools/environments/daytona.py
#	tools/environments/local.py
#	tools/environments/modal.py
#	tools/environments/vercel_sandbox.py
#	tools/fal_common.py
#	tools/file_operations.py
#	tools/lazy_deps.py
#	tools/mcp_tool.py
#	tools/neutts_synth.py
#	tools/process_registry.py
#	tools/read_extract.py
#	tools/registry.py
#	tools/skill_ledger.py
#	tools/skill_linter.py
#	tools/skill_manager_tool.py
#	tools/skill_usage.py
#	tools/skills_ast_audit.py
#	tools/skills_guard.py
#	tools/skills_hub.py
#	tools/skills_sync.py
#	tools/skills_sync_client.py
#	tools/skills_tool.py
#	tools/terminal_scope.py
#	tools/terminal_tool.py
#	tools/tirith_security.py
#	tools/transcription_tools.py
#	tools/tts_tool.py
#	tools/vision_tools.py
#	tools/voice_mode.py
#	tools/wake_word.py
#	tools/web_result_cache.py
#	tools/website_policy.py
#	tools/working_diff.py
#	tools/write_approval.py
#	tui_gateway/entry.py
#	tui_gateway/methods_tools.py
#	tui_gateway/server.py
2026-09-04 13:03:39 -04:00

647 lines
31 KiB
Python

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