Files
server-26/drb-c2-core/app/config.py
T
Logan CusanoandClaude Opus 5.5 2c2cf9d9be Discord: check bot permissions before handing a token to a node (server-26#225 S3)
start_stream now asks Discord (cached REST snapshots, S2) whether each bot
it could pick may View, Connect and Speak in the voice channel, before the
lease. Covers all four entry points: bot POST /discord/streams, web picker,
nodes discord_join, and the reconciler's re-place.

- pick_token: denied bots skipped; confirmed-ok sorts before unknown;
  unknown is allowed (fail open); all denied -> missing_permissions naming
  each bot, missing permission, trace reason and fix. The reuse branch
  falls through when its token is denied.
- Bounded: discord_perm_check_concurrency at once, within
  discord_perm_check_budget_seconds; unanswered = unknown.
- Non-voice channel -> 400 bad_channel (not missing_permissions).
- systems/{id}.preferred_token_id is now read (plan finding 1), as a hint.
- Pool bots at discord_pool_bot_guild_cap are never offered as invites;
  pool_full when nothing else is left (owner Q3).
- handle_actual drops cached access on missing_permissions, channel_full,
  not_in_guild, bad_channel.
- New GET /discord/nodes/{node_id}/guilds/{guild_id}/voice-check (org
  member, names only) and GET /discord/guilds/{guild_id}/voice-check
  (service key or admin).
- PUT /tokens/{id}/prefer/{system_id}: org owners for their own systems
  and own BYO bots; shared systems and pool bots stay admin-only (owner Q2).
  Keeps both pointers single-valued; audited.
- Kill switch: DISCORD_PERM_CHECK_ENABLED=false.
- tests/conftest.py: autouse fixture so no test ever reaches discord.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:22:45 -04:00

304 lines
18 KiB
Python

from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# MQTT
mqtt_broker: str = "localhost"
mqtt_port: int = 1883
mqtt_user: Optional[str] = None
mqtt_pass: Optional[str] = None
# mosquitto's built-in dynamic-security plugin (see app/internal/dynsec.py).
# "admin" is hardcoded by the plugin itself on first boot — not actually
# configurable — kept as a named setting rather than a literal for
# readability. mqtt_dynsec_admin_pass must equal the mosquitto
# container's own MOSQUITTO_DYNSEC_PASSWORD env var (root .env /
# root.env.j2) or c2-core can't administer node credentials at all.
mqtt_dynsec_admin_user: str = "admin"
mqtt_dynsec_admin_pass: Optional[str] = None
# GCP
gcp_credentials_path: Optional[str] = None # None → uses ADC
gcs_bucket: Optional[str] = None # None → audio upload disabled
firestore_database: str = "(default)"
# Node health
node_offline_threshold: int = 90 # seconds without checkin before marking offline
# OpenAI (STT + intelligence)
openai_api_key: Optional[str] = None
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
# AI request timeouts (server-26#153). Without these the OpenAI SDK waits
# up to 600s per attempt and retries twice, and the google-genai client
# waits forever, so a hung provider call had no upper bound at all — and
# recorrelation_sweep.py's hold-back could only guess at one. Every OpenAI
# client is built by app/internal/ai_clients.py and the one Gemini client
# by app/internal/gemini.py; both read these. A timeout raises like any
# other provider error, so every call site's existing fallback applies
# (rules-only correlation, raw transcript, no summary, ...).
# The timeout bounds each phase of one HTTP attempt (connect/write/read);
# these endpoints are non-streaming, so in practice it bounds the attempt.
stt_timeout_seconds: float = 120.0 # Whisper: uploads up to MAX_RECORDING_SECONDS of audio
openai_timeout_seconds: float = 60.0 # chat completions + embeddings
# Retries per OpenAI request (SDK default 2). The SDK retries timeouts too,
# so each one multiplies the worst case by another full timeout.
openai_max_retries: int = 1
# google-genai does not retry unless told to, so this is the whole bound.
gemini_timeout_seconds: float = 60.0
# Google Maps (geocoding)
google_maps_api_key: Optional[str] = None
# 511NY developer key (server-26#183/#198). Issued with the NYSDOT Developer
# Access Agreement grant; the keyless legacy API is gone since the 511x cutover.
ny511_api_key: Optional[str] = None
# Base URL for the 511NY feed. Defaults to the legacy API, which is dead since
# the 511x cutover (server-26#198); set it to whatever the DAA grant documents.
ny511_api_base: Optional[str] = None
# Camera providers (app/internal/cameras). Keys only; every provider is still
# OFF until an admin enables it, and licence-gated ones need an acknowledgement.
ontario511_api_key: Optional[str] = None
# Gemini (intelligence extraction, embeddings, incident summaries)
gemini_api_key: Optional[str] = None
# Correlation consensus models
# corr_cheap_model — first-pass LLM correlator (runs on every call)
# corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree)
# Both IDs below were retired by Google and returned 404 on every call from
# some point before 2026-08-18 until they were corrected. Because a failed
# LLM call falls back to the rules decision, nothing broke loudly -- the
# entire LLM tier and the consensus tiebreak were simply dead in production
# while correlation behaviour was being tuned against rules-only output.
# Verify against https://ai.google.dev/gemini-api/docs/models before changing.
corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down)
# gemini-2.5-pro was closed to new projects by 2026-09 (every tiebreak 404'd
# in the first replay run, server-26#170); Google lists no stable Pro model,
# so the smart tier is the newest stable Flash instead.
corr_smart_model: str = "gemini-3.8-flash" # was gemini-2.5-pro, gemini-1.5-pro
# Transcript correction (server-26#36). Runs inside transcription, once per
# transcribed call above MIN_WORDS_FOR_CORRECTION, so it is priced like STT
# rather than like the correlation tier — cheap model on purpose.
transcript_correction_enabled: bool = True
transcript_correction_model: str = "gemini-3.6-flash"
# Retry Whisper once when its output is degenerate. The same clip produced a
# 56-word ten-code counting run on one attempt and real speech on the next
# (2026-08-23, call e49ea32c), so a hallucination is a coin-flip rather than
# a property of the audio, and discarding on the first bad roll threw away a
# recoverable transcript.
stt_retry_on_degenerate: bool = True
# Place verification (server-26#37). Checks the corrector's location nouns
# against the talkgroup's own anchor instead of stuffing every road in town
# into the prompt, so cost scales with location nouns rather than call volume.
place_verification_enabled: bool = True
# Raw transcript text in alert payloads (server-26#85). Default CLOSED.
# Board minutes #42 suppress person names on every surface until E&O is
# bound, and an alert webhook is the least recoverable surface there is:
# once the text is in a Discord channel we do not own it, cannot unsend
# it, and cannot audit who read it. This switch is the operator-level
# gate and is deliberately NOT reachable from the app -- the per-org
# opt-in alone would let an org owner self-serve their way to somebody
# else's PII. Both gates must be open before any snippet leaves.
alert_transcript_snippet_enabled: bool = False
place_verify_max_per_call: int = 3
# How close a candidate has to sound before it may rewrite a transcript.
# Below this, Places Text Search will confidently hand back the nearest
# business for any garbage string.
place_soundalike_min_ratio: float = 0.6
# An anchor wider than this is not stored at all. A statewide radius would
# confirm any location inside it, so the check would rubber-stamp everything
# while appearing to work — absent anchor means SKIP, never "accept anything".
area_anchor_max_radius_km: float = 60.0
summary_interval_minutes: int = 2 # how often the summary loop runs
correlation_window_hours: int = 2 # slow/location path: max hours since last call
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
embedding_no_location_threshold: float = 0.97 # slow-path: match without location (very high bar)
embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units
location_proximity_km: float = 0.5 # radius for location-proximity matching
geocode_max_km: float = 40.0 # reject geocode results farther than this from the node
incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls (major / unknown severity)
# Most jobs never say 10-8 on the air (replay of 09-22, server-26#170: ~5 of
# ~25 real incidents had an audible clear), so the quiet timer IS the close
# for most of them, and one 90-minute timer kept a lockout or a plate check
# "active" on the portal an hour after it ended. Scaled by the incident's
# severity instead, and made provisional: a timer-closed incident stays
# reopenable for incident_reopen_window_minutes, so a long quiet job that
# comes back on the air rejoins its own incident rather than splitting.
incident_auto_resolve_minutes_routine: int = 30 # routine / minor
incident_auto_resolve_minutes_moderate: int = 60
incident_reopen_window_minutes: int = 90 # since last substantive call
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
# Tier-2 thin calls attach to a lone candidate idle < this, on every
# channel (server-26#133/#134 removed the dispatch/tactical split — a
# channel's name doesn't change how much scrutiny it gets). Was 10, which
# is long enough for the channel to have moved on to something else: on
# 2026-08-16 a "72 at Holland Station" incident absorbed a Grand Central
# train meet 9.6 min later, and a status check absorbed a records lookup
# at 9.7 min. Every correct thin attach in that dump was <= 3.4 min idle
# and every wrong one was >= 8.2, so 5 separates them with room on both
# sides. Genuine back-and-forth is handled by the 30-second tier-1 path
# above this. Also the escape hatch in routers/upload.py's LLM-orphan gate
# (_recent_incident_on_same_talkgroup, server-26#115) — check both call
# sites before retuning this.
tg_dispatch_thin_idle_minutes: int = 5
# ── Hard caps: an incident past either of these stops accepting calls ──────
# Enforced on every correlation path (see _incident_at_capacity). Pairwise fit
# tests judge one call against one incident and cannot see the shape of the
# chain they are building, so these are the only guard against a "work shift"
# incident regardless of how individually plausible each link looked.
#
# 120 minutes: the one incident in the 2026-08-20 dump that was genuinely a
# single event ran 63 minutes (06:15 wrong-way driver → 07:18 closeout), so
# the cap has to clear an hour with real headroom. The four junk chains ran
# 3h41m, 3h43m, 4h05m and 4h09m, so it has to sit well under three hours.
# 120 also equals correlation_window_hours: the location and slow paths
# already refuse to consider a candidate older than that, and the fast path
# was the only one exempt. Making it agree removes that inconsistency rather
# than inventing a new number.
incident_max_duration_minutes: int = 120
# 40 calls: a backstop for a burst that fills up inside the duration cap
# rather than the primary bound. The worst observed chain averaged ~16
# calls/hour while absorbing an ENTIRE dispatch backbone, so 40 calls in
# under two hours means the incident is eating most of the channel — that is
# a chain, not an event. Set deliberately above any plausible single-incident
# call volume (a multi-alarm fire on its own tactical channel) so this cap
# errs toward keeping real incidents whole and lets the duration cap do the
# cutting.
incident_max_calls: int = 40
# Vocabulary learning
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
vocabulary_induction_sample_tokens: int = 4000 # ~tokens of transcript text sampled per system
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None
# Automation/agent service key — the unattended work-session agent's own
# credential for the headless routes it needs (currently GET/PUT
# /admin/features).
#
# DELIBERATELY SEPARATE from service_key above, not a second consumer of
# it. service_key is the Discord bot's, and it is handed to a process that
# relays radio traffic to a chat server; sharing it here would make "the
# bot" and "the agent" the same principal in every log line and audit
# entry, so a global AI-cost flag flip could never be attributed to whoever
# actually made it. Two keys, two identities (server-26#64 item 1).
#
# Unset means the agent path is simply closed — the routes still accept a
# Firebase admin token. Generate with: openssl rand -hex 32
agent_service_key: Optional[str] = None
# Fleet-wide token edge nodes present to POST /nodes/enroll on first boot.
# Not a per-node secret — see routers/enrollment.py for why a leaked copy
# of this alone can't steal an already-approved node's key.
enrollment_token: Optional[str] = None
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
upload_max_bytes: int = 100 * 1024 * 1024
# Public origin this API is reachable on, e.g. "https://api.drb.example.com".
# Only used to build absolute call-audio playback links: an <audio src> is
# fetched by the browser directly, so a relative path would resolve against
# the frontend origin, not this one.
public_api_url: Optional[str] = None
# How long a minted call-audio playback link stays valid. Long enough for a
# browsing session, short enough that a copied link isn't durable access.
audio_link_ttl_seconds: int = 6 * 60 * 60
# Two nodes hearing the same transmission start recording within about a
# second of each other (measured across node-002/node-PI-2 on TG 9048).
# 10s is generous against clock skew while staying well under the gap
# between genuinely separate transmissions on a busy dispatch channel.
duplicate_window_seconds: int = 10
# Browser origins allowed to call this API cross-origin. The only browser
# caller is the frontend's Archive page (GET /calls/search) — every other
# page reads Firestore directly. The frontend is served on the BARE domain
# (see infra Caddyfile.j2 — only drb. and api. have DNS records), so the
# default is that origin, not app.<domain>. Override via CORS_ORIGINS (JSON
# list) if the frontend ever moves; keep infra/.../c2-core.env.j2 in sync.
#
# A "*" entry here still works for local dev but is refused a credentialed
# response: main.py never enables allow_credentials (auth is a Bearer
# header, not a cookie), and it logs a loud ERROR when it sees a wildcard
# in a deployment so a forgotten override is visible.
cors_origins: list[str] = ["https://drb.cusano.net"]
# Discord webhook URL that app/internal/ai_health.py posts to when an AI
# tier (transcription/correlation) transitions into or out of degraded
# state. Empty disables the POST entirely — not every self-hosted
# deployment will set this up, and skipping it must be silent.
ai_alert_webhook_url: str = ""
# --- Discord radio channels (server-26#203/#204, node-26#25) -----------
# Voice connections one node may carry at once (≈64 kbps upstream each —
# this is what protects an LTE node).
discord_node_max_streams: int = 4
# Radio channels one Discord server may have on at once.
discord_guild_max_streams: int = 4
# How often the reconciler re-places persistent streams / retries publishes.
discord_reconcile_interval_seconds: int = 30
# A node offline this long has its radio-bot tokens returned to the pool.
# MUST stay above the edge's DISCORD_MQTT_LOSS_STOP_SECONDS (default 300):
# by then the edge has logged every radio bot out by itself, so the same
# token can never end up logged in on two nodes.
discord_lease_offline_release_seconds: int = 600
# Org a Discord server that was never linked to an org (discord_guilds/{id})
# acts for. Default keeps today's single-org deployment working unchanged;
# set it to "" to require every server to be linked first. Do that once
# every server that should keep working has been linked (/drb link, or
# PUT /discord/guilds/{id}/link) — unlinked servers then get only
# /drb link and /help (server-26#213). A server an org explicitly unlinked
# never falls back to this org either way.
discord_unlinked_guild_org_id: str = "founding"
# --- Discord permission checks, REST client, OAuth linking (server-26#225)
# Every setting the #225 slices use lives here, added once (S2).
# Kill switch (S3): False restores token picking without the REST
# permission check — no Discord call before a bot is handed to a node.
# (The system's preferred bot is still tried first either way: that is
# a separate fix, plan finding 1, and makes no Discord call.)
discord_perm_check_enabled: bool = True
# Cache lifetimes for per-(bot, server) results (app/internal/discord_rest.py):
# a result that lets a bot in; denied / not-a-member / invalid token; and
# "couldn't tell" — short, but long enough that the reconciler's 30 s loop
# never becomes a REST loop against Discord.
discord_access_ttl_seconds: int = 120
discord_access_negative_ttl_seconds: int = 600
discord_access_unknown_ttl_seconds: int = 30
# S3: how many bots start_stream checks at once, and the total time it
# waits before failing open (unknown = allowed).
discord_perm_check_concurrency: int = 4
discord_perm_check_budget_seconds: float = 3.0
# Process-wide breaker: Discord bans the egress IP (shared with the main
# bot) at 10,000 401/403/429 responses per 10 min. Stop well short.
discord_rest_invalid_budget: int = 4000
discord_rest_invalid_window_seconds: int = 600
# A rate-limited request waits at most this long (one retry); longer = unknown.
discord_rest_max_retry_after_seconds: float = 3.0
discord_rest_timeout_seconds: float = 10.0
# S5: refresh a server's voice-channel list from a radio bot's REST view
# when the stored directory is older than this.
discord_directory_refresh_seconds: int = 300
# S6: per-bot OAuth "Connect a Discord server". Empty = OAuth off (routes
# 404, UI hides the button). Must match the redirect URI registered on
# each bot application exactly, e.g. https://drb.cusano.net/discord/connected
discord_oauth_redirect_uri: str = ""
discord_oauth_state_ttl_seconds: int = 600
# S6: time allowed for the permission check run right after a link.
discord_oauth_link_check_seconds: float = 5.0
# Pool bots: unverified Discord bots can't join more than 100 servers.
# Connect picks a pool bot under this cap; add bots when all are full
# (owner decision Q3, 2026-10-05).
discord_pool_bot_guild_cap: int = 90
class Config:
env_file = ".env"
settings = Settings()