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:
co-authored by
Claude Opus 5.5
parent
0a724a064f
commit
a31a8df17c
+25
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user