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
@@ -318,7 +318,11 @@ async def release_node_leases(node_id: str) -> list[str]:
|
||||
|
||||
|
||||
async def force_release_token(token_id: str) -> Optional[dict]:
|
||||
"""Admin override (/tokens/flush, Force release). Returns the old lease."""
|
||||
"""Admin override (/tokens/flush, delete). Frees the token and detaches
|
||||
every stream that named it, in one transaction, so no stream doc is left
|
||||
pointing at a token whose lease no longer lists it. Returns the old lease
|
||||
(``None`` if the token was not leased or does not exist); the caller
|
||||
republishes desired state for ``lease["node_id"]`` so the edge logs out."""
|
||||
db = fstore.db
|
||||
|
||||
def _txn(txn):
|
||||
@@ -327,7 +331,83 @@ async def force_release_token(token_id: str) -> Optional[dict]:
|
||||
if not snap.exists:
|
||||
return None
|
||||
lease = snap.to_dict().get("lease")
|
||||
stream_refs = []
|
||||
for sid in (lease or {}).get("streams") or []:
|
||||
sref = db.collection(STREAMS).document(sid)
|
||||
ssnap = sref.get(transaction=txn)
|
||||
if ssnap.exists and (ssnap.to_dict() or {}).get("token_id") == token_id:
|
||||
stream_refs.append(sref)
|
||||
txn.update(ref, _legacy_mirror(None))
|
||||
for sref in stream_refs:
|
||||
txn.set(sref, {"token_id": None, "lease_id": None}, merge=True)
|
||||
return lease
|
||||
|
||||
return await fstore.run_transaction(_txn)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token validation / guild membership (server-26#204 item 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DISCORD_API = "https://discord.com/api/v10"
|
||||
|
||||
|
||||
class TokenRejected(Exception):
|
||||
"""Discord answered 401: the string is not a working bot token."""
|
||||
|
||||
|
||||
async def inspect_token(token: str) -> dict:
|
||||
"""Ask Discord who this token is and which servers it is in.
|
||||
|
||||
Returns ``{bot_user_id, bot_name, application_id, guild_ids, valid,
|
||||
validated_at, check_error}``. Raises TokenRejected on a 401. Any other
|
||||
failure (Discord down, rate limit) is reported in ``check_error`` with
|
||||
``valid``/``guild_ids`` = None — unknown, not bad: a token is never
|
||||
rejected because Discord had a bad minute.
|
||||
"""
|
||||
import httpx
|
||||
headers = {"Authorization": f"Bot {token}"}
|
||||
out: dict = {"valid": None, "guild_ids": None, "check_error": None,
|
||||
"validated_at": _now().isoformat()}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, base_url=DISCORD_API, headers=headers) as client:
|
||||
me = await client.get("/users/@me")
|
||||
if me.status_code == 401:
|
||||
raise TokenRejected("Discord rejected this bot token (401).")
|
||||
me.raise_for_status()
|
||||
user = me.json()
|
||||
out["bot_user_id"] = str(user.get("id"))
|
||||
out["bot_name"] = user.get("username")
|
||||
app = await client.get("/oauth2/applications/@me")
|
||||
if app.status_code == 200:
|
||||
out["application_id"] = str(app.json().get("id"))
|
||||
guilds = await client.get("/users/@me/guilds", params={"limit": 200})
|
||||
guilds.raise_for_status()
|
||||
out["guild_ids"] = [str(g["id"]) for g in guilds.json()]
|
||||
out["valid"] = True
|
||||
except TokenRejected:
|
||||
raise
|
||||
except Exception as e: # network / 5xx / 429 — unknown, not invalid
|
||||
out["check_error"] = str(e)[:200]
|
||||
return out
|
||||
|
||||
|
||||
async def refresh_membership(token_ids: list[str]) -> int:
|
||||
"""Re-read guild membership for these tokens. Returns how many changed."""
|
||||
changed = 0
|
||||
for tok_id in token_ids:
|
||||
tok = await fstore.doc_get(TOKENS, tok_id)
|
||||
if not tok or not tok.get("token"):
|
||||
continue
|
||||
try:
|
||||
info = await inspect_token(tok["token"])
|
||||
except TokenRejected as e:
|
||||
await fstore.doc_update(TOKENS, tok_id, {"valid": False, "check_error": str(e),
|
||||
"validated_at": _now().isoformat()})
|
||||
changed += 1
|
||||
continue
|
||||
updates = {k: v for k, v in info.items() if v is not None or k == "check_error"}
|
||||
if updates.get("guild_ids") != tok.get("guild_ids") or updates.get("valid") != tok.get("valid"):
|
||||
changed += 1
|
||||
await fstore.doc_update(TOKENS, tok_id, updates)
|
||||
return changed
|
||||
|
||||
Reference in New Issue
Block a user