Merge pull request 'Discord picker: per-bot permission status + owner's preferred bot (#225 S4) — merge after S3' (#250) from feat/225-s4-picker-ui into main
Build & Deploy / Build & push images (push) Successful in 4m34s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m42s
Build & Deploy / Report a failed deploy (push) Skipped

This commit was merged in pull request #250.
This commit is contained in:
2026-10-05 02:38:55 -04:00
11 changed files with 986 additions and 21 deletions
+11 -1
View File
@@ -87,6 +87,16 @@ def _channel_state(rows: list[dict], enabled: bool) -> str:
return "no_bot"
def _checked_at(snaps: dict) -> str:
"""When Discord was last actually asked: the OLDEST cached snapshot behind
this answer (a denied result is cached for 10 min), so the picker's
"Permissions checked N ago" never calls a stale answer fresh (server-26#225
S4). No snapshot (check off, no member bots) = now."""
times = [s.fetched_at for s in snaps.values() if s is not None and getattr(s, "fetched_at", None)]
ts = min(times) if times else time.time()
return datetime.fromtimestamp(ts, timezone.utc).isoformat()
async def voice_check(*, guild_id: str, org_id: Optional[str], node_id: Optional[str],
channels: list[dict], channel_id: Optional[str], refresh: bool,
with_token_ids: bool) -> dict:
@@ -171,7 +181,7 @@ async def voice_check(*, guild_id: str, org_id: Optional[str], node_id: Optional
"guild_id": guild_id,
"node_id": node_id,
"enabled": enabled,
"checked_at": datetime.now(timezone.utc).isoformat(),
"checked_at": _checked_at(snaps),
"refreshed": refresh,
"channels": out_channels,
"invite": invite,
+61 -1
View File
@@ -1,5 +1,5 @@
import uuid
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from typing import Optional
from app.internal import firestore as fstore
@@ -12,6 +12,7 @@ from app.internal.auth import (
get_role,
require_admin_token,
require_firebase_token,
resolve_org_scope,
)
from app.internal.logger import logger
@@ -101,6 +102,16 @@ async def flush_tokens(_: dict = Depends(require_admin_token)):
return {"released": released}
def _forget_cached_system(system_id: str) -> None:
"""start_stream reads systems through fstore.doc_get_cached (5 min TTL):
drop the entry so a new preference applies to the next start instead of
up to five minutes later (server-26#225 S4)."""
cache = getattr(fstore, "_doc_cache", None)
path = getattr(fstore, "_path", None)
if isinstance(cache, dict) and callable(path):
cache.pop(f"{path('systems')}/{system_id}", None)
def _may_edit_system(decoded: dict, system: Optional[dict]) -> bool:
"""Platform admin: any system. Org owner: only their own org's systems —
a shared system (no org_id) serves every org, so a preference there is
@@ -167,6 +178,7 @@ async def set_preferred_system(
raise HTTPException(403, "That system is shared by every organization; only a platform admin "
"can change its radio bot.")
await fstore.doc_set("systems", old_id, {"preferred_token_id": None})
_forget_cached_system(old_id)
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
await write_audit(uid, email, "discord.token.prefer", details={
"token_id": token_id, "system_id": None, "previous_system_id": old_id or None})
@@ -183,6 +195,7 @@ async def set_preferred_system(
old_sys = await fstore.doc_get("systems", old_sys_id)
if old_sys and old_sys.get("preferred_token_id") == token_id and _may_edit_system(decoded, old_sys):
await fstore.doc_set("systems", old_sys_id, {"preferred_token_id": None})
_forget_cached_system(old_sys_id)
old_tok_id = system_doc.get("preferred_token_id")
if old_tok_id and old_tok_id != token_id:
old_tok = await fstore.doc_get("bot_tokens", old_tok_id)
@@ -190,6 +203,7 @@ async def set_preferred_system(
await fstore.doc_set("bot_tokens", old_tok_id, {"preferred_for_system_id": None})
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
_forget_cached_system(system_id)
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
await write_audit(uid, email, "discord.token.prefer", details={
"token_id": token_id, "system_id": system_id, "previous_token_id": old_tok_id,
@@ -197,6 +211,52 @@ async def set_preferred_system(
return {"ok": True, "preferred_for_system_id": system_id}
@router.get("/org/preferred")
async def org_preferred_bots(
org_id: Optional[str] = Query(None, description="Platform-admin only: act for this org."),
decoded: dict = Depends(require_firebase_token),
):
"""What an org owner needs to choose a preferred radio bot per system
(server-26#225 S4, owner decision Q2): the org's own systems, each with
its preferred bot, and the org's own bring-your-own bots — the only bots
an owner may choose (PUT /tokens/{id}/prefer/{system_id}). GET /tokens
stays platform-admin only; this returns no pool bots and no token strings.
A pool bot a platform admin chose for one of these systems is flagged,
not named."""
if get_role(decoded) != "admin" and get_org_role(decoded) != "owner":
raise HTTPException(403, "Only the organization owner or a platform admin can choose a system's radio bot.")
org = resolve_org_scope(decoded, org_id)
systems = sorted(await fstore.collection_list("systems", org_id=org),
key=lambda s: str(s.get("name") or s.get("system_id") or "").lower())
bots = sorted((t for t in await fstore.collection_list("bot_tokens", org_id=org) if t.get("token_id")),
key=lambda t: str(t.get("name") or "").lower())
names = {t["token_id"]: t.get("name") or t["token_id"] for t in bots}
out_systems = []
for s in systems:
if not s.get("system_id"):
continue
pref = s.get("preferred_token_id")
out_systems.append({
"system_id": s["system_id"],
"name": s.get("name") or s["system_id"],
"preferred_token_id": pref if pref in names else None,
"preferred_is_platform_bot": bool(pref) and pref not in names,
"preferred_name": names.get(pref) if pref in names else None,
})
return {
"org_id": org,
"systems": out_systems,
"bots": [{
"token_id": t["token_id"],
"name": names[t["token_id"]],
"valid": t.get("valid"),
"server_count": len(t["guild_ids"]) if isinstance(t.get("guild_ids"), list) else None,
"preferred_for_system_id": t.get("preferred_for_system_id"),
"invite_url": leases.invite_url(t.get("application_id")),
} for t in bots],
}
@router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id)