- Copy: picker, admin Servers/Streams/How-it-works, org Discord card, bot /help and the server-limit refusal no longer imply the DRB server bot is required; a radio bot alone is enough. - Admin overview: per-server main_bot_present, radio_bots, linked_via, directory_source; Servers tab gets a Bots column. - discord_web docstring: states the reversed "never calls Discord" rule (plan finding 5) and the optional server bot. - Server bot directory cog: re-push (which drops C2's cached permission answers) on overwrite changes, role permission changes and role deletes. - tests/test_discord_owner_flows.py: one-bot org (BYO bot, owner-set preferred, OAuth link, picker, start, reconciler) and full setup (server bot + 4 radio bots: placement, shared channel, every refusal explained), against a modelled Discord over httpx.MockTransport. - storage.download_audio removed: no caller on main since #233. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
5.6 KiB
Python
152 lines
5.6 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).
|
|
|
|
This bot is optional (server-26#225): in a server it is not in, C2 reads the
|
|
same list over Discord REST with a radio bot's token (routers/discord_web.py).
|
|
Where it is present, its push wins while fresh and also tells C2 to drop its
|
|
cached Discord answers for the server, so a permission fixed in Discord shows
|
|
up on the next check instead of after the cache expires. No token ever
|
|
reaches the browser either way.
|
|
|
|
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 — including a permission-overwrite change — and
|
|
on a role permission change or role delete (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):
|
|
# Overwrites too: the push makes C2 drop its cached permission answers
|
|
# for this server (server-26#225), so "Re-check" sees the fix at once.
|
|
if _relevant(after) and (before.name != after.name or before.position != after.position
|
|
or getattr(before, "category_id", None) != getattr(after, "category_id", None)
|
|
or before.overwrites != after.overwrites):
|
|
self.schedule(after.guild)
|
|
|
|
@commands.Cog.listener()
|
|
async def on_guild_role_update(self, before: discord.Role, after: discord.Role):
|
|
if before.permissions != after.permissions:
|
|
self.schedule(after.guild)
|
|
|
|
@commands.Cog.listener()
|
|
async def on_guild_role_delete(self, role: discord.Role):
|
|
self.schedule(role.guild)
|
|
|
|
|
|
async def setup(bot: commands.Bot):
|
|
await bot.add_cog(DirectorySync(bot))
|