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