Merge pull request 'Node page Discord picker: org's servers + voice channels, not raw IDs (#213 item 3)' (#220) from feat/213-node-join-picker into main
Reviewed-on: #220
This commit was merged in pull request #220.
This commit is contained in:
@@ -16,6 +16,7 @@ class DRBBot(commands.Bot):
|
||||
await self.load_extension("app.commands.radio")
|
||||
await self.load_extension("app.commands.drb")
|
||||
await self.load_extension("app.commands.trips")
|
||||
await self.load_extension("app.commands.directory")
|
||||
|
||||
if settings.dev_guild_id:
|
||||
guild = discord.Object(id=settings.dev_guild_id)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
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))
|
||||
@@ -30,6 +30,19 @@ class C2Client:
|
||||
return {"guild_id": str(guild_id), "org_id": None, "systems": [], "nodes": [],
|
||||
"streams": [], "bots": [], "error": True}
|
||||
|
||||
async def put_guild_directory(self, guild_id: int | str, payload: dict) -> bool:
|
||||
"""This server's name + voice channels, for the web node-page picker
|
||||
(server-26#213). Names only — no members, no messages, no tokens."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.put(f"{self.base}/discord/guilds/{guild_id}/directory",
|
||||
json=payload, headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 put_guild_directory({guild_id}) failed: {e}")
|
||||
return False
|
||||
|
||||
async def start_stream(self, payload: dict) -> tuple[bool, dict]:
|
||||
"""Returns (ok, body). On a refusal body carries detail/code/invite."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user