Resolve upload.py (no-scene fallback uses the new _correlate with #298's non_event_chatter reason) and chatter_classifier docstring (gate built, off by default). Fold intelligence._llm_bool into incident_correlator.parse_llm_bool (#88). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
417 lines
26 KiB
Python
417 lines
26 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
|
|
# The OpenAI transcription model. Used when OpenAI is the STT provider AND
|
|
# as the fallback when another provider fails (server-26#297).
|
|
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
|
|
|
# ── STT provider (server-26#297) ──────────────────────────────────────────
|
|
# Any OpenAI-compatible /audio/transcriptions endpoint. Groq serves
|
|
# whisper-large-v3-turbo at $0.04/audio-hour against whisper-1's $0.36, same
|
|
# API (min billed length 10s per request, prompt capped at 224 tokens --
|
|
# console.groq.com/docs/speech-to-text, checked 2026-10-27).
|
|
# auto -- groq when GROQ_API_KEY is set, compat when STT_BASE_URL is
|
|
# set, else openai. The default, so a deploy without either
|
|
# behaves exactly as before.
|
|
# openai -- OpenAI only (stt_model).
|
|
# groq -- Groq (stt_compat_model), OpenAI fallback.
|
|
# compat -- any other OpenAI-compatible server: stt_base_url + stt_api_key
|
|
# + stt_compat_model (e.g. a self-hosted faster-whisper, #296).
|
|
stt_provider: str = "auto"
|
|
stt_base_url: Optional[str] = None # None -> the provider's own default
|
|
stt_api_key: Optional[str] = None # None -> GROQ_API_KEY / OPENAI_API_KEY
|
|
groq_api_key: Optional[str] = None
|
|
stt_compat_model: str = "whisper-large-v3-turbo" # model on a non-OpenAI provider
|
|
# Retry a failed non-OpenAI request once on OpenAI stt_model. Needs
|
|
# OPENAI_API_KEY; without it a primary failure just means no transcript.
|
|
stt_fallback_to_openai: bool = True
|
|
# Domain vocabulary in the STT prompt (area places, local_knowledge terms,
|
|
# vocabulary). Applied to the non-OpenAI provider only: whisper-1 echoed
|
|
# vocabulary into silence before (see transcription.py), so the OpenAI
|
|
# path -- including the fallback -- keeps today's fixed prompt.
|
|
stt_vocab_prompt_enabled: bool = True
|
|
# Whisper keeps only the LAST 224 prompt tokens and silently drops the
|
|
# rest. The prompt is built against this estimated budget, with headroom.
|
|
stt_prompt_max_tokens: int = 200
|
|
|
|
# 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
|
|
|
|
# Scene extraction + embeddings (server-26#298). Were literals in
|
|
# intelligence.py; any OpenAI chat model that supports JSON mode works.
|
|
extraction_model: str = "gpt-4o-mini"
|
|
embedding_model: str = "text-embedding-3-small"
|
|
# Skip correction + extraction (+ embedding, geocoding) for transcripts
|
|
# chatter_classifier.gate_chatter calls radio housekeeping. OFF by default:
|
|
# a gated call carries no units, so it is thin and takes the no-fit-check
|
|
# thin attach (mechanism A) instead of the fit-tested path or the #115
|
|
# orphan gate, and server-26#127 set "0 false positives in a live shadow
|
|
# window" as the bar for going live. Read chatter_classifier_verdict off a
|
|
# shadow window first, then set CHATTER_GATE_ENABLED=true.
|
|
chatter_gate_enabled: bool = False
|
|
|
|
# Google Maps (geocoding)
|
|
google_maps_api_key: Optional[str] = None
|
|
# In-process cache of Geocoding API answers (server-26#298). Dispatch
|
|
# repeats the same address on every follow-up call of a job, and the
|
|
# place verifier re-geocodes the same nouns. In-process rather than
|
|
# Firestore: a Firestore read costs about what it saves, adds latency on
|
|
# every miss, and would persist Maps content -- the Maps terms allow
|
|
# caching lat/lng for at most 30 days, and an in-memory TTL can never
|
|
# outlive that. Lost on restart, which only costs a few repeat lookups.
|
|
geocode_cache_ttl_seconds: int = 24 * 60 * 60
|
|
geocode_cache_negative_ttl_seconds: int = 60 * 60 # ZERO_RESULTS etc.
|
|
geocode_cache_max_entries: int = 5000
|
|
# 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
|
|
# Weather context (server-26#237). api.weather.gov requires a User-Agent that
|
|
# names the app AND a contact (e.g. "DRB (ops@example.com)"); the placeholder
|
|
# default must be overridden in every deploy -- NWS may block anonymous agents.
|
|
nws_user_agent: str = "DRB scanner map (contact: set NWS_USER_AGENT)"
|
|
# Comma list of NWS state/marine area codes for /alerts/active (the areas served).
|
|
nws_area: str = "NY"
|
|
# NWS alerts cache TTL; the issue asks 60-120 s (floored at 30 s in code).
|
|
weather_alerts_ttl_s: int = 90
|
|
|
|
# Gemini (intelligence extraction, embeddings, incident summaries)
|
|
gemini_api_key: Optional[str] = None
|
|
# Correlation LLM (server-26#299). The rules engine decides every confident
|
|
# case; this model is called at most ONCE per scene, and only when the rules
|
|
# flag the decision as ambiguous (incident_correlator._ambiguity). There is
|
|
# no second "smart" tier any more: the cheap/smart consensus tiebreak ran on
|
|
# ~15% of scenes and resolved nearly every disagreement as "new" (#115).
|
|
# The model ID was once retired by Google and 404'd on every call for days;
|
|
# a failed LLM call falls back to the rules decision, so nothing broke
|
|
# loudly. 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)
|
|
# 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
|
|
# ── Alert confidence gate (server-26#300) ─────────────────────────────────
|
|
# Nothing in the pipeline emits a confidence, so the alerter derives one:
|
|
# how the rule matched (talkgroup 1.0 / extracted tag 0.8 / raw transcript
|
|
# text only 0.5 -- the CrimeRadar "fire alarm" -> "firearm" failure was a
|
|
# transcript match) scaled down when Whisper's own per-token probability
|
|
# (calls.stt_confidence) is below alert_stt_reference. Below the floor ->
|
|
# held, never pushed. High-severity matches also need the incident to
|
|
# have alert_corroboration_min_calls calls, or a confidence at or above
|
|
# alert_high_severity_min_confidence; held ones are released when a
|
|
# later call corroborates the incident. Starting values, not measured.
|
|
alert_min_confidence: float = 0.5
|
|
alert_high_severity_min_confidence: float = 0.85
|
|
alert_corroboration_min_calls: int = 2
|
|
# Low on purpose: exp(avg_logprob) on vocoded radio audio is often well
|
|
# under 0.5 for speech that is right, and Groq/OpenAI logprobs are not on
|
|
# a shared scale. Only clearly garbled audio should pull the score down.
|
|
alert_stt_reference: float = 0.3
|
|
alert_high_severity_terms: list[str] = [
|
|
"shots-fired", "shots fired", "shooting", "shot", "gun", "firearm", "weapon",
|
|
"stabbing", "stabbed", "officer-down", "officer down", "10-13",
|
|
"officer-needs-assistance", "active-shooter", "hostage", "barricade",
|
|
"explosion", "structure-fire", "structure fire", "working-fire",
|
|
"entrapment", "cardiac-arrest", "vehicle-pursuit", "pursuit",
|
|
]
|
|
# 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
|
|
# server-26#86: the content-divergence veto on a unit-overlap link. This is
|
|
# a DIVERGENCE bar, not a match bar, so it sits deliberately below
|
|
# embedding_similarity_threshold: two calls about different jobs on one
|
|
# dispatch channel still share ten-codes, unit IDs and address phrasing, so
|
|
# "not a match" (< 0.93) is not evidence of "a different job". Below this
|
|
# bar the link is vetoed; between the two bars the link is neither
|
|
# confirmed nor refused, so it is AMBIGUOUS and goes to the LLM (#299)
|
|
# instead of silently merging. 0.82 is the historical literal, unmeasured
|
|
# against real score distributions.
|
|
embedding_divergence_threshold: float = 0.82
|
|
# A unit-overlap link is only a confident one while the incident is fresh:
|
|
# idle under this many minutes (the old hard-coded 15 of the divergence
|
|
# veto) AND open under unit_link_confident_max_span_minutes. Past either,
|
|
# shared units alone are mechanism B (CORRELATION_REVIEW_0820.md) — patrol
|
|
# units work job after job on one channel — so the link is ambiguous.
|
|
unit_link_fresh_idle_minutes: int = 15
|
|
unit_link_confident_max_span_minutes: int = 30
|
|
# server-26#189: an incident open this long accepts no more WEAK links
|
|
# (unit overlap without location/content agreement, or an LLM link) — only
|
|
# vehicle, location or embedding evidence, until the hard
|
|
# incident_max_duration_minutes cap. The 09-27 "Rollover Entrapment"
|
|
# sink absorbed unrelated Ch 1 jobs for 2h34m this way. Thin replies are
|
|
# exempt: they never refresh updated_at, so they cannot keep a sink alive.
|
|
incident_weak_link_max_span_minutes: int = 90
|
|
# server-26#189: an LLM link may not pull a call geocoded farther than
|
|
# this from a non-pursuit incident's verified pin (the same 4x
|
|
# location_proximity_km the slow path accepts). The LLM tier had no
|
|
# distance check at all, which is how one incident drew a 37 km path.
|
|
incident_max_spread_km: float = 2.0
|
|
# server-26#301: an upload arriving more than this long after the call
|
|
# started is correlated as of its started_at (clock pin), not now. Well
|
|
# above normal upload + STT lag so on-time calls are never back-dated.
|
|
late_upload_anchor_minutes: int = 10
|
|
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.
|
|
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()
|