""" 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))