This commit is contained in:
Logan
2026-04-06 00:22:03 -04:00
parent 2f0597c81b
commit 636a847ee1
9 changed files with 133 additions and 21 deletions
+3
View File
@@ -17,6 +17,9 @@ class Settings(BaseSettings):
# Node health # Node health
node_offline_threshold: int = 90 # seconds without checkin before marking offline node_offline_threshold: int = 90 # seconds without checkin before marking offline
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None
class Config: class Config:
env_file = ".env" env_file = ".env"
+16
View File
@@ -2,6 +2,7 @@ from typing import Optional
from fastapi import HTTPException, Security from fastapi import HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from firebase_admin import auth as firebase_auth from firebase_admin import auth as firebase_auth
from app.config import settings
_bearer = HTTPBearer(auto_error=False) _bearer = HTTPBearer(auto_error=False)
@@ -18,6 +19,21 @@ async def require_firebase_token(
raise HTTPException(status_code=401, detail="Invalid or expired token") raise HTTPException(status_code=401, detail="Invalid or expired token")
async def require_service_or_firebase_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept either a Firebase ID token or the internal service key."""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials
if settings.service_key and token == settings.service_key:
return {"service": True}
try:
return firebase_auth.verify_id_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired token")
async def require_admin_token( async def require_admin_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict: ) -> dict:
+5 -5
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.internal.logger import logger from app.internal.logger import logger
from app.internal.mqtt_handler import mqtt_handler from app.internal.mqtt_handler import mqtt_handler
from app.internal.node_sweeper import sweeper_loop from app.internal.node_sweeper import sweeper_loop
from app.internal.auth import require_firebase_token from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.routers import nodes, systems, calls, upload, tokens from app.routers import nodes, systems, calls, upload, tokens
@@ -32,10 +32,10 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(nodes.router, dependencies=[Depends(require_firebase_token)]) app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(systems.router, dependencies=[Depends(require_firebase_token)]) app.include_router(systems.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(calls.router, dependencies=[Depends(require_firebase_token)]) app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(tokens.router, dependencies=[Depends(require_firebase_token)]) app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline app.include_router(upload.router) # auth is per-node, handled inline
+2 -1
View File
@@ -53,7 +53,8 @@ async def send_command(node_id: str, cmd: CommandPayload):
payload = cmd.model_dump(exclude_none=True) payload = cmd.model_dump(exclude_none=True)
if cmd.action == "discord_join": if cmd.action == "discord_join":
token = await assign_token(node_id) preferred = payload.pop("preferred_token_id", None)
token = await assign_token(node_id, preferred_token_id=preferred)
if not token: if not token:
raise HTTPException(503, "No Discord bot tokens available in the pool.") raise HTTPException(503, "No Discord bot tokens available in the pool.")
payload["token"] = token payload["token"] = token
+8 -3
View File
@@ -56,18 +56,23 @@ async def delete_token(token_id: str):
# Internal helpers — used by the nodes router, not exposed via HTTP # Internal helpers — used by the nodes router, not exposed via HTTP
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def assign_token(node_id: str) -> Optional[str]: async def assign_token(node_id: str, preferred_token_id: Optional[str] = None) -> Optional[str]:
""" """
Find a free token, mark it as in-use, return the token string. Find a free token, mark it as in-use, return the token string.
If preferred_token_id is given, try that token first (only if it's free).
Returns None if no tokens are available. Returns None if no tokens are available.
""" """
def _find_free(): def _find_free(preferred: Optional[str]):
from app.internal.firestore import db from app.internal.firestore import db
if preferred:
snap = db.collection("bot_tokens").document(preferred).get()
if snap.exists and not snap.to_dict().get("in_use"):
return [snap]
docs = db.collection("bot_tokens").where("in_use", "==", False).limit(1).stream() docs = db.collection("bot_tokens").where("in_use", "==", False).limit(1).stream()
return [d for d in docs] return [d for d in docs]
import asyncio import asyncio
results = await asyncio.to_thread(_find_free) results = await asyncio.to_thread(_find_free, preferred_token_id)
if not results: if not results:
return None return None
+12 -2
View File
@@ -28,8 +28,13 @@ export function useCalls(limitCount = 50) {
orderBy("started_at", "desc"), orderBy("started_at", "desc"),
limit(limitCount) limit(limitCount)
); );
const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
unsubFirestore = onSnapshot(q, (snap) => { unsubFirestore = onSnapshot(q, (snap) => {
setCalls(snap.docs.map((d) => d.data() as CallRecord)); setCalls(snap.docs.map((d) => {
const data = d.data();
return { ...data, started_at: toISO(data.started_at) ?? "", ended_at: toISO(data.ended_at) } as CallRecord;
}));
setLoading(false); setLoading(false);
}, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); }); }, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); });
}); });
@@ -58,8 +63,13 @@ export function useActiveCalls() {
} }
const q = query(collection(db, "calls"), where("status", "==", "active")); const q = query(collection(db, "calls"), where("status", "==", "active"));
const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
unsubFirestore = onSnapshot(q, (snap) => { unsubFirestore = onSnapshot(q, (snap) => {
setCalls(snap.docs.map((d) => d.data() as CallRecord)); setCalls(snap.docs.map((d) => {
const data = d.data();
return { ...data, started_at: toISO(data.started_at) ?? "", ended_at: toISO(data.ended_at) } as CallRecord;
}));
}, (err: FirestoreError) => { console.error("useActiveCalls:", err); }); }, (err: FirestoreError) => { console.error("useActiveCalls:", err); });
}); });
+68 -8
View File
@@ -11,7 +11,7 @@ class RadioCommands(commands.Cog):
self.bot = bot self.bot = bot
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Autocomplete — system names from C2 # Autocomplete helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def system_autocomplete( async def system_autocomplete(
@@ -24,14 +24,27 @@ class RadioCommands(commands.Cog):
if current.lower() in s["name"].lower() if current.lower() in s["name"].lower()
][:25] ][:25]
async def token_autocomplete(
self, interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]:
tokens = await c2.get_tokens()
return [
app_commands.Choice(name=f"{t['name']} {'(in use)' if t.get('in_use') else '(free)'}", value=t["token_id"])
for t in tokens
if current.lower() in t["name"].lower()
][:25]
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# /join # /join
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@app_commands.command(name="join", description="Stream a radio system to your voice channel.") @app_commands.command(name="join", description="Stream a radio system to your voice channel.")
@app_commands.describe(system="The radio system to listen to.") @app_commands.describe(
@app_commands.autocomplete(system=system_autocomplete) system="The radio system to listen to.",
async def join(self, interaction: discord.Interaction, system: str): token="Optionally pick a specific bot token from the pool.",
)
@app_commands.autocomplete(system=system_autocomplete, token=token_autocomplete)
async def join(self, interaction: discord.Interaction, system: str, token: Optional[str] = None):
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
if not interaction.user.voice or not interaction.user.voice.channel: if not interaction.user.voice or not interaction.user.voice.channel:
@@ -49,11 +62,15 @@ class RadioCommands(commands.Cog):
) )
return return
ok = await c2.send_command(node["node_id"], { cmd: dict = {
"action": "discord_join", "action": "discord_join",
"guild_id": guild_id, "guild_id": guild_id,
"channel_id": channel_id, "channel_id": channel_id,
}) }
if token:
cmd["preferred_token_id"] = token
ok = await c2.send_command(node["node_id"], cmd)
if ok: if ok:
systems = await c2.get_systems() systems = await c2.get_systems()
@@ -72,7 +89,6 @@ class RadioCommands(commands.Cog):
async def leave(self, interaction: discord.Interaction): async def leave(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
# Find any node currently streaming to this guild
nodes = await c2.get_nodes() nodes = await c2.get_nodes()
streaming_nodes = [ streaming_nodes = [
n for n in nodes if n.get("status") in ("online", "recording") n for n in nodes if n.get("status") in ("online", "recording")
@@ -82,7 +98,6 @@ class RadioCommands(commands.Cog):
await interaction.followup.send("No nodes appear to be active right now.") await interaction.followup.send("No nodes appear to be active right now.")
return return
# Send leave to all online nodes in case more than one joined
for node in streaming_nodes: for node in streaming_nodes:
await c2.send_command(node["node_id"], {"action": "discord_leave"}) await c2.send_command(node["node_id"], {"action": "discord_leave"})
@@ -125,6 +140,51 @@ class RadioCommands(commands.Cog):
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
# ------------------------------------------------------------------
# /help
# ------------------------------------------------------------------
@app_commands.command(name="help", description="How to use this bot.")
async def help(self, interaction: discord.Interaction):
embed = discord.Embed(
title="DRB Radio Bot — Help",
color=0x5865f2,
)
embed.add_field(
name="/join `system`",
value=(
"Join your current voice channel and start streaming a radio system.\n"
"Use the `token` option to pick a specific bot from the pool (optional).\n"
"You must be in a voice channel first."
),
inline=False,
)
embed.add_field(
name="/leave",
value="Stop streaming and disconnect all active radio bots in this server.",
inline=False,
)
embed.add_field(
name="/status",
value="Show all registered edge nodes and which systems they're monitoring.",
inline=False,
)
embed.add_field(
name="Radio bot direct commands",
value=(
"Once a radio bot is in your voice channel, you can control it by **mentioning it** in any text channel:\n"
"- **@botname joinme** — Move the bot to your current voice channel\n"
"- **@botname leave** — Disconnect the bot from voice\n\n"
"The radio bot's green speaking ring lights up only when radio is actively transmitting."
),
inline=False,
)
embed.set_footer(text="Audio is streamed live from SDR edge nodes via Icecast.")
await interaction.response.send_message(embed=embed)
async def setup(bot: commands.Bot): async def setup(bot: commands.Bot):
await bot.add_cog(RadioCommands(bot)) await bot.add_cog(RadioCommands(bot))
+1
View File
@@ -5,6 +5,7 @@ from typing import Optional
class Settings(BaseSettings): class Settings(BaseSettings):
discord_token: str discord_token: str
c2_url: str = "http://localhost:8000" c2_url: str = "http://localhost:8000"
c2_service_key: Optional[str] = None # must match C2_SERVICE_KEY on c2-core
dev_guild_id: Optional[int] = None # set to sync commands instantly during dev dev_guild_id: Optional[int] = None # set to sync commands instantly during dev
class Config: class Config:
@@ -8,10 +8,15 @@ class C2Client:
def __init__(self): def __init__(self):
self.base = settings.c2_url.rstrip("/") self.base = settings.c2_url.rstrip("/")
def _headers(self) -> dict:
if settings.c2_service_key:
return {"Authorization": f"Bearer {settings.c2_service_key}"}
return {}
async def get_nodes(self) -> list: async def get_nodes(self) -> list:
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{self.base}/nodes") r = await client.get(f"{self.base}/nodes", headers=self._headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except Exception as e: except Exception as e:
@@ -21,19 +26,30 @@ class C2Client:
async def get_systems(self) -> list: async def get_systems(self) -> list:
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{self.base}/systems") r = await client.get(f"{self.base}/systems", headers=self._headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except Exception as e: except Exception as e:
logger.error(f"C2 get_systems failed: {e}") logger.error(f"C2 get_systems failed: {e}")
return [] return []
async def get_tokens(self) -> list:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{self.base}/tokens", headers=self._headers())
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 get_tokens failed: {e}")
return []
async def send_command(self, node_id: str, payload: dict) -> bool: async def send_command(self, node_id: str, payload: dict) -> bool:
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
r = await client.post( r = await client.post(
f"{self.base}/nodes/{node_id}/command", f"{self.base}/nodes/{node_id}/command",
json=payload, json=payload,
headers=self._headers(),
) )
r.raise_for_status() r.raise_for_status()
return True return True