Files
server-26/drb-c2-core/app/routers/discord.py
T
Logan CusanoandClaude Opus 5.5 9c124d4c8b S8: main DRB bot optional end to end + owner-flow integration tests (server-26#225)
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot
  /help and the server-limit refusal no longer imply the DRB server bot is
  required; a radio bot alone is enough.
- Admin overview: per-server main_bot_present, radio_bots, linked_via,
  directory_source; Servers tab gets a Bots column.
- discord_web docstring: states the reversed "never calls Discord" rule
  (plan finding 5) and the optional server bot.
- Server bot directory cog: re-push (which drops C2's cached permission
  answers) on overwrite changes, role permission changes and role deletes.
- tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set
  preferred, OAuth link, picker, start, reconciler) and full setup (server
  bot + 4 radio bots: placement, shared channel, every refusal explained),
  against a modelled Discord over httpx.MockTransport.
- storage.download_audio removed: no caller on main since #233.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:24:44 -04:00

371 lines
19 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,
interactive=True, # /join, the admin page: re-read a stale "denied" (server-26#251)
)
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.
Per server, ``main_bot_present`` (the DRB server bot — optional since
server-26#225: True/False as it last reported, None = never heard from it
there) and ``radio_bots`` (names of radio bots Discord last said are in
it). A server with radio bots and no server bot is a normal setup.
"""
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"),
"linked_via": link.get("linked_via"),
# The DRB server bot is optional (server-26#225 S8): True/False
# = it said it is / is not in the server, None = never heard.
"main_bot_present": link.get("bot_present"),
"directory_source": link.get("directory_source"),
# 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, "linked_via": None,
"main_bot_present": None, "directory_source": 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")
# Radio bots Discord last said are in the server (names only). Any one
# of them is enough to list channels, check permissions and stream.
g["radio_bots"] = sorted(str(t.get("name") or tid) for tid, t in tokens.items()
if t.get("valid") is not False and isinstance(t.get("guild_ids"), list)
and g["guild_id"] in t["guild_ids"])
# 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")}