diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index 120e3b4a46..021bba82bb 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -5,11 +5,13 @@ import random from functools import partial -from discord import ButtonStyle, Colour, Embed, Interaction +from discord import ButtonStyle, Colour, Embed, HTTPException, Interaction +from discord.abc import GuildChannel from discord.ext import tasks from discord.ext.commands import Cog, Context, group, has_any_role from discord.ui import Button, View from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.channel import get_or_fetch_channel from bot.bot import Bot from bot.constants import Bot as BotConfig, Channels, MODERATION_ROLES, NEGATIVE_REPLIES @@ -23,6 +25,7 @@ OTN_FORMATTER = "ot{number}-{name}" OT_NUMBER_INDEX = 2 NAME_START_INDEX = 4 +MAX_RENAME_ATTEMPTS = 3 log = get_logger(__name__) @@ -46,29 +49,137 @@ async def cog_unload(self) -> None: self.update_names.clear_exception_types() self.update_names.stop() - @tasks.loop(time=datetime.time(), reconnect=True) - async def update_names(self) -> None: - """Background updater task that performs the daily channel name update.""" - await self.bot.wait_until_guild_available() - + async def _fetch_ot_names(self, count: int) -> list[str]: try: - channel_0_name, channel_1_name, channel_2_name = await self.bot.api_client.get( - "bot/off-topic-channel-names", params={"random_items": 3} + return await self.bot.api_client.get( + "bot/off-topic-channel-names", params={"random_items": count} ) except ResponseCodeError as e: log.error(f"Failed to get new off-topic channel names: code {e.response.status}") raise - channel_0, channel_1, channel_2 = (self.bot.get_channel(channel_id) for channel_id in CHANNELS) + @tasks.loop(time=datetime.time(), reconnect=True) + async def update_names(self) -> None: + """Background updater task that performs the daily channel name update.""" + await self.bot.wait_until_guild_available() - await channel_0.edit(name=OTN_FORMATTER.format(number=0, name=channel_0_name)) - await channel_1.edit(name=OTN_FORMATTER.format(number=1, name=channel_1_name)) - await channel_2.edit(name=OTN_FORMATTER.format(number=2, name=channel_2_name)) + ot_channels = [await get_or_fetch_channel(self.bot, channel) for channel in CHANNELS] + num_ot_channels = len(CHANNELS) + + channel_name_pool = iter(await self._fetch_ot_names(num_ot_channels)) + + renamed_ot_channels: set[int] = set() + deactivated_ot_names: list[str] = [] + + exit_early = False + for ot_indx, ot_channel in enumerate(ot_channels): + if exit_early: + break + + attempt = 0 + while attempt < MAX_RENAME_ATTEMPTS: + attempt += 1 + try: + new_name = next(channel_name_pool) + except StopIteration: + mod_meta = await get_or_fetch_channel(self.bot, Channels.mod_meta) + + failed_to_rename = [ + ot_channel.mention for ot_channel in ot_channels if ot_channel.id not in renamed_ot_channels + ] + await mod_meta.send( + f":x: The pool of off-topic names ran out whilst attempting to rename {ot_channel.mention}.\n" + f"The following off-topic channels have not been renamed: {' '.join(failed_to_rename)}" + ) + exit_early = True + break + + new_channel_name = OTN_FORMATTER.format(number=ot_indx, name=new_name) + try: + old_channel_name = ot_channel.name + log.debug( + f"Attempt #{attempt} / {MAX_RENAME_ATTEMPTS} to rename " + f"#{old_channel_name} to #{new_channel_name}" + ) + + await ot_channel.edit(name=new_channel_name) + log.debug(f"Successfully updated off-topic name #{old_channel_name} to #{new_channel_name}") + except HTTPException as e: + # We need to handle code 50035 ("invalid form body"), + # which we get when the new channel name isn't allowed. + # + # For more information see https://github.com/python-discord/bot/issues/2500 + if (e.code != 50035): + # The error isn't the one we want to handle so re-raise + log.error(f"Failed to rename #{ot_channel.name} to #{new_channel_name}") + raise + + # Deactivate the name since it's not valid + log.info( + f"Failed to rename #{ot_channel.name} to #{new_channel_name} as it's not " + "a valid name for servers in Server Discovery so removing it from the rota." + ) + await self.bot.api_client.patch( + f"bot/off-topic-channel-names/{new_name}", + data={"active": False} + ) + deactivated_ot_names.append(new_name) + log.debug(f"Successfully removed {new_name} from the pool of off-topic channel names.") + + # Add a replacement off-topic channel name to the pool if it will be used + if len(deactivated_ot_names) < (num_ot_channels * MAX_RENAME_ATTEMPTS): + channel_name_pool = iter([*channel_name_pool, *await self._fetch_ot_names(1)]) + else: + renamed_ot_channels.add(ot_channel.id) + break + + if deactivated_ot_names: + failed_to_rename = [ot_channel for ot_channel in ot_channels if ot_channel.id not in renamed_ot_channels] + await self.handle_failed_renames(self.bot, deactivated_ot_names, failed_to_rename) + + @staticmethod + async def handle_failed_renames( + bot: Bot, + deactivated_names: list[str], + ot_channels_not_renamed: list[GuildChannel] + ) -> None: + """Sends an appropriate warning/error message to mod-meta for each ot channel that had a failed rename.""" + num_failures = len(deactivated_names) + + # Handle pluralisations + if num_failures == 1: + name_or_names = "name" + its_or_theyre = "it's" + deactivated_names_joined = f"`{deactivated_names[0]}`" + else: + name_or_names = "names" + its_or_theyre = "they're" + deactivated_names_joined = ( + ", ".join(f"`{name}`" for name in deactivated_names[:-1]) + + f", and `{deactivated_names[-1]}`" + ) - log.debug( - "Updated off-topic channel names to" - f" {channel_0_name}, {channel_1_name} and {channel_2_name}" + message = ( + f":warning: The following {num_failures} off-topic channel {name_or_names} failed, as {its_or_theyre} " + f"not valid for servers in Server Discovery: {deactivated_names_joined}." ) + if num_ot_channels_not_renamed := len(ot_channels_not_renamed): + if num_ot_channels_not_renamed == 1: + ot_channels_not_renamed = ot_channels_not_renamed[0].mention + else: + ot_channels_not_renamed_joined = ( + ", ".join(ot_channel.mention for ot_channel in ot_channels_not_renamed[:-1]) + + f" and {ot_channels_not_renamed[-1].mention}" + ) + message += ( + f"\n:x: Was unable to rename {ot_channels_not_renamed_joined} " + f"within the configured maximum {MAX_RENAME_ATTEMPTS} attempts." + ) + else: + message += ("\n:white_check_mark: All off-topic channels were successfully renamed to other names.") + + mod_meta_channel = await get_or_fetch_channel(bot, Channels.mod_meta) + await mod_meta_channel.send(message) async def toggle_ot_name_activity(self, ctx: Context, name: str, active: bool) -> None: """Toggle active attribute for an off-topic name.""" @@ -185,7 +296,7 @@ async def re_roll_command(self, ctx: Context, ot_channel_index: int | None = Non "bot/off-topic-channel-names", params={"random_items": 1} ) try: - new_channel_name = response[0] + new_name = response[0] except IndexError: await ctx.send("Out of active off-topic names. Add new names to reroll.") return @@ -193,16 +304,16 @@ async def re_roll_command(self, ctx: Context, ot_channel_index: int | None = Non async def rename_channel() -> None: """Rename off-topic channel and log events.""" await channel.edit( - name=OTN_FORMATTER.format(number=old_channel_name[OT_NUMBER_INDEX], name=new_channel_name) + name=OTN_FORMATTER.format(number=old_channel_name[OT_NUMBER_INDEX], name=new_name) ) log.info( f"{ctx.author} Off-topic channel re-named from `{old_ot_name}` " - f"to `{new_channel_name}`." + f"to `{new_name}`." ) await ctx.message.reply( f":ok_hand: Off-topic channel re-named from `{old_ot_name}` " - f"to `{new_channel_name}`. " + f"to `{new_name}`. " ) try: