Discord radio channels: wire leases + desired-state streams (server-26#203, #204)

c2-core
- discord_streams/discord_leases wired in: POST /discord/streams,
  POST /discord/guilds/{id}/stop, GET /discord/guilds/{id}/overview,
  GET /discord/streams/{id}, PUT /discord/guilds/{id}/link (admin),
  GET /discord/node/{node}/tokens/{id} (node key, lease holder only).
- Retained nodes/{id}/discord/desired (token ids, never token strings),
  nodes/+/discord/actual subscribed and handled; per-node publish lock keeps
  the retained list the newest; failed publishes retried by the reconciler.
- Tokens freed only after the node acks logout (or offline past
  discord_lease_offline_release_seconds) - sweeper and C2 startup no longer
  release leases.
- /nodes/{id}/command discord_join/leave map onto streams; CommandPayload
  keeps preferred_token_id. /tokens: admin-only list, validate on add against
  Discord, refresh, flush via transactional force release.
- Guild -> org via discord_guilds, unlinked guilds fall back to
  DISCORD_UNLINKED_GUILD_ORG_ID (default "founding").
- tests/fake_firestore.py: optimistic-transaction fake; 27 lease/race tests.

discord bot
- /join, /leave, /status, autocomplete scoped to the calling guild; /leave
  never touches another server; Manage Channels default permission;
  /join edits its reply with the node's real result; /help copy fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 18:05:26 -04:00
co-authored by Claude Opus 5.5
parent 0a724a064f
commit a31a8df17c
15 changed files with 1428 additions and 359 deletions
+184
View File
@@ -0,0 +1,184 @@
"""
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.
* 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 datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Security
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
from app.config import settings
from app.internal import discord_leases as leases
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
from app.internal.auth import 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)
def _error(e: streams.StreamError) -> JSONResponse:
return JSONResponse(status_code=e.status,
content={"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", "actual", "stop_reason", "updated_at", "delivered", "reused")
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"]
bots = []
for t in await fstore.collection_list(leases.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,
"invite_url": leases.invite_url(t.get("application_id"), guild_id),
})
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
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)):
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,
)
except streams.StreamError as e:
return _error(e)
return _public_stream(doc)
@router.get("/streams/{stream_id}")
async def get_stream(stream_id: str, _: 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):
channel_id: Optional[str] = None
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)
return {"stopped": stopped}
class GuildLink(BaseModel):
org_id: str
@router.put("/guilds/{guild_id}/link")
async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(require_admin_token)):
org = await fstore.doc_get("organizations", body.org_id)
if not org:
raise HTTPException(404, "Organization not found.")
await fstore.doc_set(streams.GUILDS, guild_id, {
"guild_id": guild_id, "org_id": body.org_id,
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(),
})
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id}
# ---------------------------------------------------------------------------
# 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")}
+21 -20
View File
@@ -14,7 +14,7 @@ from app.internal.auth import (
require_service_or_firebase_token,
resolve_caller_org_id,
)
from app.routers.tokens import assign_token, release_token
from app.internal import discord_streams
router = APIRouter(prefix="/nodes", tags=["nodes"])
@@ -138,27 +138,28 @@ async def send_command(
payload = cmd.model_dump(exclude_none=True)
# Discord join/leave are desired state now (server-26#203/#204, node-26#25):
# they never go to the node as commands, and never carry a token string.
if cmd.action == "discord_join":
# Resolve system doc once — used for preferred token and presence name.
system_doc = None
system_id = node.get("assigned_system_id")
if system_id:
system_doc = await fstore.doc_get_cached("systems", system_id)
if not cmd.guild_id or not cmd.channel_id:
raise HTTPException(400, "guild_id and channel_id are required.")
system_id = discord_streams._effective_system(node)
if not system_id:
raise HTTPException(409, "This node is not listening to a system yet.")
try:
doc = await discord_streams.start_stream(
org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id,
system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id,
mode="adhoc",
)
except discord_streams.StreamError as e:
raise HTTPException(e.status, e.message)
return {"ok": True, "stream_id": doc["stream_id"], "token_id": doc.get("token_id"),
"delivered": doc.get("delivered")}
# Explicit preferred_token_id in the request beats the system-level preference.
preferred = payload.pop("preferred_token_id", None) or (system_doc or {}).get("preferred_token_id")
token = await assign_token(node_id, preferred_token_id=preferred)
if not token:
raise HTTPException(503, "No Discord bot tokens available in the pool.")
payload["token"] = token
# Pass system name so the bot can set its Discord presence on join.
system_name = (system_doc or {}).get("name")
if system_name:
payload["system_name"] = system_name
elif cmd.action == "discord_leave":
await release_token(node_id)
if cmd.action == "discord_leave":
stopped = await discord_streams.stop_node(node_id, "stopped_from_web")
return {"ok": True, "stopped": stopped}
if not mqtt_handler.send_command(node_id, payload):
raise HTTPException(503, "MQTT broker unavailable — command not delivered.")
+58 -82
View File
@@ -2,9 +2,10 @@ import uuid
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from datetime import datetime, timezone
from app.internal import firestore as fstore
from app.internal import discord_leases as leases
from app.internal.auth import require_admin_token
from app.internal.logger import logger
router = APIRouter(prefix="/tokens", tags=["tokens"])
@@ -12,53 +13,84 @@ router = APIRouter(prefix="/tokens", tags=["tokens"])
class TokenCreate(BaseModel):
name: str # friendly label e.g. "DRB Bot 1"
token: str # the actual Discord bot token
# None = platform pool. An org id = that org's bring-your-own bot, never
# leased to anyone else (discord_leases.pick_token).
org_id: Optional[str] = None
def _masked(t: dict) -> dict:
out = {**t, "token": "•••" + (t.get("token") or "")[-4:]}
out["invite_url"] = leases.invite_url(t.get("application_id"))
return out
async def _republish_after_release(lease: Optional[dict]) -> None:
if lease and lease.get("node_id"):
from app.internal.discord_streams import publish_desired
await publish_desired(lease["node_id"])
# ---------------------------------------------------------------------------
# CRUD
# CRUD — platform admin only (server-26#204 item 6: the list used to be open
# to any signed-in user of any org)
# ---------------------------------------------------------------------------
@router.get("")
async def list_tokens():
async def list_tokens(_: dict = Depends(require_admin_token)):
"""List all tokens. The actual token string is masked for safety."""
tokens = await fstore.collection_list("bot_tokens")
return [
{**t, "token": "•••" + t["token"][-4:]}
for t in tokens
]
return [_masked(t) for t in await fstore.collection_list("bot_tokens")]
@router.post("", status_code=201)
async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
"""Add a radio bot. The token is checked against Discord first
(server-26#204 item 5): a string Discord answers 401 for is refused; if
Discord is merely unreachable the token is stored with membership unknown."""
try:
info = await leases.inspect_token(body.token.strip())
except leases.TokenRejected as e:
raise HTTPException(400, str(e))
token_id = str(uuid.uuid4())
doc = {
"token_id": token_id,
"name": body.name,
"token": body.token,
"token": body.token.strip(),
"org_id": body.org_id,
"lease": None,
"in_use": False,
"assigned_node_id": None,
"assigned_at": None,
**info,
}
await fstore.doc_set("bot_tokens", token_id, doc, merge=False)
return {"token_id": token_id, "name": body.name}
return {"token_id": token_id, "name": body.name, "valid": info.get("valid"),
"guild_ids": info.get("guild_ids"), "check_error": info.get("check_error"),
"invite_url": leases.invite_url(info.get("application_id"))}
@router.post("/{token_id}/refresh", status_code=200)
async def refresh_token(token_id: str, _: dict = Depends(require_admin_token)):
"""Re-check a bot with Discord (valid? which servers is it in?)."""
if not await fstore.doc_get("bot_tokens", token_id):
raise HTTPException(404, "Token not found.")
await leases.refresh_membership([token_id])
return _masked(await fstore.doc_get("bot_tokens", token_id) or {})
@router.post("/flush", status_code=200)
async def flush_tokens(_: dict = Depends(require_admin_token)):
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
def _find():
from app.internal.firestore import db
return [d for d in db.collection("bot_tokens").where("in_use", "==", True).stream()]
import asyncio
results = await asyncio.to_thread(_find)
for doc in results:
await fstore.doc_update("bot_tokens", doc.id, {
"in_use": False,
"assigned_node_id": None,
"assigned_at": None,
})
return {"released": len(results)}
"""Force-release every leased token (admin last resort). Each holding node
is sent a desired-state list without the token, so it logs the bot out;
persistent streams that lost their token are re-placed by the reconciler."""
released = 0
for t in await fstore.collection_list("bot_tokens"):
if not (t.get("lease") or t.get("in_use")):
continue
lease = await leases.force_release_token(t["token_id"])
released += 1
await _republish_after_release(lease)
logger.warning(f"Admin flushed {released} radio-bot token lease(s)")
return {"released": released}
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
@@ -98,62 +130,6 @@ async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id)
if not existing:
raise HTTPException(404, "Token not found.")
if existing.get("in_use"):
raise HTTPException(409, "Token is currently in use by a node.")
if existing.get("lease"):
raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.")
await fstore.doc_delete("bot_tokens", token_id)
# ---------------------------------------------------------------------------
# Internal helpers — used by the nodes router, not exposed via HTTP
# ---------------------------------------------------------------------------
async def assign_token(node_id: str, preferred_token_id: Optional[str] = None) -> Optional[str]:
"""
Find a free token, mark it as in-use, return the token string.
If preferred_token_id is given, try that token first (only if it's free).
Returns None if no tokens are available.
"""
def _find_free(preferred: Optional[str]):
from app.internal.firestore import db
if preferred:
snap = db.collection("bot_tokens").document(preferred).get()
if snap.exists and not snap.to_dict().get("in_use"):
return [snap]
docs = db.collection("bot_tokens").where("in_use", "==", False).limit(1).stream()
return [d for d in docs]
import asyncio
results = await asyncio.to_thread(_find_free, preferred_token_id)
if not results:
return None
doc = results[0]
token_id = doc.id
token_value = doc.to_dict()["token"]
await fstore.doc_update("bot_tokens", token_id, {
"in_use": True,
"assigned_node_id": node_id,
"assigned_at": datetime.now(timezone.utc),
})
return token_value
async def release_token(node_id: str) -> None:
"""Free whichever token is currently assigned to this node."""
def _find_assigned():
from app.internal.firestore import db
return [
d for d in db.collection("bot_tokens")
.where("assigned_node_id", "==", node_id)
.stream()
]
import asyncio
results = await asyncio.to_thread(_find_assigned)
for doc in results:
await fstore.doc_update("bot_tokens", doc.id, {
"in_use": False,
"assigned_node_id": None,
"assigned_at": None,
})