Discord radio channels: wire leases + desired-state streams (server-26#203, #204)

c2-core
- discord_streams/discord_leases wired in: POST /discord/streams,
  POST /discord/guilds/{id}/stop, GET /discord/guilds/{id}/overview,
  GET /discord/streams/{id}, PUT /discord/guilds/{id}/link (admin),
  GET /discord/node/{node}/tokens/{id} (node key, lease holder only).
- Retained nodes/{id}/discord/desired (token ids, never token strings),
  nodes/+/discord/actual subscribed and handled; per-node publish lock keeps
  the retained list the newest; failed publishes retried by the reconciler.
- Tokens freed only after the node acks logout (or offline past
  discord_lease_offline_release_seconds) - sweeper and C2 startup no longer
  release leases.
- /nodes/{id}/command discord_join/leave map onto streams; CommandPayload
  keeps preferred_token_id. /tokens: admin-only list, validate on add against
  Discord, refresh, flush via transactional force release.
- Guild -> org via discord_guilds, unlinked guilds fall back to
  DISCORD_UNLINKED_GUILD_ORG_ID (default "founding").
- tests/fake_firestore.py: optimistic-transaction fake; 27 lease/race tests.

discord bot
- /join, /leave, /status, autocomplete scoped to the calling guild; /leave
  never touches another server; Manage Channels default permission;
  /join edits its reply with the node's real result; /help copy fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 18:05:26 -04:00
co-authored by Claude Opus 5.5
parent 0a724a064f
commit a31a8df17c
15 changed files with 1428 additions and 359 deletions
+186 -113
View File
@@ -1,144 +1,223 @@
import asyncio
import time
from typing import Optional
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
# How long /join keeps editing its reply while the node reports back.
JOIN_WATCH_SECONDS = 30
JOIN_POLL_SECONDS = 2.5
OVERVIEW_TTL = 30.0
STATE_TEXT = {
"pending": "Waiting for the node to pick it up…",
"connecting": "Connecting…",
"connected": "Connected.",
"node_offline": "The node carrying this channel is offline.",
"paused_locally": "Paused from the node's local dashboard.",
}
def _refusal_text(body: dict) -> str:
text = body.get("detail") or "DRB could not start radio in that channel."
if isinstance(text, dict): # FastAPI HTTPException detail
text = text.get("detail") or str(text)
invites = [i for i in (body.get("invite") or []) if i.get("invite_url")]
if invites:
text += "\n" + "\n".join(f"- [Invite {i['name']}]({i['invite_url']})" for i in invites[:5])
return text
class RadioCommands(commands.Cog):
"""Radio channels for ONE Discord server at a time (server-26#203).
Every C2 call carries this interaction's guild_id; C2 resolves the org that
server is linked to and never returns or touches anything outside it.
/join and /leave default to members with Manage Channels — a server admin
can hand them to a DJ role under Server Settings > Integrations.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
self._overview: dict[int, tuple[float, dict]] = {}
async def _guild(self, guild_id: int, fresh: bool = False) -> dict:
hit = self._overview.get(guild_id)
if hit and not fresh and time.monotonic() - hit[0] < OVERVIEW_TTL:
return hit[1]
data = await c2.guild_overview(guild_id)
if not data.get("error"):
self._overview[guild_id] = (time.monotonic(), data)
return data
# ------------------------------------------------------------------
# Autocomplete helpers
# Autocomplete — this server's org only
# ------------------------------------------------------------------
async def system_autocomplete(
self, interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]:
systems = await c2.get_systems()
if not interaction.guild_id:
return []
data = await self._guild(interaction.guild_id)
return [
app_commands.Choice(name=s["name"], value=s["system_id"])
for s in systems
if current.lower() in s["name"].lower()
for s in data.get("systems", [])
if s.get("name") and current.lower() in s["name"].lower()
][:25]
async def token_autocomplete(
async def bot_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]
if not interaction.guild_id:
return []
data = await self._guild(interaction.guild_id)
out = []
for b in data.get("bots", []):
name = b.get("name") or b.get("token_id")
if current.lower() not in name.lower():
continue
if b.get("busy_here"):
label = "busy here"
elif b.get("member") is False:
label = "not in this server"
else:
label = "free"
out.append(app_commands.Choice(name=f"{name} ({label})", value=b["token_id"]))
return out[:25]
# ------------------------------------------------------------------
# /join
# ------------------------------------------------------------------
@app_commands.command(name="join", description="Stream a radio system to your voice channel.")
@app_commands.guild_only()
@app_commands.default_permissions(manage_channels=True)
@app_commands.describe(
system="The radio system to listen to.",
token="Optionally pick a specific bot token from the pool.",
bot="Optionally pick which radio bot joins.",
persistent="Keep this channel on radio until someone runs /leave (reconnects by itself).",
)
@app_commands.autocomplete(system=system_autocomplete, token=token_autocomplete)
async def join(self, interaction: discord.Interaction, system: str, token: Optional[str] = None):
@app_commands.autocomplete(system=system_autocomplete, bot=bot_autocomplete)
async def join(self, interaction: discord.Interaction, system: str,
bot: Optional[str] = None, persistent: bool = False):
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.")
voice = getattr(interaction.user, "voice", None)
if not voice or not voice.channel:
await interaction.followup.send("You need to be in a voice channel first.", ephemeral=True)
return
channel = voice.channel
ok, body = await c2.start_stream({
"guild_id": str(interaction.guild_id),
"channel_id": str(channel.id),
"system_id": system,
"mode": "persistent" if persistent else "adhoc",
"preferred_token_id": bot,
"channel_name": channel.name,
"guild_name": interaction.guild.name if interaction.guild else None,
"created_by": str(interaction.user.id),
})
self._overview.pop(interaction.guild_id, None)
if not ok:
await interaction.followup.send(_refusal_text(body), ephemeral=True)
return
channel = interaction.user.voice.channel
guild_id = str(interaction.guild_id)
channel_id = str(channel.id)
system_name = body.get("system_name") or system
head = f"**{system_name}** → {channel.mention}"
if body.get("delivered") is False:
head += "\nDRB's message broker is down; the node will get this as soon as it is back."
msg = await interaction.followup.send(f"{head}\n{STATE_TEXT['pending']}", ephemeral=True, wait=True)
# The node reports what actually happened (node-26#25 item 5) — show
# that, not an optimistic "Streaming…" (server-26#203 item 4).
await self._watch(msg, body["stream_id"], head)
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
async def _watch(self, msg: discord.WebhookMessage, stream_id: str, head: str) -> None:
last = None
deadline = time.monotonic() + JOIN_WATCH_SECONDS
while time.monotonic() < deadline:
await asyncio.sleep(JOIN_POLL_SECONDS)
s = await c2.get_stream(stream_id)
if not s:
continue
actual = s.get("actual") or {}
state = actual.get("state") or "pending"
text = actual.get("error") if state == "error" else STATE_TEXT.get(state, state)
if text != last:
last = text
try:
await msg.edit(content=f"{head}\n{text}")
except discord.HTTPException as e:
logger.warning(f"/join status edit failed: {e}")
return
if state in ("connected", "error"):
return
if last == STATE_TEXT["pending"]:
try:
await msg.edit(content=f"{head}\nThe node has not answered yet — it may be offline. "
"Check `/status`.")
except discord.HTTPException:
pass
cmd: dict = {
"action": "discord_join",
"guild_id": guild_id,
"channel_id": channel_id,
}
if token:
cmd["preferred_token_id"] = token
# ------------------------------------------------------------------
# /leave — this server only
# ------------------------------------------------------------------
ok = await c2.send_command(node["node_id"], cmd)
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}."
)
@app_commands.command(name="leave", description="Stop radio in this server (or in one channel).")
@app_commands.guild_only()
@app_commands.default_permissions(manage_channels=True)
@app_commands.describe(channel="Only stop radio in this voice channel.")
async def leave(self, interaction: discord.Interaction,
channel: Optional[discord.VoiceChannel] = None):
await interaction.response.defer(ephemeral=True)
stopped = await c2.stop_guild(interaction.guild_id, str(channel.id) if channel else None)
self._overview.pop(interaction.guild_id, None)
if stopped is None:
await interaction.followup.send("Could not reach DRB. Try again in a minute.", ephemeral=True)
elif not stopped:
where = channel.mention if channel else "this server"
await interaction.followup.send(f"No radio is playing in {where}.", ephemeral=True)
else:
await interaction.followup.send("Failed to contact the node. It may be offline.")
await interaction.followup.send(f"Stopped radio in {len(stopped)} channel(s).", ephemeral=True)
# ------------------------------------------------------------------
# /leave
# /status — this server's org only
# ------------------------------------------------------------------
@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)
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
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.")
@app_commands.command(name="status", description="Show this server's radio channels and your nodes.")
@app_commands.guild_only()
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."
await interaction.response.defer(ephemeral=True)
data = await self._guild(interaction.guild_id, fresh=True)
embed = discord.Embed(title="DRB status", color=0x2b2d31)
if data.get("error"):
embed.description = "Could not reach DRB."
elif not data.get("org_id"):
embed.description = "This server is not linked to a DRB organization yet."
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")
systems = {s["system_id"]: s.get("name") for s in data.get("systems", [])}
lines = []
for s in data.get("streams", []):
actual = (s.get("actual") or {})
state = actual.get("state") or "pending"
lines.append(f"<#{s['channel_id']}> — {s.get('system_name') or s.get('system_id')} · `{state}`"
+ (" · persistent" if s.get("mode") == "persistent" else ""))
embed.add_field(name="Radio channels", value="\n".join(lines) or "None playing.", inline=False)
dot = {"online": "🟢", "recording": "🔴", "offline": "⚫", "unconfigured": "🟡"}
for n in sorted(data.get("nodes", []), key=lambda n: n.get("name") or n["node_id"])[:20]:
st = n.get("status") or "offline"
embed.add_field(
name=f"{emoji} {node.get('name', node['node_id'])}",
value=f"`{s}` — {system_name}",
name=f"{dot.get(st, '⚪')} {n.get('name') or n['node_id']}",
value=f"`{st}` — {systems.get(n.get('system_id'), 'Unassigned')}",
inline=True,
)
await interaction.followup.send(embed=embed)
await interaction.followup.send(embed=embed, ephemeral=True)
# ------------------------------------------------------------------
# /help
@@ -146,44 +225,38 @@ class RadioCommands(commands.Cog):
@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 = discord.Embed(title="DRB Radio Bot — Help", color=0x5865f2)
embed.add_field(
name="/join `system`",
name="/join `system` [`bot`] [`persistent`]",
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."
"Put a radio system on your current voice channel. A radio bot joins and plays it.\n"
"`persistent` keeps the channel on radio until `/leave`, reconnecting by itself.\n"
"Needs Manage Channels unless a server admin grants it to another role."
),
inline=False,
)
embed.add_field(
name="/leave",
value="Stop streaming and disconnect all active radio bots in this server.",
name="/leave [`channel`]",
value="Stop radio in this server, or only in one voice channel. Never affects other servers.",
inline=False,
)
embed.add_field(
name="/status",
value="Show all registered edge nodes and which systems they're monitoring.",
value="This server's radio channels and your organization's nodes.",
inline=False,
)
embed.add_field(
name="Radio bot direct commands",
name="Radio bots",
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."
"A radio bot has to be a member of this server before it can join a channel — "
"if none is, `/join` replies with an invite link.\n"
"The bot's green speaking ring lights up only while radio is transmitting."
),
inline=False,
)
embed.set_footer(text="Audio is streamed live from SDR edge nodes via Icecast.")
await interaction.response.send_message(embed=embed)
embed.set_footer(text="Audio comes live from DRB edge nodes' receivers, straight from their audio "
"path — not a re-broadcast stream.")
await interaction.response.send_message(embed=embed, ephemeral=True)
async def setup(bot: commands.Bot):
@@ -13,60 +13,60 @@ class C2Client:
return {"Authorization": f"Bearer {settings.c2_service_key}"}
return {}
async def get_nodes(self) -> list:
# ------------------------------------------------------------------
# Discord radio channels — every call is scoped to one guild
# (server-26#203). C2 resolves the guild's org; nothing here sees another
# org's nodes, systems or bots.
# ------------------------------------------------------------------
async def guild_overview(self, guild_id: int | str) -> dict:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{self.base}/nodes", headers=self._headers())
r = await client.get(f"{self.base}/discord/guilds/{guild_id}/overview", headers=self._headers())
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 get_nodes failed: {e}")
return []
logger.error(f"C2 guild_overview failed: {e}")
return {"guild_id": str(guild_id), "org_id": None, "systems": [], "nodes": [],
"streams": [], "bots": [], "error": True}
async def get_systems(self) -> list:
async def start_stream(self, payload: dict) -> tuple[bool, dict]:
"""Returns (ok, body). On a refusal body carries detail/code/invite."""
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{self.base}/discord/streams", json=payload, headers=self._headers())
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
if r.status_code >= 400:
return False, body if isinstance(body, dict) else {"detail": str(body)}
return True, body
except Exception as e:
logger.error(f"C2 start_stream failed: {e}")
return False, {"detail": "Could not reach DRB. Try again in a minute.", "code": "c2_unreachable"}
async def get_stream(self, stream_id: str) -> Optional[dict]:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{self.base}/systems", headers=self._headers())
r = await client.get(f"{self.base}/discord/streams/{stream_id}", headers=self._headers())
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 get_systems failed: {e}")
return []
logger.error(f"C2 get_stream failed: {e}")
return None
async def get_tokens(self) -> list:
async def stop_guild(self, guild_id: int | str, channel_id: Optional[str] = None,
reason: str = "leave") -> Optional[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:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{self.base}/nodes/{node_id}/command",
json=payload,
f"{self.base}/discord/guilds/{guild_id}/stop",
json={"channel_id": channel_id, "reason": reason},
headers=self._headers(),
)
r.raise_for_status()
return True
return r.json().get("stopped", [])
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
logger.error(f"C2 stop_guild failed: {e}")
return None
# ------------------------------------------------------------------
# Trips