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:
co-authored by
Claude Opus 5.5
parent
0a724a064f
commit
a31a8df17c
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user