Merge remote-tracking branch 'origin/main' into feat/225-s7-oauth-ui
This commit is contained in:
@@ -27,6 +27,16 @@ Firestore layout:
|
|||||||
unlinked_at|None, unlinked_by|None
|
unlinked_at|None, unlinked_by|None
|
||||||
|
|
||||||
Person names are never stored: the redeemer is kept as a Discord user id.
|
Person names are never stored: the redeemer is kept as a Discord user id.
|
||||||
|
|
||||||
|
Every way a server gets linked — ``/drb link code:`` (``redeem_and_settle``),
|
||||||
|
the platform admin's ``PUT /discord/guilds/{id}/link`` and the per-bot OAuth
|
||||||
|
invite (server-26#225 S6) (both ``link_and_settle``) — writes the guild doc
|
||||||
|
through ONE transaction body (``_apply_link``) and then runs ONE settle step
|
||||||
|
(``_settle``): a server that was acting for a different org (in practice the
|
||||||
|
``DISCORD_UNLINKED_GUILD_ORG_ID`` fallback) has its live radio channels
|
||||||
|
stopped, because they were placed on that org's nodes. The org it acted for
|
||||||
|
before is read inside the same transaction as the write, so the stop decision
|
||||||
|
can't race a concurrent link.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -35,6 +45,7 @@ import secrets
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
CODES = "discord_link_codes"
|
CODES = "discord_link_codes"
|
||||||
@@ -91,6 +102,63 @@ async def mint_code(org_id: str, uid: str) -> dict:
|
|||||||
"expires_minutes": CODE_TTL_MINUTES}
|
"expires_minutes": CODE_TTL_MINUTES}
|
||||||
|
|
||||||
|
|
||||||
|
# Stop reasons the settle step stamps on the streams it stops.
|
||||||
|
RELINK_STOP_REASON = "relinked"
|
||||||
|
ADMIN_RELINK_STOP_REASON = "server linked to another organization"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_link(txn, guild_ref, guild: Optional[dict], *, org_id: str, guild_id: str,
|
||||||
|
guild_name: Optional[str], linked_by: str, via: str, now: datetime,
|
||||||
|
allow_move: bool = False, extra: Optional[dict] = None):
|
||||||
|
"""The guild-side half of every link, inside a transaction whose reads are
|
||||||
|
all done. Returns a LinkError (nothing written) or the result dict:
|
||||||
|
``{org_id, already_linked, previous_org_id}`` — ``previous_org_id`` is the
|
||||||
|
org the server acted for before (its link, else the fallback org).
|
||||||
|
|
||||||
|
``allow_move`` is the platform admin's support override; every self-serve
|
||||||
|
path refuses to move a server another org has linked."""
|
||||||
|
current = (guild or {}).get("org_id")
|
||||||
|
if current and current != org_id and not allow_move:
|
||||||
|
return LinkError(409, "linked_elsewhere",
|
||||||
|
"This server is already linked to a different DRB organization. "
|
||||||
|
"That organization has to unlink it first.")
|
||||||
|
iso = now.isoformat()
|
||||||
|
name = (guild_name or "").strip()[:100]
|
||||||
|
txn.set(guild_ref, {
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"org_id": org_id,
|
||||||
|
"guild_name": name or (guild or {}).get("guild_name"),
|
||||||
|
"linked_by": linked_by,
|
||||||
|
"linked_via": via,
|
||||||
|
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
|
||||||
|
"unlinked_at": None,
|
||||||
|
"unlinked_by": None,
|
||||||
|
# A server an admin removed (streams.remove_guild) comes back on link.
|
||||||
|
"removed_at": None,
|
||||||
|
"removed_by": None,
|
||||||
|
**(extra or {}),
|
||||||
|
}, merge=True)
|
||||||
|
return {"org_id": org_id, "already_linked": current == org_id,
|
||||||
|
"previous_org_id": streams.acting_org_of(guild)}
|
||||||
|
|
||||||
|
|
||||||
|
def link_guild_txn(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
|
||||||
|
allow_move: bool = False, extra: Optional[dict] = None, now: Optional[datetime] = None):
|
||||||
|
"""Transaction body that links ``guild_id`` to ``org_id`` directly (no code)."""
|
||||||
|
db = fstore.db
|
||||||
|
when = now or _now()
|
||||||
|
|
||||||
|
def _txn(txn):
|
||||||
|
guild_ref = db.collection(GUILDS).document(guild_id)
|
||||||
|
snap = guild_ref.get(transaction=txn)
|
||||||
|
guild = snap.to_dict() if snap.exists else None
|
||||||
|
out = _apply_link(txn, guild_ref, guild, org_id=org_id, guild_id=guild_id, guild_name=guild_name,
|
||||||
|
linked_by=linked_by, via=via, now=when, allow_move=allow_move, extra=extra)
|
||||||
|
return {"error": out} if isinstance(out, LinkError) else out
|
||||||
|
|
||||||
|
return _txn
|
||||||
|
|
||||||
|
|
||||||
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
|
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
|
||||||
"""Build the transaction body. Every read happens before any write."""
|
"""Build the transaction body. Every read happens before any write."""
|
||||||
db = fstore.db
|
db = fstore.db
|
||||||
@@ -113,37 +181,20 @@ def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_use
|
|||||||
return {"error": LinkError(410, "code_expired",
|
return {"error": LinkError(410, "code_expired",
|
||||||
"That link code has expired. Generate a new one in DRB → "
|
"That link code has expired. Generate a new one in DRB → "
|
||||||
"Settings → Organization.")}
|
"Settings → Organization.")}
|
||||||
org_id = doc["org_id"]
|
out = _apply_link(txn, guild_ref, guild, org_id=doc["org_id"], guild_id=guild_id,
|
||||||
current = (guild or {}).get("org_id")
|
guild_name=guild_name, linked_by=f"discord:{discord_user_id}", via="code", now=now)
|
||||||
if current and current != org_id:
|
if isinstance(out, LinkError):
|
||||||
return {"error": LinkError(409, "linked_elsewhere",
|
return {"error": out} # the code is NOT consumed: its org can use it elsewhere
|
||||||
"This server is already linked to a different DRB organization. "
|
txn.update(code_ref, {"redeemed_at": now.isoformat(), "redeemed_guild_id": guild_id,
|
||||||
"That organization has to unlink it first.")}
|
|
||||||
|
|
||||||
iso = now.isoformat()
|
|
||||||
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
|
|
||||||
"redeemed_by_discord_id": discord_user_id})
|
"redeemed_by_discord_id": discord_user_id})
|
||||||
txn.set(guild_ref, {
|
return {**out, "created_by_uid": doc.get("created_by_uid")}
|
||||||
"guild_id": guild_id,
|
|
||||||
"org_id": org_id,
|
|
||||||
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
|
|
||||||
"linked_by": f"discord:{discord_user_id}",
|
|
||||||
"linked_via": "code",
|
|
||||||
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
|
|
||||||
"unlinked_at": None,
|
|
||||||
"unlinked_by": None,
|
|
||||||
# A server an admin removed (streams.remove_guild) comes back on link.
|
|
||||||
"removed_at": None,
|
|
||||||
"removed_by": None,
|
|
||||||
}, merge=True)
|
|
||||||
return {"org_id": org_id, "already_linked": current == org_id,
|
|
||||||
"created_by_uid": doc.get("created_by_uid")}
|
|
||||||
|
|
||||||
return _txn
|
return _txn
|
||||||
|
|
||||||
|
|
||||||
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
|
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
|
||||||
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal."""
|
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal.
|
||||||
|
Does NOT stop anything — routes call ``redeem_and_settle``."""
|
||||||
code = normalize(raw_code)
|
code = normalize(raw_code)
|
||||||
if len(code) != _CODE_LEN:
|
if len(code) != _CODE_LEN:
|
||||||
raise LinkError(404, "invalid_code",
|
raise LinkError(404, "invalid_code",
|
||||||
@@ -155,6 +206,39 @@ async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], d
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _settle(guild_id: str, result: dict, stop_reason: str) -> dict:
|
||||||
|
"""After a link committed: a server that was acting for another org (the
|
||||||
|
fallback org, or — admin override only — another linked org) has its live
|
||||||
|
radio channels stopped; they were placed on that org's nodes, and left
|
||||||
|
running the reconciler would keep re-placing them there
|
||||||
|
(server-26#225 plan finding 3: this lived in two routers before)."""
|
||||||
|
before = result.get("previous_org_id")
|
||||||
|
stopped: list = []
|
||||||
|
if before and before != result["org_id"]:
|
||||||
|
stopped = await streams.stop_guild(str(guild_id), stop_reason)
|
||||||
|
return {**result, "streams_stopped": stopped}
|
||||||
|
|
||||||
|
|
||||||
|
async def link_and_settle(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
|
||||||
|
allow_move: bool = False, extra: Optional[dict] = None,
|
||||||
|
stop_reason: str = RELINK_STOP_REASON) -> dict:
|
||||||
|
"""Link a server to an org directly (admin, OAuth) and settle. Raises
|
||||||
|
LinkError (``linked_elsewhere``) unless ``allow_move``. Returns
|
||||||
|
``{org_id, already_linked, previous_org_id, streams_stopped}``."""
|
||||||
|
result = await fstore.run_transaction(
|
||||||
|
link_guild_txn(org_id, str(guild_id), guild_name, linked_by, via, allow_move=allow_move, extra=extra))
|
||||||
|
if "error" in result:
|
||||||
|
raise result["error"]
|
||||||
|
return await _settle(guild_id, result, stop_reason)
|
||||||
|
|
||||||
|
|
||||||
|
async def redeem_and_settle(raw_code: str, guild_id: str, guild_name: Optional[str],
|
||||||
|
discord_user_id: str) -> dict:
|
||||||
|
"""``/drb link code:`` — redeem, then settle. Raises LinkError."""
|
||||||
|
result = await redeem_code(raw_code, guild_id, guild_name, discord_user_id)
|
||||||
|
return await _settle(guild_id, result, RELINK_STOP_REASON)
|
||||||
|
|
||||||
|
|
||||||
async def list_org_guilds(org_id: str) -> list[dict]:
|
async def list_org_guilds(org_id: str) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
|
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""
|
||||||
|
Per-bot OAuth "Connect a Discord server" (server-26#225 S6, owner decision Q1).
|
||||||
|
|
||||||
|
An org owner links a Discord server by inviting ONE radio bot through
|
||||||
|
Discord's OAuth2 bot invite. The invite's ``state`` carries a single-use,
|
||||||
|
short-lived nonce bound to the org and the user who started it; Discord
|
||||||
|
redirects back with ``code`` (and ``guild_id``), C2 exchanges the code with
|
||||||
|
THAT bot application's client secret, and the exchange's ``guild`` object —
|
||||||
|
never the query string — says which server to link.
|
||||||
|
|
||||||
|
Secrets:
|
||||||
|
|
||||||
|
bot_oauth/{token_id} server-only (Firestore rules deny all
|
||||||
|
client_secret, enabled, client reads; c2-core's admin SDK only)
|
||||||
|
set_by, set_at, checked_at
|
||||||
|
|
||||||
|
The secret is never on ``bot_tokens`` (GET /tokens returns an allowlist of
|
||||||
|
those fields), never returned by any route, never logged, never put in an
|
||||||
|
error string. Firestore encrypts it at rest like every other document; the
|
||||||
|
codebase has no application-level encryption pattern (bot tokens are
|
||||||
|
stored the same way), so none is invented here.
|
||||||
|
|
||||||
|
States:
|
||||||
|
|
||||||
|
discord_oauth_states/{sha256(nonce)} the nonce itself is never stored
|
||||||
|
org_id, uid, token_id, node_id, return_to, created_at, expires_at,
|
||||||
|
used_at|None, used_by|None
|
||||||
|
|
||||||
|
OAuth is off while ``settings.discord_oauth_redirect_uri`` is empty: every
|
||||||
|
route 404s and the UI hides the button.
|
||||||
|
|
||||||
|
HTTP goes through discord_rest's shared client (User-Agent, transport for
|
||||||
|
tests) and counts 401/403/429 answers against its process-wide breaker, so a
|
||||||
|
wrong secret retried in a loop can't contribute to an IP ban either.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_leases as leases
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
OAUTH = "bot_oauth"
|
||||||
|
STATES = "discord_oauth_states"
|
||||||
|
AUTHORIZE_URL = "https://discord.com/oauth2/authorize"
|
||||||
|
# Discord client secrets are 32 url-safe characters today; accept a margin.
|
||||||
|
SECRET_MIN, SECRET_MAX = 16, 128
|
||||||
|
RETURN_TO_MAX = 200
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool((settings.discord_oauth_redirect_uri or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def state_key(nonce: str) -> str:
|
||||||
|
return hashlib.sha256((nonce or "").encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_return_to(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""A same-site path only (``/nodes/abc``), never an absolute or
|
||||||
|
protocol-relative URL — the callback page redirects to it."""
|
||||||
|
v = (value or "").strip()
|
||||||
|
if not v or len(v) > RETURN_TO_MAX or not v.startswith("/") or v.startswith("//") or "\\" in v:
|
||||||
|
return None
|
||||||
|
if any(ord(ch) < 0x20 for ch in v):
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secrets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def enabled_token_ids() -> set[str]:
|
||||||
|
"""Bots whose OAuth secret is set and enabled."""
|
||||||
|
return {d.get("token_id") for d in await fstore.collection_list(OAUTH)
|
||||||
|
if d.get("token_id") and d.get("enabled") and d.get("client_secret")}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_secret(token_id: str) -> Optional[str]:
|
||||||
|
doc = await fstore.doc_get(OAUTH, token_id)
|
||||||
|
if not doc or not doc.get("enabled") or not doc.get("client_secret"):
|
||||||
|
return None
|
||||||
|
return doc["client_secret"]
|
||||||
|
|
||||||
|
|
||||||
|
async def store_secret(token_id: str, client_secret: str, uid: str) -> None:
|
||||||
|
now = _now().isoformat()
|
||||||
|
await fstore.doc_set(OAUTH, token_id, {
|
||||||
|
"token_id": token_id, "client_secret": client_secret, "enabled": True,
|
||||||
|
"set_by": uid, "set_at": now, "checked_at": now,
|
||||||
|
}, merge=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_secret(token_id: str) -> bool:
|
||||||
|
if not await fstore.doc_get(OAUTH, token_id):
|
||||||
|
return False
|
||||||
|
await fstore.doc_delete(OAUTH, token_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discord OAuth2 HTTP (client_id + client_secret, basic auth)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _post_form(path: str, data: dict, client_id: str, client_secret: str) -> discord_rest.RestResult:
|
||||||
|
"""POST an application-authenticated form. Never raises for a Discord or
|
||||||
|
network failure; the error string never holds the secret, the code or a
|
||||||
|
token (only Discord's short ``error`` field)."""
|
||||||
|
now = time.monotonic()
|
||||||
|
if discord_rest._breaker.tripped(now):
|
||||||
|
return discord_rest.RestResult(None, error="Discord REST paused (invalid-request budget reached)")
|
||||||
|
try:
|
||||||
|
r = await discord_rest._client().post(path, data=data, auth=(client_id, client_secret))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return discord_rest.RestResult(None, error=type(e).__name__)
|
||||||
|
status = r.status_code
|
||||||
|
if status in (401, 403) or (status == 429 and r.headers.get("x-ratelimit-scope") != "shared"):
|
||||||
|
discord_rest._breaker.record(time.monotonic())
|
||||||
|
body: Any = None
|
||||||
|
try:
|
||||||
|
body = r.json() if r.content else None
|
||||||
|
except ValueError:
|
||||||
|
body = None
|
||||||
|
if 200 <= status < 300:
|
||||||
|
return discord_rest.RestResult(status, body)
|
||||||
|
err = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
|
||||||
|
return discord_rest.RestResult(status, None, error=f"Discord answered {status}" + (f" ({err[:40]})" if err else ""))
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_code(client_id: str, client_secret: str, code: str) -> discord_rest.RestResult:
|
||||||
|
return await _post_form("/oauth2/token", {
|
||||||
|
"grant_type": "authorization_code", "code": code,
|
||||||
|
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
|
||||||
|
}, client_id, client_secret)
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke(client_id: str, client_secret: str, access_token: Optional[str]) -> None:
|
||||||
|
"""Best effort: C2 never uses the user's access token, so it is revoked
|
||||||
|
as soon as the guild has been read from the exchange."""
|
||||||
|
if access_token:
|
||||||
|
await _post_form("/oauth2/token/revoke", {"token": access_token, "token_type_hint": "access_token"},
|
||||||
|
client_id, client_secret)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_secret(client_id: str, client_secret: str) -> discord_rest.RestResult:
|
||||||
|
"""Prove ``client_secret`` belongs to ``client_id`` with a client-
|
||||||
|
credentials grant (scope ``identify`` — the bot owner's own token), then
|
||||||
|
revoke the token it returns. Nothing is stored from the answer."""
|
||||||
|
r = await _post_form("/oauth2/token", {"grant_type": "client_credentials", "scope": "identify"},
|
||||||
|
client_id, client_secret)
|
||||||
|
if r.ok and isinstance(r.data, dict):
|
||||||
|
await revoke(client_id, client_secret, r.data.get("access_token"))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State + authorize URL
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def authorize_url(application_id: str, nonce: str) -> str:
|
||||||
|
return AUTHORIZE_URL + "?" + urlencode({
|
||||||
|
"client_id": application_id,
|
||||||
|
"scope": "bot",
|
||||||
|
"permissions": str(leases.RADIO_BOT_PERMISSIONS),
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
|
||||||
|
"state": nonce,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def create_state(*, org_id: str, uid: str, token_id: str, node_id: Optional[str],
|
||||||
|
return_to: Optional[str]) -> tuple[str, datetime]:
|
||||||
|
nonce = secrets.token_urlsafe(32)
|
||||||
|
now = _now()
|
||||||
|
expires_at = now + timedelta(seconds=max(60, settings.discord_oauth_state_ttl_seconds))
|
||||||
|
await fstore.doc_set(STATES, state_key(nonce), {
|
||||||
|
"org_id": org_id, "uid": uid, "token_id": token_id, "node_id": node_id,
|
||||||
|
"return_to": return_to, "created_at": now.isoformat(), "expires_at": expires_at.isoformat(),
|
||||||
|
"used_at": None, "used_by": None,
|
||||||
|
}, merge=False)
|
||||||
|
return nonce, expires_at
|
||||||
|
|
||||||
|
|
||||||
|
class StateError(Exception):
|
||||||
|
def __init__(self, status: int, code: str, message: str):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status, self.code, self.message = status, code, message
|
||||||
|
|
||||||
|
|
||||||
|
def consume_state_txn(nonce: str, uid: str, now: datetime):
|
||||||
|
"""Transaction body: the state must exist, be unexpired and unused, and
|
||||||
|
belong to ``uid`` (login-CSRF: someone else's state never links into the
|
||||||
|
caller's org, and the caller's own code can't be replayed by another
|
||||||
|
account). Any presentation of a live state burns it — a failure later in
|
||||||
|
the flow means starting again, never retrying the same state."""
|
||||||
|
db = fstore.db
|
||||||
|
key = state_key(nonce)
|
||||||
|
|
||||||
|
def _txn(txn):
|
||||||
|
ref = db.collection(STATES).document(key)
|
||||||
|
snap = ref.get(transaction=txn)
|
||||||
|
doc = snap.to_dict() if snap.exists else None
|
||||||
|
if not doc:
|
||||||
|
return {"error": StateError(400, "invalid_state",
|
||||||
|
"That Discord link is not valid. Start again from DRB.")}
|
||||||
|
if doc.get("used_at"):
|
||||||
|
return {"error": StateError(409, "state_used",
|
||||||
|
"That Discord link was already used. Start again from DRB.")}
|
||||||
|
txn.update(ref, {"used_at": now.isoformat(), "used_by": uid})
|
||||||
|
try:
|
||||||
|
expired = now > datetime.fromisoformat(doc.get("expires_at") or "")
|
||||||
|
except ValueError:
|
||||||
|
expired = True
|
||||||
|
if expired:
|
||||||
|
return {"error": StateError(410, "state_expired",
|
||||||
|
"That Discord link expired. Start again from DRB.")}
|
||||||
|
if doc.get("uid") != uid:
|
||||||
|
return {"error": StateError(403, "wrong_user",
|
||||||
|
"That Discord link was started by a different DRB account. "
|
||||||
|
"Sign in as that account, or start again.")}
|
||||||
|
return {"state": doc}
|
||||||
|
|
||||||
|
return _txn
|
||||||
|
|
||||||
|
|
||||||
|
async def consume_state(nonce: str, uid: str) -> dict:
|
||||||
|
if not nonce or len(nonce) > 200:
|
||||||
|
raise StateError(400, "invalid_state", "That Discord link is not valid. Start again from DRB.")
|
||||||
|
out = await fstore.run_transaction(consume_state_txn(nonce, uid, _now()))
|
||||||
|
if "error" in out:
|
||||||
|
raise out["error"]
|
||||||
|
return out["state"]
|
||||||
@@ -655,3 +655,100 @@ async def guild_snapshot(token_doc: dict, guild_id: str, *, refresh: bool = Fals
|
|||||||
error=f"check failed ({type(e).__name__})")
|
error=f"check failed ({type(e).__name__})")
|
||||||
|
|
||||||
return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh)
|
return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Voice-channel directory from a radio bot's view (server-26#225 S5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DIRECTORY_MAX_CHANNELS = 200 # same cap as PUT /discord/guilds/{id}/directory
|
||||||
|
_DIRECTORY_NAME_MAX = 100
|
||||||
|
VOICE_CHANNEL = 2
|
||||||
|
CATEGORY_CHANNEL = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def directory_from_snapshot(snap: Optional[GuildSnapshot]) -> Optional[dict]:
|
||||||
|
"""A server's name and voice channels as one radio bot's snapshot sees
|
||||||
|
them, in the shape the DRB server bot pushes (``PUT
|
||||||
|
/discord/guilds/{id}/directory``): ``{name, voice_channels: [{channel_id,
|
||||||
|
name, category, position}]}``, in Discord's sidebar order (channels with
|
||||||
|
no category first, then each category by position; channels by position,
|
||||||
|
then id). None unless the snapshot is OK.
|
||||||
|
|
||||||
|
Every voice channel in the list is kept, including any this bot can't
|
||||||
|
View, exactly like the server bot's gateway list: whether a bot may join
|
||||||
|
is the permission check's answer (discord_streams.evaluate_access), and
|
||||||
|
the picker shows it per channel. Stage channels are not voice channels
|
||||||
|
here either."""
|
||||||
|
if snap is None or snap.status != OK or not isinstance(snap.channels, list):
|
||||||
|
return None
|
||||||
|
chans = [c for c in snap.channels if isinstance(c, dict) and c.get("id") is not None]
|
||||||
|
cats = {str(c["id"]): c for c in chans if c.get("type") == CATEGORY_CHANNEL}
|
||||||
|
|
||||||
|
def parent(c: dict) -> Optional[dict]:
|
||||||
|
return cats.get(str(c.get("parent_id") or ""))
|
||||||
|
|
||||||
|
def order(c: dict) -> tuple:
|
||||||
|
p = parent(c)
|
||||||
|
top = (0, 0, 0) if p is None else (1, _int_or(p.get("position")), _int_or(p.get("id")))
|
||||||
|
return (*top, _int_or(c.get("position")), _int_or(c.get("id")))
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
for c in sorted((c for c in chans if c.get("type") == VOICE_CHANNEL), key=order)[:DIRECTORY_MAX_CHANNELS]:
|
||||||
|
p = parent(c)
|
||||||
|
category = str(p.get("name") or "")[:_DIRECTORY_NAME_MAX] if p else ""
|
||||||
|
out.append({
|
||||||
|
"channel_id": str(c["id"]),
|
||||||
|
"name": str(c.get("name") or "")[:_DIRECTORY_NAME_MAX],
|
||||||
|
"category": category or None,
|
||||||
|
"position": len(out),
|
||||||
|
})
|
||||||
|
name = snap.guild.get("name") if isinstance(snap.guild, dict) else None
|
||||||
|
return {"name": str(name)[:_DIRECTORY_NAME_MAX] if name else None, "voice_channels": out}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DirectoryResult:
|
||||||
|
"""``guild_directory``'s answer. ``status``: OK (``name`` and
|
||||||
|
``voice_channels`` from ``token_id``'s snapshot), NOT_MEMBER (every bot
|
||||||
|
asked turned out not to be in the server, or its token is invalid) or
|
||||||
|
UNKNOWN (no usable answer — keep what is stored)."""
|
||||||
|
guild_id: str
|
||||||
|
status: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
voice_channels: Optional[list] = None
|
||||||
|
token_id: Optional[str] = None
|
||||||
|
fetched_at: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def guild_directory(token_docs: list[dict], guild_id: str, *, refresh: bool = False,
|
||||||
|
max_bots: int = 3) -> DirectoryResult:
|
||||||
|
"""The voice-channel list of one server, read from the first of
|
||||||
|
``token_docs`` (radio bots believed to be in it, best first) whose cached
|
||||||
|
snapshot is OK. It is the same snapshot the permission check reads, so
|
||||||
|
listing channels costs no extra Discord request. At most ``max_bots``
|
||||||
|
bots are asked, one after another. Never raises for a Discord failure.
|
||||||
|
|
||||||
|
The caller decides which server and which bots: this never looks a
|
||||||
|
server up by itself, so it can't discover one (a server DRB doesn't
|
||||||
|
already know is never listed from a radio bot's membership)."""
|
||||||
|
guild_id = str(guild_id)
|
||||||
|
statuses: list[str] = []
|
||||||
|
for doc in token_docs[:max(1, max_bots)]:
|
||||||
|
snap = await guild_snapshot(doc, guild_id, refresh=refresh)
|
||||||
|
statuses.append(snap.status)
|
||||||
|
d = directory_from_snapshot(snap)
|
||||||
|
if d is not None:
|
||||||
|
return DirectoryResult(guild_id=guild_id, status=OK, name=d["name"],
|
||||||
|
voice_channels=d["voice_channels"], token_id=snap.token_id,
|
||||||
|
fetched_at=snap.fetched_at)
|
||||||
|
if statuses and all(s in (NOT_MEMBER, INVALID) for s in statuses):
|
||||||
|
return DirectoryResult(guild_id=guild_id, status=NOT_MEMBER)
|
||||||
|
return DirectoryResult(guild_id=guild_id, status=UNKNOWN)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ A retained MQTT message therefore never stores a bot credential on the broker.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -93,6 +94,17 @@ def _parse_ts(value) -> Optional[datetime]:
|
|||||||
# Guild -> org
|
# Guild -> org
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def acting_org_of(link: Optional[dict]) -> Optional[str]:
|
||||||
|
"""``resolve_guild_org`` for an already-read ``discord_guilds`` doc (None =
|
||||||
|
no doc). Pure, so a link transaction can decide from the snapshot it read
|
||||||
|
(discord_guild_link.link_guild_txn, server-26#225 S6)."""
|
||||||
|
if link and link.get("org_id"):
|
||||||
|
return link["org_id"]
|
||||||
|
if link and link.get("unlinked_at"):
|
||||||
|
return None
|
||||||
|
return settings.discord_unlinked_guild_org_id or None
|
||||||
|
|
||||||
|
|
||||||
async def resolve_guild_org(guild_id: str) -> Optional[str]:
|
async def resolve_guild_org(guild_id: str) -> Optional[str]:
|
||||||
"""The org a Discord server acts for: its ``discord_guilds`` link, else
|
"""The org a Discord server acts for: its ``discord_guilds`` link, else
|
||||||
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
|
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
|
||||||
@@ -100,12 +112,7 @@ async def resolve_guild_org(guild_id: str) -> Optional[str]:
|
|||||||
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
|
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
|
||||||
gets no fallback: otherwise unlinking org B's server would quietly hand it
|
gets no fallback: otherwise unlinking org B's server would quietly hand it
|
||||||
to the founding org."""
|
to the founding org."""
|
||||||
link = await fstore.doc_get(GUILDS, str(guild_id))
|
return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id)))
|
||||||
if link and link.get("org_id"):
|
|
||||||
return link["org_id"]
|
|
||||||
if link and link.get("unlinked_at"):
|
|
||||||
return None
|
|
||||||
return settings.discord_unlinked_guild_org_id or None
|
|
||||||
|
|
||||||
|
|
||||||
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
|
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
|
||||||
@@ -352,14 +359,39 @@ async def check_bots(tokens: dict[str, dict], token_ids: list[str], guild_id: st
|
|||||||
for t in token_ids}
|
for t in token_ids}
|
||||||
|
|
||||||
|
|
||||||
|
# server-26#251: an interactive start (a person pressed Start / ran /join)
|
||||||
|
# re-reads a bot's snapshot that says "denied" once it is this old, so a
|
||||||
|
# permission fixed in Discord a moment ago is seen without waiting out the
|
||||||
|
# cache. The reconciler never does (plan finding 4: no REST loop).
|
||||||
|
RECHECK_DENIED_AFTER_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str,
|
async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str,
|
||||||
preferred_token_id: Optional[str], node_ids: set) -> Optional[dict]:
|
preferred_token_id: Optional[str], node_ids: set,
|
||||||
|
recheck_denied: bool = False) -> Optional[dict]:
|
||||||
if not settings.discord_perm_check_enabled:
|
if not settings.discord_perm_check_enabled:
|
||||||
return None
|
return None
|
||||||
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||||
ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids,
|
ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids,
|
||||||
exclude_stream_id=stream_id, preferred_token_id=preferred_token_id)
|
exclude_stream_id=stream_id, preferred_token_id=preferred_token_id)
|
||||||
return await check_bots(tokens, ids, guild_id, channel_id)
|
snaps = await gather_snapshots(tokens, ids, guild_id)
|
||||||
|
|
||||||
|
def verdict(t: str) -> dict:
|
||||||
|
return evaluate_access(snaps[t], channel_id, bot=tokens[t].get("name") or t) if snaps.get(t) \
|
||||||
|
else _unanswered()
|
||||||
|
|
||||||
|
out = {t: verdict(t) for t in ids}
|
||||||
|
if recheck_denied:
|
||||||
|
now = time.time()
|
||||||
|
stale = [t for t in ids if out[t]["status"] == leases.PERM_DENIED
|
||||||
|
and now - (getattr(snaps.get(t), "fetched_at", None) or now) >= RECHECK_DENIED_AFTER_SECONDS]
|
||||||
|
if stale:
|
||||||
|
fresh = await gather_snapshots(tokens, stale, guild_id, refresh=True)
|
||||||
|
for t, snap in fresh.items():
|
||||||
|
if snap is not None: # not answered in time: keep the cached verdict
|
||||||
|
snaps[t] = snap
|
||||||
|
out[t] = verdict(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str:
|
def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str:
|
||||||
@@ -484,6 +516,7 @@ async def start_stream(
|
|||||||
preferred_token_id: Optional[str] = None,
|
preferred_token_id: Optional[str] = None,
|
||||||
max_streams: Optional[int] = None,
|
max_streams: Optional[int] = None,
|
||||||
stream_id: Optional[str] = None,
|
stream_id: Optional[str] = None,
|
||||||
|
interactive: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Put ``system_id`` on a voice channel.
|
"""Put ``system_id`` on a voice channel.
|
||||||
|
|
||||||
@@ -504,6 +537,12 @@ async def start_stream(
|
|||||||
channel first (``_perm_check``; server-26#225). Every entry point — the
|
channel first (``_perm_check``; server-26#225). Every entry point — the
|
||||||
bot's /join, the web picker, the node command and the reconciler —
|
bot's /join, the web picker, the node command and the reconciler —
|
||||||
comes through here, so none can hand a node a bot Discord will refuse.
|
comes through here, so none can hand a node a bot Discord will refuse.
|
||||||
|
|
||||||
|
``interactive``: a person asked for this start (the routes pass True,
|
||||||
|
the reconciler never does). A bot whose cached check says "denied" is
|
||||||
|
then asked again if that answer is more than
|
||||||
|
``RECHECK_DENIED_AFTER_SECONDS`` old (server-26#251), so "fix it in
|
||||||
|
Discord, then try again" works on the next try.
|
||||||
"""
|
"""
|
||||||
if mode not in ("persistent", "adhoc"):
|
if mode not in ("persistent", "adhoc"):
|
||||||
raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400)
|
raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400)
|
||||||
@@ -527,14 +566,14 @@ async def start_stream(
|
|||||||
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode,
|
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode,
|
||||||
channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id,
|
channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id,
|
||||||
preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id,
|
preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id,
|
||||||
existing=existing,
|
existing=existing, interactive=interactive,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str,
|
async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str,
|
||||||
channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str],
|
channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str],
|
||||||
node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int],
|
node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int],
|
||||||
stream_id: Optional[str], existing: dict) -> dict:
|
stream_id: Optional[str], existing: dict, interactive: bool = False) -> dict:
|
||||||
already_on = False
|
already_on = False
|
||||||
if stream_id:
|
if stream_id:
|
||||||
existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock
|
existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock
|
||||||
@@ -612,7 +651,8 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
|
|||||||
|
|
||||||
node_ids = {n["node_id"] for n in candidates}
|
node_ids = {n["node_id"] for n in candidates}
|
||||||
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id,
|
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id,
|
||||||
preferred_token_id=preferred_token_id, node_ids=node_ids)
|
preferred_token_id=preferred_token_id, node_ids=node_ids,
|
||||||
|
recheck_denied=interactive)
|
||||||
if perms and any(p.get("status") == "bad_channel" for p in perms.values()):
|
if perms and any(p.get("status") == "bad_channel" for p in perms.values()):
|
||||||
raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. "
|
raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. "
|
||||||
"Radio can only play in a voice channel — pick one from the list.",
|
"Radio can only play in a voice channel — pick one from the list.",
|
||||||
@@ -638,7 +678,7 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
|
|||||||
# Bots that just turned out to be members get checked too.
|
# Bots that just turned out to be members get checked too.
|
||||||
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id,
|
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id,
|
||||||
stream_id=stream_id, preferred_token_id=preferred_token_id,
|
stream_id=stream_id, preferred_token_id=preferred_token_id,
|
||||||
node_ids=node_ids)
|
node_ids=node_ids, recheck_denied=interactive)
|
||||||
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
|
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
|
||||||
preferred_token_id=preferred_token_id, stream_fields=fields,
|
preferred_token_id=preferred_token_id, stream_fields=fields,
|
||||||
soft_preferred_token_id=soft_pref, perms=perms)
|
soft_preferred_token_id=soft_pref, perms=perms)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Pluggable power-outage providers (map layer, server-26#184).
|
||||||
|
|
||||||
|
Same shape as ``app.internal.cameras``: every source is one adapter carrying
|
||||||
|
its catalogued licence status, every provider defaults OFF, and a licence-gated
|
||||||
|
one serves only after an admin acknowledgement recorded in audit_log
|
||||||
|
(``registry``). Scraped utility storm-center JSON is never shipped -- those
|
||||||
|
sources are stubs until a data-sharing agreement exists.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Outage provider interface, normalised record and the per-provider feed cache.
|
||||||
|
|
||||||
|
The licence model (``Licence``, ``LicenceInfo``, the GATED set) is the camera
|
||||||
|
layer's, imported -- one catalogue vocabulary for every licence-gated source.
|
||||||
|
Only the acknowledgement wording differs (outage data, not imagery), so
|
||||||
|
``OutageLicenceInfo`` overrides ``warning()`` and nothing else.
|
||||||
|
|
||||||
|
Outage feeds are public and identical for every org, so -- like the camera
|
||||||
|
catalogs -- they are cached in memory, fetched lazily on request, and never
|
||||||
|
written to Firestore. A failed refresh keeps serving the last good feed and
|
||||||
|
reports the error and its age: a dead feed must show as an error, never as an
|
||||||
|
empty layer (the AI-silent-failures lesson).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app.internal.cameras.base import ( # noqa: F401 (re-exported for adapters)
|
||||||
|
BBox, Licence, LicenceInfo, bbox_intersects, finite, get_json, require_parsed, safe_error,
|
||||||
|
)
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
RETRY_AFTER_FAILURE_S = 60
|
||||||
|
DEFAULT_FEED_TTL_S = 5 * 60 # ODIN asks utilities for updates every 5-15 min
|
||||||
|
USER_AGENT = "DRB-c2-core/1.0 (+public power-outage data; contact via odin.ornl.gov)"
|
||||||
|
|
||||||
|
OutageArea = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OutageLicenceInfo(LicenceInfo):
|
||||||
|
"""``LicenceInfo`` with the acknowledgement worded for outage data."""
|
||||||
|
|
||||||
|
def warning(self) -> Optional[str]:
|
||||||
|
if self.status == Licence.PROHIBITED:
|
||||||
|
return f"{self.label}. This source's terms forbid this use; it cannot be enabled."
|
||||||
|
if not self.gated:
|
||||||
|
return None
|
||||||
|
return (f"{self.label}. {self.terms} Enabling it means DRB shows this source's outage data "
|
||||||
|
f"to every signed-in user. Only confirm if DRB actually holds the right to do so.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OutageProviderMeta:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
region: str
|
||||||
|
coverage: BBox # rough bbox; a query outside it never triggers a fetch
|
||||||
|
licence: LicenceInfo
|
||||||
|
granularity: str # "county", "point/polygon", ...
|
||||||
|
implemented: bool
|
||||||
|
notes: str # what is verified vs unverified about this adapter
|
||||||
|
homepage: Optional[str] = None
|
||||||
|
ttl_s: int = DEFAULT_FEED_TTL_S
|
||||||
|
|
||||||
|
|
||||||
|
class OutageProvider:
|
||||||
|
"""Base adapter. Subclasses set ``meta`` and implement ``fetch``."""
|
||||||
|
meta: OutageProviderMeta
|
||||||
|
|
||||||
|
async def fetch(self) -> List[OutageArea]:
|
||||||
|
raise NotImplementedError(f"{self.meta.id}: adapter is a stub ({self.meta.notes})")
|
||||||
|
|
||||||
|
def coverage_note(self) -> Optional[str]:
|
||||||
|
"""What this source can and cannot see, shown next to its data."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubProvider(OutageProvider):
|
||||||
|
"""A catalogued source with no adapter -- listed so Admin shows its licence
|
||||||
|
status, but it can never be enabled."""
|
||||||
|
|
||||||
|
def __init__(self, meta: OutageProviderMeta):
|
||||||
|
assert not meta.implemented
|
||||||
|
self.meta = meta
|
||||||
|
|
||||||
|
|
||||||
|
class Feed:
|
||||||
|
"""In-memory cache of one provider's normalised outage areas."""
|
||||||
|
|
||||||
|
def __init__(self, provider: OutageProvider):
|
||||||
|
self.provider = provider
|
||||||
|
self.items: List[OutageArea] = []
|
||||||
|
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
|
||||||
|
self.last_attempt_at: Optional[float] = None
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self._next_attempt = 0.0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get(self) -> "Feed":
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
async with self._lock:
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
ttl = self.provider.meta.ttl_s
|
||||||
|
self.last_attempt_at = time.time()
|
||||||
|
try:
|
||||||
|
items = await self.provider.fetch()
|
||||||
|
self.items = items
|
||||||
|
self.fetched_at = time.time()
|
||||||
|
self.error = None
|
||||||
|
self._next_attempt = time.time() + ttl
|
||||||
|
except Exception as e:
|
||||||
|
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
|
||||||
|
self.error = safe_error(e)
|
||||||
|
logger.warning(f"outages/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {"fetched_at": self.fetched_at, "last_attempt_at": self.last_attempt_at,
|
||||||
|
"error": self.error, "count": len(self.items)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers shared by adapters ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_area(provider_id: str, fips: str, *, county: Optional[str], state: Optional[str], customers_out: int,
|
||||||
|
customers_served: Optional[int] = None, etr: Optional[str] = None, since: Optional[str] = None,
|
||||||
|
updated_at: Optional[str] = None, lat: Optional[float] = None, lon: Optional[float] = None,
|
||||||
|
bbox: Optional[BBox] = None, utilities: Optional[List[dict]] = None,
|
||||||
|
stale_customers_out: int = 0) -> OutageArea:
|
||||||
|
"""One normalised county outage record. ``id`` is provider-prefixed.
|
||||||
|
|
||||||
|
``customers_out`` counts only live events. ``stale_customers_out`` counts
|
||||||
|
events the source still lists whose own restoration estimate passed long
|
||||||
|
ago (ODIN carries April rows in October) -- kept visible, never summed in.
|
||||||
|
``stale`` is True when every event in the county is stale."""
|
||||||
|
return {
|
||||||
|
"id": f"{provider_id}:{fips}",
|
||||||
|
"provider": provider_id,
|
||||||
|
"fips": fips, "county": county, "state": state,
|
||||||
|
"customers_out": customers_out,
|
||||||
|
"stale_customers_out": stale_customers_out,
|
||||||
|
"stale": customers_out == 0 and stale_customers_out > 0,
|
||||||
|
"customers_served": customers_served,
|
||||||
|
"etr": etr, # latest estimated restoration across the county's outages, if any
|
||||||
|
"since": since, # earliest reported start across the county's outages
|
||||||
|
"updated_at": updated_at, # newest time a reporting utility in this county last sent data
|
||||||
|
# Approximate: the centre of the county's bounding box, for placing a label.
|
||||||
|
"lat": lat, "lon": lon,
|
||||||
|
"bbox": list(bbox) if bbox else None, # south, west, north, east
|
||||||
|
"utilities": utilities or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def area_in_bbox(area: OutageArea, bbox: BBox) -> bool:
|
||||||
|
"""County bbox intersects the query; falls back to the label point."""
|
||||||
|
b = area.get("bbox")
|
||||||
|
if b and len(b) == 4:
|
||||||
|
return bbox_intersects((b[0], b[1], b[2], b[3]), bbox)
|
||||||
|
lat, lon = area.get("lat"), area.get("lon")
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return False
|
||||||
|
s, w, n, e = bbox
|
||||||
|
return s <= lat <= n and w <= lon <= e
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso(v: Any) -> Optional[datetime]:
|
||||||
|
"""ISO-8601 (with Z or offset) -> aware UTC datetime; None when unparseable."""
|
||||||
|
if not isinstance(v, str) or not v:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
d = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return (d if d.tzinfo else d.replace(tzinfo=timezone.utc)).astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def iso(d: Optional[datetime]) -> Optional[str]:
|
||||||
|
return d.isoformat().replace("+00:00", "Z") if d else None
|
||||||
|
|
||||||
|
|
||||||
|
def geometry_bbox(geom: Any) -> Optional[BBox]:
|
||||||
|
"""Bounding box (south, west, north, east) of a GeoJSON Polygon/MultiPolygon."""
|
||||||
|
if not isinstance(geom, dict):
|
||||||
|
return None
|
||||||
|
lats: List[float] = []
|
||||||
|
lons: List[float] = []
|
||||||
|
|
||||||
|
def walk(c: Any) -> None:
|
||||||
|
if isinstance(c, list) and len(c) >= 2 and not isinstance(c[0], list):
|
||||||
|
lon, lat = finite(c[0]), finite(c[1])
|
||||||
|
if lon is not None and lat is not None and -180 <= lon <= 180 and -90 <= lat <= 90:
|
||||||
|
lons.append(lon)
|
||||||
|
lats.append(lat)
|
||||||
|
elif isinstance(c, list):
|
||||||
|
for x in c:
|
||||||
|
walk(x)
|
||||||
|
|
||||||
|
walk(geom.get("coordinates"))
|
||||||
|
if not lats:
|
||||||
|
return None
|
||||||
|
return (min(lats), min(lons), max(lats), max(lons))
|
||||||
|
|
||||||
|
|
||||||
|
def bbox_centre(b: Optional[BBox]) -> Tuple[Optional[float], Optional[float]]:
|
||||||
|
if not b:
|
||||||
|
return None, None
|
||||||
|
return (round((b[0] + b[2]) / 2, 5), round((b[1] + b[3]) / 2, 5))
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Outage sources -- server-26#184 (source-terms table in its 2026-10-05 comment).
|
||||||
|
|
||||||
|
Only ODIN has an adapter. Every other source is listed so Admin shows its
|
||||||
|
licence status, and is a stub because DRB holds no right to use it: the utility
|
||||||
|
storm-center maps are undocumented JSON with no reuse licence (the panel ruling
|
||||||
|
stands -- do not ship scraped utility data commercially), PowerOutage.us
|
||||||
|
licenses its API for personal/non-commercial use only, and EAGLE-I's live feed
|
||||||
|
is a DOE partner system. Do not "fix" a stub by scraping.
|
||||||
|
|
||||||
|
ODIN, verified first-hand 2026-10-05 against https://odin.ornl.gov/v3/api-docs
|
||||||
|
(the OpenAPI document the ODIN developer guide points at) and live responses:
|
||||||
|
|
||||||
|
GET /odi?format=JSON public, no token. ``{"outage": [CIM Outage, ...]}``,
|
||||||
|
one row per outage event, every row aggregated to a
|
||||||
|
county: ``communityDescriptor`` = 5-digit county FIPS,
|
||||||
|
``metersAffected``, ``reportedStartTime``,
|
||||||
|
``estimatedRestorationTime.ert``, ``names`` carrying
|
||||||
|
``UtilityName`` / ``UtilityId`` (EIA id).
|
||||||
|
GET /odi/map?scale=COUNTY public. One row per county with active outages:
|
||||||
|
``id`` (FIPS), ``countyName``, ``stateName``,
|
||||||
|
``metersOut`` (= sum of /odi metersAffected, checked),
|
||||||
|
``feature`` (county Polygon/MultiPolygon).
|
||||||
|
GET /odi/status public. One row per reporting utility: ``eiaId``,
|
||||||
|
``name``, ``receivedDate``, ``hasError``.
|
||||||
|
|
||||||
|
``metersServed`` on /odi/map is the utility's system-wide total (National Grid
|
||||||
|
NY carries 1,686,884 on every county), so it is NOT a county denominator and
|
||||||
|
``customers_served`` stays None.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app.internal.outages.base import (
|
||||||
|
Licence, OutageArea, OutageLicenceInfo, OutageProvider, OutageProviderMeta, StubProvider, USER_AGENT,
|
||||||
|
bbox_centre, finite, geometry_bbox, get_json, iso, make_area, parse_iso, require_parsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
US_BBOX = (17.5, -180.0, 72.0, -64.0) # CONUS + AK + HI + PR, rough
|
||||||
|
NY_BBOX = (40.4, -80.0, 45.1, -71.7)
|
||||||
|
|
||||||
|
ODIN_BASE = "https://odin.ornl.gov"
|
||||||
|
ODIN_OPENAPI = "https://odin.ornl.gov/v3/api-docs"
|
||||||
|
_FIPS = re.compile(r"^\d{5}$")
|
||||||
|
# An event whose own estimated restoration passed this long ago is stale: ODIN
|
||||||
|
# still listed Avangrid rows from April 2026 (ETR April/May) on 2026-10-05.
|
||||||
|
# Those are not live outages and must not be summed into one or set "since".
|
||||||
|
STALE_AFTER_ETR = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
class OdinProvider(OutageProvider):
|
||||||
|
meta = OutageProviderMeta(
|
||||||
|
id="odin",
|
||||||
|
name="ODIN (Oak Ridge National Laboratory / DOE)",
|
||||||
|
region="United States -- participating utilities only (NY: National Grid, Central Hudson, Avangrid reporting 2026-10-05)",
|
||||||
|
coverage=US_BBOX,
|
||||||
|
licence=OutageLicenceInfo(
|
||||||
|
status=Licence.UNVERIFIED,
|
||||||
|
label="Licence unverified -- public county-level API, but no terms of use or reuse licence found",
|
||||||
|
terms=("ODIN documents GET /odi, /odi/map and /odi/status as public, non-authenticated endpoints "
|
||||||
|
"returning current outages aggregated to county FIPS (OpenAPI read first-hand 2026-10-05). "
|
||||||
|
"That document carries no licence or terms-of-service field, and no terms-of-use page was "
|
||||||
|
"found on odin.ornl.gov. ORNL is operated for DOE by a contractor and the data is "
|
||||||
|
"utility-reported, so federal public-domain status cannot be assumed. Confirm reuse terms "
|
||||||
|
"with ODIN (odin.ornl.gov/pages/contact.html) before relying on it commercially."),
|
||||||
|
terms_url=ODIN_OPENAPI,
|
||||||
|
attribution="Outage data: ODIN, Oak Ridge National Laboratory (utility-reported)",
|
||||||
|
attribution_url="https://odin.ornl.gov",
|
||||||
|
disclaimer=("County-level counts of meters reported out by utilities that participate in ODIN. "
|
||||||
|
"A utility that does not report to ODIN shows no outages here, even during one. "
|
||||||
|
"Restoration times are the utility's estimate, when given."),
|
||||||
|
issue="server-26#184",
|
||||||
|
),
|
||||||
|
granularity="county (FIPS)",
|
||||||
|
implemented=True,
|
||||||
|
notes=("Public API, verified first-hand 2026-10-05: /odi?format=JSON (counts, start, ETR, utility EIA id), "
|
||||||
|
"/odi/map?scale=COUNTY (county name, state, county polygon -> bbox and label point) and /odi/status "
|
||||||
|
"(utility last-received time). Counts are meters, used as customers. Con Edison / O&R are on ODIN's "
|
||||||
|
"participant list but were NOT reporting on 2026-10-05, so NYC/Westchester show nothing from ODIN "
|
||||||
|
"until they do. Schema canary: rows upstream but none parseable is an error, never an empty layer."),
|
||||||
|
homepage="https://odin.ornl.gov",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.reporting_utilities: Optional[int] = None
|
||||||
|
self.utilities_with_error: Optional[int] = None
|
||||||
|
|
||||||
|
async def fetch(self) -> List[OutageArea]:
|
||||||
|
h = {"User-Agent": USER_AGENT}
|
||||||
|
odi, county_map, status = await asyncio.gather(
|
||||||
|
get_json(f"{ODIN_BASE}/odi", params={"format": "JSON"}, headers=h),
|
||||||
|
get_json(f"{ODIN_BASE}/odi/map", params={"scale": "COUNTY"}, headers=h),
|
||||||
|
get_json(f"{ODIN_BASE}/odi/status", headers=h),
|
||||||
|
)
|
||||||
|
areas, info = parse_odin(odi, county_map, status)
|
||||||
|
self.reporting_utilities = info["reporting_utilities"]
|
||||||
|
self.utilities_with_error = info["utilities_with_error"]
|
||||||
|
return areas
|
||||||
|
|
||||||
|
def coverage_note(self) -> Optional[str]:
|
||||||
|
n = self.reporting_utilities
|
||||||
|
base = "Only utilities that report to ODIN appear; one outside ODIN shows no outages here even during one."
|
||||||
|
if n is None:
|
||||||
|
return base
|
||||||
|
err = f", {self.utilities_with_error} with a feed error" if self.utilities_with_error else ""
|
||||||
|
return f"{base} {n} utilities reporting nationwide{err}."
|
||||||
|
|
||||||
|
|
||||||
|
def _utility(names: object) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
name = eia = None
|
||||||
|
for n in names if isinstance(names, list) else []:
|
||||||
|
if not isinstance(n, dict):
|
||||||
|
continue
|
||||||
|
if n.get("nameType") == "UtilityName" and isinstance(n.get("name"), str):
|
||||||
|
name = n["name"]
|
||||||
|
elif n.get("nameType") == "UtilityId" and n.get("name") is not None:
|
||||||
|
eia = str(n["name"])
|
||||||
|
return name, eia
|
||||||
|
|
||||||
|
|
||||||
|
def parse_odin(odi: object, county_map: object, status: object,
|
||||||
|
now: Optional[datetime] = None) -> Tuple[List[OutageArea], dict]:
|
||||||
|
"""The three public ODIN responses -> one normalised record per county.
|
||||||
|
|
||||||
|
Events whose estimated restoration is more than ``STALE_AFTER_ETR`` before
|
||||||
|
``now`` are counted separately (``stale_customers_out``), not as live.
|
||||||
|
|
||||||
|
Raises ValueError when a payload has the wrong top-level shape or when rows
|
||||||
|
came back but none could be read -- an upstream schema change must surface
|
||||||
|
as a feed error, not as an empty map."""
|
||||||
|
if not isinstance(odi, dict) or not isinstance(odi.get("outage"), list):
|
||||||
|
raise ValueError("ODIN /odi: expected {'outage': [...]} -- upstream schema change?")
|
||||||
|
if not isinstance(county_map, list):
|
||||||
|
raise ValueError("ODIN /odi/map: expected a list -- upstream schema change?")
|
||||||
|
if not isinstance(status, list):
|
||||||
|
raise ValueError("ODIN /odi/status: expected a list -- upstream schema change?")
|
||||||
|
|
||||||
|
# Utility freshness, by EIA id.
|
||||||
|
received: Dict[str, str] = {}
|
||||||
|
errors = 0
|
||||||
|
for u in status:
|
||||||
|
if not isinstance(u, dict):
|
||||||
|
continue
|
||||||
|
errors += 1 if u.get("hasError") is True else 0
|
||||||
|
eia, d = u.get("eiaId"), parse_iso(u.get("receivedDate"))
|
||||||
|
if eia is not None and d is not None:
|
||||||
|
received[str(eia)] = iso(d)
|
||||||
|
require_parsed(len(status), list(received), "ODIN /odi/status")
|
||||||
|
|
||||||
|
# County geography.
|
||||||
|
geo: Dict[str, dict] = {}
|
||||||
|
for row in county_map:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
fips = str(row.get("id") or "")
|
||||||
|
if not _FIPS.match(fips):
|
||||||
|
continue
|
||||||
|
b = geometry_bbox(row.get("feature"))
|
||||||
|
geo[fips] = {"county": row.get("countyName") if isinstance(row.get("countyName"), str) else None,
|
||||||
|
"state": row.get("stateName") if isinstance(row.get("stateName"), str) else None,
|
||||||
|
"bbox": b}
|
||||||
|
require_parsed(len(county_map), list(geo), "ODIN /odi/map county")
|
||||||
|
|
||||||
|
# Outage events, aggregated per county.
|
||||||
|
stale_before = (now or datetime.now(timezone.utc)) - STALE_AFTER_ETR
|
||||||
|
agg: Dict[str, dict] = {}
|
||||||
|
parsed = stale_events = 0
|
||||||
|
for o in odi["outage"]:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
continue
|
||||||
|
fips = str(o.get("communityDescriptor") or "")
|
||||||
|
meters = finite(o.get("metersAffected"))
|
||||||
|
if not _FIPS.match(fips) or meters is None or meters < 0:
|
||||||
|
continue
|
||||||
|
parsed += 1
|
||||||
|
area = o.get("outageArea") if isinstance(o.get("outageArea"), dict) else {}
|
||||||
|
start = parse_iso(o.get("reportedStartTime")) or parse_iso(area.get("earliestReportedTime"))
|
||||||
|
ert_obj = o.get("estimatedRestorationTime") if isinstance(o.get("estimatedRestorationTime"), dict) else {}
|
||||||
|
ert = parse_iso(ert_obj.get("ert"))
|
||||||
|
if ert is not None and ert.year < 2000: # Puget Sound Energy sends 0001-01-01T00:00:00Z for "no ETR"
|
||||||
|
ert = None
|
||||||
|
stale = ert is not None and ert < stale_before
|
||||||
|
stale_events += stale
|
||||||
|
a = agg.setdefault(fips, {"out": 0, "stale_out": 0, "utilities": {},
|
||||||
|
"live": {"since": None, "etr": None}, "stale": {"since": None, "etr": None}})
|
||||||
|
a["stale_out" if stale else "out"] += int(meters)
|
||||||
|
t = a["stale" if stale else "live"] # since/etr come from live events whenever there are any
|
||||||
|
if start and (t["since"] is None or start < t["since"]):
|
||||||
|
t["since"] = start
|
||||||
|
if ert and (t["etr"] is None or ert > t["etr"]):
|
||||||
|
t["etr"] = ert
|
||||||
|
name, eia = _utility(o.get("names"))
|
||||||
|
key = eia or name or "unknown"
|
||||||
|
u = a["utilities"].setdefault(key, {"name": name, "eia_id": eia, "customers_out": 0,
|
||||||
|
"stale_customers_out": 0})
|
||||||
|
u["stale_customers_out" if stale else "customers_out"] += int(meters)
|
||||||
|
require_parsed(len(odi["outage"]), [None] * parsed, "ODIN /odi outage")
|
||||||
|
|
||||||
|
areas: List[OutageArea] = []
|
||||||
|
for fips, a in agg.items():
|
||||||
|
g = geo.get(fips, {})
|
||||||
|
utilities = sorted(a["utilities"].values(), key=lambda u: (-u["customers_out"], -u["stale_customers_out"]))
|
||||||
|
stamps = [received[u["eia_id"]] for u in utilities if u["eia_id"] in received]
|
||||||
|
lat, lon = bbox_centre(g.get("bbox"))
|
||||||
|
t = a["live"] if a["out"] else a["stale"]
|
||||||
|
areas.append(make_area(
|
||||||
|
"odin", fips, county=g.get("county"), state=g.get("state"), customers_out=a["out"],
|
||||||
|
stale_customers_out=a["stale_out"], customers_served=None, etr=iso(t["etr"]), since=iso(t["since"]),
|
||||||
|
updated_at=max(stamps) if stamps else None, lat=lat, lon=lon, bbox=g.get("bbox"),
|
||||||
|
utilities=utilities,
|
||||||
|
))
|
||||||
|
areas.sort(key=lambda r: (-r["customers_out"], -r["stale_customers_out"]))
|
||||||
|
return areas, {"reporting_utilities": len(status), "utilities_with_error": errors, "stale_events": stale_events}
|
||||||
|
|
||||||
|
|
||||||
|
# ── stubs: catalogued, never enable-able ─────────────────────────────────────
|
||||||
|
|
||||||
|
_STORM_CENTER_TERMS = ("The utility's public outage map is backed by undocumented JSON (vendor storm-center "
|
||||||
|
"endpoints) with no published licence for automated reuse; the endpoints can change "
|
||||||
|
"without notice. Panel ruling (server-26#184): do not ship scraped utility data "
|
||||||
|
"commercially. A direct data-sharing agreement is the licensed path (also a sales lead).")
|
||||||
|
|
||||||
|
|
||||||
|
def _stub(id: str, name: str, region: str, coverage, status: Licence, label: str, terms: str,
|
||||||
|
terms_url: Optional[str], granularity: str, notes: str, homepage: Optional[str]) -> StubProvider:
|
||||||
|
return StubProvider(OutageProviderMeta(
|
||||||
|
id=id, name=name, region=region, coverage=coverage,
|
||||||
|
licence=OutageLicenceInfo(status=status, label=label, terms=terms, terms_url=terms_url,
|
||||||
|
issue="server-26#184"),
|
||||||
|
granularity=granularity, implemented=False, notes=notes, homepage=homepage,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDERS: List[OutageProvider] = [
|
||||||
|
OdinProvider(),
|
||||||
|
_stub("poweroutage_us", "PowerOutage.us (FE Bluefire LLC)", "United States", US_BBOX, Licence.PAID,
|
||||||
|
"Paid API -- licence is personal/non-commercial (news media excepted); commercial use needs an agreement DRB does not hold",
|
||||||
|
("API & Content Terms: the licence is for \"personal and noncommercial use (except for News Media "
|
||||||
|
"Organizations)\"; commercial use needs a separate agreement; pricing by contact. Quoted from a "
|
||||||
|
"search summary -- the terms page returned 403 to a direct fetch, so re-read it first-hand before "
|
||||||
|
"any acknowledgement."),
|
||||||
|
"https://poweroutage.us/legal/apitermsofuse", "county / utility",
|
||||||
|
"No adapter: needs a commercial contract and its API documentation.", "https://poweroutage.us"),
|
||||||
|
_stub("coned_oru_stormcenter", "Con Edison / Orange & Rockland outage map", "NYC, Westchester, Orange & Rockland",
|
||||||
|
NY_BBOX, Licence.AGREEMENT_REQUIRED,
|
||||||
|
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
|
||||||
|
_STORM_CENTER_TERMS, "https://www.coned.com/en/services-and-outages/outage-map-faq", "point / polygon + ETR",
|
||||||
|
("No adapter by design. Con Edison and O&R are on ODIN's participant list but were not reporting to "
|
||||||
|
"ODIN on 2026-10-05; if they start, the ODIN adapter covers them at county level."),
|
||||||
|
"https://outagemap.coned.com"),
|
||||||
|
_stub("nyseg_rge_stormcenter", "NYSEG / RG&E outage map (Avangrid)", "Upstate NY", NY_BBOX,
|
||||||
|
Licence.AGREEMENT_REQUIRED,
|
||||||
|
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
|
||||||
|
_STORM_CENTER_TERMS, None, "point / polygon + ETR",
|
||||||
|
("No adapter by design. Avangrid (NYSEG/RG&E) reports to ODIN, so county-level data arrives through "
|
||||||
|
"the ODIN adapter."),
|
||||||
|
"https://www.nyseg.com"),
|
||||||
|
_stub("central_hudson_stormcenter", "Central Hudson outage map", "Hudson Valley, NY", NY_BBOX,
|
||||||
|
Licence.AGREEMENT_REQUIRED,
|
||||||
|
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
|
||||||
|
_STORM_CENTER_TERMS, None, "point / polygon + ETR",
|
||||||
|
("No adapter by design. Central Hudson reports to ODIN, so county-level data arrives through the "
|
||||||
|
"ODIN adapter."),
|
||||||
|
"https://www.cenhud.com"),
|
||||||
|
_stub("eagle_i_live", "EAGLE-I live feed (ORNL / DOE)", "United States", US_BBOX, Licence.AGREEMENT_REQUIRED,
|
||||||
|
"Live feed is a DOE / government-partner system -- DRB has no access agreement",
|
||||||
|
("EAGLE-I's live 15-minute county outage feed is restricted to DOE and government partners. The public "
|
||||||
|
"ORNL Constellation dataset is historic only (no terms stated on the dataset) and is no use for a "
|
||||||
|
"live map."),
|
||||||
|
"https://doi.ccs.ornl.gov/dataset/d26451af-7ea4-577c-8285-78b72cd8a2dc", "county",
|
||||||
|
"No adapter: needs a DOE partner agreement.", "https://eagle-i.doe.gov"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Outage provider registry: settings, licence gate, and bbox queries.
|
||||||
|
|
||||||
|
Mirrors ``app.internal.cameras.registry`` rule for rule. Settings live in
|
||||||
|
Firestore ``config/outage_providers``::
|
||||||
|
|
||||||
|
{"providers": {"<id>": {"enabled": bool, "updated_at": iso, "updated_by": {...},
|
||||||
|
"acknowledgement": {"by_uid", "by_email", "at", "licence_status", "warning"} | None}}}
|
||||||
|
|
||||||
|
Rules (owner decision #247 on server-26#184):
|
||||||
|
* every provider defaults OFF -- a missing doc/entry means off, and an
|
||||||
|
unreadable settings doc means everything is off (fail closed);
|
||||||
|
* a stub (no adapter) or a PROHIBITED source can never be enabled;
|
||||||
|
* a GATED licence (agreement required / non-commercial / unverified / paid)
|
||||||
|
can only be enabled with an explicit acknowledgement, recorded with who and
|
||||||
|
when in the settings doc AND audit_log. The audit write is a precondition
|
||||||
|
for a gated enable: no record, no enable;
|
||||||
|
* an acknowledgement is bound to the licence status it was given for. If a
|
||||||
|
provider's catalogued status changes in code, the old acknowledgement no
|
||||||
|
longer counts and the provider stops serving until re-acknowledged.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal.outages import providers as sources
|
||||||
|
from app.internal.outages.base import BBox, Feed, Licence, OutageArea, OutageProvider, area_in_bbox, bbox_intersects
|
||||||
|
|
||||||
|
COLLECTION, DOC_ID = "config", "outage_providers"
|
||||||
|
_TTL_S = 30.0
|
||||||
|
MAX_AREAS = 3000 # per response; ODIN's whole country is ~100-300 counties on a normal day
|
||||||
|
|
||||||
|
PROVIDERS: Dict[str, OutageProvider] = {p.meta.id: p for p in sources.PROVIDERS}
|
||||||
|
FEEDS: Dict[str, Feed] = {pid: Feed(p) for pid, p in PROVIDERS.items() if p.meta.implemented}
|
||||||
|
|
||||||
|
NO_SOURCE_MESSAGE = "No licensed outage source covers this area."
|
||||||
|
|
||||||
|
_cache: Dict[str, dict] = {}
|
||||||
|
_cache_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderError(Exception):
|
||||||
|
"""A refused settings change. ``code`` is the HTTP status the router returns."""
|
||||||
|
|
||||||
|
def __init__(self, code: int, message: str, **extra):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code, self.message, self.extra = code, message, extra
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
global _cache_ts
|
||||||
|
_cache_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings() -> Dict[str, dict]:
|
||||||
|
"""Per-provider settings. Unreadable Firestore means everything is OFF (fail closed)."""
|
||||||
|
global _cache, _cache_ts
|
||||||
|
if _cache_ts and time.monotonic() - _cache_ts < _TTL_S:
|
||||||
|
return _cache
|
||||||
|
try:
|
||||||
|
doc = await fstore.doc_get(COLLECTION, DOC_ID) or {}
|
||||||
|
provs = doc.get("providers") if isinstance(doc.get("providers"), dict) else {}
|
||||||
|
_cache = {k: v for k, v in provs.items() if isinstance(v, dict)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"outage providers: settings unreadable ({e}); treating every provider as OFF")
|
||||||
|
_cache = {}
|
||||||
|
_cache_ts = time.monotonic()
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
def effective(provider: OutageProvider, entry: Optional[dict]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""(serving?, reason-if-not) for a provider given its settings entry."""
|
||||||
|
meta = provider.meta
|
||||||
|
if not meta.implemented:
|
||||||
|
return False, "adapter is a stub"
|
||||||
|
if meta.licence.status == Licence.PROHIBITED:
|
||||||
|
return False, "source terms forbid this use"
|
||||||
|
if not entry or not entry.get("enabled"):
|
||||||
|
return False, "disabled (default)"
|
||||||
|
if meta.licence.gated:
|
||||||
|
ack = entry.get("acknowledgement") or {}
|
||||||
|
if ack.get("licence_status") != meta.licence.status.value:
|
||||||
|
return False, "licence acknowledgement missing or out of date"
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
async def is_serving(provider_id: str) -> bool:
|
||||||
|
p = PROVIDERS.get(provider_id)
|
||||||
|
if p is None:
|
||||||
|
return False
|
||||||
|
return effective(p, (await get_settings()).get(provider_id))[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_enabled(provider_id: str, enabled: bool, actor: Tuple[str, str],
|
||||||
|
acknowledge_licence: bool = False) -> dict:
|
||||||
|
p = PROVIDERS.get(provider_id)
|
||||||
|
if p is None:
|
||||||
|
raise ProviderError(404, f"unknown outage provider '{provider_id}'")
|
||||||
|
meta, lic = p.meta, p.meta.licence
|
||||||
|
uid, email = actor
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
entry: dict = {"enabled": bool(enabled), "updated_at": now, "updated_by": {"uid": uid, "email": email}}
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
if not meta.implemented:
|
||||||
|
raise ProviderError(409, f"{meta.name}: no working adapter -- {meta.notes}")
|
||||||
|
if lic.status == Licence.PROHIBITED:
|
||||||
|
raise ProviderError(409, lic.warning() or "prohibited")
|
||||||
|
if lic.gated:
|
||||||
|
if not acknowledge_licence:
|
||||||
|
raise ProviderError(409, f"{meta.name}: licence acknowledgement required",
|
||||||
|
requires_acknowledgement=True, warning=lic.warning(),
|
||||||
|
licence=lic.as_dict())
|
||||||
|
entry["acknowledgement"] = {"by_uid": uid, "by_email": email, "at": now,
|
||||||
|
"licence_status": lic.status.value, "warning": lic.warning()}
|
||||||
|
# Precondition, not a side effect: a gated enable with no audit
|
||||||
|
# record must not happen. A failure here aborts before the write.
|
||||||
|
try:
|
||||||
|
await _audit(actor, "outage_provider.enable_acknowledged", provider_id,
|
||||||
|
{"licence_status": lic.status.value, "licence_label": lic.label,
|
||||||
|
"warning": lic.warning(), "acknowledged_at": now})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"outage providers: audit write failed, refusing gated enable of {provider_id} ({e})")
|
||||||
|
raise ProviderError(503, "audit log unavailable -- a licence-gated provider cannot be enabled "
|
||||||
|
"without an audit record; try again")
|
||||||
|
else:
|
||||||
|
entry["acknowledgement"] = None # re-enabling a gated source needs a fresh acknowledgement
|
||||||
|
|
||||||
|
before = (await get_settings()).get(provider_id) or {}
|
||||||
|
await fstore.doc_set(COLLECTION, DOC_ID, {"providers": {provider_id: entry}}, merge=True)
|
||||||
|
invalidate_cache()
|
||||||
|
if not (enabled and lic.gated):
|
||||||
|
try:
|
||||||
|
await _audit(actor, "outage_provider.enable" if enabled else "outage_provider.disable", provider_id,
|
||||||
|
{"from": bool(before.get("enabled")), "to": bool(enabled),
|
||||||
|
"licence_status": lic.status.value})
|
||||||
|
except Exception as e: # the toggle already happened; never 500 a completed write
|
||||||
|
logger.error(f"outage providers: audit write failed for {provider_id} ({e})")
|
||||||
|
logger.info(f"outage provider {provider_id} -> enabled={enabled} by {email or uid}")
|
||||||
|
return await describe(provider_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _audit(actor: Tuple[str, str], action: str, provider_id: str, details: dict) -> None:
|
||||||
|
from app.internal import audit
|
||||||
|
await audit.write_audit(actor_uid=actor[0], actor_email=actor[1], action=action,
|
||||||
|
details={"provider_id": provider_id, **details})
|
||||||
|
|
||||||
|
|
||||||
|
async def describe(provider_id: str) -> dict:
|
||||||
|
p = PROVIDERS[provider_id]
|
||||||
|
entry = (await get_settings()).get(provider_id) or {}
|
||||||
|
serving, reason = effective(p, entry)
|
||||||
|
m = p.meta
|
||||||
|
feed = FEEDS.get(provider_id)
|
||||||
|
return {
|
||||||
|
"id": m.id, "name": m.name, "region": m.region, "granularity": m.granularity,
|
||||||
|
"implemented": m.implemented, "notes": m.notes, "homepage": m.homepage,
|
||||||
|
"licence": m.licence.as_dict(),
|
||||||
|
"enabled": bool(entry.get("enabled")),
|
||||||
|
"serving": serving, "not_serving_reason": reason,
|
||||||
|
"acknowledgement": entry.get("acknowledgement"),
|
||||||
|
"updated_at": entry.get("updated_at"), "updated_by": entry.get("updated_by"),
|
||||||
|
"coverage_note": p.coverage_note(),
|
||||||
|
"health": feed.status() if feed else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_providers() -> List[dict]:
|
||||||
|
await get_settings()
|
||||||
|
return [await describe(pid) for pid in PROVIDERS]
|
||||||
|
|
||||||
|
|
||||||
|
def credit(provider_id: str) -> dict:
|
||||||
|
lic = PROVIDERS[provider_id].meta.licence
|
||||||
|
return {"attribution": lic.attribution, "attribution_url": lic.attribution_url, "disclaimer": lic.disclaimer}
|
||||||
|
|
||||||
|
|
||||||
|
async def outages_in_bbox(bbox: BBox) -> dict:
|
||||||
|
"""Outage areas from every SERVING provider whose coverage meets ``bbox``.
|
||||||
|
Providers that are off are never fetched.
|
||||||
|
|
||||||
|
``status`` says why the list is what it is, so an empty layer is never
|
||||||
|
ambiguous: ``none_enabled`` (no provider is on), ``no_source`` (none of the
|
||||||
|
serving providers covers this area), ``error`` (every covering provider
|
||||||
|
failed and has nothing cached), else ``ok`` -- which may legitimately
|
||||||
|
carry zero areas when nothing is out."""
|
||||||
|
conf = await get_settings()
|
||||||
|
serving = [pid for pid in FEEDS if effective(PROVIDERS[pid], conf.get(pid))[0]]
|
||||||
|
live = [pid for pid in serving if bbox_intersects(PROVIDERS[pid].meta.coverage, bbox)]
|
||||||
|
await asyncio.gather(*(FEEDS[pid].get() for pid in live))
|
||||||
|
areas: List[OutageArea] = []
|
||||||
|
providers = []
|
||||||
|
for pid in serving:
|
||||||
|
feed = FEEDS[pid]
|
||||||
|
hits = [a for a in feed.items if area_in_bbox(a, bbox)] if pid in live else []
|
||||||
|
room = max(0, MAX_AREAS - len(areas))
|
||||||
|
areas.extend(hits[:room])
|
||||||
|
providers.append({"id": pid, "name": PROVIDERS[pid].meta.name, **credit(pid),
|
||||||
|
"granularity": PROVIDERS[pid].meta.granularity,
|
||||||
|
"coverage_note": PROVIDERS[pid].coverage_note(),
|
||||||
|
"covers_bbox": pid in live,
|
||||||
|
"fetched_at": feed.fetched_at, "error": feed.error,
|
||||||
|
"total_in_bbox": len(hits), "truncated": len(hits) > room})
|
||||||
|
|
||||||
|
if not serving:
|
||||||
|
status, message = "none_enabled", "No outage source is enabled. An admin can enable one under Admin -> Outage providers."
|
||||||
|
elif not live:
|
||||||
|
status, message = "no_source", NO_SOURCE_MESSAGE
|
||||||
|
elif all(FEEDS[pid].error and FEEDS[pid].fetched_at is None for pid in live):
|
||||||
|
status, message = "error", "Every outage source covering this area failed to load; see each provider's error."
|
||||||
|
else:
|
||||||
|
status, message = "ok", None
|
||||||
|
return {"areas": areas, "providers": providers, "serving_count": len(serving),
|
||||||
|
"status": status, "message": message}
|
||||||
@@ -121,6 +121,67 @@ async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
|||||||
return None
|
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
|
||||||
|
# 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
|
||||||
|
# credentials every time.
|
||||||
|
_gcs_client_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def _gcs_client():
|
||||||
|
global _gcs_client_cache
|
||||||
|
if _gcs_client_cache is None:
|
||||||
|
from google.cloud import storage
|
||||||
|
if settings.gcp_credentials_path:
|
||||||
|
_gcs_client_cache = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||||
|
else:
|
||||||
|
_gcs_client_cache = storage.Client()
|
||||||
|
return _gcs_client_cache
|
||||||
|
|
||||||
|
|
||||||
|
async def audio_object_size(gcs_uri: str) -> Optional[int]:
|
||||||
|
"""Size in bytes of a stored audio object, or None if it is missing/unreadable."""
|
||||||
|
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||||
|
if not bucket_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _size() -> Optional[int]:
|
||||||
|
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
|
||||||
|
blob.reload()
|
||||||
|
return blob.size
|
||||||
|
|
||||||
|
try:
|
||||||
|
size = await asyncio.to_thread(_size)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"GCS metadata read failed for {gcs_uri}: {e}")
|
||||||
|
return None
|
||||||
|
return int(size) if size is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def download_audio_range(gcs_uri: str, start: int, end: int) -> Optional[bytes]:
|
||||||
|
"""Bytes ``start``..``end`` INCLUSIVE of a stored object (GCS's own convention).
|
||||||
|
|
||||||
|
Reads only that span from GCS — never the whole object.
|
||||||
|
"""
|
||||||
|
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||||
|
if not bucket_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _read() -> bytes:
|
||||||
|
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
|
||||||
|
return blob.download_as_bytes(start=start, end=end)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_read)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"GCS ranged read failed for {gcs_uri} [{start}-{end}]: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
|
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
|
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
|
||||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ 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 nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||||
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
|
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
|
||||||
|
from app.routers import outages
|
||||||
from app.routers import discord as discord_router
|
from app.routers import discord as discord_router
|
||||||
from app.routers import discord_link as discord_link_router
|
from app.routers import discord_link as discord_link_router
|
||||||
from app.internal import dynsec
|
from app.internal import dynsec
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.routers import discord_web
|
from app.routers import discord_web
|
||||||
from app.routers import discord_checks
|
from app.routers import discord_checks
|
||||||
|
from app.routers import discord_oauth
|
||||||
|
|
||||||
|
|
||||||
async def _clear_legacy_token_flags():
|
async def _clear_legacy_token_flags():
|
||||||
@@ -147,6 +149,8 @@ app.include_router(places.router, dependencies=[Depends(require_service_or_fi
|
|||||||
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
|
app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
|
||||||
|
app.include_router(outages.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(outages.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
|
||||||
app.include_router(upload.router) # auth is per-node, handled inline
|
app.include_router(upload.router) # auth is per-node, handled inline
|
||||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||||
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
|
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
|
||||||
@@ -166,6 +170,7 @@ app.include_router(discord_web.router)
|
|||||||
app.include_router(discord_router.router)
|
app.include_router(discord_router.router)
|
||||||
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
|
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
|
||||||
app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225
|
app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225
|
||||||
|
app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signed-in starter); 404 while OAuth is off — #225 S6
|
||||||
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
|
# 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
|
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
||||||
# is archived (no CVE patches) and was rejected for an internet-facing
|
# is archived (no CVE patches) and was rejected for an internet-facing
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ Auth:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Security
|
from fastapi import APIRouter, Depends, HTTPException, Path, Security
|
||||||
@@ -26,6 +25,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.internal import discord_guild_link as glink
|
||||||
from app.internal import discord_leases as leases
|
from app.internal import discord_leases as leases
|
||||||
from app.internal import discord_rest
|
from app.internal import discord_rest
|
||||||
from app.internal import discord_streams as streams
|
from app.internal import discord_streams as streams
|
||||||
@@ -138,6 +138,7 @@ async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_
|
|||||||
mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name,
|
mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name,
|
||||||
created_by=body.created_by, preferred_token_id=body.preferred_token_id,
|
created_by=body.created_by, preferred_token_id=body.preferred_token_id,
|
||||||
max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id,
|
max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id,
|
||||||
|
interactive=True, # /join, the admin page: re-read a stale "denied" (server-26#251)
|
||||||
)
|
)
|
||||||
except streams.StreamError as e:
|
except streams.StreamError as e:
|
||||||
return _error(e)
|
return _error(e)
|
||||||
@@ -181,23 +182,18 @@ async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(req
|
|||||||
org = await fstore.doc_get("organizations", body.org_id)
|
org = await fstore.doc_get("organizations", body.org_id)
|
||||||
if not org:
|
if not org:
|
||||||
raise HTTPException(404, "Organization not found.")
|
raise HTTPException(404, "Organization not found.")
|
||||||
previous_org = await streams.resolve_guild_org(guild_id)
|
# Support override: may move a server between orgs. Re-linking a server
|
||||||
doc = {
|
# that was unlinked or removed brings it back. Live channels placed on the
|
||||||
"guild_id": guild_id, "org_id": body.org_id,
|
# previous org's nodes are stopped (glink._settle) — left running, the
|
||||||
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(),
|
# reconciler would keep re-placing them there.
|
||||||
# Re-linking a server that was unlinked or removed brings it back.
|
out = await glink.link_and_settle(body.org_id, guild_id, body.guild_name, decoded.get("uid") or "admin",
|
||||||
"unlinked_at": None, "unlinked_by": None, "removed_at": None, "removed_by": None,
|
"admin", allow_move=True, stop_reason=glink.ADMIN_RELINK_STOP_REASON)
|
||||||
}
|
uid, email = describe_actor(decoded)
|
||||||
if body.guild_name and body.guild_name.strip():
|
await write_audit(uid, email, "discord.guild.link", details={
|
||||||
doc["guild_name"] = body.guild_name.strip()[:100]
|
"guild_id": guild_id, "org_id": body.org_id, "previous_org_id": out["previous_org_id"],
|
||||||
await fstore.doc_set(streams.GUILDS, guild_id, doc)
|
"already_linked": out["already_linked"], "streams_stopped": len(out["streams_stopped"]), "via": "admin",
|
||||||
# Live channels in this server were placed on the previous org's nodes;
|
})
|
||||||
# left running, the reconciler would keep re-placing them there, so one
|
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": out["streams_stopped"]}
|
||||||
# org's equipment would keep serving a server that now belongs to another.
|
|
||||||
stopped: list[str] = []
|
|
||||||
if previous_org and previous_org != body.org_id:
|
|
||||||
stopped = await streams.stop_guild(guild_id, "server linked to another organization")
|
|
||||||
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/streams/{stream_id}")
|
@router.delete("/streams/{stream_id}")
|
||||||
|
|||||||
@@ -101,23 +101,21 @@ async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = De
|
|||||||
redeem_guild_limiter.check(f"guild:{guild_id}")
|
redeem_guild_limiter.check(f"guild:{guild_id}")
|
||||||
redeem_user_limiter.check(f"user:{body.discord_user_id}")
|
redeem_user_limiter.check(f"user:{body.discord_user_id}")
|
||||||
actor = f"discord:{body.discord_user_id}"
|
actor = f"discord:{body.discord_user_id}"
|
||||||
before = await streams.resolve_guild_org(guild_id)
|
|
||||||
try:
|
try:
|
||||||
result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id)
|
# Links, then stops anything the server had playing for the org it
|
||||||
|
# acted for before (the DISCORD_UNLINKED_GUILD_ORG_ID fallback).
|
||||||
|
result = await glink.redeem_and_settle(body.code, guild_id, body.guild_name, body.discord_user_id)
|
||||||
except glink.LinkError as e:
|
except glink.LinkError as e:
|
||||||
await write_audit(actor, "", "discord.guild.link_refused",
|
await write_audit(actor, "", "discord.guild.link_refused",
|
||||||
details={"guild_id": guild_id, "reason": e.code})
|
details={"guild_id": guild_id, "reason": e.code})
|
||||||
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
|
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
|
||||||
|
|
||||||
org_id = result["org_id"]
|
org_id = result["org_id"]
|
||||||
stopped: list = []
|
stopped = result["streams_stopped"]
|
||||||
if before and before != org_id:
|
|
||||||
# The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it
|
|
||||||
# had playing belongs to that org, not the one it just linked to.
|
|
||||||
stopped = await streams.stop_guild(guild_id, "relinked")
|
|
||||||
await write_audit(actor, "", "discord.guild.link", details={
|
await write_audit(actor, "", "discord.guild.link", details={
|
||||||
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
||||||
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
|
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
|
||||||
|
"via": "code",
|
||||||
})
|
})
|
||||||
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
|
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
|
||||||
org = await fstore.doc_get("organizations", org_id)
|
org = await fstore.doc_get("organizations", org_id)
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
"Connect a Discord server" through a radio bot's own OAuth2 invite
|
||||||
|
(server-26#225 S6, owner decision Q1 = per-bot OAuth).
|
||||||
|
|
||||||
|
GET /discord/oauth/options org owner / platform admin — which bot Connect
|
||||||
|
would invite (no state minted). 404 = off.
|
||||||
|
POST /discord/oauth/start org owner / platform admin — mint a state,
|
||||||
|
return Discord's authorize URL
|
||||||
|
POST /discord/oauth/complete signed-in user who started it — exchange the
|
||||||
|
code, link the server, check permissions
|
||||||
|
|
||||||
|
Platform admins may pass ?org_id= to act for another org (resolve_org_scope).
|
||||||
|
Every route is a 404 while ``discord_oauth_redirect_uri`` is empty or no
|
||||||
|
eligible bot has an OAuth secret (PUT /tokens/{id}/oauth).
|
||||||
|
|
||||||
|
Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the
|
||||||
|
radio bot preferred for the node's system (``node_id``); else the org's own
|
||||||
|
bring-your-own bot; else the pool bot with the fewest servers that is under
|
||||||
|
``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth
|
||||||
|
secret set count. A bot the caller's org can't use is a 404, never a 403.
|
||||||
|
|
||||||
|
The org a server links to is the one the STATE names, re-checked against a
|
||||||
|
fresh read of the caller's membership; the server is the one Discord's code
|
||||||
|
exchange names. The redirect's ``guild_id`` query parameter is ignored.
|
||||||
|
|
||||||
|
This reverses ``discord_web``'s old "C2 never calls Discord on the browser's
|
||||||
|
behalf" on purpose (plan finding 5): the call is made with the bot
|
||||||
|
application's credentials, after the org-scope checks and rate limits here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_guild_link as glink
|
||||||
|
from app.internal import discord_leases as leases
|
||||||
|
from app.internal import discord_oauth as oauth
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.audit import write_audit
|
||||||
|
from app.internal.auth import (
|
||||||
|
_RateLimiter, describe_actor, get_role, require_firebase_token, require_org_owner_token, resolve_org_scope,
|
||||||
|
)
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.routers.discord_checks import voice_check
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/discord/oauth", tags=["discord"])
|
||||||
|
|
||||||
|
start_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||||
|
complete_limiter = _RateLimiter(max_calls=10, window_seconds=600)
|
||||||
|
_ID = r"^[A-Za-z0-9_-]{1,64}$"
|
||||||
|
_OFF = "Connecting a Discord server through DRB isn't available."
|
||||||
|
|
||||||
|
|
||||||
|
def _require_enabled() -> None:
|
||||||
|
if not oauth.enabled():
|
||||||
|
raise HTTPException(404, _OFF)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(status: int, code: str, message: str, **extra) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=status, content={"detail": message, "code": code, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
def _server_count(tok: dict) -> Optional[int]:
|
||||||
|
members = tok.get("guild_ids")
|
||||||
|
return len(members) if isinstance(members, list) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _usable(tok: dict, org_id: str, oauth_ids: set) -> bool:
|
||||||
|
return (tok.get("token_id") in oauth_ids
|
||||||
|
and tok.get("valid") is not False
|
||||||
|
and bool(tok.get("application_id"))
|
||||||
|
and tok.get("org_id") in (None, org_id)
|
||||||
|
and leases.invitable(tok))
|
||||||
|
|
||||||
|
|
||||||
|
async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
|
||||||
|
token_id: Optional[str] = None) -> tuple[dict, str]:
|
||||||
|
"""(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` |
|
||||||
|
``pool``. Raises HTTPException 404 when nothing fits."""
|
||||||
|
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||||
|
oauth_ids = await oauth.enabled_token_ids()
|
||||||
|
|
||||||
|
if token_id:
|
||||||
|
tok = tokens.get(token_id)
|
||||||
|
if not tok or tok.get("org_id") not in (None, org_id):
|
||||||
|
raise HTTPException(404, "Radio bot not found.")
|
||||||
|
if not _usable(tok, org_id, oauth_ids):
|
||||||
|
raise HTTPException(404, f"{tok.get('name') or 'That radio bot'} can't be used to connect a server "
|
||||||
|
"right now.")
|
||||||
|
return tok, "chosen"
|
||||||
|
|
||||||
|
if node_id:
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node or node.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, "Node not found.")
|
||||||
|
system_id = streams._effective_system(node)
|
||||||
|
system = await fstore.doc_get("systems", system_id) if system_id else None
|
||||||
|
pref = tokens.get((system or {}).get("preferred_token_id") or "")
|
||||||
|
if pref and _usable(pref, org_id, oauth_ids):
|
||||||
|
return pref, "preferred"
|
||||||
|
|
||||||
|
own = sorted((t for t in tokens.values() if t.get("org_id") == org_id and _usable(t, org_id, oauth_ids)),
|
||||||
|
key=lambda t: (str(t.get("name") or "").lower(), t["token_id"]))
|
||||||
|
if own:
|
||||||
|
return own[0], "org_bot"
|
||||||
|
|
||||||
|
cap = settings.discord_pool_bot_guild_cap
|
||||||
|
pool = sorted((t for t in tokens.values() if t.get("org_id") is None and _usable(t, org_id, oauth_ids)),
|
||||||
|
key=lambda t: (_server_count(t) if _server_count(t) is not None else cap,
|
||||||
|
str(t.get("name") or "").lower(), t["token_id"]))
|
||||||
|
if pool:
|
||||||
|
return pool[0], "pool"
|
||||||
|
raise HTTPException(404, _OFF)
|
||||||
|
|
||||||
|
|
||||||
|
def _bot_summary(tok: dict, why: str) -> dict:
|
||||||
|
return {"name": tok.get("name") or "DRB radio bot", "reason": why,
|
||||||
|
"own_bot": tok.get("org_id") is not None}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/options")
|
||||||
|
async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID),
|
||||||
|
token_id: Optional[str] = Query(None, pattern=_ID),
|
||||||
|
org_id: Optional[str] = None,
|
||||||
|
decoded: dict = Depends(require_org_owner_token)):
|
||||||
|
"""Whether Connect is offered here, and which bot it would invite."""
|
||||||
|
_require_enabled()
|
||||||
|
scope = resolve_org_scope(decoded, org_id)
|
||||||
|
tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id)
|
||||||
|
return {"available": True, "bot": _bot_summary(tok, why)}
|
||||||
|
|
||||||
|
|
||||||
|
class StartBody(BaseModel):
|
||||||
|
node_id: Optional[str] = Field(None, pattern=_ID)
|
||||||
|
token_id: Optional[str] = Field(None, pattern=_ID)
|
||||||
|
return_to: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def oauth_start(body: StartBody, org_id: Optional[str] = None,
|
||||||
|
decoded: dict = Depends(require_org_owner_token)):
|
||||||
|
_require_enabled()
|
||||||
|
scope = resolve_org_scope(decoded, org_id)
|
||||||
|
uid, email = describe_actor(decoded)
|
||||||
|
start_limiter.check(f"{uid}:discord_oauth_start")
|
||||||
|
tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id)
|
||||||
|
return_to = oauth.safe_return_to(body.return_to)
|
||||||
|
nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"],
|
||||||
|
node_id=body.node_id, return_to=return_to)
|
||||||
|
await write_audit(uid, email, "discord.oauth.start", details={
|
||||||
|
"org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id})
|
||||||
|
return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce),
|
||||||
|
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)}
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteBody(BaseModel):
|
||||||
|
# Lengths checked in the handler: a pydantic 422 would echo the code back.
|
||||||
|
code: str
|
||||||
|
state: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _still_owner(decoded: dict, uid: str, org_id: str) -> bool:
|
||||||
|
"""Fresh read: the claims in an ID token can be up to an hour old."""
|
||||||
|
if get_role(decoded) == "admin":
|
||||||
|
return True
|
||||||
|
member = await fstore.doc_get("org_members", uid)
|
||||||
|
return bool(member and member.get("org_id") == org_id and member.get("org_role") == "owner")
|
||||||
|
|
||||||
|
|
||||||
|
async def _channel_summary(guild_id: str, org_id: str, node_id: Optional[str]) -> Optional[dict]:
|
||||||
|
"""Permissions right after the link, within discord_oauth_link_check_seconds.
|
||||||
|
None = not answered in time (the picker's badges will say)."""
|
||||||
|
link = await fstore.doc_get(streams.GUILDS, guild_id) or {}
|
||||||
|
try:
|
||||||
|
out = await asyncio.wait_for(
|
||||||
|
voice_check(guild_id=guild_id, org_id=org_id, node_id=node_id,
|
||||||
|
channels=link.get("voice_channels") or [], channel_id=None, refresh=True,
|
||||||
|
with_token_ids=False),
|
||||||
|
timeout=settings.discord_oauth_link_check_seconds)
|
||||||
|
except Exception as e: # timeout or anything else: the link already stands
|
||||||
|
logger.info(f"Discord OAuth link {guild_id}: permission check skipped ({type(e).__name__})")
|
||||||
|
return None
|
||||||
|
channels = [{"channel_id": c["channel_id"], "name": c.get("name"), "state": c["state"],
|
||||||
|
"fix": next((b.get("fix") for b in c.get("bots") or [] if b.get("fix")), None)}
|
||||||
|
for c in out.get("channels") or []]
|
||||||
|
return {
|
||||||
|
"enabled": out.get("enabled"),
|
||||||
|
"channels_ready": sum(1 for c in channels if c["state"] == "ready"),
|
||||||
|
"channels_blocked": sum(1 for c in channels if c["state"] == "needs_fix"),
|
||||||
|
"channels": channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete")
|
||||||
|
async def oauth_complete(body: CompleteBody, decoded: dict = Depends(require_firebase_token)):
|
||||||
|
_require_enabled()
|
||||||
|
uid, email = describe_actor(decoded)
|
||||||
|
complete_limiter.check(f"{uid}:discord_oauth_complete")
|
||||||
|
if not (0 < len(body.code) <= 200):
|
||||||
|
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
|
||||||
|
|
||||||
|
# 1. Consume the state (single use; must be this caller's).
|
||||||
|
try:
|
||||||
|
state = await oauth.consume_state(body.state, uid)
|
||||||
|
except oauth.StateError as e:
|
||||||
|
await write_audit(uid, email, "discord.oauth.refused", details={"reason": e.code})
|
||||||
|
return _err(e.status, e.code, e.message)
|
||||||
|
org_id, token_id = state["org_id"], state["token_id"]
|
||||||
|
return_to = state.get("return_to")
|
||||||
|
|
||||||
|
# 2. Still the owner of that org (fresh read, not the token's claims).
|
||||||
|
if not await _still_owner(decoded, uid, org_id):
|
||||||
|
await write_audit(uid, email, "discord.oauth.refused", details={"org_id": org_id, "reason": "not_owner"})
|
||||||
|
return _err(403, "not_owner", "Only the organization owner can connect a Discord server.")
|
||||||
|
|
||||||
|
# 3. Exchange the code with that bot application's own secret.
|
||||||
|
tok = await fstore.doc_get(leases.TOKENS, token_id)
|
||||||
|
secret = await oauth.load_secret(token_id)
|
||||||
|
if (not tok or not tok.get("application_id") or not secret
|
||||||
|
or tok.get("org_id") not in (None, org_id) or tok.get("valid") is False):
|
||||||
|
return _err(409, "bot_unavailable", "That radio bot can no longer connect servers. Start again from DRB.")
|
||||||
|
client_id = str(tok["application_id"])
|
||||||
|
r = await oauth.exchange_code(client_id, secret, body.code)
|
||||||
|
if not r.ok or not isinstance(r.data, dict):
|
||||||
|
await write_audit(uid, email, "discord.oauth.refused", details={
|
||||||
|
"org_id": org_id, "token_id": token_id, "reason": "exchange_failed", "status": r.status})
|
||||||
|
if r.status is not None and 400 <= r.status < 500 and r.status != 429:
|
||||||
|
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
|
||||||
|
return _err(502, "discord_unavailable", "Couldn't reach Discord to finish connecting. Start again from DRB.")
|
||||||
|
await oauth.revoke(client_id, secret, r.data.get("access_token"))
|
||||||
|
|
||||||
|
# 4. The server is the one Discord's exchange names — never the query string.
|
||||||
|
guild = r.data.get("guild") if isinstance(r.data.get("guild"), dict) else None
|
||||||
|
guild_id = str((guild or {}).get("id") or "")
|
||||||
|
if not guild_id.isdigit():
|
||||||
|
await write_audit(uid, email, "discord.oauth.refused", details={
|
||||||
|
"org_id": org_id, "token_id": token_id, "reason": "no_guild"})
|
||||||
|
return _err(400, "no_guild", "Discord didn't say which server the bot was added to. Start again from DRB.")
|
||||||
|
guild_name = guild.get("name") if isinstance(guild.get("name"), str) else None
|
||||||
|
|
||||||
|
# 5. Link + settle (a server another org linked is never moved: 409).
|
||||||
|
try:
|
||||||
|
result = await glink.link_and_settle(org_id, guild_id, guild_name, uid, "oauth",
|
||||||
|
extra={"linked_with_token_id": token_id})
|
||||||
|
except glink.LinkError as e:
|
||||||
|
await write_audit(uid, email, "discord.guild.link_refused", details={
|
||||||
|
"guild_id": guild_id, "org_id": org_id, "reason": e.code, "via": "oauth", "token_id": token_id})
|
||||||
|
return _err(e.status, e.code, e.message, guild_name=guild_name, bot_name=tok.get("name"))
|
||||||
|
|
||||||
|
# 6. The bot is in the server now: record it, drop stale cached answers, audit.
|
||||||
|
members = tok.get("guild_ids")
|
||||||
|
if isinstance(members, list) and guild_id not in members:
|
||||||
|
await fstore.doc_update(leases.TOKENS, token_id, {"guild_ids": [*members, guild_id]})
|
||||||
|
discord_rest.invalidate(guild_id=guild_id)
|
||||||
|
await write_audit(uid, email, "discord.guild.link", details={
|
||||||
|
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
||||||
|
"previous_org_id": result["previous_org_id"], "streams_stopped": len(result["streams_stopped"]),
|
||||||
|
"via": "oauth", "token_id": token_id,
|
||||||
|
})
|
||||||
|
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} via OAuth (bot {token_id}) by uid={uid}")
|
||||||
|
|
||||||
|
# 7. Which voice channels are ready, in the same request (capped).
|
||||||
|
check = await _channel_summary(guild_id, org_id, state.get("node_id"))
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"guild_name": guild_name,
|
||||||
|
"org_id": org_id,
|
||||||
|
"bot_name": tok.get("name"),
|
||||||
|
"already_linked": result["already_linked"],
|
||||||
|
"streams_stopped": len(result["streams_stopped"]),
|
||||||
|
"node_id": state.get("node_id"),
|
||||||
|
"return_to": return_to,
|
||||||
|
"checked": check is not None,
|
||||||
|
"channels_ready": check["channels_ready"] if check else None,
|
||||||
|
"channels_blocked": check["channels_blocked"] if check else None,
|
||||||
|
"channels": check["channels"] if check else [],
|
||||||
|
}
|
||||||
@@ -6,15 +6,38 @@ The node page used to take raw guild/channel IDs. It now picks from:
|
|||||||
* the Discord servers the caller's org owns — ``discord_guilds`` docs that
|
* the Discord servers the caller's org owns — ``discord_guilds`` docs that
|
||||||
resolve to the node's org (an explicit link, or the unlinked-server
|
resolve to the node's org (an explicit link, or the unlinked-server
|
||||||
fallback org, same rule the bot uses: streams.resolve_guild_org), and
|
fallback org, same rule the bot uses: streams.resolve_guild_org), and
|
||||||
* their voice channels, from a directory the DRB server bot pushes
|
* their voice channels, from a directory kept on ``discord_guilds/{id}``.
|
||||||
(``PUT /discord/guilds/{id}/directory``). The bot already holds its
|
|
||||||
gateway cache of every server it is in; C2 never calls Discord with a
|
Where the directory comes from (server-26#225 S5 — the main bot is optional):
|
||||||
token on the browser's behalf and no token string ever reaches the browser.
|
|
||||||
|
* the DRB server bot, when it is in the server: it pushes its gateway view
|
||||||
|
(``PUT /discord/guilds/{id}/directory``) on start, on changes and every
|
||||||
|
15 minutes (``directory_source: "main_bot"``);
|
||||||
|
* otherwise a radio bot that is in the server: C2 reads the channel list
|
||||||
|
over Discord REST with that bot's token (discord_rest.guild_directory —
|
||||||
|
the same cached snapshot the permission check reads, so no extra
|
||||||
|
request) and stores it (``directory_source: "radio_bot"``,
|
||||||
|
``radio_bot_present``). That happens when the picker is opened and the
|
||||||
|
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.
|
||||||
|
|
||||||
|
Which servers an org sees (``_org_guilds``):
|
||||||
|
|
||||||
|
* linked to the org: shown unless the server bot said it left AND no
|
||||||
|
radio bot the org may use is in it (``bot_tokens.guild_ids``);
|
||||||
|
* acting for the org only through the unlinked-server fallback: shown only
|
||||||
|
while the server bot is there, exactly as before. A radio bot's
|
||||||
|
membership never discovers a server, and REST never creates a
|
||||||
|
``discord_guilds`` doc.
|
||||||
|
|
||||||
A pick becomes a ``discord_streams`` desired-state doc **pinned to this node**
|
A pick becomes a ``discord_streams`` desired-state doc **pinned to this node**
|
||||||
(streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with
|
(streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with
|
||||||
the checks that path skipped: the server must belong to the node's org, the
|
the checks that path skipped: the server must belong to the node's org, the
|
||||||
channel must be a voice channel the bot reported in that server, and the
|
channel must be a voice channel in that server's directory, and the
|
||||||
per-server and per-node stream caps apply.
|
per-server and per-node stream caps apply.
|
||||||
|
|
||||||
Auth:
|
Auth:
|
||||||
@@ -29,6 +52,7 @@ Auth:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -38,6 +62,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal import discord_leases as leases
|
from app.internal import discord_leases as leases
|
||||||
|
from app.internal import discord_rest
|
||||||
from app.internal import discord_streams as streams
|
from app.internal import discord_streams as streams
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.audit import write_audit
|
from app.internal.audit import write_audit
|
||||||
@@ -54,6 +79,14 @@ router = APIRouter(prefix="/discord", tags=["discord"])
|
|||||||
|
|
||||||
MAX_CHANNELS = 200
|
MAX_CHANNELS = 200
|
||||||
_NAME_MAX = 100
|
_NAME_MAX = 100
|
||||||
|
MAIN_BOT = "main_bot"
|
||||||
|
RADIO_BOT = "radio_bot"
|
||||||
|
# The server bot re-pushes every 15 min. A list it pushed more than three
|
||||||
|
# pushes ago means it is down (it never said it left): a radio bot's view
|
||||||
|
# takes over until it is back.
|
||||||
|
MAIN_BOT_STALE_SECONDS = 45 * 60
|
||||||
|
# How many member radio bots are asked, one after another, for the list.
|
||||||
|
DIRECTORY_BOTS = 3
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
@@ -80,16 +113,20 @@ class GuildDirectory(BaseModel):
|
|||||||
@router.put("/guilds/{guild_id}/directory")
|
@router.put("/guilds/{guild_id}/directory")
|
||||||
async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)):
|
async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)):
|
||||||
"""The server bot's view of one Discord server: its name and voice channels.
|
"""The server bot's view of one Discord server: its name and voice channels.
|
||||||
Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link)."""
|
Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link).
|
||||||
|
Cached Discord snapshots of the server are dropped (a channel was added,
|
||||||
|
removed or renamed), so the next permission check reads it fresh."""
|
||||||
if not guild_id.isdigit():
|
if not guild_id.isdigit():
|
||||||
raise HTTPException(400, "guild_id must be a Discord snowflake.")
|
raise HTTPException(400, "guild_id must be a Discord snowflake.")
|
||||||
data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()}
|
data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()}
|
||||||
if body.present:
|
if body.present:
|
||||||
data["voice_channels"] = [c.model_dump() for c in body.voice_channels]
|
data["voice_channels"] = [c.model_dump() for c in body.voice_channels]
|
||||||
|
data["directory_source"] = MAIN_BOT
|
||||||
if body.name:
|
if body.name:
|
||||||
# Same field the /drb link flow writes (feat/213-guild-link, PR #218).
|
# Same field the /drb link flow writes (feat/213-guild-link, PR #218).
|
||||||
data["guild_name"] = body.name
|
data["guild_name"] = body.name
|
||||||
await fstore.doc_set(streams.GUILDS, guild_id, data)
|
await fstore.doc_set(streams.GUILDS, guild_id, data)
|
||||||
|
discord_rest.invalidate(guild_id=guild_id)
|
||||||
return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)}
|
return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)}
|
||||||
|
|
||||||
|
|
||||||
@@ -130,24 +167,156 @@ def _require_control(can: bool) -> None:
|
|||||||
raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.")
|
raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.")
|
||||||
|
|
||||||
|
|
||||||
async def _org_guilds(org_id: str) -> list[dict]:
|
async def _load_tokens() -> dict[str, dict]:
|
||||||
"""``discord_guilds`` docs this org may stream into, bot still present.
|
return {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||||
|
|
||||||
|
|
||||||
|
def _member_radio_bots(tokens: dict[str, dict], guild_id: str, org_id: Optional[str]) -> list[str]:
|
||||||
|
"""Radio bots this org may use (pool, or the org's own) that Discord last
|
||||||
|
said are in the server, best first. Unknown membership (``guild_ids``
|
||||||
|
None) does not count: presence has to be positive."""
|
||||||
|
out = [tid for tid, t in tokens.items()
|
||||||
|
if t.get("token") and t.get("valid") is not False and t.get("org_id") in (None, org_id)
|
||||||
|
and isinstance(t.get("guild_ids"), list) and guild_id in t["guild_ids"]]
|
||||||
|
out.sort(key=lambda tid: leases._candidate_order(tid, tokens[tid], "", org_id, None))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _org_guilds(org_id: str, tokens: Optional[dict] = None) -> list[dict]:
|
||||||
|
"""``discord_guilds`` docs this org may stream into, with a bot there.
|
||||||
Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server
|
Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server
|
||||||
fallback org — except a server an org explicitly unlinked (``unlinked_at``,
|
fallback org — except a server an org explicitly unlinked (``unlinked_at``,
|
||||||
PR #218), which gets no fallback."""
|
PR #218), which gets no fallback.
|
||||||
|
|
||||||
|
A linked server is shown while the server bot is there or a radio bot the
|
||||||
|
org may use is (server-26#225 S5: the main bot is optional). A server
|
||||||
|
that acts for the org only through the fallback still needs the server
|
||||||
|
bot: a radio bot's membership never discovers one."""
|
||||||
fallback = settings.discord_unlinked_guild_org_id or None
|
fallback = settings.discord_unlinked_guild_org_id or None
|
||||||
|
if tokens is None:
|
||||||
|
tokens = await _load_tokens()
|
||||||
out = []
|
out = []
|
||||||
for g in await fstore.collection_list(streams.GUILDS):
|
for g in await fstore.collection_list(streams.GUILDS):
|
||||||
gid = str(g.get("guild_id") or "")
|
gid = str(g.get("guild_id") or "")
|
||||||
if not gid or g.get("bot_present") is False:
|
if not gid:
|
||||||
continue
|
continue
|
||||||
owner = g.get("org_id") or (None if g.get("unlinked_at") else fallback)
|
if g.get("org_id"):
|
||||||
if owner and owner == org_id:
|
if g["org_id"] != org_id:
|
||||||
out.append(g)
|
continue
|
||||||
|
if g.get("bot_present") is False and not _member_radio_bots(tokens, gid, org_id):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if g.get("unlinked_at") or not fallback or fallback != org_id or g.get("bot_present") is False:
|
||||||
|
continue
|
||||||
|
out.append(g)
|
||||||
out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"]))
|
out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"]))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Directory from a radio bot (server-26#225 S5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _age_seconds(ts) -> Optional[float]:
|
||||||
|
when = streams._parse_ts(ts)
|
||||||
|
return (datetime.now(timezone.utc) - when).total_seconds() if when else None
|
||||||
|
|
||||||
|
|
||||||
|
def _directory_due(g: dict) -> Optional[str]:
|
||||||
|
"""None when the stored list is fine; "missing" when there is none;
|
||||||
|
"stale" when it is old. Only for a server linked to an org — a fallback
|
||||||
|
server's list is the server bot's alone."""
|
||||||
|
if not g.get("org_id"):
|
||||||
|
return None
|
||||||
|
if g.get("voice_channels") is None:
|
||||||
|
return "missing"
|
||||||
|
from_main = g.get("bot_present") is True and (g.get("directory_source") or MAIN_BOT) == MAIN_BOT
|
||||||
|
limit = MAIN_BOT_STALE_SECONDS if from_main else settings.discord_directory_refresh_seconds
|
||||||
|
age = _age_seconds(g.get("directory_at"))
|
||||||
|
return "stale" if age is None or age > limit else None
|
||||||
|
|
||||||
|
|
||||||
|
def _main_bot_list_fresh(g: dict) -> bool:
|
||||||
|
"""The server bot is there and its own list is current: it wins."""
|
||||||
|
return (g.get("bot_present") is True and (g.get("directory_source") or MAIN_BOT) == MAIN_BOT
|
||||||
|
and _directory_due(g) is None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_directory(g: dict, org_id: str, tokens: dict[str, dict]) -> Optional[dict]:
|
||||||
|
"""Read one linked server's voice channels from a member radio bot and
|
||||||
|
store them. Returns the fields written (also merged into ``g``), or None
|
||||||
|
when nothing was learned. Writes only onto the existing doc, and only
|
||||||
|
while it is still linked to ``org_id``: never creates a doc, never
|
||||||
|
touches ``org_id`` or the server bot's ``bot_present``."""
|
||||||
|
gid = g["guild_id"]
|
||||||
|
members = _member_radio_bots(tokens, gid, org_id)
|
||||||
|
if not members:
|
||||||
|
return None
|
||||||
|
docs = [{**tokens[t], "token_id": t} for t in members]
|
||||||
|
res = await discord_rest.guild_directory(docs, gid, max_bots=DIRECTORY_BOTS)
|
||||||
|
now = _now_iso()
|
||||||
|
if res.status == discord_rest.OK:
|
||||||
|
updates: dict = {"voice_channels": res.voice_channels, "directory_at": now,
|
||||||
|
"directory_source": RADIO_BOT, "radio_bot_present": True, "radio_bot_checked_at": now}
|
||||||
|
if res.name:
|
||||||
|
updates["guild_name"] = res.name
|
||||||
|
elif res.status == discord_rest.NOT_MEMBER:
|
||||||
|
updates = {"radio_bot_present": False, "radio_bot_checked_at": now}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
cur = await fstore.doc_get(streams.GUILDS, gid)
|
||||||
|
if not cur or cur.get("org_id") != org_id:
|
||||||
|
return None # unlinked / moved / removed meanwhile
|
||||||
|
if _main_bot_list_fresh(cur):
|
||||||
|
return None # the server bot pushed a fresh list meanwhile; it wins
|
||||||
|
await fstore.doc_update(streams.GUILDS, gid, updates)
|
||||||
|
g.update(updates)
|
||||||
|
logger.info(f"Discord directory for {gid} from radio bot {res.token_id or '-'}: "
|
||||||
|
f"{res.status}, {len(res.voice_channels or [])} voice channel(s)")
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
_directory_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_quietly(g: dict, org_id: str, tokens: dict[str, dict]) -> None:
|
||||||
|
try:
|
||||||
|
await _refresh_directory(g, org_id, tokens)
|
||||||
|
except Exception as e: # a background refresh must never surface anywhere
|
||||||
|
logger.warning(f"Discord directory refresh for {g.get('guild_id')} failed: {type(e).__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_in_background(g: dict, org_id: str, tokens: dict[str, dict]) -> None:
|
||||||
|
gid = g["guild_id"]
|
||||||
|
running = _directory_tasks.get(gid)
|
||||||
|
if running is not None and not running.done():
|
||||||
|
return
|
||||||
|
task = asyncio.create_task(_refresh_quietly(dict(g), org_id, tokens))
|
||||||
|
_directory_tasks[gid] = task
|
||||||
|
task.add_done_callback(lambda t, gid=gid: _directory_tasks.pop(gid, None)
|
||||||
|
if _directory_tasks.get(gid) is t else None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fill_directories(guilds: list[dict], org_id: str, tokens: dict[str, dict]) -> None:
|
||||||
|
"""Missing lists are read now (all servers at once, inside the
|
||||||
|
permission-check budget — an answer that comes later is cached and
|
||||||
|
stored by the next look); stale ones are refreshed in the background and
|
||||||
|
the stored list is shown meanwhile."""
|
||||||
|
now: list[dict] = []
|
||||||
|
for g in guilds:
|
||||||
|
due = _directory_due(g)
|
||||||
|
if not due or not _member_radio_bots(tokens, g["guild_id"], org_id):
|
||||||
|
continue
|
||||||
|
if due == "missing":
|
||||||
|
now.append(g)
|
||||||
|
else:
|
||||||
|
_refresh_in_background(g, org_id, tokens)
|
||||||
|
if not now:
|
||||||
|
return
|
||||||
|
tasks = [asyncio.create_task(_refresh_quietly(g, org_id, tokens)) for g in now]
|
||||||
|
await asyncio.wait(tasks, timeout=settings.discord_perm_check_budget_seconds)
|
||||||
|
|
||||||
|
|
||||||
def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict:
|
def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict:
|
||||||
res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id)
|
res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id)
|
||||||
if res.ok:
|
if res.ok:
|
||||||
@@ -181,7 +350,10 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
|
|||||||
system_id = streams._effective_system(node)
|
system_id = streams._effective_system(node)
|
||||||
system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None
|
system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None
|
||||||
|
|
||||||
guilds = await _org_guilds(org_id) if org_id else []
|
tokens = await _load_tokens()
|
||||||
|
guilds = await _org_guilds(org_id, tokens) if org_id else []
|
||||||
|
if org_id:
|
||||||
|
await _fill_directories(guilds, org_id, tokens)
|
||||||
live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on")
|
live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on")
|
||||||
if s.get("org_id") == org_id]
|
if s.get("org_id") == org_id]
|
||||||
nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id)
|
nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id)
|
||||||
@@ -190,7 +362,6 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
|
|||||||
by_channel: dict[tuple, list] = {}
|
by_channel: dict[tuple, list] = {}
|
||||||
for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")):
|
for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")):
|
||||||
by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s)
|
by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s)
|
||||||
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
|
||||||
token_names = {tid: t.get("name") or tid for tid, t in tokens.items()}
|
token_names = {tid: t.get("name") or tid for tid, t in tokens.items()}
|
||||||
|
|
||||||
out_guilds = []
|
out_guilds = []
|
||||||
@@ -216,6 +387,10 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
|
|||||||
"linked": bool(g.get("org_id")),
|
"linked": bool(g.get("org_id")),
|
||||||
"synced": g.get("voice_channels") is not None,
|
"synced": g.get("voice_channels") is not None,
|
||||||
"synced_at": g.get("directory_at"),
|
"synced_at": g.get("directory_at"),
|
||||||
|
# Who listed the channels: "main_bot" (DRB server bot) or "radio_bot" (S5).
|
||||||
|
"directory_source": (g.get("directory_source") or (MAIN_BOT if g.get("voice_channels") is not None
|
||||||
|
else None)),
|
||||||
|
"main_bot_present": g.get("bot_present") is True,
|
||||||
"voice_channels": channels,
|
"voice_channels": channels,
|
||||||
"streams_on": sum(1 for s in live if s.get("guild_id") == gid),
|
"streams_on": sum(1 for s in live if s.get("guild_id") == gid),
|
||||||
"bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id),
|
"bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id),
|
||||||
@@ -261,10 +436,24 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
|
|||||||
raise HTTPException(409, "This node is not listening to a system yet.")
|
raise HTTPException(409, "This node is not listening to a system yet.")
|
||||||
|
|
||||||
guild_id, channel_id = str(body.guild_id), str(body.channel_id)
|
guild_id, channel_id = str(body.guild_id), str(body.channel_id)
|
||||||
guild = next((g for g in await _org_guilds(org_id) if g["guild_id"] == guild_id), None)
|
tokens = await _load_tokens()
|
||||||
|
guild = next((g for g in await _org_guilds(org_id, tokens) if g["guild_id"] == guild_id), None)
|
||||||
if not guild:
|
if not guild:
|
||||||
raise HTTPException(404, "That Discord server is not linked to this organization.")
|
raise HTTPException(404, "That Discord server is not linked to this organization.")
|
||||||
channel = next((c for c in guild.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
|
|
||||||
|
def _find(g: dict) -> Optional[dict]:
|
||||||
|
return next((c for c in g.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
|
||||||
|
|
||||||
|
channel = _find(guild)
|
||||||
|
if not channel and guild.get("org_id") and not _main_bot_list_fresh(guild):
|
||||||
|
# No list yet, or a radio bot's list that may predate this channel:
|
||||||
|
# read it once more (cached snapshot) before saying no.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_refresh_directory(guild, org_id, tokens),
|
||||||
|
timeout=settings.discord_perm_check_budget_seconds)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
channel = _find(guild)
|
||||||
if not channel:
|
if not channel:
|
||||||
raise HTTPException(400, "That is not a voice channel DRB can see in this server. "
|
raise HTTPException(400, "That is not a voice channel DRB can see in this server. "
|
||||||
"Refresh the list and pick again.")
|
"Refresh the list and pick again.")
|
||||||
@@ -287,6 +476,7 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
|
|||||||
channel_name=channel.get("name"), guild_name=_guild_name(guild),
|
channel_name=channel.get("name"), guild_name=_guild_name(guild),
|
||||||
created_by=f"web:{decoded.get('uid') or 'unknown'}",
|
created_by=f"web:{decoded.get('uid') or 'unknown'}",
|
||||||
max_streams=settings.discord_guild_max_streams,
|
max_streams=settings.discord_guild_max_streams,
|
||||||
|
interactive=True,
|
||||||
)
|
)
|
||||||
except streams.StreamError as e:
|
except streams.StreamError as e:
|
||||||
return JSONResponse(status_code=e.status,
|
return JSONResponse(status_code=e.status,
|
||||||
|
|||||||
@@ -10,19 +10,100 @@ happens inline below, in the same spirit as routers/enrollment.py.
|
|||||||
The bucket stays fully private and c2-core reads the object server-side with
|
The bucket stays fully private and c2-core reads the object server-side with
|
||||||
Application Default Credentials, so no GCS signed URL — and therefore no
|
Application Default Credentials, so no GCS signed URL — and therefore no
|
||||||
service-account private key on the VM — is involved anywhere in this path.
|
service-account private key on the VM — is involved anywhere in this path.
|
||||||
|
|
||||||
|
HTTP Range (server-26#233): the body is streamed from GCS in fixed-size span
|
||||||
|
reads instead of downloaded whole first. A 600 s FLAC call is ~13 MB, and the
|
||||||
|
old full-body response (``Accept-Ranges: none``) made the browser wait for all
|
||||||
|
of it before playback started and made seeking re-download it. Now playback
|
||||||
|
starts after the first span and a seek is a ``206`` for just the bytes needed.
|
||||||
|
Only a single range is honoured; a multi-range request gets the full body,
|
||||||
|
which RFC 9110 §14.2 permits.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
import re
|
||||||
|
from typing import AsyncIterator, Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Query, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for
|
from app.internal.logger import logger
|
||||||
|
from app.internal.storage import (
|
||||||
|
audio_object_size,
|
||||||
|
content_type_for,
|
||||||
|
download_audio_range,
|
||||||
|
gcs_uri_for_call,
|
||||||
|
verify_audio_link,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/media", tags=["media"])
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
|
|
||||||
|
# Bytes per GCS span read. ~25 s of FLAC at ~1.3 MB/min, so the first span —
|
||||||
|
# the only one the browser waits on — is one small GCS round-trip.
|
||||||
|
STREAM_CHUNK_BYTES = 512 * 1024
|
||||||
|
|
||||||
|
# Immutable content, but the URL expires — cache privately only.
|
||||||
|
_CACHE_CONTROL = "private, max-age=3600"
|
||||||
|
|
||||||
|
_RANGE_RE = re.compile(r"^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class _Unsatisfiable(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_range(header: Optional[str], size: int) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Resolve a ``Range`` header against an object of ``size`` bytes.
|
||||||
|
|
||||||
|
Returns an inclusive ``(start, end)``, or None when the header is absent or
|
||||||
|
should be ignored (not ``bytes``, multi-range, malformed — RFC 9110 lets a
|
||||||
|
server answer those with the full 200). Raises _Unsatisfiable for a
|
||||||
|
well-formed range that selects nothing (-> 416).
|
||||||
|
"""
|
||||||
|
if not header:
|
||||||
|
return None
|
||||||
|
m = _RANGE_RE.match(header)
|
||||||
|
if not m:
|
||||||
|
return None # other unit, multi-range ("a-b,c-d") or garbage
|
||||||
|
first, last = m.group(1), m.group(2)
|
||||||
|
if first == "" and last == "":
|
||||||
|
return None
|
||||||
|
if first == "":
|
||||||
|
# Suffix range: the final N bytes.
|
||||||
|
n = int(last)
|
||||||
|
if n == 0 or size == 0:
|
||||||
|
raise _Unsatisfiable()
|
||||||
|
return max(0, size - n), size - 1
|
||||||
|
start = int(first)
|
||||||
|
if last != "" and int(last) < start:
|
||||||
|
return None # invalid spec -> ignore the header
|
||||||
|
if start >= size:
|
||||||
|
raise _Unsatisfiable()
|
||||||
|
end = size - 1 if last == "" else min(int(last), size - 1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream(gcs_uri: str, first_chunk: bytes, start: int, end: int) -> AsyncIterator[bytes]:
|
||||||
|
yield first_chunk
|
||||||
|
pos = start + len(first_chunk)
|
||||||
|
while pos <= end:
|
||||||
|
chunk_end = min(pos + STREAM_CHUNK_BYTES - 1, end)
|
||||||
|
data = await download_audio_range(gcs_uri, pos, chunk_end)
|
||||||
|
if not data:
|
||||||
|
# Headers are already sent, so the status can't change; ending the
|
||||||
|
# body short makes the client see a truncated response rather than
|
||||||
|
# silently wrong audio.
|
||||||
|
logger.warning(f"/media stream cut short at byte {pos} of {gcs_uri}")
|
||||||
|
return
|
||||||
|
yield data
|
||||||
|
pos += len(data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calls/{call_id}/audio")
|
@router.get("/calls/{call_id}/audio")
|
||||||
async def get_call_audio(
|
async def get_call_audio(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
exp: int = Query(..., description="Link expiry, unix seconds."),
|
exp: int = Query(..., description="Link expiry, unix seconds."),
|
||||||
sig: str = Query(..., description="HMAC over call_id and expiry."),
|
sig: str = Query(..., description="HMAC over call_id and expiry."),
|
||||||
|
range_header: Optional[str] = Header(None, alias="Range"),
|
||||||
):
|
):
|
||||||
# Verify before touching Firestore so an invalid link costs nothing.
|
# Verify before touching Firestore so an invalid link costs nothing.
|
||||||
if not verify_audio_link(call_id, exp, sig):
|
if not verify_audio_link(call_id, exp, sig):
|
||||||
@@ -36,26 +117,48 @@ async def get_call_audio(
|
|||||||
if not gcs_uri:
|
if not gcs_uri:
|
||||||
raise HTTPException(404, "No audio for this call.")
|
raise HTTPException(404, "No audio for this call.")
|
||||||
|
|
||||||
data = await download_audio(gcs_uri)
|
size = await audio_object_size(gcs_uri)
|
||||||
if not data:
|
if size is None:
|
||||||
raise HTTPException(404, "Audio object missing from storage.")
|
raise HTTPException(404, "Audio object missing from storage.")
|
||||||
|
|
||||||
return Response(
|
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser will
|
||||||
content=data,
|
# not play a FLAC body labelled audio/mpeg. Derived from the stored
|
||||||
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser
|
# object's extension so old .mp3 recordings keep working unchanged.
|
||||||
# will not play a FLAC body labelled audio/mpeg. Derived from the stored
|
media_type = content_type_for(gcs_uri)
|
||||||
# object's extension so old .mp3 recordings keep working unchanged.
|
|
||||||
media_type=content_type_for(gcs_uri),
|
try:
|
||||||
headers={
|
span = parse_range(range_header, size)
|
||||||
"Content-Length": str(len(data)),
|
except _Unsatisfiable:
|
||||||
# Whole body at once, no Range support. This was comfortable at
|
return Response(
|
||||||
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a
|
status_code=416,
|
||||||
# long call is now tens of MB and the browser must download all of
|
headers={"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"},
|
||||||
# it before playback starts. Acceptable for typical few-second
|
)
|
||||||
# transmissions, but this is the change that makes Range support
|
|
||||||
# actually matter — see DEFERRED.md.
|
if size == 0:
|
||||||
"Accept-Ranges": "none",
|
return Response(
|
||||||
# Immutable content, but the URL expires — cache privately only.
|
content=b"",
|
||||||
"Cache-Control": "private, max-age=3600",
|
media_type=media_type,
|
||||||
},
|
headers={"Accept-Ranges": "bytes", "Cache-Control": _CACHE_CONTROL},
|
||||||
|
)
|
||||||
|
|
||||||
|
start, end = span if span else (0, size - 1)
|
||||||
|
# Fetched before the response starts so a read failure is still a clean
|
||||||
|
# 404 rather than a stream that dies after a 200/206 has gone out.
|
||||||
|
first = await download_audio_range(gcs_uri, start, min(start + STREAM_CHUNK_BYTES - 1, end))
|
||||||
|
if not first:
|
||||||
|
raise HTTPException(404, "Audio object missing from storage.")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Length": str(end - start + 1),
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Cache-Control": _CACHE_CONTROL,
|
||||||
|
}
|
||||||
|
if span:
|
||||||
|
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream(gcs_uri, first, start, end),
|
||||||
|
status_code=206 if span else 200,
|
||||||
|
media_type=media_type,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ async def send_command(
|
|||||||
doc = await discord_streams.start_stream(
|
doc = await discord_streams.start_stream(
|
||||||
org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id,
|
org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id,
|
||||||
system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id,
|
system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id,
|
||||||
mode="adhoc",
|
mode="adhoc", interactive=True,
|
||||||
)
|
)
|
||||||
except discord_streams.StreamError as e:
|
except discord_streams.StreamError as e:
|
||||||
raise HTTPException(e.status, e.message)
|
raise HTTPException(e.status, e.message)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Power-outage endpoints (map layer, server-26#184).
|
||||||
|
|
||||||
|
``GET /outages`` -- any signed-in user (auth applied at include time in main.py):
|
||||||
|
outage areas inside a bbox from every provider an admin has enabled, each with
|
||||||
|
the provider's attribution, disclaimer, coverage note and feed freshness, plus
|
||||||
|
a ``status`` that says why an empty list is empty.
|
||||||
|
|
||||||
|
``/admin/outage-providers`` -- platform admin only: list every catalogued
|
||||||
|
provider with its licence status and health, and toggle it. Gated licences
|
||||||
|
need ``acknowledge_licence: true``; that acknowledgement is stored with who and
|
||||||
|
when and written to audit_log (see app/internal/outages/registry.py).
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.internal.auth import describe_actor, require_admin_token
|
||||||
|
from app.internal.outages import registry
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/outages", tags=["outages"])
|
||||||
|
admin_router = APIRouter(prefix="/admin/outage-providers", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_outages(
|
||||||
|
south: float = Query(..., ge=-90, le=90),
|
||||||
|
west: float = Query(..., ge=-180, le=180),
|
||||||
|
north: float = Query(..., ge=-90, le=90),
|
||||||
|
east: float = Query(..., ge=-180, le=180),
|
||||||
|
):
|
||||||
|
if south > north or west > east:
|
||||||
|
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
|
||||||
|
return await registry.outages_in_bbox((south, west, north, east))
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderToggle(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
acknowledge_licence: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("")
|
||||||
|
async def list_outage_providers(_=Depends(require_admin_token)):
|
||||||
|
return {"providers": await registry.list_providers()}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.put("/{provider_id}")
|
||||||
|
async def set_outage_provider(provider_id: str, body: ProviderToggle, principal: dict = Depends(require_admin_token)):
|
||||||
|
try:
|
||||||
|
return await registry.set_enabled(provider_id, body.enabled, describe_actor(principal),
|
||||||
|
acknowledge_licence=body.acknowledge_licence)
|
||||||
|
except registry.ProviderError as e:
|
||||||
|
return JSONResponse(status_code=e.code, content={"detail": e.message, **e.extra})
|
||||||
@@ -2,8 +2,10 @@ import uuid
|
|||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from app.config import settings
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal import discord_leases as leases
|
from app.internal import discord_leases as leases
|
||||||
|
from app.internal import discord_oauth
|
||||||
from app.internal import discord_rest
|
from app.internal import discord_rest
|
||||||
from app.internal.audit import write_audit
|
from app.internal.audit import write_audit
|
||||||
from app.internal.auth import (
|
from app.internal.auth import (
|
||||||
@@ -27,9 +29,27 @@ class TokenCreate(BaseModel):
|
|||||||
org_id: Optional[str] = None
|
org_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _masked(t: dict) -> dict:
|
# What GET /tokens may return of a bot_tokens doc (server-26#225 S6: an
|
||||||
out = {**t, "token": "•••" + (t.get("token") or "")[-4:]}
|
# allowlist, so a field added to the doc later — a secret, say — never
|
||||||
|
# leaks by default). The token string itself is masked; OAuth client secrets
|
||||||
|
# live in bot_oauth/{id}, never on this doc, and are never returned.
|
||||||
|
_PUBLIC_TOKEN_FIELDS = (
|
||||||
|
"token_id", "name", "org_id", "valid", "guild_ids", "check_error", "validated_at",
|
||||||
|
"application_id", "bot_user_id", "bot_name", "lease", "in_use", "assigned_node_id", "assigned_at",
|
||||||
|
"preferred_for_system_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _masked(t: dict, oauth_ids: Optional[set] = None) -> dict:
|
||||||
|
out = {k: t[k] for k in _PUBLIC_TOKEN_FIELDS if k in t}
|
||||||
|
out["token"] = "•••" + (t.get("token") or "")[-4:]
|
||||||
out["invite_url"] = leases.invite_url(t.get("application_id"))
|
out["invite_url"] = leases.invite_url(t.get("application_id"))
|
||||||
|
members = t.get("guild_ids")
|
||||||
|
out["server_count"] = len(members) if isinstance(members, list) else None
|
||||||
|
# Pool bots stop being offered at the cap (owner decision Q3); BYO bots have none.
|
||||||
|
out["server_cap"] = settings.discord_pool_bot_guild_cap if t.get("org_id") is None else None
|
||||||
|
if oauth_ids is not None:
|
||||||
|
out["oauth_enabled"] = t.get("token_id") in oauth_ids
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +67,8 @@ async def _republish_after_release(lease: Optional[dict]) -> None:
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_tokens(_: dict = Depends(require_admin_token)):
|
async def list_tokens(_: dict = Depends(require_admin_token)):
|
||||||
"""List all tokens. The actual token string is masked for safety."""
|
"""List all tokens. The actual token string is masked for safety."""
|
||||||
return [_masked(t) for t in await fstore.collection_list("bot_tokens")]
|
oauth_ids = await discord_oauth.enabled_token_ids()
|
||||||
|
return [_masked(t, oauth_ids) for t in await fstore.collection_list("bot_tokens")]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
@@ -83,7 +104,7 @@ async def refresh_token(token_id: str, _: dict = Depends(require_admin_token)):
|
|||||||
if not await fstore.doc_get("bot_tokens", token_id):
|
if not await fstore.doc_get("bot_tokens", token_id):
|
||||||
raise HTTPException(404, "Token not found.")
|
raise HTTPException(404, "Token not found.")
|
||||||
await discord_rest.refresh_membership([token_id])
|
await discord_rest.refresh_membership([token_id])
|
||||||
return _masked(await fstore.doc_get("bot_tokens", token_id) or {})
|
return _masked(await fstore.doc_get("bot_tokens", token_id) or {}, await discord_oauth.enabled_token_ids())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/flush", status_code=200)
|
@router.post("/flush", status_code=200)
|
||||||
@@ -265,3 +286,57 @@ async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
|
|||||||
if existing.get("lease"):
|
if existing.get("lease"):
|
||||||
raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.")
|
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)
|
await fstore.doc_delete("bot_tokens", token_id)
|
||||||
|
await discord_oauth.clear_secret(token_id)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthSecret(BaseModel):
|
||||||
|
# Length is checked in the handler, not here: a pydantic 422 echoes the
|
||||||
|
# rejected input back in its body.
|
||||||
|
client_secret: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{token_id}/oauth", status_code=200)
|
||||||
|
async def set_oauth_secret(token_id: str, body: OAuthSecret, decoded: dict = Depends(require_admin_token)):
|
||||||
|
"""Set (or replace) this radio bot application's OAuth2 client secret, so
|
||||||
|
"Connect a Discord server" can invite it (server-26#225 S6). Checked with
|
||||||
|
Discord first (client-credentials grant) — a wrong secret is refused and
|
||||||
|
nothing is stored. The secret goes to bot_oauth/{id}, server-side only:
|
||||||
|
it is never echoed, never returned by any route and never logged."""
|
||||||
|
if "/" in token_id:
|
||||||
|
raise HTTPException(400, "Bad id.")
|
||||||
|
tok = await fstore.doc_get("bot_tokens", token_id)
|
||||||
|
if not tok:
|
||||||
|
raise HTTPException(404, "Token not found.")
|
||||||
|
if not tok.get("application_id"):
|
||||||
|
raise HTTPException(409, "DRB doesn't know this bot's application id yet. Re-check the bot, then try again.")
|
||||||
|
secret = (body.client_secret or "").strip()
|
||||||
|
if not (discord_oauth.SECRET_MIN <= len(secret) <= discord_oauth.SECRET_MAX):
|
||||||
|
raise HTTPException(400, "That doesn't look like a Discord client secret. Copy it from the Discord "
|
||||||
|
"Developer Portal → OAuth2.")
|
||||||
|
r = await discord_oauth.check_secret(str(tok["application_id"]), secret)
|
||||||
|
uid, email = describe_actor(decoded)
|
||||||
|
if not r.ok:
|
||||||
|
await write_audit(uid, email, "discord.token.oauth_refused", details={
|
||||||
|
"token_id": token_id, "status": r.status})
|
||||||
|
if r.status in (400, 401):
|
||||||
|
raise HTTPException(400, "Discord didn't accept that client secret for this bot's application. "
|
||||||
|
"Copy it again from the Discord Developer Portal → OAuth2.")
|
||||||
|
raise HTTPException(503, "Couldn't check the secret with Discord right now. Nothing was saved; "
|
||||||
|
"try again in a minute.")
|
||||||
|
await discord_oauth.store_secret(token_id, secret, uid)
|
||||||
|
await write_audit(uid, email, "discord.token.oauth_set", details={"token_id": token_id})
|
||||||
|
logger.info(f"Radio bot {token_id}: OAuth secret set by uid={uid}")
|
||||||
|
return {"ok": True, "token_id": token_id, "oauth_enabled": True,
|
||||||
|
"redirect_uri_configured": discord_oauth.enabled()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{token_id}/oauth", status_code=200)
|
||||||
|
async def clear_oauth_secret(token_id: str, decoded: dict = Depends(require_admin_token)):
|
||||||
|
"""Forget this bot's OAuth secret: Connect stops offering it."""
|
||||||
|
if "/" in token_id:
|
||||||
|
raise HTTPException(400, "Bad id.")
|
||||||
|
removed = await discord_oauth.clear_secret(token_id)
|
||||||
|
uid, email = describe_actor(decoded)
|
||||||
|
if removed:
|
||||||
|
await write_audit(uid, email, "discord.token.oauth_clear", details={"token_id": token_id})
|
||||||
|
return {"ok": True, "token_id": token_id, "oauth_enabled": False}
|
||||||
|
|||||||
+3384
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,392 @@
|
|||||||
|
"""
|
||||||
|
Channel list and presence without the main bot (server-26#225 S5), plus the
|
||||||
|
interactive re-check of a cached "denied" answer (server-26#251).
|
||||||
|
|
||||||
|
* discord_rest.directory_from_snapshot / guild_directory: a radio bot's REST
|
||||||
|
snapshot becomes the same directory the DRB server bot pushes.
|
||||||
|
* _org_guilds: a linked server stays visible with only a radio bot in it; a
|
||||||
|
fallback-org server still needs the server bot; REST never discovers a
|
||||||
|
server or creates a ``discord_guilds`` doc.
|
||||||
|
* join_options / start_node_stream: a missing list is read inline from a
|
||||||
|
member radio bot, a stale one in the background; the server bot's fresh
|
||||||
|
list is never overwritten; nothing is written onto a server that was
|
||||||
|
unlinked meanwhile.
|
||||||
|
* start_stream(interactive=True) re-reads a "denied" snapshot older than
|
||||||
|
RECHECK_DENIED_AFTER_SECONDS once; the reconciler never does.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
|
from app.routers import discord as discord_router
|
||||||
|
from app.models import CommandPayload
|
||||||
|
from app.routers import discord_web as web
|
||||||
|
from app.routers import nodes as nodes_router
|
||||||
|
from tests.test_discord_perm_check import ( # noqa: F401 (fs = fixture)
|
||||||
|
ADMIN, GUILD, ORG, OTHER, OWNER, SYS, _guild, _snap, _token, fs, run,
|
||||||
|
)
|
||||||
|
|
||||||
|
LINKED = "300" # linked to ORG, never seen by the server bot
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(seconds: float) -> str:
|
||||||
|
return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _rest_channels():
|
||||||
|
return [
|
||||||
|
{"id": "9", "name": "Radio", "type": 4, "position": 1},
|
||||||
|
{"id": "8", "name": "Admin", "type": 4, "position": 0},
|
||||||
|
{"id": "3001", "name": "dispatch", "type": 2, "position": 1, "parent_id": "9", "permission_overwrites": []},
|
||||||
|
{"id": "3002", "name": "fireground", "type": 2, "position": 0, "parent_id": "9", "permission_overwrites": []},
|
||||||
|
{"id": "3003", "name": "staff", "type": 2, "position": 0, "parent_id": "8", "permission_overwrites": []},
|
||||||
|
{"id": "3004", "name": "lobby", "type": 2, "position": 5, "permission_overwrites": []},
|
||||||
|
{"id": "3005", "name": "general", "type": 0, "position": 0, "permission_overwrites": []},
|
||||||
|
{"id": "3006", "name": "town hall", "type": 13, "position": 0, "permission_overwrites": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _rest_snap(tok_id, guild_id=LINKED, status=discord_rest.OK):
|
||||||
|
if status != discord_rest.OK:
|
||||||
|
return discord_rest.GuildSnapshot(token_id=tok_id, guild_id=guild_id, status=status, error="x")
|
||||||
|
return discord_rest.GuildSnapshot(
|
||||||
|
token_id=tok_id, guild_id=guild_id, status=discord_rest.OK, bot_user_id=f"u-{tok_id}",
|
||||||
|
guild={**_guild(), "id": guild_id, "name": "Station 9"}, member={"roles": []},
|
||||||
|
channels=_rest_channels())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dfs(fs, monkeypatch):
|
||||||
|
"""The perm-check fixture plus a linked server only radio bots know."""
|
||||||
|
fs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": ORG, "linked_at": _ago(60)})
|
||||||
|
fs.dir_mode = {} # token_id -> "ok" | "not_member" | "unknown"
|
||||||
|
|
||||||
|
async def _snapshot(token_doc, guild_id, *, refresh=False):
|
||||||
|
tid = token_doc["token_id"]
|
||||||
|
fs.snap_calls.append((tid, guild_id, refresh))
|
||||||
|
if guild_id != LINKED:
|
||||||
|
mode = fs.snap_mode.get(tid, "ok")
|
||||||
|
return _snap(tid, muted=(mode == "muted"))
|
||||||
|
mode = fs.dir_mode.get(tid, "ok")
|
||||||
|
status = {"ok": discord_rest.OK, "not_member": discord_rest.NOT_MEMBER}.get(mode, discord_rest.UNKNOWN)
|
||||||
|
return _rest_snap(tid, status=status)
|
||||||
|
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
|
||||||
|
web._directory_tasks.clear()
|
||||||
|
return fs
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain():
|
||||||
|
while web._directory_tasks:
|
||||||
|
await asyncio.gather(*list(web._directory_tasks.values()), return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(opts):
|
||||||
|
return [g["guild_id"] for g in opts["guilds"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# discord_rest — directory from a snapshot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_directory_from_snapshot_matches_the_server_bots_shape_and_order():
|
||||||
|
d = discord_rest.directory_from_snapshot(_rest_snap("t1"))
|
||||||
|
assert d["name"] == "Station 9"
|
||||||
|
assert [(c["channel_id"], c["category"], c["position"]) for c in d["voice_channels"]] == [
|
||||||
|
("3004", None, 0), # no category first
|
||||||
|
("3003", "Admin", 1), # categories by position
|
||||||
|
("3002", "Radio", 2), # channels by position inside one
|
||||||
|
("3001", "Radio", 3),
|
||||||
|
] # text and stage channels are not listed
|
||||||
|
assert set(d["voice_channels"][0]) == {"channel_id", "name", "category", "position"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_from_snapshot_is_none_unless_ok():
|
||||||
|
assert discord_rest.directory_from_snapshot(None) is None
|
||||||
|
for status in (discord_rest.NOT_MEMBER, discord_rest.UNKNOWN, discord_rest.INVALID):
|
||||||
|
assert discord_rest.directory_from_snapshot(_rest_snap("t1", status=status)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_caps_channels_and_name_length():
|
||||||
|
snap = _rest_snap("t1")
|
||||||
|
snap.channels = [{"id": str(5000 + i), "name": "x" * 300, "type": 2, "position": i} for i in range(250)]
|
||||||
|
d = discord_rest.directory_from_snapshot(snap)
|
||||||
|
assert len(d["voice_channels"]) == discord_rest.DIRECTORY_MAX_CHANNELS
|
||||||
|
assert len(d["voice_channels"][0]["name"]) == 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guild_directory_tries_the_next_bot_and_reports_not_member(dfs):
|
||||||
|
_token(dfs, "a", guilds=(LINKED,))
|
||||||
|
_token(dfs, "b", guilds=(LINKED,))
|
||||||
|
docs = [{**dfs.get("bot_tokens", t)} for t in ("a", "b")]
|
||||||
|
dfs.dir_mode["a"] = "not_member"
|
||||||
|
got = await discord_rest.guild_directory(docs, LINKED)
|
||||||
|
assert got.status == discord_rest.OK and got.token_id == "b" and len(got.voice_channels) == 4
|
||||||
|
dfs.dir_mode["b"] = "not_member"
|
||||||
|
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.NOT_MEMBER
|
||||||
|
dfs.dir_mode["b"] = "unknown"
|
||||||
|
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.UNKNOWN
|
||||||
|
assert (await discord_rest.guild_directory([], LINKED)).status == discord_rest.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Which servers an org sees
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_linked_server_with_only_a_radio_bot_is_listed_with_its_channels(dfs):
|
||||||
|
"""Owner requirement 1: invite only the radio bot, no main bot."""
|
||||||
|
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
|
||||||
|
opts = run(web.join_options("n1", OWNER))
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
|
||||||
|
assert g["synced"] is True and g["directory_source"] == "radio_bot" and g["main_bot_present"] is False
|
||||||
|
assert [c["name"] for c in g["voice_channels"]] == ["lobby", "staff", "fireground", "dispatch"]
|
||||||
|
assert g["name"] == "Station 9" and g["linked"] is True and g["bot"]["ok"] is True
|
||||||
|
doc = dfs.get("discord_guilds", LINKED)
|
||||||
|
assert doc["org_id"] == ORG and "bot_present" not in doc # never the server bot's field
|
||||||
|
assert doc["radio_bot_present"] is True and doc["directory_source"] == "radio_bot"
|
||||||
|
assert doc["guild_name"] == "Station 9" and len(doc["voice_channels"]) == 4
|
||||||
|
assert "secret" not in repr(opts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_server_without_any_radio_bot_keeps_todays_behaviour(dfs):
|
||||||
|
opts = run(web.join_options("n1", OWNER))
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
|
||||||
|
assert g["synced"] is False and g["bot"]["ok"] is False # invite links, as before
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tok", [
|
||||||
|
{"org": OTHER}, # another org's branded bot
|
||||||
|
{"guilds": None}, # membership never checked
|
||||||
|
{"valid": False},
|
||||||
|
])
|
||||||
|
def test_only_a_usable_member_bot_keeps_a_server_without_the_main_bot(dfs, tok):
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": False})
|
||||||
|
kw = {"guilds": (LINKED,), **tok}
|
||||||
|
_token(dfs, "x", **kw)
|
||||||
|
assert LINKED not in _ids(run(web.join_options("n1", OWNER)))
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_bot_leaving_keeps_server_while_a_radio_bot_is_in_it(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(GUILD,))
|
||||||
|
run(web.put_guild_directory(GUILD, web.GuildDirectory(present=False), {"service": True}))
|
||||||
|
opts = run(web.join_options("n1", OWNER))
|
||||||
|
assert GUILD in _ids(opts)
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == GUILD)
|
||||||
|
assert g["main_bot_present"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_server_is_never_discovered_by_a_radio_bot(dfs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
|
||||||
|
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": False, "voice_channels": []})
|
||||||
|
_token(dfs, "t1", guilds=("400", "401", LINKED)) # also in a server DRB has no doc for
|
||||||
|
ids = _ids(run(web.join_options("n1", OWNER)))
|
||||||
|
assert "400" not in ids and "401" not in ids
|
||||||
|
assert dfs.get("discord_guilds", "401") is None # REST never creates a doc
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] in ("400", "401")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_server_list_is_never_read_over_rest(dfs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
|
||||||
|
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": True, "voice_channels": None,
|
||||||
|
"directory_at": _ago(99999)})
|
||||||
|
_token(dfs, "t1", guilds=("400",))
|
||||||
|
assert "400" in _ids(run(web.join_options("n1", OWNER)))
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] == "400"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# When the list is read, and who wins
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_fresh_main_bot_list_is_never_read_over_rest(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
|
||||||
|
"directory_source": "main_bot", "directory_at": _ago(600),
|
||||||
|
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
|
||||||
|
opts = run(web.join_options("n1", OWNER))
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
|
||||||
|
assert g["directory_source"] == "main_bot" and len(g["voice_channels"]) == 1
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_list_is_refreshed_in_the_background(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
old = [{"channel_id": "3001", "name": "old name"}]
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": old,
|
||||||
|
"directory_source": "radio_bot",
|
||||||
|
"directory_at": _ago(settings.discord_directory_refresh_seconds + 5)})
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
opts = await web.join_options("n1", OWNER)
|
||||||
|
await _drain()
|
||||||
|
return opts
|
||||||
|
opts = run(go())
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
|
||||||
|
assert [c["name"] for c in g["voice_channels"]] == ["old name"] # stored list shown meanwhile
|
||||||
|
assert len(dfs.get("discord_guilds", LINKED)["voice_channels"]) == 4 # and replaced right after
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_main_bot_list_is_taken_over_by_a_radio_bot(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
|
||||||
|
"directory_at": _ago(web.MAIN_BOT_STALE_SECONDS + 60),
|
||||||
|
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await web.join_options("n1", OWNER)
|
||||||
|
await _drain()
|
||||||
|
run(go())
|
||||||
|
doc = dfs.get("discord_guilds", LINKED)
|
||||||
|
assert doc["directory_source"] == "radio_bot" and len(doc["voice_channels"]) == 4
|
||||||
|
assert doc["bot_present"] is True # not ours to change
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_member_marks_presence_and_keeps_the_list(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
keep = [{"channel_id": "3001", "name": "dispatch"}]
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": keep,
|
||||||
|
"directory_source": "radio_bot", "directory_at": _ago(99999)})
|
||||||
|
dfs.dir_mode["t1"] = "not_member"
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await web.join_options("n1", OWNER)
|
||||||
|
await _drain()
|
||||||
|
run(go())
|
||||||
|
doc = dfs.get("discord_guilds", LINKED)
|
||||||
|
assert doc["radio_bot_present"] is False and doc["voice_channels"] == keep
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_never_writes_onto_a_server_unlinked_meanwhile(dfs, monkeypatch):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
real = discord_rest.guild_directory
|
||||||
|
|
||||||
|
async def _unlink_then_answer(docs, guild_id, **kw):
|
||||||
|
dfs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": None, "unlinked_at": _ago(0)})
|
||||||
|
return await real(docs, guild_id, **kw)
|
||||||
|
monkeypatch.setattr(discord_rest, "guild_directory", _unlink_then_answer)
|
||||||
|
g = dict(dfs.get("discord_guilds", LINKED), org_id=ORG)
|
||||||
|
assert run(web._refresh_directory(g, ORG, {"t1": dfs.get("bot_tokens", "t1")})) is None
|
||||||
|
assert "voice_channels" not in dfs.get("discord_guilds", LINKED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_discord_never_holds_the_picker_past_the_budget(dfs, monkeypatch):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
monkeypatch.setattr(settings, "discord_perm_check_budget_seconds", 0.05)
|
||||||
|
|
||||||
|
async def _slow(docs, guild_id, **kw):
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
monkeypatch.setattr(discord_rest, "guild_directory", _slow)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
t0 = time.monotonic()
|
||||||
|
opts = await web.join_options("n1", OWNER)
|
||||||
|
return opts, time.monotonic() - t0
|
||||||
|
opts, took = run(go())
|
||||||
|
assert took < 1.0
|
||||||
|
assert next(g for g in opts["guilds"] if g["guild_id"] == LINKED)["synced"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_push_marks_main_bot_and_drops_cached_snapshots(dfs, monkeypatch):
|
||||||
|
dropped = []
|
||||||
|
monkeypatch.setattr(discord_rest, "invalidate", lambda token_id=None, guild_id=None: dropped.append(
|
||||||
|
(token_id, guild_id)) or 0)
|
||||||
|
run(web.put_guild_directory(LINKED, web.GuildDirectory(name="S9", voice_channels=[
|
||||||
|
web.VoiceChannelIn(channel_id="3001", name="dispatch")]), {"service": True}))
|
||||||
|
doc = dfs.get("discord_guilds", LINKED)
|
||||||
|
assert doc["directory_source"] == "main_bot" and doc["bot_present"] is True and doc["org_id"] == ORG
|
||||||
|
assert dropped == [(None, LINKED)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Starting from the picker without the main bot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_start_checks_the_channel_against_the_radio_bots_list(dfs):
|
||||||
|
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
|
||||||
|
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id="3001"), OWNER))
|
||||||
|
assert doc["ok"] is True and doc["token_id"] == "byo"
|
||||||
|
s = dfs.get("discord_streams", doc["stream_id"])
|
||||||
|
assert s["channel_name"] == "dispatch" and s["guild_name"] == "Station 9" and s["node_id"] == "n1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_refuses_a_channel_the_radio_bot_does_not_list(dfs):
|
||||||
|
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
|
||||||
|
for bad in ("3005", "3999"): # a text channel; no such channel
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id=bad), OWNER))
|
||||||
|
assert e.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# server-26#251 — an interactive start re-reads a stale "denied"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def aging(fs, monkeypatch):
|
||||||
|
"""Snapshots that are ``fs.age`` seconds old unless refreshed; ``muted``
|
||||||
|
until ``fs.fixed`` (the owner fixed the permission in Discord)."""
|
||||||
|
fs.age, fs.fixed = 60.0, False
|
||||||
|
|
||||||
|
async def _snapshot(token_doc, guild_id, *, refresh=False):
|
||||||
|
tid = token_doc["token_id"]
|
||||||
|
fs.snap_calls.append((tid, guild_id, refresh))
|
||||||
|
s = _snap(tid, muted=not (refresh and fs.fixed))
|
||||||
|
s.fetched_at = time.time() - (0 if refresh else fs.age)
|
||||||
|
return s
|
||||||
|
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
|
||||||
|
_token(fs, "t1", name="DRB Radio 2")
|
||||||
|
return fs
|
||||||
|
|
||||||
|
|
||||||
|
def _join():
|
||||||
|
return discord_router.start_stream(discord_router.StreamStart(guild_id=GUILD, channel_id="1001",
|
||||||
|
system_id=SYS), {"service": True})
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_after_fixing_the_permission_succeeds_at_once(aging):
|
||||||
|
aging.fixed = True
|
||||||
|
r = run(_join())
|
||||||
|
assert r["token_id"] == "t1"
|
||||||
|
assert ("t1", GUILD, True) in aging.snap_calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_still_denied_after_the_recheck_is_refused(aging):
|
||||||
|
r = run(_join())
|
||||||
|
assert r.status_code == 409 and b"can't Speak in #dispatch" in r.body
|
||||||
|
assert [c for c in aging.snap_calls if c[2]] == [("t1", GUILD, True)] # once
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fresh_denied_answer_is_not_asked_again(aging):
|
||||||
|
aging.age = 1.0
|
||||||
|
aging.fixed = True
|
||||||
|
r = run(_join())
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert not [c for c in aging.snap_calls if c[2]]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconciler_never_rechecks(aging):
|
||||||
|
aging.fixed = True
|
||||||
|
with pytest.raises(streams.StreamError) as e:
|
||||||
|
await streams.start_stream(org_id=ORG, guild_id=GUILD, channel_id="1001", system_id=SYS)
|
||||||
|
assert e.value.code == "missing_permissions"
|
||||||
|
assert not [c for c in aging.snap_calls if c[2]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_picker_start_is_interactive(aging):
|
||||||
|
aging.fixed = True
|
||||||
|
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=GUILD, channel_id="1001"), OWNER))
|
||||||
|
assert doc["ok"] is True and doc["token_id"] == "t1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_discord_join_command_is_interactive(aging):
|
||||||
|
aging.fixed = True
|
||||||
|
out = run(nodes_router.send_command("n1", CommandPayload(action="discord_join", guild_id=GUILD,
|
||||||
|
channel_id="1001"), ADMIN))
|
||||||
|
assert out["ok"] is True and out["token_id"] == "t1"
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""
|
||||||
|
Per-bot OAuth "Connect a Discord server" (server-26#225 S6).
|
||||||
|
|
||||||
|
Discord is an httpx.MockTransport; Firestore is tests/fake_firestore.py.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_guild_link as glink
|
||||||
|
from app.internal import discord_oauth as oauth
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
|
from app.routers import discord_oauth as router
|
||||||
|
from app.routers import tokens as tokens_router
|
||||||
|
from tests.fake_firestore import FakeFirestore
|
||||||
|
|
||||||
|
REDIRECT = "https://drb.example.test/discord/connected"
|
||||||
|
SECRET_A = "s3cr3t-app-a-0123456789abcdefXYZ"
|
||||||
|
SECRET_POOL = "s3cr3t-pool-0123456789abcdefXYZ"
|
||||||
|
GUILD = "100000000000000001"
|
||||||
|
OWNER_A = {"uid": "owner-a", "email": "a@example.test", "org_id": "org-a", "org_role": "owner"}
|
||||||
|
OWNER_B = {"uid": "owner-b", "email": "b@example.test", "org_id": "org-b", "org_role": "owner"}
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "admin@example.test", "role": "admin", "org_id": "founding"}
|
||||||
|
|
||||||
|
|
||||||
|
class Discord:
|
||||||
|
"""Scripted OAuth endpoints. ``token_reply`` answers POST /oauth2/token."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls: list[httpx.Request] = []
|
||||||
|
self.token_reply = httpx.Response(200, json={
|
||||||
|
"access_token": "user-access-token", "token_type": "Bearer", "scope": "bot",
|
||||||
|
"guild": {"id": GUILD, "name": "Station 7"}})
|
||||||
|
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
self.calls.append(request)
|
||||||
|
path = request.url.path.removeprefix("/api/v10")
|
||||||
|
if path == "/oauth2/token":
|
||||||
|
r = self.token_reply
|
||||||
|
return r(request) if callable(r) else r
|
||||||
|
if path == "/oauth2/token/revoke":
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
return httpx.Response(404, json={"code": 0})
|
||||||
|
|
||||||
|
def form(self, i: int) -> dict:
|
||||||
|
return {k: v[0] for k, v in parse_qs(self.calls[i].content.decode()).items()}
|
||||||
|
|
||||||
|
def basic(self, i: int) -> str:
|
||||||
|
raw = self.calls[i].headers["authorization"].split(" ", 1)[1]
|
||||||
|
return base64.b64decode(raw).decode()
|
||||||
|
|
||||||
|
def paths(self):
|
||||||
|
return [r.url.path.removeprefix("/api/v10") for r in self.calls]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def discord(monkeypatch):
|
||||||
|
d = Discord()
|
||||||
|
discord_rest.reset()
|
||||||
|
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(d))
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs(monkeypatch, discord):
|
||||||
|
fake = FakeFirestore.install(monkeypatch)
|
||||||
|
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", REDIRECT)
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||||
|
monkeypatch.setattr(settings, "discord_pool_bot_guild_cap", 90)
|
||||||
|
for org in ("org-a", "org-b", "founding"):
|
||||||
|
fake.put("organizations", org, {"org_id": org, "name": org})
|
||||||
|
fake.put("org_members", "owner-a", {"uid": "owner-a", "org_id": "org-a", "org_role": "owner"})
|
||||||
|
fake.put("org_members", "owner-b", {"uid": "owner-b", "org_id": "org-b", "org_role": "owner"})
|
||||||
|
# org-a's own bot, two pool bots (one full), org-b's own bot.
|
||||||
|
fake.put("bot_tokens", "own-a", {"token_id": "own-a", "name": "Riverton Radio", "token": "tok-own-a",
|
||||||
|
"org_id": "org-a", "application_id": "111", "guild_ids": []})
|
||||||
|
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "tok-pool-1",
|
||||||
|
"org_id": None, "application_id": "201", "guild_ids": ["9"] * 40})
|
||||||
|
fake.put("bot_tokens", "pool-2", {"token_id": "pool-2", "name": "DRB Radio 2", "token": "tok-pool-2",
|
||||||
|
"org_id": None, "application_id": "202", "guild_ids": ["9"] * 90})
|
||||||
|
fake.put("bot_tokens", "own-b", {"token_id": "own-b", "name": "Harbor Radio", "token": "tok-own-b",
|
||||||
|
"org_id": "org-b", "application_id": "333", "guild_ids": []})
|
||||||
|
for tid, secret in (("own-a", SECRET_A), ("pool-1", SECRET_POOL), ("pool-2", SECRET_POOL),
|
||||||
|
("own-b", SECRET_A)):
|
||||||
|
fake.put(oauth.OAUTH, tid, {"token_id": tid, "client_secret": secret, "enabled": True})
|
||||||
|
for lim in (router.start_limiter, router.complete_limiter):
|
||||||
|
lim._log.clear()
|
||||||
|
stopped: list = []
|
||||||
|
|
||||||
|
async def _stop_guild(guild_id, reason, channel_id=None, stream_id=None):
|
||||||
|
stopped.append((guild_id, reason))
|
||||||
|
return [f"{guild_id}:1"]
|
||||||
|
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
|
||||||
|
fake.stopped = stopped
|
||||||
|
|
||||||
|
async def _voice_check(**kw):
|
||||||
|
return {"enabled": True, "channels": [
|
||||||
|
{"channel_id": "1", "name": "dispatch", "state": "ready", "bots": [{"fix": None}]},
|
||||||
|
{"channel_id": "2", "name": "fireground", "state": "needs_fix",
|
||||||
|
"bots": [{"fix": "In Discord: Edit Channel → Permissions → add Riverton Radio → allow Speak, "
|
||||||
|
"then try again."}]},
|
||||||
|
{"channel_id": "3", "name": "ops", "state": "unknown", "bots": []},
|
||||||
|
]}
|
||||||
|
monkeypatch.setattr(router, "voice_check", _voice_check)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(fake, action):
|
||||||
|
return [e for e in (fake.data.get("audit_log") or {}).values() if e["action"] == action]
|
||||||
|
|
||||||
|
|
||||||
|
def _status(resp):
|
||||||
|
return getattr(resp, "status_code", 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _body(resp):
|
||||||
|
return json.loads(resp.body) if hasattr(resp, "body") else resp
|
||||||
|
|
||||||
|
|
||||||
|
async def _start(decoded=OWNER_A, **kw):
|
||||||
|
out = await router.oauth_start(router.StartBody(**kw), org_id=None, decoded=decoded)
|
||||||
|
q = parse_qs(urlparse(out["authorize_url"]).query)
|
||||||
|
return out, {k: v[0] for k, v in q.items()}
|
||||||
|
|
||||||
|
|
||||||
|
async def _complete(state, decoded=OWNER_A, code="the-code"):
|
||||||
|
return await router.oauth_complete(router.CompleteBody(code=code, state=state), decoded=decoded)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Off switch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_every_route_is_404_while_redirect_uri_is_empty(fs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", "")
|
||||||
|
for call in (router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A),
|
||||||
|
router.oauth_start(router.StartBody(), org_id=None, decoded=OWNER_A),
|
||||||
|
_complete("anything")):
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await call
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_404_when_no_usable_bot_has_a_secret(fs):
|
||||||
|
for tid in ("own-a", "pool-1", "pool-2"):
|
||||||
|
fs.data[oauth.OAUTH].pop(tid)
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Which bot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_org_bot_first_then_least_full_pool_bot_under_cap(fs):
|
||||||
|
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert out["bot"] == {"name": "Riverton Radio", "reason": "org_bot", "own_bot": True}
|
||||||
|
fs.data[oauth.OAUTH].pop("own-a")
|
||||||
|
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert out["bot"]["name"] == "DRB Radio 1" and out["bot"]["reason"] == "pool"
|
||||||
|
# pool-2 is at the cap (90): never offered, even when it's the only one left
|
||||||
|
fs.data[oauth.OAUTH].pop("pool-1")
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preferred_bot_for_the_nodes_system_wins(fs):
|
||||||
|
fs.put("nodes", "n1", {"node_id": "n1", "org_id": "org-a", "assigned_system_id": "s1"})
|
||||||
|
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-1"})
|
||||||
|
out = await router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert out["bot"]["name"] == "DRB Radio 1" and out["bot"]["reason"] == "preferred"
|
||||||
|
# another org's node is a 404
|
||||||
|
fs.put("nodes", "nb", {"node_id": "nb", "org_id": "org-b", "assigned_system_id": "s1"})
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id="nb", token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explicit_bot_must_be_usable_by_the_org(fs):
|
||||||
|
out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A)
|
||||||
|
assert out["bot"]["reason"] == "chosen"
|
||||||
|
for tid in ("own-b", "pool-2", "nope"): # other org's bot, full pool bot, unknown
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id=None, token_id=tid, org_id=None, decoded=OWNER_A)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Start
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_start_returns_the_bot_invite_and_stores_only_a_hash_of_the_state(fs):
|
||||||
|
out, q = await _start(return_to="/nodes/n1")
|
||||||
|
assert urlparse(out["authorize_url"]).netloc == "discord.com"
|
||||||
|
assert q["client_id"] == "111" and q["scope"] == "bot" and q["response_type"] == "code"
|
||||||
|
assert q["permissions"] == str((1 << 10) | (1 << 20) | (1 << 21))
|
||||||
|
assert q["redirect_uri"] == REDIRECT
|
||||||
|
nonce = q["state"]
|
||||||
|
assert len(nonce) >= 40
|
||||||
|
assert fs.get(oauth.STATES, nonce) is None # the nonce itself is never stored
|
||||||
|
st = fs.get(oauth.STATES, oauth.state_key(nonce))
|
||||||
|
assert st["org_id"] == "org-a" and st["uid"] == "owner-a" and st["token_id"] == "own-a"
|
||||||
|
assert st["return_to"] == "/nodes/n1" and st["used_at"] is None
|
||||||
|
assert len(_audit(fs, "discord.oauth.start")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["https://evil.test/x", "//evil.test/x", "/\\evil.test", "nodes", "/a\nb"])
|
||||||
|
async def test_return_to_is_a_same_site_path_only(fs, bad):
|
||||||
|
_, q = await _start(return_to=bad)
|
||||||
|
assert fs.get(oauth.STATES, oauth.state_key(q["state"]))["return_to"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_start_rate_limited_per_user(fs):
|
||||||
|
for _ in range(5):
|
||||||
|
await _start()
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await _start()
|
||||||
|
assert e.value.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Complete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_complete_links_the_server_from_the_exchange_and_reports_channels(fs, discord):
|
||||||
|
_, q = await _start(return_to="/settings/discord")
|
||||||
|
out = await _complete(q["state"])
|
||||||
|
assert out["ok"] and out["guild_id"] == GUILD and out["guild_name"] == "Station 7"
|
||||||
|
assert out["org_id"] == "org-a" and out["bot_name"] == "Riverton Radio"
|
||||||
|
assert (out["channels_ready"], out["channels_blocked"]) == (1, 1)
|
||||||
|
assert out["channels"][1]["fix"].startswith("In Discord")
|
||||||
|
assert out["return_to"] == "/settings/discord"
|
||||||
|
|
||||||
|
g = fs.get(streams.GUILDS, GUILD)
|
||||||
|
assert g["org_id"] == "org-a" and g["linked_via"] == "oauth" and g["linked_by"] == "owner-a"
|
||||||
|
assert g["linked_with_token_id"] == "own-a"
|
||||||
|
assert fs.get("bot_tokens", "own-a")["guild_ids"] == [GUILD]
|
||||||
|
# the exchange used that bot application's own credentials and the exact redirect URI
|
||||||
|
assert discord.paths() == ["/oauth2/token", "/oauth2/token/revoke"]
|
||||||
|
assert discord.basic(0) == f"111:{SECRET_A}"
|
||||||
|
assert discord.form(0) == {"grant_type": "authorization_code", "code": "the-code", "redirect_uri": REDIRECT}
|
||||||
|
assert discord.form(1)["token"] == "user-access-token" # revoked straight away
|
||||||
|
assert fs.get(oauth.STATES, oauth.state_key(q["state"]))["used_at"]
|
||||||
|
[entry] = _audit(fs, "discord.guild.link")
|
||||||
|
assert entry["details"]["via"] == "oauth" and entry["details"]["guild_id"] == GUILD
|
||||||
|
|
||||||
|
|
||||||
|
async def test_state_is_single_use(fs):
|
||||||
|
_, q = await _start()
|
||||||
|
assert _status(await _complete(q["state"])) == 200
|
||||||
|
again = await _complete(q["state"])
|
||||||
|
assert _status(again) == 409 and _body(again)["code"] == "state_used"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_another_users_state_is_refused_and_burned(fs):
|
||||||
|
_, q = await _start(OWNER_A)
|
||||||
|
resp = await _complete(q["state"], decoded=OWNER_B) # login-CSRF shape
|
||||||
|
assert _status(resp) == 403 and _body(resp)["code"] == "wrong_user"
|
||||||
|
assert fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
assert _status(await _complete(q["state"], decoded=OWNER_A)) == 409 # consumed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_expired_and_unknown_states(fs):
|
||||||
|
_, q = await _start()
|
||||||
|
key = oauth.state_key(q["state"])
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
|
||||||
|
fs.put(oauth.STATES, key, {**fs.get(oauth.STATES, key), "expires_at": past})
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 410 and fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
assert _status(await _complete("not-a-state")) == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caller_who_is_no_longer_owner_is_refused(fs, discord):
|
||||||
|
_, q = await _start()
|
||||||
|
fs.put("org_members", "owner-a", {"uid": "owner-a", "org_id": "org-a", "org_role": "member"})
|
||||||
|
resp = await _complete(q["state"]) # token claims still say owner
|
||||||
|
assert _status(resp) == 403 and _body(resp)["code"] == "not_owner"
|
||||||
|
assert discord.calls == [] and fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_exchange_without_a_guild_links_nothing(fs, discord):
|
||||||
|
discord.token_reply = httpx.Response(200, json={"access_token": "x", "scope": "bot"})
|
||||||
|
_, q = await _start()
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 400 and _body(resp)["code"] == "no_guild"
|
||||||
|
assert fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejected_code_and_unreachable_discord(fs, discord):
|
||||||
|
discord.token_reply = httpx.Response(400, json={"error": "invalid_grant"})
|
||||||
|
_, q = await _start()
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 400 and _body(resp)["code"] == "code_rejected"
|
||||||
|
|
||||||
|
def _down(request):
|
||||||
|
raise httpx.ConnectError("down", request=request)
|
||||||
|
discord.token_reply = _down
|
||||||
|
_, q = await _start()
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 502 and fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_server_linked_to_another_org_is_never_moved(fs):
|
||||||
|
fs.put(streams.GUILDS, GUILD, {"guild_id": GUILD, "org_id": "org-b", "guild_name": "Harbor"})
|
||||||
|
_, q = await _start()
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 409 and _body(resp)["code"] == "linked_elsewhere"
|
||||||
|
assert fs.get(streams.GUILDS, GUILD)["org_id"] == "org-b"
|
||||||
|
assert fs.stopped == []
|
||||||
|
assert [e["details"]["via"] for e in _audit(fs, "discord.guild.link_refused")] == ["oauth"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_linking_away_from_the_fallback_org_stops_its_streams(fs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
|
||||||
|
_, q = await _start()
|
||||||
|
out = await _complete(q["state"])
|
||||||
|
assert out["streams_stopped"] == 1 and fs.stopped == [(GUILD, glink.RELINK_STOP_REASON)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_permission_check_timeout_leaves_the_link_standing(fs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_oauth_link_check_seconds", 0.05)
|
||||||
|
|
||||||
|
async def _slow(**kw):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
monkeypatch.setattr(router, "voice_check", _slow)
|
||||||
|
_, q = await _start()
|
||||||
|
out = await _complete(q["state"])
|
||||||
|
assert out["ok"] and out["checked"] is False and out["channels_ready"] is None
|
||||||
|
assert fs.get(streams.GUILDS, GUILD)["org_id"] == "org-a"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bot_handed_to_another_org_after_start_is_refused(fs):
|
||||||
|
_, q = await _start()
|
||||||
|
fs.put("bot_tokens", "own-a", {**fs.get("bot_tokens", "own-a"), "org_id": "org-b"})
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 409 and _body(resp)["code"] == "bot_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secrets: PUT /tokens/{id}/oauth, never returned, never logged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_set_secret_checks_it_with_discord_and_never_echoes_it(fs, discord, caplog):
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
fs.data[oauth.OAUTH].clear()
|
||||||
|
discord.token_reply = httpx.Response(401, json={"error": "invalid_client"})
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
|
||||||
|
decoded=ADMIN)
|
||||||
|
assert e.value.status_code == 400 and fs.get(oauth.OAUTH, "pool-1") is None
|
||||||
|
|
||||||
|
discord.token_reply = httpx.Response(200, json={"access_token": "cc-token", "scope": "identify"})
|
||||||
|
out = await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
|
||||||
|
decoded=ADMIN)
|
||||||
|
assert out["oauth_enabled"] is True
|
||||||
|
assert discord.form(1) == {"grant_type": "client_credentials", "scope": "identify"}
|
||||||
|
assert discord.basic(1) == f"201:{SECRET_POOL}"
|
||||||
|
assert discord.form(2)["token"] == "cc-token" # the check's token is revoked
|
||||||
|
assert fs.get(oauth.OAUTH, "pool-1")["client_secret"] == SECRET_POOL
|
||||||
|
assert "client_secret" not in fs.get("bot_tokens", "pool-1")
|
||||||
|
|
||||||
|
listed = await tokens_router.list_tokens(_=ADMIN)
|
||||||
|
everything = json.dumps([listed, out, fs.data.get("audit_log")]) + caplog.text
|
||||||
|
assert SECRET_POOL not in everything and "tok-pool-1" not in everything
|
||||||
|
row = next(t for t in listed if t["token_id"] == "pool-1")
|
||||||
|
assert row["oauth_enabled"] is True and row["server_count"] == 40 and row["server_cap"] == 90
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unreachable_discord_saves_nothing(fs, discord):
|
||||||
|
fs.data[oauth.OAUTH].clear()
|
||||||
|
|
||||||
|
def _down(request):
|
||||||
|
raise httpx.ConnectError("down", request=request)
|
||||||
|
discord.token_reply = _down
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
|
||||||
|
decoded=ADMIN)
|
||||||
|
assert e.value.status_code == 503 and fs.get(oauth.OAUTH, "pool-1") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_list_is_an_allowlist(fs):
|
||||||
|
fs.put("bot_tokens", "pool-1", {**fs.get("bot_tokens", "pool-1"), "client_secret": "LEAK", "future": "x"})
|
||||||
|
row = next(t for t in await tokens_router.list_tokens(_=ADMIN) if t["token_id"] == "pool-1")
|
||||||
|
assert "client_secret" not in row and "future" not in row
|
||||||
|
assert row["token"] == "•••ol-1" and row["name"] == "DRB Radio 1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clear_and_delete_remove_the_secret(fs):
|
||||||
|
await tokens_router.clear_oauth_secret("pool-1", decoded=ADMIN)
|
||||||
|
assert fs.get(oauth.OAUTH, "pool-1") is None
|
||||||
|
await tokens_router.delete_token("own-a", _=ADMIN)
|
||||||
|
assert fs.get(oauth.OAUTH, "own-a") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_malformed_secret_is_refused_without_echo(fs, discord):
|
||||||
|
fs.data[oauth.OAUTH].clear()
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret="short-secret"),
|
||||||
|
decoded=ADMIN)
|
||||||
|
assert e.value.status_code == 400 and "short-secret" not in str(e.value.detail)
|
||||||
|
assert discord.calls == [] and fs.get(oauth.OAUTH, "pool-1") is None
|
||||||
@@ -80,7 +80,10 @@ def test_directory_push_keeps_org_link(fs):
|
|||||||
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
|
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
|
||||||
|
|
||||||
|
|
||||||
def test_directory_bot_removed_hides_guild(fs):
|
def test_directory_bot_removed_hides_guild_without_a_radio_bot(fs):
|
||||||
|
# server-26#225 S5: the server bot leaving hides a linked server only when
|
||||||
|
# no radio bot the org may use is in it either.
|
||||||
|
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
|
||||||
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
|
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
|
||||||
opts = run(web.join_options("n1", OWNER))
|
opts = run(web.join_options("n1", OWNER))
|
||||||
assert opts["guilds"] == []
|
assert opts["guilds"] == []
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
HTTP Range on /media/calls/{id}/audio (server-26#233).
|
||||||
|
|
||||||
|
The GCS reads are replaced by an in-memory object: ``audio_object_size`` and
|
||||||
|
``download_audio_range`` are patched where routers/media.py looks them up, and
|
||||||
|
every span read is recorded so the tests can assert the route never asks GCS
|
||||||
|
for more than the requested range (plus at most one STREAM_CHUNK_BYTES span).
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal import storage
|
||||||
|
from app.routers import media
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(media.router)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
CALL_ID = "call-abc"
|
||||||
|
GCS_URI = "gs://bucket/calls/call-abc.flac"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeObject:
|
||||||
|
"""A stored object plus a log of every (start, end) span read from it."""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes):
|
||||||
|
self.data = data
|
||||||
|
self.reads: list[tuple[int, int]] = []
|
||||||
|
self.size_calls = 0
|
||||||
|
|
||||||
|
async def size(self, gcs_uri):
|
||||||
|
self.size_calls += 1
|
||||||
|
return len(self.data)
|
||||||
|
|
||||||
|
async def read(self, gcs_uri, start, end):
|
||||||
|
self.reads.append((start, end))
|
||||||
|
return self.data[start:end + 1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def signed(monkeypatch):
|
||||||
|
monkeypatch.setattr(storage.settings, "service_key", "test-key", raising=False)
|
||||||
|
exp = int(time.time()) + 600
|
||||||
|
return {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(obj: FakeObject, params, headers=None, doc=None):
|
||||||
|
doc_get = AsyncMock(return_value=doc if doc is not None else {"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})
|
||||||
|
with patch.object(media.fstore, "doc_get", doc_get), \
|
||||||
|
patch.object(media, "audio_object_size", obj.size), \
|
||||||
|
patch.object(media, "download_audio_range", obj.read):
|
||||||
|
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=params, headers=headers or {})
|
||||||
|
return resp, doc_get
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounded_range_returns_206_with_exact_bytes(signed):
|
||||||
|
obj = FakeObject(bytes(range(256)) * 40) # 10240 bytes
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=100-199"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == obj.data[100:200]
|
||||||
|
assert resp.headers["content-range"] == f"bytes 100-199/{len(obj.data)}"
|
||||||
|
assert resp.headers["content-length"] == "100"
|
||||||
|
assert resp.headers["accept-ranges"] == "bytes"
|
||||||
|
assert resp.headers["content-type"] == "audio/flac"
|
||||||
|
assert resp.headers["cache-control"] == "private, max-age=3600"
|
||||||
|
# Only the requested span was read from GCS — not the whole object.
|
||||||
|
assert obj.reads == [(100, 199)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_ended_range(signed):
|
||||||
|
obj = FakeObject(b"x" * 5000 + b"tail")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=5000-"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == b"tail"
|
||||||
|
assert resp.headers["content-range"] == "bytes 5000-5003/5004"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suffix_range(signed):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=-3"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == b"789"
|
||||||
|
assert resp.headers["content-range"] == "bytes 7-9/10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suffix_longer_than_object_returns_whole_object(signed):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=-500"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == obj.data
|
||||||
|
assert resp.headers["content-range"] == "bytes 0-9/10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_past_object_is_clamped(signed):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=8-999"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == b"89"
|
||||||
|
assert resp.headers["content-range"] == "bytes 8-9/10"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rng", ["bytes=10-", "bytes=50-60", "bytes=-0"])
|
||||||
|
def test_unsatisfiable_range_is_416(signed, rng):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": rng})
|
||||||
|
assert resp.status_code == 416
|
||||||
|
assert resp.headers["content-range"] == "bytes */10"
|
||||||
|
assert obj.reads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_range_is_200_full_body_and_advertises_ranges(signed):
|
||||||
|
obj = FakeObject(b"abcdef" * 100)
|
||||||
|
resp, _ = _run(obj, signed)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == obj.data
|
||||||
|
assert resp.headers["accept-ranges"] == "bytes"
|
||||||
|
assert resp.headers["content-length"] == str(len(obj.data))
|
||||||
|
assert "content-range" not in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rng", ["bytes=0-1,4-5", "items=0-5", "bytes=5-2", "garbage"])
|
||||||
|
def test_ignored_range_headers_fall_back_to_200(signed, rng):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": rng})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == obj.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_object_is_streamed_in_bounded_spans(signed):
|
||||||
|
# A ~13 MB "600 s FLAC": a seek near the end reads only from there on, in
|
||||||
|
# spans no larger than STREAM_CHUNK_BYTES, and the first span is all the
|
||||||
|
# route waits on before the response starts.
|
||||||
|
size = 13 * 1024 * 1024
|
||||||
|
obj = FakeObject(bytes(size))
|
||||||
|
start = size - 1_500_000
|
||||||
|
resp, _ = _run(obj, signed, {"Range": f"bytes={start}-"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert len(resp.content) == 1_500_000
|
||||||
|
assert obj.reads[0][0] == start
|
||||||
|
assert all(e - s + 1 <= media.STREAM_CHUNK_BYTES for s, e in obj.reads)
|
||||||
|
assert sum(e - s + 1 for s, e in obj.reads) == 1_500_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_signature_is_403_before_any_lookup(signed):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, doc_get = _run(obj, {"exp": signed["exp"], "sig": "0" * 64}, {"Range": "bytes=0-3"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
doc_get.assert_not_called()
|
||||||
|
assert obj.size_calls == 0
|
||||||
|
assert obj.reads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_link_is_403(signed, monkeypatch):
|
||||||
|
exp = int(time.time()) - 5
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, doc_get = _run(obj, {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
doc_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_object_is_404(signed):
|
||||||
|
obj = FakeObject(b"")
|
||||||
|
|
||||||
|
async def no_size(gcs_uri):
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(media.fstore, "doc_get", AsyncMock(return_value={"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})), \
|
||||||
|
patch.object(media, "audio_object_size", no_size), \
|
||||||
|
patch.object(media, "download_audio_range", obj.read):
|
||||||
|
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=signed)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_mp3_keeps_its_content_type(signed):
|
||||||
|
obj = FakeObject(b"ID3" + b"\0" * 50)
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=0-2"}, doc={"call_id": CALL_ID, "audio_gcs_uri": "gs://b/calls/x.mp3"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.headers["content-type"] == "audio/mpeg"
|
||||||
|
assert resp.content == b"ID3"
|
||||||
|
|
||||||
|
|
||||||
|
# ── storage.py: the ranged reads never pull the whole object ────────────────
|
||||||
|
def _fake_client(monkeypatch, size=1234, payload=b"span"):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
client = MagicMock()
|
||||||
|
blob = client.bucket.return_value.blob.return_value
|
||||||
|
blob.size = size
|
||||||
|
blob.download_as_bytes.return_value = payload
|
||||||
|
monkeypatch.setattr(storage, "_gcs_client_cache", client)
|
||||||
|
return client, blob
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_size_reloads_metadata_only(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
client, blob = _fake_client(monkeypatch, size=13_000_000)
|
||||||
|
assert asyncio.run(storage.audio_object_size(GCS_URI)) == 13_000_000
|
||||||
|
client.bucket.assert_called_with("bucket")
|
||||||
|
client.bucket.return_value.blob.assert_called_with("calls/call-abc.flac")
|
||||||
|
blob.reload.assert_called_once()
|
||||||
|
blob.download_as_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_range_read_passes_inclusive_bounds(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
_, blob = _fake_client(monkeypatch)
|
||||||
|
assert asyncio.run(storage.download_audio_range(GCS_URI, 100, 199)) == b"span"
|
||||||
|
blob.download_as_bytes.assert_called_once_with(start=100, end=199)
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_range_read_failure_returns_none(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
_, blob = _fake_client(monkeypatch)
|
||||||
|
blob.download_as_bytes.side_effect = RuntimeError("gcs down")
|
||||||
|
assert asyncio.run(storage.download_audio_range(GCS_URI, 0, 10)) is None
|
||||||
|
blob.reload.side_effect = RuntimeError("404")
|
||||||
|
assert asyncio.run(storage.audio_object_size(GCS_URI)) is None
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""Power-outage providers (server-26#184): registry, licence gate, admin routes, ODIN adapter.
|
||||||
|
|
||||||
|
What is held here (mirrors tests/test_camera_providers.py):
|
||||||
|
* every provider defaults OFF, and an off provider is never fetched;
|
||||||
|
* stubs can never be enabled (409);
|
||||||
|
* a gated licence needs ``acknowledge_licence``, which is stored with who/when
|
||||||
|
and audited -- and an audit failure refuses the enable (fail closed);
|
||||||
|
* an acknowledgement given for a different licence status no longer counts;
|
||||||
|
* the admin routes are admin-only;
|
||||||
|
* the ODIN parser reads the recorded public payloads, and an all-unparseable
|
||||||
|
payload is an error, not an empty layer;
|
||||||
|
* an area no serving provider covers says so instead of returning a bare [].
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token
|
||||||
|
from app.internal.outages import base, providers, registry
|
||||||
|
from app.internal.outages.base import Licence
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "admin@example.com", "role": "admin"}
|
||||||
|
FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "odin_public_2026-10-05.json").read_text())
|
||||||
|
STUBS = ("poweroutage_us", "coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live")
|
||||||
|
NY_BBOX = (42.5, -78.5, 43.5, -75.5) # Rochester .. Syracuse
|
||||||
|
RECORDED_AT = datetime(2026, 10, 5, 6, 45, tzinfo=timezone.utc) # when the fixture was captured
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.docs = {}
|
||||||
|
self.audit = []
|
||||||
|
|
||||||
|
async def doc_get(self, col, doc):
|
||||||
|
return self.docs.get((col, doc))
|
||||||
|
|
||||||
|
async def doc_set(self, col, doc, data, merge=True):
|
||||||
|
cur = self.docs.setdefault((col, doc), {})
|
||||||
|
if col == "audit_log":
|
||||||
|
self.audit.append(data)
|
||||||
|
return
|
||||||
|
for k, v in data.items(): # one-level deep merge is all registry needs
|
||||||
|
if merge and isinstance(v, dict) and isinstance(cur.get(k), dict):
|
||||||
|
cur[k] = {**cur[k], **v}
|
||||||
|
else:
|
||||||
|
cur[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def store():
|
||||||
|
s = FakeStore()
|
||||||
|
registry.invalidate_cache()
|
||||||
|
for feed in registry.FEEDS.values():
|
||||||
|
feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0
|
||||||
|
with patch.object(registry.fstore, "doc_get", s.doc_get), patch.object(registry.fstore, "doc_set", s.doc_set), \
|
||||||
|
patch("app.internal.audit.fstore.doc_set", s.doc_set):
|
||||||
|
yield s
|
||||||
|
registry.invalidate_cache()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _odin_areas():
|
||||||
|
return providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
|
||||||
|
now=RECORDED_AT)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry / gate ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_every_catalogued_source_is_registered_and_defaults_off():
|
||||||
|
listed = run(registry.list_providers())
|
||||||
|
assert {p["id"] for p in listed} == {"odin", *STUBS}
|
||||||
|
assert all(p["enabled"] is False and p["serving"] is False for p in listed)
|
||||||
|
assert all(p["licence"]["label"] for p in listed)
|
||||||
|
odin = next(p for p in listed if p["id"] == "odin")
|
||||||
|
assert odin["licence"]["status"] == "unverified" and odin["licence"]["gated"] is True
|
||||||
|
assert odin["licence"]["terms_url"] == "https://odin.ornl.gov/v3/api-docs"
|
||||||
|
assert odin["not_serving_reason"] == "disabled (default)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogued_licence_statuses_match_the_source_terms():
|
||||||
|
lic = {pid: p.meta.licence.status for pid, p in registry.PROVIDERS.items()}
|
||||||
|
assert lic["poweroutage_us"] == Licence.PAID
|
||||||
|
for pid in ("coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live"):
|
||||||
|
assert lic[pid] == Licence.AGREEMENT_REQUIRED, pid
|
||||||
|
assert all(not registry.PROVIDERS[pid].meta.implemented for pid in STUBS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_is_worded_for_outage_data():
|
||||||
|
w = registry.PROVIDERS["odin"].meta.licence.warning()
|
||||||
|
assert "outage data" in w and "imagery" not in w
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_provider_is_never_fetched():
|
||||||
|
fetch = AsyncMock(return_value=[])
|
||||||
|
with patch.object(registry.PROVIDERS["odin"], "fetch", fetch):
|
||||||
|
out = run(registry.outages_in_bbox(NY_BBOX))
|
||||||
|
assert out["areas"] == [] and out["providers"] == [] and fetch.await_count == 0
|
||||||
|
assert out["serving_count"] == 0 and out["status"] == "none_enabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stubs_cannot_be_enabled():
|
||||||
|
for pid in STUBS:
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled(pid, True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
assert e.value.code == 409, pid
|
||||||
|
|
||||||
|
|
||||||
|
def test_gated_licence_requires_acknowledgement(store):
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled("odin", True, ("u1", "a@x")))
|
||||||
|
assert e.value.code == 409 and e.value.extra["requires_acknowledgement"] is True
|
||||||
|
assert "Licence unverified" in e.value.extra["warning"]
|
||||||
|
assert not store.audit and not run(registry.is_serving("odin"))
|
||||||
|
|
||||||
|
out = run(registry.set_enabled("odin", True, ("u1", "a@x"), acknowledge_licence=True))
|
||||||
|
assert out["serving"] is True
|
||||||
|
ack = out["acknowledgement"]
|
||||||
|
assert ack["by_uid"] == "u1" and ack["by_email"] == "a@x" and ack["at"]
|
||||||
|
assert ack["licence_status"] == Licence.UNVERIFIED.value
|
||||||
|
assert store.audit[-1]["action"] == "outage_provider.enable_acknowledged"
|
||||||
|
assert store.audit[-1]["details"]["provider_id"] == "odin"
|
||||||
|
assert store.docs[("config", "outage_providers")]["providers"]["odin"]["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_gated_enable_refused_when_audit_cannot_be_written(store):
|
||||||
|
with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))):
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
assert e.value.code == 503
|
||||||
|
assert not run(registry.is_serving("odin"))
|
||||||
|
assert ("config", "outage_providers") not in store.docs
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_clears_acknowledgement_and_is_audited(store):
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
out = run(registry.set_enabled("odin", False, ("u", "e")))
|
||||||
|
assert out["serving"] is False and out["acknowledgement"] is None
|
||||||
|
assert store.audit[-1]["action"] == "outage_provider.disable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_acknowledgement_stops_serving():
|
||||||
|
p = registry.PROVIDERS["odin"]
|
||||||
|
entry = {"enabled": True, "acknowledgement": {"licence_status": "paid"}}
|
||||||
|
assert registry.effective(p, entry) == (False, "licence acknowledgement missing or out of date")
|
||||||
|
entry["acknowledgement"]["licence_status"] = p.meta.licence.status.value
|
||||||
|
assert registry.effective(p, entry) == (True, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_settings_fail_closed():
|
||||||
|
with patch.object(registry.fstore, "doc_get", AsyncMock(side_effect=RuntimeError("boom"))):
|
||||||
|
registry.invalidate_cache()
|
||||||
|
assert run(registry.get_settings()) == {}
|
||||||
|
assert not run(registry.is_serving("odin"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── bbox query ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_query_returns_areas_in_bbox_with_credit(store):
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(return_value=_odin_areas())):
|
||||||
|
out = run(registry.outages_in_bbox(NY_BBOX))
|
||||||
|
assert out["status"] == "ok" and out["serving_count"] == 1
|
||||||
|
assert {a["fips"] for a in out["areas"]} == {"36055", "36067"} # not Albany, not King County WA
|
||||||
|
prov = out["providers"][0]
|
||||||
|
assert prov["attribution"].startswith("Outage data: ODIN") and prov["disclaimer"]
|
||||||
|
assert prov["total_in_bbox"] == 2 and prov["fetched_at"] and prov["error"] is None
|
||||||
|
assert "Only utilities that report to ODIN appear" in prov["coverage_note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_area_no_serving_provider_covers_says_so(store):
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
fetch = AsyncMock(return_value=_odin_areas())
|
||||||
|
with patch.object(registry.PROVIDERS["odin"], "fetch", fetch):
|
||||||
|
out = run(registry.outages_in_bbox((51.4, -0.2, 51.6, 0.1))) # London
|
||||||
|
assert fetch.await_count == 0 # outside ODIN coverage -> never fetched
|
||||||
|
assert out["areas"] == [] and out["status"] == "no_source"
|
||||||
|
assert out["message"] == "No licensed outage source covers this area."
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_feed_is_an_error_not_an_empty_layer(store):
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(side_effect=ValueError("schema change?"))):
|
||||||
|
out = run(registry.outages_in_bbox(NY_BBOX))
|
||||||
|
assert out["status"] == "error" and "schema change" in out["providers"][0]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_refresh_keeps_last_good_feed():
|
||||||
|
feed = registry.FEEDS["odin"]
|
||||||
|
with patch.object(feed.provider, "fetch", AsyncMock(return_value=_odin_areas())):
|
||||||
|
run(feed.get())
|
||||||
|
feed._next_attempt = 0.0
|
||||||
|
with patch.object(feed.provider, "fetch", AsyncMock(side_effect=RuntimeError("down"))):
|
||||||
|
run(feed.get())
|
||||||
|
assert len(feed.items) == 4 and "down" in feed.error and feed.fetched_at
|
||||||
|
|
||||||
|
|
||||||
|
# ── admin routes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_admin_routes_require_admin():
|
||||||
|
def deny():
|
||||||
|
raise HTTPException(403, "Admin access required")
|
||||||
|
app.dependency_overrides[require_admin_token] = deny
|
||||||
|
assert client.get("/admin/outage-providers").status_code == 403
|
||||||
|
assert client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_route_toggle_and_gate(store):
|
||||||
|
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
||||||
|
r = client.put("/admin/outage-providers/odin", json={"enabled": True})
|
||||||
|
assert r.status_code == 409 and r.json()["requires_acknowledgement"] is True
|
||||||
|
assert r.json()["licence"]["status"] == "unverified"
|
||||||
|
r = client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True})
|
||||||
|
assert r.status_code == 200 and r.json()["acknowledgement"]["by_email"] == "admin@example.com"
|
||||||
|
assert client.put("/admin/outage-providers/poweroutage_us",
|
||||||
|
json={"enabled": True, "acknowledge_licence": True}).status_code == 409
|
||||||
|
assert client.put("/admin/outage-providers/nope", json={"enabled": True}).status_code == 404
|
||||||
|
listed = client.get("/admin/outage-providers").json()["providers"]
|
||||||
|
assert next(p for p in listed if p["id"] == "odin")["serving"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_outages_route_rejects_inverted_bbox_and_reports_status():
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"uid": "v"}
|
||||||
|
assert client.get("/outages", params={"south": 2, "west": 0, "north": 1, "east": 1}).status_code == 400
|
||||||
|
r = client.get("/outages", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
|
||||||
|
assert r.status_code == 200 and r.json()["areas"] == [] and r.json()["status"] == "none_enabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_outages_route_requires_sign_in():
|
||||||
|
def deny():
|
||||||
|
raise HTTPException(401, "Not signed in")
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = deny
|
||||||
|
assert client.get("/outages", params={"south": 40, "west": -74, "north": 41, "east": -73}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── ODIN adapter (recorded public payloads, 2026-10-05) ──────────────────────
|
||||||
|
|
||||||
|
def test_odin_parser_reads_recorded_payload():
|
||||||
|
areas, info = providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
|
||||||
|
now=RECORDED_AT)
|
||||||
|
by = {a["fips"]: a for a in areas}
|
||||||
|
assert set(by) == {"36001", "36067", "36055", "53033"}
|
||||||
|
onondaga = by["36067"]
|
||||||
|
assert onondaga["id"] == "odin:36067" and onondaga["provider"] == "odin"
|
||||||
|
assert onondaga["county"] == "Onondaga" and onondaga["state"] == "New York"
|
||||||
|
assert onondaga["customers_out"] == 11 and onondaga["customers_served"] is None
|
||||||
|
assert onondaga["etr"] == "2026-10-05T08:00:00Z" and onondaga["since"] == "2026-10-04T18:35:40Z"
|
||||||
|
assert onondaga["updated_at"].startswith("2026-10-05T06:20:47")
|
||||||
|
assert onondaga["utilities"] == [{"name": "NATIONAL GRID", "eia_id": "13573", "customers_out": 11,
|
||||||
|
"stale_customers_out": 0}]
|
||||||
|
assert onondaga["stale"] is False and onondaga["stale_customers_out"] == 0
|
||||||
|
s, w, n, e = onondaga["bbox"]
|
||||||
|
assert s < onondaga["lat"] < n and w < onondaga["lon"] < e
|
||||||
|
assert 42.7 < onondaga["lat"] < 43.3 and -76.6 < onondaga["lon"] < -75.8
|
||||||
|
# King County WA: several outage events from two utilities, summed per county
|
||||||
|
king = by["53033"]
|
||||||
|
assert king["customers_out"] == sum(r["metersOut"] for r in FIXTURE["odi_map_scale_county"] if r["id"] == "53033")
|
||||||
|
assert len(king["utilities"]) == 2
|
||||||
|
# Puget Sound Energy's "0001-01-01T00:00:00Z" means no ETR -- not a restoration time, not stale.
|
||||||
|
assert king["etr"] is None and king["stale_customers_out"] == 0
|
||||||
|
assert info["reporting_utilities"] == len(FIXTURE["odi_status"]) and info["utilities_with_error"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_odin_events_long_past_their_etr_are_stale_not_live():
|
||||||
|
# ODIN still listed Avangrid's Monroe County row from April 2026 (ETR
|
||||||
|
# 2026-04-10) on 2026-10-05. It must not read as 18 customers out right now.
|
||||||
|
by = {a["fips"]: a for a in _odin_areas()}
|
||||||
|
monroe = by["36055"]
|
||||||
|
assert monroe["customers_out"] == 0 and monroe["stale_customers_out"] == 18 and monroe["stale"] is True
|
||||||
|
assert monroe["since"].startswith("2026-04-10") # still shown, so the stale row is explainable
|
||||||
|
# Same payload, captured the day the outage was live: not stale.
|
||||||
|
live = {a["fips"]: a for a in providers.parse_odin(
|
||||||
|
FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
|
||||||
|
now=datetime(2026, 4, 10, 20, 0, tzinfo=timezone.utc))[0]}
|
||||||
|
assert live["36055"]["customers_out"] == 18 and live["36055"]["stale"] is False
|
||||||
|
# Live counties sort ahead of stale ones.
|
||||||
|
assert [a["stale"] for a in _odin_areas()] == sorted(a["stale"] for a in _odin_areas())
|
||||||
|
|
||||||
|
|
||||||
|
def test_odin_fetch_uses_the_three_public_endpoints():
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_get_json(url, params=None, headers=None):
|
||||||
|
calls.append((url, params))
|
||||||
|
return {"https://odin.ornl.gov/odi": FIXTURE["odi_format_json"],
|
||||||
|
"https://odin.ornl.gov/odi/map": FIXTURE["odi_map_scale_county"],
|
||||||
|
"https://odin.ornl.gov/odi/status": FIXTURE["odi_status"]}[url]
|
||||||
|
|
||||||
|
p = providers.OdinProvider()
|
||||||
|
with patch.object(providers, "get_json", fake_get_json):
|
||||||
|
areas = run(p.fetch())
|
||||||
|
assert len(areas) == 4
|
||||||
|
assert ("https://odin.ornl.gov/odi", {"format": "JSON"}) in calls
|
||||||
|
assert ("https://odin.ornl.gov/odi/map", {"scale": "COUNTY"}) in calls
|
||||||
|
assert p.reporting_utilities == len(FIXTURE["odi_status"])
|
||||||
|
assert "utilities reporting nationwide" in p.coverage_note()
|
||||||
|
|
||||||
|
|
||||||
|
def test_odin_schema_canary_trips_on_unparseable_payload():
|
||||||
|
odi, cmap, status = FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"]
|
||||||
|
renamed_odi = {"outage": [{"county": "36067", "count": 11}]} # fields renamed upstream
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
providers.parse_odin(renamed_odi, cmap, status)
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
providers.parse_odin(odi, [{"fips": "36067"}], status)
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
providers.parse_odin(odi, cmap, [{"utility": "x"}])
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
providers.parse_odin([], cmap, status) # wrapper changed
|
||||||
|
# A genuinely quiet day is NOT an error: nothing upstream, nothing parsed.
|
||||||
|
assert providers.parse_odin({"outage": []}, [], status)[0] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_area_in_bbox_uses_county_extent():
|
||||||
|
a = base.make_area("odin", "36067", county="Onondaga", state="New York", customers_out=1,
|
||||||
|
lat=43.0, lon=-76.2, bbox=(42.77, -76.5, 43.27, -75.9))
|
||||||
|
assert base.area_in_bbox(a, (43.2, -76.0, 43.5, -75.5)) # overlaps the county's corner only
|
||||||
|
assert not base.area_in_bbox(a, (44.0, -76.0, 44.5, -75.5))
|
||||||
@@ -4,6 +4,7 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { ReplayTab } from "@/components/admin/ReplayTab";
|
import { ReplayTab } from "@/components/admin/ReplayTab";
|
||||||
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
|
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
|
||||||
|
import { OutageProvidersTab } from "@/components/admin/OutageProvidersTab";
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
||||||
@@ -1268,11 +1269,12 @@ function SttEvalTab() {
|
|||||||
// Main admin page
|
// Main admin page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type AdminTab = "features" | "cameras" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
|
type AdminTab = "features" | "cameras" | "outages" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
|
||||||
|
|
||||||
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||||||
{ key: "features", label: "AI Features" },
|
{ key: "features", label: "AI Features" },
|
||||||
{ key: "cameras", label: "Camera providers" },
|
{ key: "cameras", label: "Camera providers" },
|
||||||
|
{ key: "outages", label: "Outage providers" },
|
||||||
{ key: "correlation", label: "Correlation Debug" },
|
{ key: "correlation", label: "Correlation Debug" },
|
||||||
{ key: "replay", label: "Replay" },
|
{ key: "replay", label: "Replay" },
|
||||||
{ key: "calls", label: "Calls" },
|
{ key: "calls", label: "Calls" },
|
||||||
@@ -1285,7 +1287,7 @@ export default function AdminPage() {
|
|||||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tab, setTab] = useState<AdminTab>("features");
|
const [tab, setTab] = useState<AdminTab>("features");
|
||||||
// Deep link to a tab (/admin#cameras), e.g. from the live wall's empty state.
|
// Deep link to a tab (/admin#cameras, /admin#outages), e.g. from a map layer's empty state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = window.location.hash.slice(1);
|
const h = window.location.hash.slice(1);
|
||||||
if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab);
|
if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab);
|
||||||
@@ -1304,7 +1306,7 @@ export default function AdminPage() {
|
|||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
// Users/Audit tabs benefit from full width; everything else is narrow
|
// Users/Audit tabs benefit from full width; everything else is narrow
|
||||||
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras";
|
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||||
@@ -1326,6 +1328,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{tab === "features" && <FeaturesTab />}
|
{tab === "features" && <FeaturesTab />}
|
||||||
{tab === "cameras" && <CameraProvidersTab />}
|
{tab === "cameras" && <CameraProvidersTab />}
|
||||||
|
{tab === "outages" && <OutageProvidersTab />}
|
||||||
{tab === "correlation" && <CorrelationDebugTab />}
|
{tab === "correlation" && <CorrelationDebugTab />}
|
||||||
{tab === "replay" && <ReplayTab />}
|
{tab === "replay" && <ReplayTab />}
|
||||||
{tab === "calls" && <StaleCallsTab />}
|
{tab === "calls" && <StaleCallsTab />}
|
||||||
|
|||||||
@@ -8,105 +8,15 @@
|
|||||||
* memorable thing per §2.4: "where was he when he said that" is answered
|
* memorable thing per §2.4: "where was he when he said that" is answered
|
||||||
* by looking, not cross-referencing timestamps.
|
* by looking, not cross-referencing timestamps.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import type { CallRecord } from "@/lib/types";
|
import type { CallRecord } from "@/lib/types";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { InlinePlayer } from "@/components/InlinePlayer";
|
||||||
|
import { hasAudio as callHasAudio } from "@/lib/audioQueue";
|
||||||
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
|
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
|
||||||
|
|
||||||
function fmtTime(iso: string): string {
|
function fmtTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
}
|
}
|
||||||
function fmtClock(s: number): string {
|
|
||||||
if (!Number.isFinite(s)) return "0:00";
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
const r = Math.floor(s % 60);
|
|
||||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlinePlayer({ callId }: { callId: string }) {
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
const [current, setCurrent] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
|
|
||||||
async function ensureUrl() {
|
|
||||||
if (url || loading) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const full = await c2api.getCall(callId);
|
|
||||||
setUrl(full.audio_url ?? null);
|
|
||||||
if (!full.audio_url) setError(true);
|
|
||||||
} catch {
|
|
||||||
setError(true);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggle() {
|
|
||||||
if (!url) {
|
|
||||||
await ensureUrl();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = audioRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (playing) el.pause();
|
|
||||||
else el.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the URL lands, autoplay (the click that fetched it was the play intent).
|
|
||||||
useEffect(() => {
|
|
||||||
if (url && audioRef.current) audioRef.current.play().catch(() => {});
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <span className="text-xs text-sev-major">Audio unavailable</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
onClick={toggle}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
|
|
||||||
title={playing ? "Pause" : "Play"}
|
|
||||||
>
|
|
||||||
{loading ? "…" : playing ? "❚❚" : "▶"}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={duration || 0}
|
|
||||||
value={current}
|
|
||||||
onChange={(e) => {
|
|
||||||
const t = Number(e.target.value);
|
|
||||||
if (audioRef.current) audioRef.current.currentTime = t;
|
|
||||||
setCurrent(t);
|
|
||||||
}}
|
|
||||||
className="flex-1 h-1 accent-accent"
|
|
||||||
disabled={!url}
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
|
||||||
{fmtClock(current)}
|
|
||||||
</span>
|
|
||||||
{url && (
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={url}
|
|
||||||
onPlay={() => setPlaying(true)}
|
|
||||||
onPause={() => setPlaying(false)}
|
|
||||||
onEnded={() => setPlaying(false)}
|
|
||||||
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
|
|
||||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
|
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
|
||||||
if (!stopNumber) {
|
if (!stopNumber) {
|
||||||
@@ -132,7 +42,7 @@ export function CallSpineEntry({
|
|||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
const hasAudio = callHasAudio(call);
|
||||||
const transcript = call.transcript_corrected || call.transcript;
|
const transcript = call.transcript_corrected || call.transcript;
|
||||||
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
|
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
|
||||||
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
|
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-to-listen control for one call — server-26#233. Moved out of
|
||||||
|
* CallSpineEntry.tsx so the live wall's spine, the map's path-stop popups and
|
||||||
|
* rail cards share it. It no longer owns an <audio> element: playback goes
|
||||||
|
* through lib/audioQueue.ts's single element, so starting a call anywhere
|
||||||
|
* stops whatever else was playing, and the map can ring the stop that's
|
||||||
|
* speaking.
|
||||||
|
*
|
||||||
|
* Every hook runs before any early return (the server-26#109 item 2 bug was a
|
||||||
|
* return between hooks). Nothing plays until the button is clicked.
|
||||||
|
*/
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { canPlayAudio, seek, toggle, useAudio } from "@/lib/audioQueue";
|
||||||
|
|
||||||
|
function fmtClock(s: number): string {
|
||||||
|
if (!Number.isFinite(s)) return "0:00";
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const r = Math.floor(s % 60);
|
||||||
|
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just the round play/pause button — for map popups, rail cards, spine rows. */
|
||||||
|
export function PlayButton({ callId, label, className = "" }: { callId: string; label?: string; className?: string }) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const active = useAudio((s) => s.playingCallId === callId);
|
||||||
|
const status = useAudio((s) => (s.playingCallId === callId ? s.status : "idle"));
|
||||||
|
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
|
||||||
|
if (!canPlayAudio(auth)) return null;
|
||||||
|
const playing = active && status === "playing";
|
||||||
|
const loading = active && status === "loading";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggle(callId); }}
|
||||||
|
disabled={loading}
|
||||||
|
title={failed ? "Audio unavailable — click to retry" : playing ? "Pause" : label ?? "Play this call"}
|
||||||
|
aria-label={playing ? "Pause call audio" : label ?? "Play call audio"}
|
||||||
|
aria-pressed={playing}
|
||||||
|
className={`w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[10px] disabled:opacity-50 ${failed ? "bg-raised text-sev-major border border-sev-major" : "bg-accent text-white"} ${className}`}
|
||||||
|
>
|
||||||
|
{loading ? "…" : playing ? "❚❚" : failed ? "!" : "▶"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button + seek bar + clock. `compact` shows only the button until this call
|
||||||
|
* is the one playing, then expands — for dense lists like the wall's spine.
|
||||||
|
*/
|
||||||
|
export function InlinePlayer({ callId, compact = false }: { callId: string; compact?: boolean }) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const active = useAudio((s) => s.playingCallId === callId);
|
||||||
|
const current = useAudio((s) => (s.playingCallId === callId ? s.current : 0));
|
||||||
|
const duration = useAudio((s) => (s.playingCallId === callId ? s.duration : 0));
|
||||||
|
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
|
||||||
|
|
||||||
|
if (!canPlayAudio(auth)) return null;
|
||||||
|
if (failed && !compact) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<PlayButton callId={callId} />
|
||||||
|
<span className="text-xs text-sev-major">Audio unavailable</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (compact && !active) return <PlayButton callId={callId} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<PlayButton callId={callId} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
step="any"
|
||||||
|
value={Math.min(current, duration || 0)}
|
||||||
|
onChange={(e) => seek(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1 accent-accent"
|
||||||
|
disabled={!active || !duration}
|
||||||
|
aria-label="Seek"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
||||||
|
{fmtClock(current)}{duration ? ` / ${fmtClock(duration)}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import { TimeScrubber } from "@/components/TimeScrubber";
|
|||||||
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
|
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
|
||||||
import { CameraDrill } from "@/components/live/CameraDrill";
|
import { CameraDrill } from "@/components/live/CameraDrill";
|
||||||
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
|
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
|
||||||
|
import { TruthStrip } from "@/components/live/TruthStrip";
|
||||||
|
import { usePipelineHealth } from "@/lib/usePipelineHealth";
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
@@ -93,12 +95,11 @@ export function LiveView() {
|
|||||||
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
|
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
|
||||||
const { aircraft } = useAircraft();
|
const { aircraft } = useAircraft();
|
||||||
const { vessels } = useVessels();
|
const { vessels } = useVessels();
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
||||||
const [clock, setClock] = useState<string>("");
|
const [clock, setClock] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
// No heartbeat-driven "last updated" any more (server-26#232): a node
|
||||||
if (!nodesLoading) setLastUpdated(new Date());
|
// check-in kept '● Live' fresh while STT/correlation were dead (#196). The
|
||||||
}, [nodes, activeCalls, activeIncidents, nodesLoading]);
|
// wall's liveness now comes from usePipelineHealth, keyed to pipeline output.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
|
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
|
||||||
tick();
|
tick();
|
||||||
@@ -156,6 +157,13 @@ export function LiveView() {
|
|||||||
}, [pointsById, cams.cameras]);
|
}, [pointsById, cams.cameras]);
|
||||||
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
|
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
|
||||||
const events = useRegionEvents(regionBox);
|
const events = useRegionEvents(regionBox);
|
||||||
|
const health = usePipelineHealth({
|
||||||
|
calls: recentCalls,
|
||||||
|
incidents: activeIncidents,
|
||||||
|
nodes,
|
||||||
|
eventsStatus: events.events_status,
|
||||||
|
cameraProviders: cams.providers,
|
||||||
|
});
|
||||||
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
|
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
|
||||||
|
|
||||||
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
|
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
|
||||||
@@ -220,13 +228,14 @@ export function LiveView() {
|
|||||||
|
|
||||||
// ── KPIs ──────────────────────────────────────────────────────────────────
|
// ── KPIs ──────────────────────────────────────────────────────────────────
|
||||||
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
||||||
const nodesUp = nodes.filter((n) => n.status !== "offline").length;
|
// "Nodes hearing": a call in the last N min, not merely not-offline — a node
|
||||||
|
// that checks in but hears nothing is not contributing to the picture.
|
||||||
|
const nodesHearing = health.nodesHearing;
|
||||||
const eventsOff = !!events.events_status?.disabled;
|
const eventsOff = !!events.events_status?.disabled;
|
||||||
|
|
||||||
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0;
|
// "Listening" only when every stage is healthy; otherwise the amber bar says why.
|
||||||
const mostRecentSeen = configuredButQuiet
|
const configuredButQuiet = !nodesLoading && !callsLoading && nodes.length > 0
|
||||||
? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1)
|
&& activeIncidents.length === 0 && !health.degraded;
|
||||||
: null;
|
|
||||||
|
|
||||||
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
|
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
|
||||||
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
|
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
|
||||||
@@ -251,13 +260,14 @@ export function LiveView() {
|
|||||||
<Kpi value={count("major")} label="major" />
|
<Kpi value={count("major")} label="major" />
|
||||||
<Kpi value={count("moderate")} label="moderate" />
|
<Kpi value={count("moderate")} label="moderate" />
|
||||||
<Kpi value={activeIncidents.length} label="active" />
|
<Kpi value={activeIncidents.length} label="active" />
|
||||||
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" />
|
<Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
|
||||||
<Kpi value={cams.cameras.length} label="cams" />
|
<Kpi value={cams.cameras.length} label="cams" />
|
||||||
<Kpi value={aircraft.length} label="aircraft" />
|
<Kpi value={aircraft.length} label="aircraft" />
|
||||||
<Kpi value={vessels.length} label="vessels" />
|
<Kpi value={vessels.length} label="vessels" />
|
||||||
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
|
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<TruthStrip health={health} placement="chips" />
|
||||||
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
|
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
|
||||||
title="Wall mode: cycle through active incidents, worst first"
|
title="Wall mode: cycle through active incidents, worst first"
|
||||||
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
||||||
@@ -271,9 +281,13 @@ export function LiveView() {
|
|||||||
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TruthStrip health={health} placement="bar" />
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
|
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
|
||||||
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
|
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
|
||||||
|
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
||||||
|
MapView, so it is wrapped here rather than edited). */}
|
||||||
|
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
|
||||||
{nodesLoading || callsLoading ? (
|
{nodesLoading || callsLoading ? (
|
||||||
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -282,7 +296,6 @@ export function LiveView() {
|
|||||||
activeCalls={activeCalls}
|
activeCalls={activeCalls}
|
||||||
incidents={activeIncidents}
|
incidents={activeIncidents}
|
||||||
calls={recentCalls}
|
calls={recentCalls}
|
||||||
lastUpdated={lastUpdated}
|
|
||||||
selectedIncidentId={selected?.incident_id ?? null}
|
selectedIncidentId={selected?.incident_id ?? null}
|
||||||
onSelectIncident={selectIncident}
|
onSelectIncident={selectIncident}
|
||||||
nearCameras={near}
|
nearCameras={near}
|
||||||
@@ -291,13 +304,14 @@ export function LiveView() {
|
|||||||
camCountByIncident={camCountByIncident}
|
camCountByIncident={camCountByIncident}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
||||||
Activation screen handles the zero-node case). */}
|
Activation screen handles the zero-node case). */}
|
||||||
{configuredButQuiet && (
|
{configuredButQuiet && (
|
||||||
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||||
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
||||||
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
|
● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Ev
|
|||||||
import { CameraFeed } from "@/components/CameraFeed";
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
|
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
|
||||||
|
import { PlayButton } from "@/components/InlinePlayer";
|
||||||
|
import { hasAudio, useAudio } from "@/lib/audioQueue";
|
||||||
|
|
||||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||||
@@ -788,20 +790,26 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Incident path stop marker — numbered, shared index with the call spine ───
|
// ── Incident path stop marker — numbered, shared index with the call spine ───
|
||||||
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon {
|
// `playing` (server-26#233): the stop whose call is on the speaker gets a
|
||||||
const size = 22;
|
// thick accent ring outside the severity halo — "where was he when he said
|
||||||
|
// that", answered while it is being said.
|
||||||
|
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string, playing = false): L.DivIcon {
|
||||||
|
const size = playing ? 30 : 22;
|
||||||
|
const c = size / 2;
|
||||||
const fill = isFirst ? "var(--surface)" : color;
|
const fill = isFirst ? "var(--surface)" : color;
|
||||||
const textColor = isFirst ? color : "var(--page)";
|
const textColor = isFirst ? color : "var(--page)";
|
||||||
const halo = isLast ? `<circle cx="11" cy="11" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
|
const halo = isLast ? `<circle cx="${c}" cy="${c}" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
|
||||||
|
const ring = playing ? `<circle cx="${c}" cy="${c}" r="13.5" fill="none" stroke="var(--accent)" stroke-width="3"/>` : "";
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<svg width="${size}" height="${size}" viewBox="0 0 22 22">
|
html: `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
${ring}
|
||||||
${halo}
|
${halo}
|
||||||
<circle cx="11" cy="11" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
|
<circle cx="${c}" cy="${c}" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
|
||||||
<text x="11" y="14.5" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
|
<text x="${c}" y="${c + 3.5}" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
|
||||||
</svg>`,
|
</svg>`,
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [c, c],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,6 +1031,8 @@ function IncidentPathLayer({
|
|||||||
incidents: IncidentRecord[];
|
incidents: IncidentRecord[];
|
||||||
callsByIncident: Map<string, CallRecord[]>;
|
callsByIncident: Map<string, CallRecord[]>;
|
||||||
}) {
|
}) {
|
||||||
|
// Only the playing id, so a timeupdate never re-renders the map.
|
||||||
|
const playingCallId = useAudio((s) => s.playingCallId);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{incidents.map((inc) => {
|
{incidents.map((inc) => {
|
||||||
@@ -1043,13 +1053,20 @@ function IncidentPathLayer({
|
|||||||
<Marker
|
<Marker
|
||||||
key={c.call_id}
|
key={c.call_id}
|
||||||
position={[c.location_coords!.lat, c.location_coords!.lng]}
|
position={[c.location_coords!.lat, c.location_coords!.lng]}
|
||||||
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color)}
|
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color, c.call_id === playingCallId)}
|
||||||
|
zIndexOffset={c.call_id === playingCallId ? 1000 : 0}
|
||||||
>
|
>
|
||||||
<Popup minWidth={160}>
|
<Popup minWidth={160}>
|
||||||
<div className="text-gray-900">
|
<div className="text-gray-900">
|
||||||
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
|
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
|
||||||
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
|
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
|
||||||
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
|
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
|
||||||
|
{hasAudio(c) && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
<PlayButton callId={c.call_id} label={`Play stop ${i + 1}`} />
|
||||||
|
<span className="text-xs text-gray-600">Play this call</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
||||||
View incident →
|
View incident →
|
||||||
</a>
|
</a>
|
||||||
@@ -1388,6 +1405,11 @@ export default function MapView({
|
|||||||
const live = onAir?.has(inc.incident_id) ?? false;
|
const live = onAir?.has(inc.incident_id) ?? false;
|
||||||
const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
|
const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
|
||||||
const callCount = inc.call_ids?.length ?? 0;
|
const callCount = inc.call_ids?.length ?? 0;
|
||||||
|
// "Play latest" (server-26#233): newest call of this incident in
|
||||||
|
// the loaded calls that has a recording.
|
||||||
|
const latest = (callsByIncident.get(inc.incident_id) ?? [])
|
||||||
|
.filter(hasAudio)
|
||||||
|
.reduce<CallRecord | null>((best, c) => (!best || c.started_at > best.started_at ? c : best), null);
|
||||||
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
|
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
|
||||||
const cardBody = (
|
const cardBody = (
|
||||||
<>
|
<>
|
||||||
@@ -1425,28 +1447,37 @@ export default function MapView({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
// The play control is a sibling of the card, not inside it — a
|
||||||
|
// <button> can't nest in the card's <button>/<a>.
|
||||||
|
const playLatest = latest && (
|
||||||
|
<PlayButton callId={latest.call_id} label="Play latest call" className="absolute bottom-2 right-2 shadow" />
|
||||||
|
);
|
||||||
if (inc.location_coords || onSelectIncident) {
|
if (inc.location_coords || onSelectIncident) {
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={inc.incident_id} className="relative shrink-0">
|
||||||
key={inc.incident_id}
|
<button
|
||||||
onClick={() => handleIncidentSelect(inc)}
|
onClick={() => handleIncidentSelect(inc)}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
className={baseClass}
|
className={`${baseClass} ${latest ? "pr-10" : ""}`}
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: color + "55" }}
|
||||||
>
|
>
|
||||||
{cardBody}
|
{cardBody}
|
||||||
</button>
|
</button>
|
||||||
|
{playLatest}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<a
|
<div key={inc.incident_id} className="relative shrink-0">
|
||||||
key={inc.incident_id}
|
<a
|
||||||
href={`/incidents/${inc.incident_id}`}
|
href={`/incidents/${inc.incident_id}`}
|
||||||
className={`block ${baseClass}`}
|
className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: color + "55" }}
|
||||||
>
|
>
|
||||||
{cardBody}
|
{cardBody}
|
||||||
</a>
|
</a>
|
||||||
|
{playLatest}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin → Outage providers (server-26#184). Same licence gate as Camera
|
||||||
|
* providers: every source is listed with its licence status shown first, all
|
||||||
|
* default OFF. A licence-gated source (unverified / agreement required / paid)
|
||||||
|
* shows a blocking warning and needs an explicit confirmation, which c2-core
|
||||||
|
* records with who/when in config/outage_providers and audit_log. Stubs —
|
||||||
|
* utility storm-center maps, PowerOutage.us, EAGLE-I live — cannot be switched
|
||||||
|
* on at all: DRB holds no right to that data.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
|
||||||
|
|
||||||
|
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
||||||
|
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
||||||
|
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
|
||||||
|
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||||
|
paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||||
|
prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtEpoch(s: number | null | undefined): string {
|
||||||
|
return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutageProvidersTab() {
|
||||||
|
const [providers, setProviders] = useState<OutageProviderAdmin[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState<OutageProviderAdmin | null>(null);
|
||||||
|
const [ackChecked, setAckChecked] = useState(false);
|
||||||
|
|
||||||
|
const load = () =>
|
||||||
|
c2api.listOutageProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function toggle(p: OutageProviderAdmin, enabled: boolean, acknowledge = false) {
|
||||||
|
if (enabled && p.licence.gated && !acknowledge) {
|
||||||
|
setError(null);
|
||||||
|
setAckChecked(false);
|
||||||
|
setConfirming(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(p.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await c2api.setOutageProvider(p.id, enabled, acknowledge);
|
||||||
|
setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null);
|
||||||
|
setConfirming(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-gray-400 text-xs leading-snug">
|
||||||
|
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
||||||
|
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
|
||||||
|
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
|
||||||
|
agreement exists.
|
||||||
|
</p>
|
||||||
|
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
||||||
|
{!providers ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||||
|
{providers.map((p) => {
|
||||||
|
const lic = LICENCE_STYLE[p.licence.status];
|
||||||
|
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="px-5 py-4 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
||||||
|
<p className="text-white text-sm font-semibold">{p.name}</p>
|
||||||
|
<p className="text-gray-400 text-xs">{p.licence.label}</p>
|
||||||
|
<p className="text-gray-500 text-xs">{p.region} · {p.granularity}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={p.enabled}
|
||||||
|
aria-label={`${p.enabled ? "Disable" : "Enable"} ${p.name}`}
|
||||||
|
disabled={busy === p.id || (!!lockReason && !p.enabled)}
|
||||||
|
title={lockReason ?? undefined}
|
||||||
|
onClick={() => toggle(p, !p.enabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-40 ${p.enabled ? "bg-indigo-600" : "bg-gray-700"}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||||
|
</button>
|
||||||
|
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
|
||||||
|
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.implemented && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-gray-400">
|
||||||
|
<span>counties out: {p.health ? p.health.count : "—"}</span>
|
||||||
|
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
||||||
|
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
||||||
|
{p.coverage_note && <p className="text-[11px] text-gray-400">{p.coverage_note}</p>}
|
||||||
|
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with the data: “{p.licence.attribution}”</p>}
|
||||||
|
{p.acknowledgement && (
|
||||||
|
<p className="text-[11px] text-amber-300">
|
||||||
|
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<details className="text-[11px] text-gray-500">
|
||||||
|
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
||||||
|
<p className="mt-1">{p.licence.terms}</p>
|
||||||
|
<p className="mt-1">{p.notes}</p>
|
||||||
|
<p className="mt-1 space-x-2">
|
||||||
|
{p.licence.terms_url && <a className="underline" href={p.licence.terms_url} target="_blank" rel="noopener noreferrer">terms</a>}
|
||||||
|
{p.homepage && <a className="underline" href={p.homepage} target="_blank" rel="noopener noreferrer">source</a>}
|
||||||
|
{p.licence.issue && <span>tracking: {p.licence.issue}</span>}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirming && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
|
||||||
|
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
||||||
|
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
||||||
|
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
||||||
|
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
|
||||||
|
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
|
||||||
|
<label className="flex items-start gap-2 text-sm text-gray-200">
|
||||||
|
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
||||||
|
<span>I confirm DRB holds the right to show this source's outage data. This confirmation is recorded with my account and the time in the audit log.</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
|
||||||
|
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
||||||
|
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truth strip (server-26#232) — replaces the heartbeat-driven '● Live' pill.
|
||||||
|
*
|
||||||
|
* Healthy: compact per-stage chips (Radio · Transcripts · AI · Feeds), each
|
||||||
|
* with its last-output age. Any core stage dry past the threshold, or any
|
||||||
|
* /health/ai tier degraded: a full-width amber bar that says what broke and
|
||||||
|
* since when — "DEGRADED — radio heard, not transcribed since 12:15".
|
||||||
|
* Theme tokens only (sev-moderate is the amber), so both themes follow.
|
||||||
|
*/
|
||||||
|
import type { PipelineHealth, PipelineStage } from "@/lib/usePipelineHealth";
|
||||||
|
|
||||||
|
function dotClass(s: PipelineStage["state"]): string {
|
||||||
|
if (s === "degraded") return "bg-sev-moderate";
|
||||||
|
if (s === "unknown") return "bg-ink-muted";
|
||||||
|
return "bg-ink-2";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TruthChips({ health }: { health: PipelineHealth }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap" role="status" aria-label="Pipeline health">
|
||||||
|
{health.stages.map((s) => (
|
||||||
|
<span key={s.key} title={s.detail}
|
||||||
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[11px] whitespace-nowrap ${
|
||||||
|
s.state === "degraded" ? "border-sev-moderate text-ink bg-sev-moderate/10" : "border-line text-ink-2"}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${dotClass(s.state)}`} aria-hidden="true" />
|
||||||
|
{s.label}
|
||||||
|
<span className="sr-only">: {s.state === "ok" ? "healthy" : s.state}, {s.detail}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TruthBar({ health }: { health: PipelineHealth }) {
|
||||||
|
if (!health.degraded || !health.message) return null;
|
||||||
|
return (
|
||||||
|
<div role="alert"
|
||||||
|
className="shrink-0 w-full px-3 py-1.5 border-b border-sev-moderate bg-sev-moderate/15 text-xs text-ink flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<b className="font-bold tracking-wide">DEGRADED</b>
|
||||||
|
<span>— {health.message}</span>
|
||||||
|
<span className="ml-auto text-ink-muted whitespace-nowrap">
|
||||||
|
stale after {health.staleMin} min · map shows last-known state
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chips for the wall bar, plus the bar itself directly below it when degraded. */
|
||||||
|
export function TruthStrip({ health, placement }: { health: PipelineHealth; placement: "chips" | "bar" }) {
|
||||||
|
return placement === "chips" ? <TruthChips health={health} /> : <TruthBar health={health} />;
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { CameraFeed } from "@/components/CameraFeed";
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
|
import { InlinePlayer } from "@/components/InlinePlayer";
|
||||||
|
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
|
||||||
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
|
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
|
||||||
|
|
||||||
function sevColor(s: string | null | undefined) {
|
function sevColor(s: string | null | undefined) {
|
||||||
@@ -31,6 +33,8 @@ function hhmm(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
|
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
|
||||||
|
// Spine rows rendered before "show all loaded" (server-26#239).
|
||||||
|
const SPINE_CAP = 30;
|
||||||
const SEC = "px-4 py-3 border-b border-line";
|
const SEC = "px-4 py-3 border-b border-line";
|
||||||
|
|
||||||
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
|
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
|
||||||
@@ -183,6 +187,20 @@ export function IncidentPanel({
|
|||||||
return m;
|
return m;
|
||||||
}, [calls]);
|
}, [calls]);
|
||||||
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
|
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
|
||||||
|
// "Play incident" (server-26#233): every loaded call with a recording, oldest first.
|
||||||
|
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
|
||||||
|
const auth = useAuth();
|
||||||
|
const audioOk = canPlayAudio(auth);
|
||||||
|
const playingCallId = useAudio((s) => s.playingCallId);
|
||||||
|
const queueLen = useAudio((s) => s.queue.length);
|
||||||
|
const playingHere = !!playingCallId && spine.some((c) => c.call_id === playingCallId);
|
||||||
|
// Truncation is said out loud, not silent (server-26#239): the spine shows
|
||||||
|
// what this page has loaded, which can be less than the incident holds.
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
useEffect(() => { setShowAll(false); }, [inc.incident_id]);
|
||||||
|
const totalCalls = Math.max(inc.call_ids?.length ?? 0, calls.length);
|
||||||
|
const outsideWindow = Math.max(0, (inc.call_ids?.length ?? 0) - calls.length);
|
||||||
|
const shown = showAll ? spine : spine.slice(0, SPINE_CAP);
|
||||||
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
|
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
|
||||||
const cleared = inc.units_cleared ?? [];
|
const cleared = inc.units_cleared ?? [];
|
||||||
|
|
||||||
@@ -270,23 +288,57 @@ export function IncidentPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
<h3 className={H3}>Call spine · newest first</h3>
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<h3 className={`${H3} mb-0`}>Call spine · newest first</h3>
|
||||||
|
{audioOk && playable.length > 0 && (
|
||||||
|
playingHere ? (
|
||||||
|
<button type="button" onClick={stopAudio}
|
||||||
|
className="ml-auto text-[10px] px-1.5 py-0.5 rounded border border-line text-ink-2 hover:bg-raised">
|
||||||
|
■ Stop{queueLen ? ` · ${queueLen} queued` : ""}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => playIncident(playable)}
|
||||||
|
title="Play this incident's loaded calls back to back, oldest first"
|
||||||
|
className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-accent text-white hover:brightness-110">
|
||||||
|
▶ Play incident · {playable.length}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{spine.length > SPINE_CAP || outsideWindow > 0 ? (
|
||||||
|
<p className="text-[10px] text-ink-muted mb-1.5">
|
||||||
|
{showAll || spine.length <= SPINE_CAP
|
||||||
|
? `Showing all ${spine.length} loaded of ${totalCalls}`
|
||||||
|
: `Showing latest ${SPINE_CAP} of ${totalCalls}`}
|
||||||
|
{outsideWindow > 0 && ` · ${outsideWindow} older call${outsideWindow !== 1 ? "s" : ""} outside the loaded window`}
|
||||||
|
{spine.length > SPINE_CAP && (
|
||||||
|
<button type="button" onClick={() => setShowAll((v) => !v)} className="ml-1.5 text-accent hover:underline">
|
||||||
|
{showAll ? "show latest 30" : "show all loaded"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{spine.length ? (
|
{spine.length ? (
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{spine.slice(0, 30).map((c) => {
|
{shown.map((c) => {
|
||||||
const stop = stopOf.get(c.call_id);
|
const stop = stopOf.get(c.call_id);
|
||||||
const text = c.transcript_corrected || c.transcript;
|
const text = c.transcript_corrected || c.transcript;
|
||||||
|
const isPlaying = c.call_id === playingCallId;
|
||||||
return (
|
return (
|
||||||
<li key={c.call_id} className="flex gap-2 text-xs">
|
<li key={c.call_id}
|
||||||
|
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
|
||||||
|
aria-current={isPlaying ? "true" : undefined}>
|
||||||
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
|
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
|
||||||
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
|
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex gap-1.5 items-center">
|
<div className="flex gap-1.5 items-center">
|
||||||
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
||||||
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
|
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
|
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
|
||||||
|
{isPlaying && hasAudio(c) && <div className="mt-1"><InlinePlayer callId={c.call_id} /></div>}
|
||||||
</div>
|
</div>
|
||||||
|
{hasAudio(c) && !isPlaying && <InlinePlayer callId={c.call_id} compact />}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call-audio playback store — server-26#233.
|
||||||
|
*
|
||||||
|
* ONE module-level <audio> element for the whole app, so exactly one call
|
||||||
|
* plays at a time no matter how many players are on screen (spine rows, map
|
||||||
|
* path-stop popups, rail cards, the incident page). Components read it through
|
||||||
|
* useSyncExternalStore, which is what lets the wall panel and the map share
|
||||||
|
* "now playing" without LiveView.tsx threading state between them.
|
||||||
|
*
|
||||||
|
* Nothing here runs until play()/playIncident() is called from a click, so
|
||||||
|
* nothing autoplays on page load. The playback URL is minted per play by
|
||||||
|
* GET /calls/{id} (a short-lived HMAC link, see c2-core storage.py) rather
|
||||||
|
* than read off the Firestore doc, which only holds the gs:// location.
|
||||||
|
* c2-core serves it with HTTP Range, so a long call starts after the first
|
||||||
|
* span and seeking is a 206 for just the bytes needed.
|
||||||
|
*/
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { CallRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
export type AudioStatus = "idle" | "loading" | "playing" | "paused";
|
||||||
|
|
||||||
|
export interface AudioState {
|
||||||
|
/** Calls still to play after the current one, oldest first. */
|
||||||
|
queue: string[];
|
||||||
|
playingCallId: string | null;
|
||||||
|
status: AudioStatus;
|
||||||
|
/** The last call whose audio could not be fetched or decoded. */
|
||||||
|
errorCallId: string | null;
|
||||||
|
current: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE: AudioState = {
|
||||||
|
queue: [], playingCallId: null, status: "idle", errorCallId: null, current: 0, duration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let state: AudioState = IDLE;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let el: HTMLAudioElement | null = null;
|
||||||
|
// Bumped on every play/stop so a getCall() that resolves after the user has
|
||||||
|
// already moved on is dropped instead of hijacking the player.
|
||||||
|
let token = 0;
|
||||||
|
|
||||||
|
function set(patch: Partial<AudioState>) {
|
||||||
|
state = { ...state, ...patch };
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
function audio(): HTMLAudioElement {
|
||||||
|
if (el) return el;
|
||||||
|
el = new Audio();
|
||||||
|
el.preload = "auto";
|
||||||
|
el.addEventListener("play", () => set({ status: "playing" }));
|
||||||
|
el.addEventListener("pause", () => { if (state.status === "playing") set({ status: "paused" }); });
|
||||||
|
el.addEventListener("timeupdate", () => set({ current: el!.currentTime }));
|
||||||
|
el.addEventListener("loadedmetadata", () => set({ duration: Number.isFinite(el!.duration) ? el!.duration : 0 }));
|
||||||
|
el.addEventListener("ended", advance);
|
||||||
|
el.addEventListener("error", () => {
|
||||||
|
if (!state.playingCallId || !el!.getAttribute("src")) return;
|
||||||
|
set({ errorCallId: state.playingCallId });
|
||||||
|
advance();
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance() {
|
||||||
|
const [next, ...rest] = state.queue;
|
||||||
|
if (next) void start(next, rest);
|
||||||
|
else stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(callId: string, queue: string[]) {
|
||||||
|
const mine = ++token;
|
||||||
|
const a = audio();
|
||||||
|
a.pause();
|
||||||
|
set({ queue, playingCallId: callId, status: "loading", current: 0, duration: 0,
|
||||||
|
errorCallId: state.errorCallId === callId ? null : state.errorCallId });
|
||||||
|
let url: string | null = null;
|
||||||
|
try {
|
||||||
|
url = (await c2api.getCall(callId)).audio_url ?? null;
|
||||||
|
} catch {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
if (mine !== token) return;
|
||||||
|
if (!url) {
|
||||||
|
set({ errorCallId: callId });
|
||||||
|
advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
a.src = url;
|
||||||
|
// The click that got us here is the play intent; a rejection (autoplay
|
||||||
|
// policy after a slow fetch) leaves it paused for one more click.
|
||||||
|
a.play().catch(() => { if (mine === token) set({ status: "paused" }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play one call now, replacing whatever is playing or queued. */
|
||||||
|
export function play(callId: string) {
|
||||||
|
void start(callId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play an incident's calls back to back. Pass them oldest first. */
|
||||||
|
export function playIncident(callIdsOldestFirst: string[]) {
|
||||||
|
const [first, ...rest] = callIdsOldestFirst;
|
||||||
|
if (first) void start(first, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stop() {
|
||||||
|
token++;
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.removeAttribute("src");
|
||||||
|
el.load();
|
||||||
|
}
|
||||||
|
set({ ...IDLE, errorCallId: state.errorCallId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pause() {
|
||||||
|
el?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resume() {
|
||||||
|
el?.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seek(t: number) {
|
||||||
|
if (el && Number.isFinite(t)) {
|
||||||
|
el.currentTime = t;
|
||||||
|
set({ current: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play/pause toggle for one call's button. */
|
||||||
|
export function toggle(callId: string) {
|
||||||
|
if (state.playingCallId !== callId) play(callId);
|
||||||
|
else if (state.status === "playing") pause();
|
||||||
|
else if (state.status === "paused") resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(l: () => void): () => void {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(l);
|
||||||
|
// Last player unmounted (navigated away from every page that shows one):
|
||||||
|
// stop rather than keep playing audio nothing on screen can pause.
|
||||||
|
// Deferred so a same-tick remount (StrictMode, route swap) doesn't stop it.
|
||||||
|
if (!listeners.size) setTimeout(() => { if (!listeners.size && state.playingCallId) stop(); }, 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a slice of the store. Return a primitive from `select` (an id,
|
||||||
|
* a status, a number) so a component re-renders only when that slice changes
|
||||||
|
* — the map must not re-render on every timeupdate.
|
||||||
|
*/
|
||||||
|
export function useAudio<T>(select: (s: AudioState) => T): T {
|
||||||
|
return useSyncExternalStore(subscribe, () => select(state), () => select(IDLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a call has a stored recording c2-core can serve. */
|
||||||
|
export function hasAudio(c: Pick<CallRecord, "audio_gcs_uri" | "audio_url">): boolean {
|
||||||
|
return !!(c.audio_gcs_uri || c.audio_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONE gate for call-audio playback in the UI. BUSINESS_MODEL.md §3 puts
|
||||||
|
* audio playback in Scanner and up, with the Public tier getting none; no tier
|
||||||
|
* concept exists in the frontend yet (and Gate A keeps tier names off public
|
||||||
|
* surfaces), so today this is true for any signed-in org member. When tiers
|
||||||
|
* land, switch the Public tier off HERE rather than at each player.
|
||||||
|
*/
|
||||||
|
export function canPlayAudio(auth: { user: unknown; orgId: string | null; isAdmin?: boolean }): boolean {
|
||||||
|
return !!auth.user && (!!auth.orgId || !!auth.isAdmin);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types";
|
import type {
|
||||||
|
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
||||||
|
} from "@/lib/types";
|
||||||
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
||||||
|
|
||||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||||
@@ -514,6 +516,22 @@ export const c2api = {
|
|||||||
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Power outages from every admin-enabled provider inside a bbox (server-26#184).
|
||||||
|
// `status` explains an empty list: none_enabled / no_source / error / ok.
|
||||||
|
getOutages: (bbox: { south: number; west: number; north: number; east: number }) =>
|
||||||
|
request<OutagesResponse>(`/outages?${new URLSearchParams({
|
||||||
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
|
||||||
|
})}`),
|
||||||
|
|
||||||
|
// Admin → Outage providers. Same licence gate as cameras: a gated provider
|
||||||
|
// answers 409 with `requires_acknowledgement` until `acknowledge_licence` is sent.
|
||||||
|
listOutageProviders: () => request<{ providers: OutageProviderAdmin[] }>("/admin/outage-providers"),
|
||||||
|
setOutageProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
|
||||||
|
request<OutageProviderAdmin>(`/admin/outage-providers/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Public waitlist — no auth, see routers/waitlist.py
|
// Public waitlist — no auth, see routers/waitlist.py
|
||||||
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
||||||
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export interface DiscordGuildOption {
|
|||||||
linked: boolean;
|
linked: boolean;
|
||||||
synced: boolean;
|
synced: boolean;
|
||||||
synced_at: string | null;
|
synced_at: string | null;
|
||||||
|
/** Who listed the channels (server-26#225 S5): the DRB server bot, or a radio bot read over REST. */
|
||||||
|
directory_source?: "main_bot" | "radio_bot" | null;
|
||||||
|
/** The DRB server bot is in this server. Not required: a radio bot alone is enough. */
|
||||||
|
main_bot_present?: boolean;
|
||||||
voice_channels: DiscordVoiceChannel[];
|
voice_channels: DiscordVoiceChannel[];
|
||||||
streams_on: number;
|
streams_on: number;
|
||||||
bot: { ok: boolean; code: string | null; message: string | null; invite: DiscordInvite[] };
|
bot: { ok: boolean; code: string | null; message: string | null; invite: DiscordInvite[] };
|
||||||
|
|||||||
@@ -217,6 +217,74 @@ export interface CameraProviderAdmin {
|
|||||||
health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null;
|
health: { fetched_at: number | null; last_attempt_at: number | null; error: string | null; count: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Power outages (server-26#184) — GET /outages, cached server-side, not Firestore.
|
||||||
|
/**
|
||||||
|
* One county with active outages from an enabled provider. `id` is
|
||||||
|
* provider-prefixed ("odin:36067"). Counts are utility-reported meters, used
|
||||||
|
* as customers. `lat`/`lon` is the centre of the county's bounding box — a
|
||||||
|
* label point, not an outage location. Times are ISO-8601 UTC.
|
||||||
|
*/
|
||||||
|
export interface OutageArea {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
fips: string;
|
||||||
|
county: string | null;
|
||||||
|
state: string | null;
|
||||||
|
customers_out: number; // live events only
|
||||||
|
/** Meters on events the source still lists whose own ETR passed >24h ago (ODIN keeps months-old rows). */
|
||||||
|
stale_customers_out: number;
|
||||||
|
stale: boolean; // every event in the county is stale: dim or hide, never show as live
|
||||||
|
customers_served: number | null; // null: no county-level denominator from the source
|
||||||
|
etr: string | null; // latest estimated restoration across the county's outages
|
||||||
|
since: string | null; // earliest reported start
|
||||||
|
updated_at: string | null; // newest time a reporting utility here last sent data
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
bbox: [number, number, number, number] | null; // south, west, north, east
|
||||||
|
utilities: { name: string | null; eia_id: string | null; customers_out: number; stale_customers_out: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutageProviderFeed extends CameraCredit {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
granularity: string;
|
||||||
|
coverage_note: string | null;
|
||||||
|
covers_bbox: boolean;
|
||||||
|
fetched_at: number | null; // epoch s of the provider's last successful fetch
|
||||||
|
error: string | null;
|
||||||
|
total_in_bbox: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutagesResponse {
|
||||||
|
areas: OutageArea[];
|
||||||
|
providers: OutageProviderFeed[];
|
||||||
|
serving_count: number;
|
||||||
|
/** Why the list is what it is — an empty layer is never ambiguous. */
|
||||||
|
status: "ok" | "none_enabled" | "no_source" | "error";
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin → Outage providers (GET /admin/outage-providers). */
|
||||||
|
export interface OutageProviderAdmin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
region: string;
|
||||||
|
granularity: string;
|
||||||
|
implemented: boolean;
|
||||||
|
notes: string;
|
||||||
|
homepage: string | null;
|
||||||
|
licence: CameraProviderAdmin["licence"];
|
||||||
|
enabled: boolean;
|
||||||
|
serving: boolean;
|
||||||
|
not_serving_reason: string | null;
|
||||||
|
acknowledgement: CameraProviderAdmin["acknowledgement"];
|
||||||
|
updated_at: string | null;
|
||||||
|
updated_by: { uid: string; email: string } | null;
|
||||||
|
coverage_note: string | null;
|
||||||
|
health: CameraProviderAdmin["health"];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Ny511Event {
|
export interface Ny511Event {
|
||||||
id: string;
|
id: string;
|
||||||
lat: number;
|
lat: number;
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline truth for the live wall (server-26#232).
|
||||||
|
*
|
||||||
|
* The old '● Live · Ns ago' pill was reset by any node heartbeat, so the wall
|
||||||
|
* read live while transcription/correlation were dead (#196). This hook keys
|
||||||
|
* health to the PIPELINE instead: radio → STT → AI, plus the context feeds.
|
||||||
|
*
|
||||||
|
* Two sources, deliberately both:
|
||||||
|
* - Data timestamps on what the wall already subscribes to (recent calls,
|
||||||
|
* active incidents). These survive a c2-core restart.
|
||||||
|
* - GET /health/ai (main.py, auth-free) — per-tier provider state. It is
|
||||||
|
* in-memory per process and resets on restart, so it can only ADD a
|
||||||
|
* degraded verdict, never clear one the data shows.
|
||||||
|
*/
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { CallRecord, CameraProviderFeed, IncidentRecord, NodeRecord, Ny511FeedStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||||
|
const HEALTH_POLL_MS = 60_000;
|
||||||
|
const TICK_MS = 30_000;
|
||||||
|
|
||||||
|
function envMinutes(): number {
|
||||||
|
const n = Number(process.env.NEXT_PUBLIC_WALL_STALE_MIN);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 10;
|
||||||
|
}
|
||||||
|
/** Minutes a stage may be dry before the wall calls itself degraded. */
|
||||||
|
export const WALL_STALE_MIN = envMinutes();
|
||||||
|
/**
|
||||||
|
* Radio silence and "no incident written" are weaker evidence than "radio
|
||||||
|
* heard but not transcribed" — a quiet county at 4am is not an outage, and the
|
||||||
|
* correlator orphans in doubt — so those two stages get a longer leash.
|
||||||
|
*/
|
||||||
|
const ABSENCE_FACTOR = 3;
|
||||||
|
/**
|
||||||
|
* Calls Whisper returned nothing for (silence, kerchunks, degenerate output)
|
||||||
|
* get no transcript by design, so one or two blank calls after the last
|
||||||
|
* transcript are normal. It takes this many to call STT dry.
|
||||||
|
*/
|
||||||
|
const MIN_UNSERVED = 3;
|
||||||
|
|
||||||
|
// ── /health/ai response (app/internal/ai_health.py snapshot()) ──────────────
|
||||||
|
export interface AiTierState {
|
||||||
|
degraded: boolean;
|
||||||
|
permanent: boolean;
|
||||||
|
provider: string | null;
|
||||||
|
model: string | null;
|
||||||
|
problem: string | null;
|
||||||
|
fix: string | null;
|
||||||
|
first_seen: string | null;
|
||||||
|
last_seen: string | null;
|
||||||
|
consecutive_failures: number;
|
||||||
|
alerted: boolean;
|
||||||
|
}
|
||||||
|
export interface AiHealthResponse {
|
||||||
|
tiers: Record<string, AiTierState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StageKey = "radio" | "stt" | "ai" | "feeds";
|
||||||
|
export type StageState = "ok" | "degraded" | "unknown";
|
||||||
|
|
||||||
|
export interface PipelineStage {
|
||||||
|
key: StageKey;
|
||||||
|
label: string;
|
||||||
|
state: StageState;
|
||||||
|
/** Newest evidence this stage produced output, or null if none in the window. */
|
||||||
|
lastAt: Date | null;
|
||||||
|
/** One-line human explanation, shown as the chip tooltip / bar text. */
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineHealth {
|
||||||
|
stages: PipelineStage[];
|
||||||
|
/** True when a core stage (radio/STT/AI) is dry or an AI tier is degraded. */
|
||||||
|
degraded: boolean;
|
||||||
|
/** Bar text, e.g. "radio heard, not transcribed since 12:15 · transcription tier degraded (openai: …)". */
|
||||||
|
message: string | null;
|
||||||
|
degradedTiers: { tier: string; state: AiTierState }[];
|
||||||
|
/** null until the first /health/ai answer; false if it could not be reached. */
|
||||||
|
aiHealthReachable: boolean | null;
|
||||||
|
/** Distinct nodes with a call started in the last WALL_STALE_MIN minutes. */
|
||||||
|
nodesHearing: number;
|
||||||
|
newestCallAt: Date | null;
|
||||||
|
staleMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAiHealth(): { data: AiHealthResponse | null; reachable: boolean | null } {
|
||||||
|
const [data, setData] = useState<AiHealthResponse | null>(null);
|
||||||
|
const [reachable, setReachable] = useState<boolean | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = () => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
fetch(`${BASE}/health/ai`, { cache: "no-store" })
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||||
|
.then((j: AiHealthResponse) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setData(j && typeof j.tiers === "object" ? j : { tiers: {} });
|
||||||
|
setReachable(true);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error("health/ai:", e);
|
||||||
|
setReachable(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, HEALTH_POLL_MS);
|
||||||
|
const onVis = () => { if (!document.hidden) load(); };
|
||||||
|
document.addEventListener("visibilitychange", onVis);
|
||||||
|
return () => { cancelled = true; clearInterval(id); document.removeEventListener("visibilitychange", onVis); };
|
||||||
|
}, []);
|
||||||
|
return { data, reachable };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = (iso: string | null | undefined): number => {
|
||||||
|
if (!iso) return NaN;
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
return Number.isFinite(t) ? t : NaN;
|
||||||
|
};
|
||||||
|
const maxDate = (vals: number[]): Date | null => {
|
||||||
|
const ok = vals.filter((v) => Number.isFinite(v));
|
||||||
|
return ok.length ? new Date(Math.max(...ok)) : null;
|
||||||
|
};
|
||||||
|
export function clockTime(d: Date): string {
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
|
}
|
||||||
|
function agoText(d: Date | null, now: number): string {
|
||||||
|
if (!d) return "none in window";
|
||||||
|
const s = Math.max(0, Math.floor((now - d.getTime()) / 1000));
|
||||||
|
if (s < 60) return `${s}s ago`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
|
return `${Math.floor(s / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
const hasTranscript = (c: CallRecord) => !!(c.transcript || c.transcript_corrected);
|
||||||
|
const isAiTier = (t: string) => t !== "transcription";
|
||||||
|
|
||||||
|
export interface PipelineInput {
|
||||||
|
calls: CallRecord[];
|
||||||
|
incidents: IncidentRecord[];
|
||||||
|
nodes: NodeRecord[];
|
||||||
|
eventsStatus?: Ny511FeedStatus;
|
||||||
|
cameraProviders: CameraProviderFeed[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePipelineHealth(input: PipelineInput): PipelineHealth {
|
||||||
|
const { calls, incidents, nodes, eventsStatus, cameraProviders } = input;
|
||||||
|
const { data: ai, reachable } = useAiHealth();
|
||||||
|
// Staleness is a function of wall-clock time, not only of new data arriving.
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), TICK_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
return useMemo(
|
||||||
|
() => derivePipelineHealth({ calls, incidents, nodes, eventsStatus, cameraProviders }, ai, reachable, now),
|
||||||
|
[calls, incidents, nodes, eventsStatus, cameraProviders, ai, reachable, now],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure derivation — separated from the hook so it can be exercised without React. */
|
||||||
|
export function derivePipelineHealth(
|
||||||
|
input: PipelineInput,
|
||||||
|
ai: AiHealthResponse | null,
|
||||||
|
reachable: boolean | null,
|
||||||
|
now: number,
|
||||||
|
): PipelineHealth {
|
||||||
|
const { calls, incidents, nodes, eventsStatus, cameraProviders } = input;
|
||||||
|
const staleMs = WALL_STALE_MIN * 60_000;
|
||||||
|
const absenceMs = staleMs * ABSENCE_FACTOR;
|
||||||
|
// Canonical, finished recordings only: a second node's copy of the same
|
||||||
|
// transmission (duplicate_of) is never transcribed, and an in-flight call
|
||||||
|
// has not been uploaded yet.
|
||||||
|
const real = calls.filter((c) => !c.duplicate_of && c.status !== "active" && Number.isFinite(ms(c.started_at)));
|
||||||
|
|
||||||
|
const tiers = Object.entries(ai?.tiers ?? {});
|
||||||
|
const degradedTiers = tiers.filter(([, s]) => s?.degraded).map(([tier, state]) => ({ tier, state }));
|
||||||
|
const tierText = (tier: string, s: AiTierState) =>
|
||||||
|
`${tier} tier degraded${s.provider ? ` (${s.provider}${s.model ? ` ${s.model}` : ""})` : ""}${s.problem ? `: ${s.problem}` : ""}`;
|
||||||
|
|
||||||
|
// ── Radio ────────────────────────────────────────────────────────────────
|
||||||
|
const newestCallAt = maxDate(calls.map((c) => ms(c.started_at)));
|
||||||
|
const anyNodeUp = nodes.some((n) => n.status !== "offline");
|
||||||
|
// Every configured node offline is not "Listening" — nothing can be heard.
|
||||||
|
const allNodesOffline = nodes.length > 0 && !anyNodeUp;
|
||||||
|
const radioDry = anyNodeUp && (!newestCallAt || now - newestCallAt.getTime() > absenceMs);
|
||||||
|
const radio: PipelineStage = {
|
||||||
|
key: "radio", label: "Radio", lastAt: newestCallAt,
|
||||||
|
state: nodes.length === 0 ? "unknown" : radioDry || allNodesOffline ? "degraded" : "ok",
|
||||||
|
detail: allNodesOffline
|
||||||
|
? `all ${nodes.length} node${nodes.length === 1 ? "" : "s"} offline`
|
||||||
|
: newestCallAt ? `last call heard ${agoText(newestCallAt, now)}` : "no calls in the recent window",
|
||||||
|
};
|
||||||
|
// Bounded to configured nodes so a call from a since-removed node can't push
|
||||||
|
// the KPI past N/N.
|
||||||
|
const knownNodes = new Set(nodes.map((n) => n.node_id));
|
||||||
|
const nodesHearing = new Set(
|
||||||
|
calls.filter((c) => now - ms(c.started_at) <= staleMs && knownNodes.has(c.node_id)).map((c) => c.node_id),
|
||||||
|
).size;
|
||||||
|
|
||||||
|
// ── STT: radio heard, nothing transcribed ────────────────────────────────
|
||||||
|
const newestTranscriptAt = maxDate(real.filter(hasTranscript).map((c) => ms(c.started_at)));
|
||||||
|
const unserved = real
|
||||||
|
.filter((c) => !hasTranscript(c) && (!newestTranscriptAt || ms(c.started_at) > newestTranscriptAt.getTime()))
|
||||||
|
.map((c) => ms(c.started_at));
|
||||||
|
const oldestUnserved = unserved.length ? Math.min(...unserved) : NaN;
|
||||||
|
const sttDry = unserved.length >= MIN_UNSERVED && now - oldestUnserved > staleMs;
|
||||||
|
const sttTier = degradedTiers.find((t) => t.tier === "transcription");
|
||||||
|
const sttSince = newestTranscriptAt ?? (Number.isFinite(oldestUnserved) ? new Date(oldestUnserved) : null);
|
||||||
|
const stt: PipelineStage = {
|
||||||
|
key: "stt", label: "Transcripts", lastAt: newestTranscriptAt,
|
||||||
|
state: sttDry || sttTier ? "degraded" : real.length ? "ok" : "unknown",
|
||||||
|
detail: sttDry
|
||||||
|
? `radio heard, not transcribed since ${sttSince ? clockTime(sttSince) : "—"} (${unserved.length} calls waiting)`
|
||||||
|
: newestTranscriptAt ? `last transcript ${agoText(newestTranscriptAt, now)}` : "no transcripts in the recent window",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AI: transcribed, but nothing correlated ─────────────────────────────
|
||||||
|
const newestAiAt = maxDate([
|
||||||
|
...incidents.flatMap((i) => [ms(i.updated_at), ms(i.last_thin_at)]),
|
||||||
|
...real.filter((c) => (c.incident_ids?.length ?? 0) > 0 || !!c.incident_id).map((c) => ms(c.started_at)),
|
||||||
|
]);
|
||||||
|
const transcribedSinceAi = real
|
||||||
|
.filter((c) => hasTranscript(c) && (!newestAiAt || ms(c.started_at) > newestAiAt.getTime()))
|
||||||
|
.map((c) => ms(c.started_at));
|
||||||
|
const oldestUncorrelated = transcribedSinceAi.length ? Math.min(...transcribedSinceAi) : NaN;
|
||||||
|
const aiDry = transcribedSinceAi.length >= MIN_UNSERVED && now - oldestUncorrelated > absenceMs;
|
||||||
|
const aiTiers = degradedTiers.filter((t) => isAiTier(t.tier));
|
||||||
|
const ai_: PipelineStage = {
|
||||||
|
key: "ai", label: "AI", lastAt: newestAiAt,
|
||||||
|
state: aiDry || aiTiers.length ? "degraded" : reachable === false && !newestAiAt ? "unknown" : "ok",
|
||||||
|
detail: aiDry
|
||||||
|
? `transcribed, no incident written since ${newestAiAt ? clockTime(newestAiAt) : clockTime(new Date(oldestUncorrelated))}`
|
||||||
|
: `${newestAiAt ? `last incident write ${agoText(newestAiAt, now)}` : "no incident writes in the recent window"}${reachable === false ? " · /health/ai unreachable" : ""}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Context feeds (chip only — a dead camera feed doesn't blind the pipeline) ──
|
||||||
|
const feedProblems: string[] = [];
|
||||||
|
if (eventsStatus && !eventsStatus.disabled) {
|
||||||
|
if (eventsStatus.error) feedProblems.push(`511: ${eventsStatus.error}`);
|
||||||
|
else if (eventsStatus.fetched_at && now / 1000 - eventsStatus.fetched_at > absenceMs / 1000)
|
||||||
|
feedProblems.push(`511 data ${Math.round((now / 1000 - eventsStatus.fetched_at) / 60)}m old`);
|
||||||
|
}
|
||||||
|
for (const p of cameraProviders) if (p.error) feedProblems.push(`${p.name}: ${p.error}`);
|
||||||
|
const feeds: PipelineStage = {
|
||||||
|
key: "feeds", label: "Feeds", lastAt: null,
|
||||||
|
state: feedProblems.length ? "degraded" : "ok",
|
||||||
|
detail: feedProblems.length ? feedProblems.join(" · ") : "511 / camera providers OK",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Verdict ─────────────────────────────────────────────────────────────
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (allNodesOffline) parts.push(`${radio.detail}${newestCallAt ? `, last call heard ${clockTime(newestCallAt)}` : ""}`);
|
||||||
|
else if (radioDry) parts.push(`nodes checking in, no radio heard since ${newestCallAt ? clockTime(newestCallAt) : "—"}`);
|
||||||
|
if (sttDry) parts.push(stt.detail);
|
||||||
|
if (aiDry) parts.push(ai_.detail);
|
||||||
|
for (const t of degradedTiers) parts.push(tierText(t.tier, t.state));
|
||||||
|
const degraded = parts.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stages: [radio, stt, ai_, feeds],
|
||||||
|
degraded,
|
||||||
|
message: degraded ? parts.join(" · ") : null,
|
||||||
|
degradedTiers,
|
||||||
|
aiHealthReachable: reachable,
|
||||||
|
nodesHearing,
|
||||||
|
newestCallAt,
|
||||||
|
staleMin: WALL_STALE_MIN,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -154,6 +154,16 @@ service cloud.firestore {
|
|||||||
allow read, write: if false;
|
allow read, write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Radio-bot OAuth2 client secrets and in-flight "Connect a Discord
|
||||||
|
// server" states (server-26#225 S6). Secrets: c2-core only, never any client.
|
||||||
|
match /bot_oauth/{tokenId} {
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match /discord_oauth_states/{stateHash} {
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
match /waitlist/{entryId} {
|
match /waitlist/{entryId} {
|
||||||
allow read, write: if false; // POST /waitlist writes via the admin SDK
|
allow read, write: if false; // POST /waitlist writes via the admin SDK
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user