changes
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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); });
|
||||
});
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user