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
+25 -8
View File
@@ -10,6 +10,7 @@ from app.internal.summarizer import summarizer_loop
from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop
from app.internal import ai_health
from app.internal import discord_streams
from app.config import settings
from app.internal.auth import (
require_firebase_token,
@@ -18,31 +19,42 @@ from app.internal.auth import (
)
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic
from app.routers import discord as discord_router
from app.internal import dynsec
from app.internal import firestore as fstore
async def _release_orphaned_tokens():
"""Release all in-use tokens on startup — voice connections don't survive server restarts."""
async def _clear_legacy_token_flags():
"""Tokens written by the pre-lease code carry ``in_use`` with no ``lease``.
Nothing reads ``in_use`` for decisions any more (discord_leases.py decides
on ``lease``), but the /tokens page shows it, so a legacy "in use" with no
lease behind it would be a permanent false label. A token that HAS a lease
is left alone: voice connections now belong to the edge and survive a C2
restart, so releasing leases at startup (what this used to do) would hand
a still-logged-in token to a second node.
"""
def _find():
from app.internal.firestore import db
return [d for d in db.collection("bot_tokens").where("in_use", "==", True).stream()]
results = await asyncio.to_thread(_find)
cleared = 0
for doc in results:
if (doc.to_dict() or {}).get("lease"):
continue
await fstore.doc_update("bot_tokens", doc.id, {
"in_use": False,
"assigned_node_id": None,
"assigned_at": None,
"lease": None, "in_use": False, "assigned_node_id": None, "assigned_at": None,
})
if results:
logger.info(f"Released {len(results)} orphaned token(s) on startup.")
cleared += 1
if cleared:
logger.info(f"Cleared legacy in_use flag on {cleared} token(s) with no lease.")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("DRB C2 Core starting.")
await _release_orphaned_tokens()
await _clear_legacy_token_flags()
# dynsec bootstrap + reconcile — must happen before mqtt_handler.connect()
# so that by the time the app is serving requests, c2-core's own dynsec
@@ -65,6 +77,7 @@ async def lifespan(app: FastAPI):
summarizer_task = asyncio.create_task(summarizer_loop())
induction_task = asyncio.create_task(vocabulary_induction_loop())
recorrelation_task = asyncio.create_task(recorrelation_loop())
discord_task = asyncio.create_task(discord_streams.reconcile_loop())
yield # --- app running ---
@@ -73,6 +86,7 @@ async def lifespan(app: FastAPI):
summarizer_task.cancel()
induction_task.cancel()
recorrelation_task.cancel()
discord_task.cancel()
await mqtt_handler.disconnect()
@@ -139,6 +153,9 @@ app.include_router(waitlist.router) # public — no auth, source-IP rate limi
# public by necessity — an <audio src> can't send a bearer token, so the
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
app.include_router(media.router)
# auth is per-endpoint: bot routes take the service key (or a platform admin);
# the token-fetch route takes the calling node's own API key.
app.include_router(discord_router.router)
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
# is archived (no CVE patches) and was rejected for an internet-facing