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
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user