Files
server-26/drb-server-discord-bot/app/commands/directory.py
T
Logan CusanoandClaude Opus 5.5 62e6d69bfa Node page Discord picker: org's linked servers + voice channels, not raw IDs (#213 item 3, #109 item 13)
- c2-core routers/discord_web.py:
  PUT  /discord/guilds/{id}/directory (service key): the server bot pushes each
       server's name + voice channels; merged onto discord_guilds, never touches org_id.
  GET  /discord/nodes/{id}/join-options: the node org's servers (same
       resolve rule as the bot), their voice channels with any live stream,
       radio-bot readiness + invite links, and this node's streams. No tokens.
  POST /discord/nodes/{id}/streams: stream pinned to the node; server must be
       the node org's, channel must be in the bot's directory, guild + node caps.
  POST /discord/nodes/{id}/streams/stop: one stream on this node, or all.
  Control = platform admin, org owner, or operator owning the node.
- nodes.py: legacy raw-ID discord_join now refuses a server outside the node's org.
- discord bot: DirectorySync cog pushes on ready (15-min loop), join/leave,
  rename, voice/category channel changes (debounced).
- frontend: NodeDiscordRadio panel + picker replaces DiscordJoinModal and the
  admin-only Join/Leave buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:02:16 -04:00

133 lines
4.7 KiB
Python

"""
Server directory push (server-26#213 item 3).
The web node page picks a Discord server + voice channel instead of taking raw
IDs. It needs each server's name and voice channels, and this bot already has
them in its gateway cache (default intents include ``guilds``), so it pushes
them to C2 (PUT /discord/guilds/{id}/directory, service key). C2 never calls
Discord on the browser's behalf and no token reaches the browser.
Pushed: server name, and per voice channel its id, name, category name and
order. Nothing about members, messages or roles.
When: every server on ready, on join/leave/rename, on any voice-channel or
category create/delete/update (debounced per server), and a full re-push every
15 minutes in case C2 missed one (it was down, or redeployed).
"""
import asyncio
from typing import Optional
import discord
from discord.ext import commands, tasks
from app.internal.c2_client import c2
from app.internal.logger import logger
DEBOUNCE_SECONDS = 3.0
MAX_CHANNELS = 200
def directory_payload(guild: discord.Guild) -> dict:
channels = []
for category, chans in guild.by_category():
for ch in chans:
if isinstance(ch, discord.VoiceChannel):
channels.append({
"channel_id": str(ch.id),
"name": ch.name[:100],
"category": category.name[:100] if category else None,
"position": len(channels),
})
return {"name": guild.name[:100], "present": True, "voice_channels": channels[:MAX_CHANNELS]}
def _relevant(channel: discord.abc.GuildChannel) -> bool:
return isinstance(channel, (discord.VoiceChannel, discord.CategoryChannel))
class DirectorySync(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self._pending: dict[int, asyncio.Task] = {}
async def cog_load(self):
self.resync.start()
async def cog_unload(self):
self.resync.cancel()
for t in self._pending.values():
t.cancel()
async def push(self, guild: discord.Guild) -> bool:
return await c2.put_guild_directory(guild.id, directory_payload(guild))
def schedule(self, guild: Optional[discord.Guild]) -> None:
if guild is None:
return
prev = self._pending.get(guild.id)
if prev and not prev.done():
prev.cancel()
async def _later():
try:
await asyncio.sleep(DEBOUNCE_SECONDS)
live = self.bot.get_guild(guild.id)
if live is not None:
await self.push(live)
except asyncio.CancelledError:
pass
finally:
if self._pending.get(guild.id) is asyncio.current_task():
self._pending.pop(guild.id, None)
self._pending[guild.id] = asyncio.create_task(_later())
async def push_all(self) -> None:
ok = 0
for guild in list(self.bot.guilds):
ok += bool(await self.push(guild))
logger.info(f"Server directory pushed to C2: {ok}/{len(self.bot.guilds)} servers")
@tasks.loop(minutes=15)
async def resync(self):
await self.push_all()
@resync.before_loop
async def _before_resync(self):
await self.bot.wait_until_ready()
# on_ready can fire again after a gateway resume-failure; the first full
# push comes from resync's first iteration right after the first ready.
@commands.Cog.listener()
async def on_guild_join(self, guild: discord.Guild):
await self.push(guild)
@commands.Cog.listener()
async def on_guild_remove(self, guild: discord.Guild):
await c2.put_guild_directory(guild.id, {"present": False})
@commands.Cog.listener()
async def on_guild_update(self, before: discord.Guild, after: discord.Guild):
if before.name != after.name:
self.schedule(after)
@commands.Cog.listener()
async def on_guild_channel_create(self, channel: discord.abc.GuildChannel):
if _relevant(channel):
self.schedule(channel.guild)
@commands.Cog.listener()
async def on_guild_channel_delete(self, channel: discord.abc.GuildChannel):
if _relevant(channel):
self.schedule(channel.guild)
@commands.Cog.listener()
async def on_guild_channel_update(self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel):
if _relevant(after) and (before.name != after.name or before.position != after.position
or getattr(before, "category_id", None) != getattr(after, "category_id", None)):
self.schedule(after.guild)
async def setup(bot: commands.Bot):
await bot.add_cog(DirectorySync(bot))