diff --git a/drb-c2-core/app/config.py b/drb-c2-core/app/config.py index cd589d1..740e373 100644 --- a/drb-c2-core/app/config.py +++ b/drb-c2-core/app/config.py @@ -17,6 +17,9 @@ class Settings(BaseSettings): # Node health 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: env_file = ".env" diff --git a/drb-c2-core/app/internal/auth.py b/drb-c2-core/app/internal/auth.py index 9f89ca8..cfc53b4 100644 --- a/drb-c2-core/app/internal/auth.py +++ b/drb-c2-core/app/internal/auth.py @@ -2,6 +2,7 @@ from typing import Optional from fastapi import HTTPException, Security from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from firebase_admin import auth as firebase_auth +from app.config import settings _bearer = HTTPBearer(auto_error=False) @@ -18,6 +19,21 @@ async def require_firebase_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( credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), ) -> dict: diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index c333bd7..b2a4734 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.internal.logger import logger from app.internal.mqtt_handler import mqtt_handler 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 @@ -32,10 +32,10 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router(nodes.router, dependencies=[Depends(require_firebase_token)]) -app.include_router(systems.router, dependencies=[Depends(require_firebase_token)]) -app.include_router(calls.router, dependencies=[Depends(require_firebase_token)]) -app.include_router(tokens.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_service_or_firebase_token)]) +app.include_router(calls.router, dependencies=[Depends(require_service_or_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 diff --git a/drb-c2-core/app/routers/nodes.py b/drb-c2-core/app/routers/nodes.py index 4bd1032..b484cbf 100644 --- a/drb-c2-core/app/routers/nodes.py +++ b/drb-c2-core/app/routers/nodes.py @@ -53,7 +53,8 @@ async def send_command(node_id: str, cmd: CommandPayload): payload = cmd.model_dump(exclude_none=True) 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: raise HTTPException(503, "No Discord bot tokens available in the pool.") payload["token"] = token diff --git a/drb-c2-core/app/routers/tokens.py b/drb-c2-core/app/routers/tokens.py index 489f68f..8eb3ff1 100644 --- a/drb-c2-core/app/routers/tokens.py +++ b/drb-c2-core/app/routers/tokens.py @@ -56,18 +56,23 @@ async def delete_token(token_id: str): # 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. + If preferred_token_id is given, try that token first (only if it's free). Returns None if no tokens are available. """ - def _find_free(): + def _find_free(preferred: Optional[str]): 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() return [d for d in docs] import asyncio - results = await asyncio.to_thread(_find_free) + results = await asyncio.to_thread(_find_free, preferred_token_id) if not results: return None diff --git a/drb-frontend/lib/useCalls.ts b/drb-frontend/lib/useCalls.ts index 880d6db..c61793e 100644 --- a/drb-frontend/lib/useCalls.ts +++ b/drb-frontend/lib/useCalls.ts @@ -28,8 +28,13 @@ export function useCalls(limitCount = 50) { orderBy("started_at", "desc"), limit(limitCount) ); + const toISO = (v: any): string | null => + v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); 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); }, (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 toISO = (v: any): string | null => + v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); 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); }); }); diff --git a/drb-server-discord-bot/app/commands/radio.py b/drb-server-discord-bot/app/commands/radio.py index 9864d47..9e30968 100644 --- a/drb-server-discord-bot/app/commands/radio.py +++ b/drb-server-discord-bot/app/commands/radio.py @@ -11,7 +11,7 @@ class RadioCommands(commands.Cog): self.bot = bot # ------------------------------------------------------------------ - # Autocomplete — system names from C2 + # Autocomplete helpers # ------------------------------------------------------------------ async def system_autocomplete( @@ -24,14 +24,27 @@ class RadioCommands(commands.Cog): if current.lower() in s["name"].lower() ][: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 # ------------------------------------------------------------------ @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.autocomplete(system=system_autocomplete) - async def join(self, interaction: discord.Interaction, system: str): + @app_commands.describe( + system="The radio system to listen to.", + 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) if not interaction.user.voice or not interaction.user.voice.channel: @@ -49,11 +62,15 @@ class RadioCommands(commands.Cog): ) return - ok = await c2.send_command(node["node_id"], { + cmd: dict = { "action": "discord_join", "guild_id": guild_id, "channel_id": channel_id, - }) + } + if token: + cmd["preferred_token_id"] = token + + ok = await c2.send_command(node["node_id"], cmd) if ok: systems = await c2.get_systems() @@ -72,7 +89,6 @@ class RadioCommands(commands.Cog): async def leave(self, interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) - # Find any node currently streaming to this guild nodes = await c2.get_nodes() streaming_nodes = [ 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.") return - # Send leave to all online nodes in case more than one joined for node in streaming_nodes: await c2.send_command(node["node_id"], {"action": "discord_leave"}) @@ -125,6 +140,51 @@ class RadioCommands(commands.Cog): 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): await bot.add_cog(RadioCommands(bot)) diff --git a/drb-server-discord-bot/app/config.py b/drb-server-discord-bot/app/config.py index 26e33b0..22e2d9c 100644 --- a/drb-server-discord-bot/app/config.py +++ b/drb-server-discord-bot/app/config.py @@ -5,6 +5,7 @@ from typing import Optional class Settings(BaseSettings): discord_token: str 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 class Config: diff --git a/drb-server-discord-bot/app/internal/c2_client.py b/drb-server-discord-bot/app/internal/c2_client.py index 3b9f89d..558c0dd 100644 --- a/drb-server-discord-bot/app/internal/c2_client.py +++ b/drb-server-discord-bot/app/internal/c2_client.py @@ -8,10 +8,15 @@ class C2Client: def __init__(self): 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: try: 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() return r.json() except Exception as e: @@ -21,19 +26,30 @@ class C2Client: async def get_systems(self) -> list: try: 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() return r.json() except Exception as e: logger.error(f"C2 get_systems failed: {e}") 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: try: async with httpx.AsyncClient(timeout=10) as client: r = await client.post( f"{self.base}/nodes/{node_id}/command", json=payload, + headers=self._headers(), ) r.raise_for_status() return True