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
+68 -8
View File
@@ -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))
+1
View File
@@ -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