Initial commit — DRB server stack
Includes c2-core (FastAPI/MQTT/Firestore), discord-bot (slash commands), frontend (Next.js admin UI), and mosquitto config.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
.pytest_cache
|
||||
@@ -0,0 +1,7 @@
|
||||
DISCORD_TOKEN=your-bot-token-here
|
||||
|
||||
# C2 core API
|
||||
C2_URL=http://c2-core:8000
|
||||
|
||||
# Optional: restrict slash command sync to one guild during dev (faster than global)
|
||||
# DEV_GUILD_ID=123456789
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
CMD ["python", "-m", "app.bot"]
|
||||
@@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
class DRBBot(commands.Bot):
|
||||
def __init__(self):
|
||||
intents = discord.Intents.default()
|
||||
super().__init__(command_prefix="!", intents=intents)
|
||||
|
||||
async def setup_hook(self):
|
||||
await self.load_extension("app.commands.radio")
|
||||
|
||||
if settings.dev_guild_id:
|
||||
guild = discord.Object(id=settings.dev_guild_id)
|
||||
self.tree.copy_global_to(guild=guild)
|
||||
await self.tree.sync(guild=guild)
|
||||
logger.info(f"Slash commands synced to dev guild {settings.dev_guild_id}.")
|
||||
else:
|
||||
await self.tree.sync()
|
||||
logger.info("Slash commands synced globally.")
|
||||
|
||||
async def on_ready(self):
|
||||
logger.info(f"Bot ready: {self.user} ({self.user.id})")
|
||||
await self.change_presence(
|
||||
activity=discord.Activity(
|
||||
type=discord.ActivityType.listening,
|
||||
name="the radio"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
bot = DRBBot()
|
||||
|
||||
if __name__ == "__main__":
|
||||
bot.run(settings.discord_token)
|
||||
@@ -0,0 +1,130 @@
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
from typing import Optional
|
||||
from app.internal.c2_client import c2
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
class RadioCommands(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Autocomplete — system names from C2
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def system_autocomplete(
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
systems = await c2.get_systems()
|
||||
return [
|
||||
app_commands.Choice(name=s["name"], value=s["system_id"])
|
||||
for s in systems
|
||||
if current.lower() in s["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):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if not interaction.user.voice or not interaction.user.voice.channel:
|
||||
await interaction.followup.send("You need to be in a voice channel first.")
|
||||
return
|
||||
|
||||
channel = interaction.user.voice.channel
|
||||
guild_id = str(interaction.guild_id)
|
||||
channel_id = str(channel.id)
|
||||
|
||||
node = await c2.find_node_for_system(system)
|
||||
if not node:
|
||||
await interaction.followup.send(
|
||||
"No online node is assigned to that system. Check `/status` for availability."
|
||||
)
|
||||
return
|
||||
|
||||
ok = await c2.send_command(node["node_id"], {
|
||||
"action": "discord_join",
|
||||
"guild_id": guild_id,
|
||||
"channel_id": channel_id,
|
||||
})
|
||||
|
||||
if ok:
|
||||
systems = await c2.get_systems()
|
||||
system_name = next((s["name"] for s in systems if s["system_id"] == system), system)
|
||||
await interaction.followup.send(
|
||||
f"Streaming **{system_name}** from node `{node['node_id']}` to {channel.mention}."
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send("Failed to contact the node. It may be offline.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /leave
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app_commands.command(name="leave", description="Stop streaming radio in this server.")
|
||||
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")
|
||||
]
|
||||
|
||||
if not streaming_nodes:
|
||||
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"})
|
||||
|
||||
await interaction.followup.send("Disconnected.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /status
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app_commands.command(name="status", description="Show all node and system status.")
|
||||
async def status(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
nodes = await c2.get_nodes()
|
||||
systems = await c2.get_systems()
|
||||
|
||||
system_map = {s["system_id"]: s["name"] for s in systems}
|
||||
|
||||
status_emoji = {
|
||||
"online": "🟢",
|
||||
"recording": "🔴",
|
||||
"offline": "⚫",
|
||||
"unconfigured": "🟡",
|
||||
}
|
||||
|
||||
embed = discord.Embed(title="DRB Node Status", color=0x2b2d31)
|
||||
|
||||
if not nodes:
|
||||
embed.description = "No nodes registered."
|
||||
else:
|
||||
for node in sorted(nodes, key=lambda n: n.get("name", "")):
|
||||
s = node.get("status", "offline")
|
||||
emoji = status_emoji.get(s, "⚪")
|
||||
system_name = system_map.get(node.get("assigned_system_id", ""), "Unassigned")
|
||||
embed.add_field(
|
||||
name=f"{emoji} {node.get('name', node['node_id'])}",
|
||||
value=f"`{s}` — {system_name}",
|
||||
inline=True,
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(RadioCommands(bot))
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
discord_token: str
|
||||
c2_url: str = "http://localhost:8000"
|
||||
dev_guild_id: Optional[int] = None # set to sync commands instantly during dev
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,56 @@
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
class C2Client:
|
||||
def __init__(self):
|
||||
self.base = settings.c2_url.rstrip("/")
|
||||
|
||||
async def get_nodes(self) -> list:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/nodes")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_nodes failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_systems(self) -> list:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/systems")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_systems 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,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 send_command failed: {e}")
|
||||
return False
|
||||
|
||||
async def find_node_for_system(self, system_id: str) -> Optional[dict]:
|
||||
"""Return the first online node assigned to the given system."""
|
||||
nodes = await self.get_nodes()
|
||||
for node in nodes:
|
||||
if (
|
||||
node.get("assigned_system_id") == system_id
|
||||
and node.get("status") in ("online", "recording")
|
||||
):
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
c2 = C2Client()
|
||||
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
|
||||
logger = logging.getLogger("drb-server-bot")
|
||||
@@ -0,0 +1,3 @@
|
||||
discord.py>=2.3.0
|
||||
pydantic-settings
|
||||
httpx
|
||||
Reference in New Issue
Block a user