Files
server-26/drb-c2-core/app/routers/discord.py
T
Logan CusanoandClaude Opus 5.5 db4e326332 Discord: per-bot OAuth "Connect a Discord server" backend (server-26#225 S6)
An org owner links a server by inviting one radio bot through that bot's
own OAuth2 invite (owner decision Q1). The state is a single-use, 10-minute
nonce bound to org + user (stored only as its sha256); the server linked is
the one Discord's code exchange names, never the redirect query.

- routers/discord_oauth.py: GET /discord/oauth/options, POST /start,
  POST /complete. 404 while discord_oauth_redirect_uri is empty or no
  usable bot has a secret. Bot order: explicit token_id, the node system's
  preferred bot, the org's own bot, least-full pool bot under the cap.
- internal/discord_oauth.py: state mint/consume transaction (login-CSRF:
  state.uid must be the caller; any presentation burns it), code exchange,
  revoke, client-credentials secret check. Through discord_rest's shared
  client and breaker; secrets/codes/tokens never logged or in errors.
- discord_guild_link: one link transaction body (_apply_link) and one
  settle step that stops a server's streams when the org it acted for
  changes (plan finding 3). /drb link redeem, the admin PUT link and OAuth
  all use it; the previous org is now read inside the link transaction.
- tokens: GET /tokens is an allowlist (+ oauth_enabled, server_count,
  server_cap); PUT/DELETE /tokens/{id}/oauth (admin) checks the secret with
  Discord before storing it in bot_oauth/{id}, never echoes it.
- firestore.rules: explicit deny for bot_oauth and discord_oauth_states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:52:57 -04:00

354 lines
18 KiB
Python

"""
Discord radio channels — the HTTP face of internal/discord_streams.py.
Every bot-facing route is scoped to ONE Discord server (``guild_id`` in the
path or body) and acts for the org that server is linked to
(server-26#203): /leave in server A can no longer touch server B, and the
system/node/bot lists a server sees are its own org's.
Auth:
* bot routes — the Discord bot's service key, or a platform admin.
* PUT /discord/guilds/{id}/link — platform admin only.
* DELETE /discord/streams/{stream_id}, DELETE /discord/guilds/{id} — platform
admin only: permanently remove a radio channel / a whole server.
* GET /discord/node/{node_id}/tokens/{token_id} — that node's own API key;
the token string is handed only to the node currently holding its lease.
"""
from __future__ import annotations
import secrets
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Security
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from app.config import settings
from app.internal import discord_guild_link as glink
from app.internal import discord_leases as leases
from app.internal import discord_rest
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
from app.internal.audit import write_audit
from app.internal.auth import describe_actor, require_admin_token, require_service_key_or_admin
from app.internal.logger import logger
router = APIRouter(prefix="/discord", tags=["discord"])
_bearer = HTTPBearer(auto_error=False)
# guild:channel[:suffix] (leases.STREAM_ID_PATTERN) — old one-per-channel ids
# and new per-stream ids. Anything else is a 422, never a Firestore path.
STREAM_ID = leases.STREAM_ID_PATTERN
def _error(e: streams.StreamError) -> JSONResponse:
return JSONResponse(status_code=e.status,
content={**e.extra, "detail": e.message, "code": e.code, "invite": e.invite})
async def _org_or_403(guild_id: str) -> str:
org_id = await streams.resolve_guild_org(guild_id)
if not org_id:
raise HTTPException(403, streams.ERROR_TEXT["no_org"])
return org_id
def _public_stream(s: dict) -> dict:
keep = ("stream_id", "guild_id", "channel_id", "channel_name", "system_id", "system_name", "mode",
"desired", "node_id", "token_id", "token_name", "actual", "stop_reason", "updated_at", "delivered",
"reused", "already_on")
return {k: s.get(k) for k in keep if k in s}
# ---------------------------------------------------------------------------
# Bot routes
# ---------------------------------------------------------------------------
@router.get("/guilds/{guild_id}/overview")
async def guild_overview(guild_id: str, _: dict = Depends(require_service_key_or_admin)):
"""Everything one Discord server may see: its org's systems and nodes, its
own streams, and which radio bots could serve it. No token strings."""
org_id = await streams.resolve_guild_org(guild_id)
if not org_id:
return {"guild_id": guild_id, "org_id": None, "linked": False,
"systems": [], "nodes": [], "streams": [], "bots": []}
link = await fstore.doc_get(streams.GUILDS, guild_id)
systems = await fstore.collection_list("systems", org_id=org_id)
nodes = await fstore.collection_list("nodes", org_id=org_id)
gstreams = [s for s in await fstore.collection_list(streams.STREAMS, guild_id=guild_id)
if s.get("desired") == "on"]
all_tokens = await fstore.collection_list(leases.TOKENS)
names = {t.get("token_id"): t.get("name") for t in all_tokens}
# Several streams can share a voice channel; the bot names each by its bot.
gstreams = [{**s, "token_name": names.get(s.get("token_id"))} for s in gstreams]
gstreams.sort(key=lambda s: (s.get("channel_name") or "", s.get("channel_id") or "", s.get("created_at") or ""))
bots = []
for t in all_tokens:
if t.get("org_id") not in (None, org_id) or t.get("valid") is False:
continue
lease = t.get("lease") or {}
member = t.get("guild_ids")
bots.append({
"token_id": t.get("token_id"),
"name": t.get("name"),
"busy_here": any(leases.guild_of(s) == guild_id for s in lease.get("streams") or []),
"member": None if member is None else guild_id in member,
# A pool bot at discord_pool_bot_guild_cap is never offered (#225, owner Q3).
"invite_url": (leases.invite_url(t.get("application_id"), guild_id)
if guild_id in (member or []) or leases.invitable(t) else None),
})
return {
"guild_id": guild_id,
"org_id": org_id,
"linked": bool(link and link.get("org_id")),
"systems": [{"system_id": s.get("system_id"), "name": s.get("name")} for s in systems],
"nodes": [{"node_id": n.get("node_id"), "name": n.get("name"), "status": n.get("status"),
"system_id": streams._effective_system(n)} for n in nodes],
"streams": [_public_stream(s) for s in gstreams],
"bots": bots,
}
class StreamStart(BaseModel):
guild_id: str
channel_id: str
system_id: str
# Set = restart / edit THAT stream. Unset = add a stream to the channel
# (never overwrites another system's stream there — several may share it).
stream_id: Optional[str] = Field(None, pattern=STREAM_ID)
mode: str = "adhoc"
preferred_token_id: Optional[str] = None
channel_name: Optional[str] = None
guild_name: Optional[str] = None
created_by: Optional[str] = None
@router.post("/streams")
async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_or_admin)):
# The bot always sends snowflakes; the web admin page types them in.
if not (str(body.guild_id).isdigit() and str(body.channel_id).isdigit()):
raise HTTPException(400, "guild_id and channel_id must be Discord ids (digits only).")
org_id = await _org_or_403(body.guild_id)
system = await fstore.doc_get_cached("systems", body.system_id)
if not system or system.get("org_id") not in (None, org_id):
raise HTTPException(404, "System not found.")
try:
doc = await streams.start_stream(
org_id=org_id, guild_id=body.guild_id, channel_id=body.channel_id, system_id=body.system_id,
mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name,
created_by=body.created_by, preferred_token_id=body.preferred_token_id,
max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id,
)
except streams.StreamError as e:
return _error(e)
return _public_stream(doc)
@router.get("/streams/{stream_id}")
async def get_stream(stream_id: str = Path(..., pattern=STREAM_ID),
_: dict = Depends(require_service_key_or_admin)):
doc = await fstore.doc_get(streams.STREAMS, stream_id)
if not doc:
raise HTTPException(404, "Stream not found.")
return _public_stream(doc)
class GuildStop(BaseModel):
# Neither: every live stream in the server. channel_id: every stream in
# that voice channel. stream_id: just that one (it must be this server's).
channel_id: Optional[str] = None
stream_id: Optional[str] = Field(None, pattern=STREAM_ID)
reason: str = "stopped"
@router.post("/guilds/{guild_id}/stop")
async def stop_guild(guild_id: str, body: GuildStop, _: dict = Depends(require_service_key_or_admin)):
stopped = await streams.stop_guild(guild_id, body.reason[:60], channel_id=body.channel_id,
stream_id=body.stream_id)
return {"stopped": stopped}
class GuildLink(BaseModel):
org_id: str
# Display label only (the admin page); Discord never reads it.
guild_name: Optional[str] = None
@router.put("/guilds/{guild_id}/link")
async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(require_admin_token)):
if not guild_id.isdigit():
raise HTTPException(400, "guild_id must be a Discord server id (digits only).")
org = await fstore.doc_get("organizations", body.org_id)
if not org:
raise HTTPException(404, "Organization not found.")
# Support override: may move a server between orgs. Re-linking a server
# that was unlinked or removed brings it back. Live channels placed on the
# previous org's nodes are stopped (glink._settle) — left running, the
# reconciler would keep re-placing them there.
out = await glink.link_and_settle(body.org_id, guild_id, body.guild_name, decoded.get("uid") or "admin",
"admin", allow_move=True, stop_reason=glink.ADMIN_RELINK_STOP_REASON)
uid, email = describe_actor(decoded)
await write_audit(uid, email, "discord.guild.link", details={
"guild_id": guild_id, "org_id": body.org_id, "previous_org_id": out["previous_org_id"],
"already_linked": out["already_linked"], "streams_stopped": len(out["streams_stopped"]), "via": "admin",
})
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": out["streams_stopped"]}
@router.delete("/streams/{stream_id}")
async def delete_stream(stream_id: str = Path(..., pattern=STREAM_ID),
decoded: dict = Depends(require_admin_token)):
"""Permanently remove one radio channel: stopped through the desired-state
path first (node logs the bot out, lease released on its ack), then the
doc is deleted. Platform admin only; org owners use the node page route."""
try:
doc = await streams.delete_stream(stream_id, "removed_from_web_admin")
except streams.StreamError as e:
return _error(e)
if not doc:
raise HTTPException(404, "Radio channel not found.")
uid, email = describe_actor(decoded)
await write_audit(uid, email, "discord.stream.delete", details={
"stream_id": stream_id, "guild_id": doc.get("guild_id"), "channel_id": doc.get("channel_id"),
"org_id": doc.get("org_id"), "node_id": doc.get("node_id"), "was_desired": doc.get("desired"),
"via": "settings_discord",
})
return {"ok": True, "stream_id": stream_id}
@router.delete("/guilds/{guild_id}")
async def remove_guild(guild_id: str, leave_radio_bots: bool = False,
decoded: dict = Depends(require_admin_token)):
"""Remove a Discord server from DRB: delete its radio channels (each
stopped first), then unlink it with no fallback org (streams.remove_guild).
``leave_radio_bots`` also makes the pool radio bots (and the server's org's
own BYO bots) leave it via Discord's REST API. The DRB server bot is not
one of those and stays until someone kicks it in Discord."""
if not guild_id.isdigit():
raise HTTPException(400, "guild_id must be a Discord server id (digits only).")
known = await fstore.doc_get(streams.GUILDS, guild_id)
if not known and not await fstore.collection_list(streams.STREAMS, guild_id=guild_id):
raise HTTPException(404, "Discord server not found.")
uid, email = describe_actor(decoded)
acting_org = await streams.resolve_guild_org(guild_id)
try:
out = await streams.remove_guild(guild_id, uid)
except streams.StreamError as e:
# Channels deleted before the race are gone for good — audit them.
await write_audit(uid, email, "discord.guild.remove", details={
"guild_id": guild_id, "acting_org_id": acting_org, "outcome": "incomplete", "error": e.code,
"streams_removed": getattr(e, "removed", []), "leave_radio_bots": leave_radio_bots,
})
return _error(e)
left: list = []
if leave_radio_bots:
left = await discord_rest.leave_guild(guild_id, out["previous_org_id"] or acting_org)
await write_audit(uid, email, "discord.guild.remove", details={
"guild_id": guild_id, "previous_org_id": out["previous_org_id"], "acting_org_id": acting_org,
"streams_removed": out["streams_removed"], "leave_radio_bots": leave_radio_bots,
"radio_bots_left": [r["token_id"] for r in left if r["left"]],
"radio_bots_failed": [r["token_id"] for r in left if not r["left"]],
})
return {"ok": True, "guild_id": guild_id, "streams_removed": out["streams_removed"],
"radio_bots": [{"name": r["name"], "left": r["left"], "error": r["error"]} for r in left]}
# ---------------------------------------------------------------------------
# Admin view — the web Discord page (server-26#213 item 2)
# ---------------------------------------------------------------------------
@router.get("/admin/overview")
async def admin_overview(_: dict = Depends(require_admin_token)):
"""Every Discord server DRB knows about, every radio channel (desired vs
actual) and what the page needs to act on them. Platform admin only.
A server is "known" when it is linked (``discord_guilds``) or when it has
ever had a radio channel — an unlinked server that ran ``/join`` acts for
``DISCORD_UNLINKED_GUILD_ORG_ID`` and should be visible so it can be linked.
Radio bots come from ``GET /tokens``; no token string is returned here.
Discord user ids (``created_by_discord_id``) and the linking admin's uid
are deliberately left out.
"""
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
if g.get("guild_id")}
all_streams = await fstore.collection_list(streams.STREAMS)
orgs = {o.get("org_id"): o for o in await fstore.collection_list("organizations") if o.get("org_id")}
nodes = {n.get("node_id"): n for n in await fstore.collection_list("nodes") if n.get("node_id")}
tokens = {t.get("token_id"): t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
fallback = settings.discord_unlinked_guild_org_id or None
guilds: dict[str, dict] = {}
for gid, link in links.items():
if link.get("removed_at"):
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
# Explicitly unlinked servers get no fallback org (mirrors
# streams.resolve_guild_org once server-26#218 lands).
"_no_fallback": bool(link.get("unlinked_at"))}
rows = []
for s in all_streams:
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
"org_id": None, "linked_at": None})
if s.get("guild_name") and not g.get("guild_name"):
g["guild_name"] = s["guild_name"]
node = nodes.get(s.get("node_id")) or {}
tok = tokens.get(s.get("token_id")) or {}
rows.append({**_public_stream(s), "guild_id": gid, "org_id": s.get("org_id"), "guild_name": s.get("guild_name"),
"created_at": s.get("created_at"), "node_name": node.get("name"),
"node_status": node.get("status"), "token_name": tok.get("name")})
for g in guilds.values():
no_fallback = g.pop("_no_fallback", False)
acting = g["org_id"] if g["linked"] else (None if no_fallback else fallback)
g["acting_org_id"] = acting
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
# Streams sharing a voice channel sit together, oldest first.
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
r.get("channel_id") or "", r.get("created_at") or "", r.get("stream_id") or ""))
systems = await fstore.collection_list("systems")
# Names for every node a stream or a radio-bot lease points at.
referenced = {r.get("node_id") for r in rows} | {(t.get("lease") or {}).get("node_id") for t in tokens.values()}
node_names = {nid: nodes[nid].get("name") or nid for nid in referenced if nid in nodes}
return {
"guilds": sorted(guilds.values(), key=lambda g: (not g["linked"], g.get("guild_name") or g["guild_id"])),
"streams": rows,
"orgs": sorted(({"org_id": oid, "name": o.get("name") or oid} for oid, o in orgs.items()),
key=lambda o: o["name"].lower()),
"node_names": node_names,
"systems": [{"system_id": s.get("system_id"), "name": s.get("name"), "org_id": s.get("org_id")}
for s in systems if s.get("system_id")],
"limits": {"guild_max_streams": settings.discord_guild_max_streams,
"node_max_streams": settings.discord_node_max_streams,
"unlinked_guild_org_id": fallback,
"unlinked_guild_org_name": (orgs.get(fallback) or {}).get("name") if fallback else None},
}
# ---------------------------------------------------------------------------
# Node route — token pickup
# ---------------------------------------------------------------------------
@router.get("/node/{node_id}/tokens/{token_id}")
async def node_token(
node_id: str,
token_id: str,
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
):
"""The bot token string, for the node that holds its lease and nobody else."""
if not credentials:
raise HTTPException(401, "Missing authorization")
key_doc = await fstore.doc_get("node_keys", node_id)
stored = (key_doc or {}).get("api_key") or ""
if not stored or not secrets.compare_digest(stored, credentials.credentials):
raise HTTPException(401, "Invalid node API key")
tok = await fstore.doc_get(leases.TOKENS, token_id)
lease = (tok or {}).get("lease") or {}
if not tok or lease.get("node_id") != node_id or lease.get("releasing"):
logger.warning(f"Node {node_id} asked for token {token_id} it does not hold a lease on")
raise HTTPException(403, "This node does not hold a lease on that token.")
return {"token_id": token_id, "token": tok["token"], "lease_id": lease.get("lease_id"),
"name": tok.get("name")}