Files
hermes-agent/plugins/platforms/discord/onboarding.py
teknium1 937f23db2d fix(discord): invite link and docs drop the unused Use External Emojis permission
The wizard's invite integer and the Discord docs' Recommended row carried
Use External Emojis (1<<18), a permission the adapter never exercises (it
only reacts with unicode emoji) and that no permission list in the docs
names. The voice-mode page and the zh-Hans Discord page already used the
correct integers, so the pages disagreed with each other.

- onboarding.INVITE_PERMISSION_BITS: drop Use External Emojis
  (wizard invite 309241171008 -> 309240908864, matching voice-mode.md)
- discord.md: Recommended 309238025280 -> 309237763136; the Minimal row
  names Embed Links (117760 already includes it)
- .env.example and the zh-Hans table follow

Co-authored-by: Emil <etoegersen@gmail.com>
2026-10-04 00:09:15 -07:00

295 lines
14 KiB
Python

"""Discord onboarding: check a bot token against Discord and walk the user through setup.
The Developer Portal is where Discord setup goes wrong: a token copied from the wrong page, the
Message Content Intent left off (Discord then refuses the bot's connection), an invite URL built
by hand, and Developer Mode just to learn your own user ID. ``GET /applications/@me`` with the
bot token answers all of it at once — the token works, which intents are on, how many servers the
bot is in, and who owns the application — so the CLI wizard and the dashboard/Desktop check route
both read :func:`check_bot_token` instead of asking the user to verify those by hand.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlencode
# Every permission the adapter exercises (text, threads, reactions, voice). Named so the integer
# in the invite link and the docs can be re-derived instead of trusted.
INVITE_PERMISSION_BITS = {
"Add Reactions": 6,
"View Channels": 10,
"Send Messages": 11,
"Embed Links": 14,
"Attach Files": 15,
"Read Message History": 16,
"Connect": 20,
"Speak": 21,
"Create Public Threads": 35,
"Send Messages in Threads": 38,
}
INVITE_PERMISSIONS = sum(1 << bit for bit in INVITE_PERMISSION_BITS.values())
_TEAM_MEMBER_ACCEPTED = 2
@dataclass(frozen=True)
class DiscordBotCheck:
app_id: str
bot_name: str
# (user_id, username): the application owner, or every accepted member of the owning team.
owners: tuple[tuple[str, str], ...]
message_content: bool
server_members: bool
server_count: Optional[int]
@property
def invite_url(self) -> str:
# integration_type=0 is a server install, so Discord skips the "add to server or to my
# apps?" question for applications that also allow user installs.
query = urlencode({"client_id": self.app_id, "scope": "bot applications.commands",
"permissions": INVITE_PERMISSIONS, "integration_type": 0})
return f"https://discord.com/oauth2/authorize?{query}"
@property
def bot_settings_url(self) -> str:
return f"https://discord.com/developers/applications/{self.app_id}/bot"
def _owners(app: dict) -> tuple[tuple[str, str], ...]:
team = app.get("team")
if isinstance(team, dict):
return tuple(
(str(m["user"]["id"]), str(m["user"].get("username") or m["user"]["id"]))
for m in team.get("members") or []
if m.get("membership_state") == _TEAM_MEMBER_ACCEPTED and m.get("user", {}).get("id")
)
owner = app.get("owner") or {}
return ((str(owner["id"]), str(owner.get("username") or owner["id"])),) if owner.get("id") else ()
def check_bot_token(token: str) -> DiscordBotCheck:
"""Read the application behind ``token``. Raises :class:`DiscordAPIError` (401 = bad token)
or ``OSError`` when Discord is unreachable."""
from tools.discord_tool import _FLAGS_GUILD_MEMBERS, _FLAGS_MESSAGE_CONTENT, _discord_request
app = _discord_request("GET", "/applications/@me", token.strip(), timeout=10)
flags = int(app.get("flags") or 0)
bot = app.get("bot") or {}
count = app.get("approximate_guild_count")
return DiscordBotCheck(
app_id=str(app["id"]),
bot_name=str(bot.get("username") or app.get("name") or app["id"]),
owners=_owners(app),
message_content=bool(flags & _FLAGS_MESSAGE_CONTENT),
server_members=bool(flags & _FLAGS_GUILD_MEMBERS),
server_count=count if isinstance(count, int) else None,
)
# ── CLI wizard (hermes setup / hermes gateway setup) ─────────────────────────
def _clean_discord_user_ids(raw: str) -> list:
"""Strip common Discord mention prefixes from a comma-separated ID string."""
cleaned = []
for uid in raw.replace(" ", "").split(","):
uid = uid.strip()
if uid.startswith("<@") and uid.endswith(">"):
uid = uid.lstrip("<@!").rstrip(">")
if uid.lower().startswith("user:"):
uid = uid[5:]
if uid:
cleaned.append(uid)
return cleaned
def _discord_token_shape_error(token: str) -> Optional[str]:
"""Reject a Discord bot token that is really the numeric application ID.
Users routinely paste the application ID from the Developer Portal's General Information page
instead of the bot token (Bot page). A real bot token is dot-separated base64 and never purely
numeric, so this is a safe, narrow shape check.
"""
if token and token.strip().isdigit():
return ("That looks like a numeric application ID, not a bot token. "
"Paste the bot token from the Discord Developer Portal (Bot page), "
"not the application ID (General Information page).")
return None
def _prompt_discord_bot_token(prompt) -> str:
"""Prompt for the bot token, re-prompting once when the answer is a numeric app ID."""
from hermes_cli.cli_output import print_error
token = ""
for _attempt in range(2):
token = prompt("Discord bot token", password=True)
if not token:
return ""
error = _discord_token_shape_error(token)
if error is None:
return token
print_error(error)
# Second consecutive numeric answer: trust the user, keep the value.
return token
def _prompt_checked_token(prompt) -> tuple[str, Optional[DiscordBotCheck]]:
"""A token Discord accepted (with its check), an unverifiable one (offline: ``None`` check),
or ``("", None)`` when the user gave up."""
from hermes_cli.cli_output import print_error, print_success, print_warning
from hermes_cli.config import _check_non_ascii_credential
from tools.discord_tool import DiscordAPIError
for _attempt in range(3):
# Same stripping save_env_value applies, done first: a non-ASCII paste can't go in an HTTP header.
token = _check_non_ascii_credential("DISCORD_BOT_TOKEN", _prompt_discord_bot_token(prompt)).strip()
if not token:
return "", None
try:
check = check_bot_token(token)
except ValueError: # whitespace/control characters inside the paste: not a header-safe token
print_error("That isn't a bot token (it contains a line break). Copy it again from the Bot page.")
continue
except DiscordAPIError as exc:
if exc.status == 401:
print_error("Discord rejected that token. On the Bot page click Reset Token, "
"copy the new token and paste it here.")
continue
print_warning(f"Couldn't verify the token (Discord answered {exc.status}); saving it anyway.")
return token, None
except OSError as exc:
print_warning(f"Couldn't reach Discord to verify the token ({exc}); saving it anyway.")
return token, None
print_success(f"Token works: this is the bot \"{check.bot_name}\"")
return token, check
print_error("Discord rejected three tokens in a row; nothing was saved.")
return "", None
def _ensure_message_content_intent(token: str, check: DiscordBotCheck, prompt) -> DiscordBotCheck:
from hermes_cli.cli_output import print_info, print_success, print_warning
from tools.discord_tool import DiscordAPIError
for _attempt in range(5):
if check.message_content:
break
print_warning("Message Content Intent is OFF: Discord will refuse the bot's connection until it's on.")
print_info(f" Turn it on here: {check.bot_settings_url}")
print_info(" (Privileged Gateway Intents → Message Content Intent → Save Changes)")
if prompt("Press Enter once it's saved to re-check, or type 'skip'").strip().lower() == "skip":
return check
try:
check = check_bot_token(token)
except (DiscordAPIError, OSError):
return check
if check.message_content:
print_success("Message Content Intent is on")
if not check.server_members:
print_info("Optional: turn on Server Members Intent on the same page if you want to "
"allow people by username or role instead of user ID.")
return check
def _print_invite(check: DiscordBotCheck) -> None:
from hermes_cli.cli_output import print_info
print()
if check.server_count == 0:
print_info("📨 The bot isn't in any server yet. Open this link to add it to yours:")
else:
print_info("📨 Invite link (adds the bot to a server with the permissions Hermes uses):")
print_info(f" {check.invite_url}")
print_info(" Once you share a server with the bot you can also DM it directly.")
def _prompt_allowlist(check: Optional[DiscordBotCheck], prompt, prompt_yes_no) -> None:
from hermes_cli.cli_output import print_info, print_success
from hermes_cli.config import get_env_value, save_env_value
print()
print_info("🔒 Security: only allowlisted Discord users can talk to your bot.")
# Reconfiguring keeps whoever is already allowed; the wizard only adds.
allowed = _clean_discord_user_ids(get_env_value("DISCORD_ALLOWED_USERS") or "")
if allowed:
print_info(f" Already allowed: {', '.join(allowed)}")
if check and check.owners and not all(uid in allowed for uid, _name in check.owners):
who = ", ".join(f"@{name}" for _uid, name in check.owners)
question = (f"Allow yourself ({who}) to talk to the bot?" if len(check.owners) == 1
else f"Allow your team ({who}) to talk to the bot?")
if prompt_yes_no(question, True):
allowed += [uid for uid, _name in check.owners if uid not in allowed]
print_info(" Others need their Discord user ID: Settings → Advanced → Developer Mode,")
print_info(" then right-click their name → Copy User ID. Usernames work too.")
raw = prompt("Other allowed users (comma-separated, Enter to skip)" if allowed
else "Allowed user IDs or usernames (comma-separated, leave empty for open access)")
extra = _clean_discord_user_ids(raw or "")
allowed += [uid for uid in extra if uid not in allowed]
if check and not check.server_members and any(not uid.isdigit() for uid in extra):
print_info(" Usernames are resolved when the gateway connects, which needs Server Members Intent: "
f"{check.bot_settings_url}")
if allowed:
save_env_value("DISCORD_ALLOWED_USERS", ",".join(allowed))
print_success("Discord allowlist configured")
else:
print_info(
"⚠️ No allowlist set. Discord will deny messages until you set "
"DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, DISCORD_ALLOWED_CHANNELS, "
"or DISCORD_ALLOW_ALL_USERS=true for open access."
)
def _check_saved_token(token: str) -> Optional[DiscordBotCheck]:
from tools.discord_tool import DiscordAPIError
try:
return check_bot_token(token) if token else None
except (DiscordAPIError, OSError):
return None
def interactive_setup() -> None:
"""Guide the user through Discord bot setup: token (checked live), intents, invite link,
allowlist (defaults to the bot's owner) and home channel. CLI imports are lazy."""
from hermes_cli.cli_output import print_header, print_info, print_success, prompt, prompt_yes_no
from hermes_cli.config import get_env_value, remove_env_value, save_env_value
from hermes_cli.setup_platforms import declines_reconfigure
print_header("Discord")
if declines_reconfigure("Discord", "Reconfigure Discord?", "DISCORD_BOT_TOKEN"):
if not get_env_value("DISCORD_ALLOWED_USERS"):
print_info(
"⚠️ Discord has no user allowlist. With the fail-closed default, "
"messages are denied unless you configure allowed users, roles, "
"or channels, or set DISCORD_ALLOW_ALL_USERS=true."
)
if prompt_yes_no("Add allowed users now?", True):
saved = _check_saved_token(get_env_value("DISCORD_BOT_TOKEN") or "")
_prompt_allowlist(saved, prompt, prompt_yes_no)
return
for line in (
"1. Open https://discord.com/developers/applications → New Application",
"2. Open the Bot page → Reset Token → copy the token",
"Hermes checks the token, the intents and the invite link for you next.",
"Guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord",
):
print_info(line)
token, check = _prompt_checked_token(prompt)
if not token:
return
save_env_value("DISCORD_BOT_TOKEN", token)
print_success("Discord token saved")
if check is None:
print_info("Make sure Message Content Intent is on (Bot page → Privileged Gateway Intents), "
"or Discord will refuse the bot's connection.")
else:
check = _ensure_message_content_intent(token, check, prompt)
_print_invite(check)
_prompt_allowlist(check, prompt, prompt_yes_no)
print()
for line in (
"📬 Home Channel: where Hermes delivers cron job results,",
" cross-platform messages, and notifications.",
" Easiest: type /set-home in a Discord channel once the bot is running.",
" Or paste a channel ID (Developer Mode → right-click a channel → Copy Channel ID).",
):
print_info(line)
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)").strip()
if home_channel:
save_env_value("DISCORD_HOME_CHANNEL", home_channel)
elif remove_env_value("DISCORD_HOME_CHANNEL"):
print_info("Home channel cleared.")