S8: main DRB bot optional end to end + owner-flow integration tests (server-26#225)
- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot /help and the server-limit refusal no longer imply the DRB server bot is required; a radio bot alone is enough. - Admin overview: per-server main_bot_present, radio_bots, linked_via, directory_source; Servers tab gets a Bots column. - discord_web docstring: states the reversed "never calls Discord" rule (plan finding 5) and the optional server bot. - Server bot directory cog: re-push (which drops C2's cached permission answers) on overwrite changes, role permission changes and role deletes. - tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set preferred, OAuth link, picker, start, reconciler) and full setup (server bot + 4 radio bots: placement, shared channel, every refusal explained), against a modelled Discord over httpx.MockTransport. - storage.download_audio removed: no caller on main since #233. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f49dc86111
commit
9c124d4c8b
@@ -441,7 +441,9 @@ ERROR_TEXT = {
|
||||
"pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice "
|
||||
"channel per server, and a bot carrying radio on another node can't be shared. Add another "
|
||||
"radio bot to DRB and invite it to this server, or stop a radio channel here."),
|
||||
"limit": "This server already has its maximum number of radio channels. Stop one with /leave first.",
|
||||
# Reaches the web picker and /join alike: name both ways to stop one.
|
||||
"limit": "This server already has its maximum number of radio channels. Stop one first (Stop on a node's "
|
||||
"Discord radio panel, or /leave in Discord).",
|
||||
"preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to "
|
||||
"another organization). Pick another bot, or let DRB choose any free one.",
|
||||
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
|
||||
|
||||
@@ -100,31 +100,11 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
|
||||
return gcs_uri
|
||||
|
||||
|
||||
async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
||||
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
|
||||
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||
if not bucket_name:
|
||||
return None
|
||||
|
||||
def _download() -> bytes:
|
||||
from google.cloud import storage
|
||||
if settings.gcp_credentials_path:
|
||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||
else:
|
||||
client = storage.Client()
|
||||
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_download)
|
||||
except Exception as e:
|
||||
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
|
||||
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object
|
||||
# into memory, so /media used to make the browser wait for all of it before
|
||||
# the first sample played. These two read only what a request asks for: the
|
||||
# A 600 s FLAC call is ~13 MB. /media used to pull the whole object into
|
||||
# memory (the old download_audio(), removed once nothing called it — #225 S8),
|
||||
# so the browser waited for all of it before the first sample played. These
|
||||
# two read only what a request asks for: the
|
||||
# object size (one metadata GET) and then an inclusive byte span, which the
|
||||
# router streams out in chunks. One cached client, because the router issues
|
||||
# several span reads per playback and a fresh Client() per read re-resolves
|
||||
|
||||
@@ -269,6 +269,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
Radio bots come from ``GET /tokens``; no token string is returned here.
|
||||
Discord user ids (``created_by_discord_id``) and the linking admin's uid
|
||||
are deliberately left out.
|
||||
|
||||
Per server, ``main_bot_present`` (the DRB server bot — optional since
|
||||
server-26#225: True/False as it last reported, None = never heard from it
|
||||
there) and ``radio_bots`` (names of radio bots Discord last said are in
|
||||
it). A server with radio bots and no server bot is a normal setup.
|
||||
"""
|
||||
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
|
||||
if g.get("guild_id")}
|
||||
@@ -284,6 +289,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
|
||||
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
|
||||
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
|
||||
"linked_via": link.get("linked_via"),
|
||||
# The DRB server bot is optional (server-26#225 S8): True/False
|
||||
# = it said it is / is not in the server, None = never heard.
|
||||
"main_bot_present": link.get("bot_present"),
|
||||
"directory_source": link.get("directory_source"),
|
||||
# Explicitly unlinked servers get no fallback org (mirrors
|
||||
# streams.resolve_guild_org once server-26#218 lands).
|
||||
"_no_fallback": bool(link.get("unlinked_at"))}
|
||||
@@ -291,7 +301,8 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
for s in all_streams:
|
||||
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
|
||||
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
|
||||
"org_id": None, "linked_at": None})
|
||||
"org_id": None, "linked_at": None, "linked_via": None,
|
||||
"main_bot_present": None, "directory_source": None})
|
||||
if s.get("guild_name") and not g.get("guild_name"):
|
||||
g["guild_name"] = s["guild_name"]
|
||||
node = nodes.get(s.get("node_id")) or {}
|
||||
@@ -305,6 +316,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
g["acting_org_id"] = acting
|
||||
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
|
||||
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
|
||||
# Radio bots Discord last said are in the server (names only). Any one
|
||||
# of them is enough to list channels, check permissions and stream.
|
||||
g["radio_bots"] = sorted(str(t.get("name") or tid) for tid, t in tokens.items()
|
||||
if t.get("valid") is not False and isinstance(t.get("guild_ids"), list)
|
||||
and g["guild_id"] in t["guild_ids"])
|
||||
|
||||
# Streams sharing a voice channel sit together, oldest first.
|
||||
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
||||
|
||||
@@ -21,9 +21,20 @@ Where the directory comes from (server-26#225 S5 — the main bot is optional):
|
||||
stored list is missing (inline, inside the permission-check time budget)
|
||||
or older than ``discord_directory_refresh_seconds`` (in the background).
|
||||
|
||||
So C2 does call Discord when a signed-in user opens the picker — with a
|
||||
radio bot's token, for a server already linked to the caller's org, through
|
||||
the rate-limited, cached client. No token string ever reaches the browser.
|
||||
This reverses the old rule "C2 never calls Discord on the browser's behalf"
|
||||
on purpose (server-26#225 plan finding 5): C2 does call Discord when a
|
||||
signed-in user opens the picker, presses Start or Re-check — with a radio
|
||||
bot's token, only for a server already linked to the caller's org, through
|
||||
discord_rest's rate-limited, cached, breaker-guarded client (refresh=1 at
|
||||
most once per 10 s per user and server). No token string ever reaches the
|
||||
browser, and REST answers never create or re-link a ``discord_guilds`` doc.
|
||||
|
||||
The DRB server bot is optional end to end (server-26#225 S8). One radio bot
|
||||
in the server is enough to link it (per-bot OAuth, routers/discord_oauth.py),
|
||||
list its voice channels (here), check permissions (routers/discord_checks.py)
|
||||
and stream (streams.start_stream). The server bot adds its slash commands
|
||||
(/join, /leave, /status, /drb link) and a gateway-fed channel list; every
|
||||
path it uses keeps working.
|
||||
|
||||
Which servers an org sees (``_org_guilds``):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user