Repository: python-discord/bot

Issue:
Create a delete and reroll command for OT names
We decided in a staff meeting today that there will be a new command that, for a given off-topic channel, picks a different name from the available pool and deletes the current name from the pool (rather than marking it used) and (updated 20 August) marks the removed name as "inactive" in the database. When attempting to add a name to the pool that exists in the pool as inactive, the command should fail with the bot explaining as much. One should be able to circumvent this with `forceadd`. This would be a moderator+ command that lives in the off-topic cog. 

Other details of the spec have not been decided on, including:
- The name of the command. `reroll` is a contender, though one might not infer that the command both rerolls and deletes.
- The argument that is passed to it. Should it be an integer in `{0, 1, 2}`, or should it be a channel ID? Should it be optional and in that case act upon the channel in which it in invoked?

I may be able to implement this myself, as I'm pretty much done with school.


Discussion:
cc @RohanJnr 
@RohanJnr would like to implement this. I chatted with him earlier, and for now the specification is that `!otn reroll n` for `n in {0, 1, 2}`, though keep in mind that it will both reroll the name for that channel *and* delete the then-current name. The `n` argument will be optional when evoked in an off-topic channel.

Whoever is knowledgeable about rate limits as they apply to channel renames, I'd be interested to know if this command needs a cooldown.
We will need a cool down, we can edit the channel 2 times before we hit a 10min rate limit cooldown.
The command can have a cooldown of 5min to avoid the rate limit.
Do we want the cooldown for the entire command or do we want the cooldown per channel?
@RohanJnr whichever count cooldown pertains to.
## Plan 1  to handle rate limits.

Store the number of times an `off-topic channel` has been edited as `times_edited` and also the `datetime` instance for the `last edit`

data structure:
```python
{
 channel_id: {
    "times_edited": int,
    "last_edit": datetime
 }
}
```
or the above can be done using a `Class` ( I have not decided which one I want to use yet).

### Conditions before editing a channel via the `reroll` command or the `daily channel name updater task`,

- if `times_edited` is `2` and 10minutes have `not` passed since the `last_edit`, then:
  - Reschedule the channel edit if it's the `daily channel name updater task`.
  - Tell the user to try the command later if it's the `reroll` command or schedule the reroll command.

- if `times_edited` is `2` and 10minutes have passed since the `last_edit`, then:
  - Go ahead and edit the channel.
  - Reset `times_edited` to 1 and store `last_edit`.

- if `times_edited` is 1, then we just edit the channel, update `times_edited` to 2, and `last_edit` to the current time.


## Plan 2
Just to set a 10minute cooldown on the reroll command.


The former allows you to edit multiple channels 2 times within 10min(mostly) and then not allowing you to edit the channels for the next 10min (to reset the rate limit cooldown) whereas the latter allows you to edit only 1 channel every 10min though easier to implement.

 
If you go with plan one, can you store that information in an async redis cache? How will you handle the daily non-deleting rerolls (since those also count towards the rate limit)?

If you're worried that plan one would slow down development too much, you can go with plan 2 for now and we can plan to migrate to plan 1 later. The underlying logic for the actual deletion-reroll should be unaffected.
> How will you handle the daily non-deleting rerolls

I did mention it as `daily channel name updater task`.




Resolving patch:
diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py
--- a/bot/exts/fun/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -1,18 +1,30 @@
+import asyncio
 import datetime
 import difflib
+import json
+import random
+from functools import partial
+from typing import Optional
 
 from botcore.site_api import ResponseCodeError
-from discord import Colour, Embed
+from discord import ButtonStyle, Colour, Embed, Interaction
 from discord.ext import tasks
 from discord.ext.commands import Cog, Context, group, has_any_role
+from discord.ui import Button, View
 
 from bot.bot import Bot
-from bot.constants import Bot as BotConfig, Channels, MODERATION_ROLES
+from bot.constants import Bot as BotConfig, Channels, MODERATION_ROLES, NEGATIVE_REPLIES
 from bot.converters import OffTopicName
 from bot.log import get_logger
 from bot.pagination import LinePaginator
 
 CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2)
+
+# In case, the off-topic channel name format is modified.
+OTN_FORMATTER = "ot{number}-{name}"
+OT_NUMBER_INDEX = 2
+NAME_START_INDEX = 4
+
 log = get_logger(__name__)
 
 
@@ -45,19 +57,42 @@ async def update_names(self) -> None:
                 'bot/off-topic-channel-names', params={'random_items': 3}
             )
         except ResponseCodeError as e:
-            log.error(f"Failed to get new off topic channel names: code {e.response.status}")
+            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)
 
-        await channel_0.edit(name=f'ot0-{channel_0_name}')
-        await channel_1.edit(name=f'ot1-{channel_1_name}')
-        await channel_2.edit(name=f'ot2-{channel_2_name}')
+        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))
+
         log.debug(
             "Updated off-topic channel names to"
             f" {channel_0_name}, {channel_1_name} and {channel_2_name}"
         )
 
+    async def toggle_ot_name_activity(self, ctx: Context, name: str, active: bool) -> None:
+        """Toggle active attribute for an off-topic name."""
+        data = {
+            "active": active
+        }
+        await self.bot.api_client.patch(f"bot/off-topic-channel-names/{name}", data=data)
+        await ctx.send(f"Off-topic name `{name}` has been {'activated' if active else 'deactivated'}.")
+
+    async def list_ot_names(self, ctx: Context, active: bool = True) -> None:
+        """Send an embed containing active/deactivated off-topic channel names."""
+        result = await self.bot.api_client.get('bot/off-topic-channel-names', params={'active': json.dumps(active)})
+        lines = sorted(f"• {name}" for name in result)
+        embed = Embed(
+            title=f"{'Active' if active else 'Deactivated'} off-topic names (`{len(result)}` total)",
+            colour=Colour.blue()
+        )
+        if result:
+            await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+        else:
+            embed.description = "Hmmm, seems like there's nothing here yet."
+            await ctx.send(embed=embed)
+
     @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
     @has_any_role(*MODERATION_ROLES)
     async def otname_group(self, ctx: Context) -> None:
@@ -109,7 +144,111 @@ async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None:
         log.info(f"{ctx.author} deleted the off-topic channel name '{name}'")
         await ctx.send(f":ok_hand: Removed `{name}` from the names list.")
 
-    @otname_group.command(name='list', aliases=('l',))
+    @otname_group.command(name='activate', aliases=('whitelist',))
+    @has_any_role(*MODERATION_ROLES)
+    async def activate_ot_name(self, ctx: Context, name: OffTopicName) -> None:
+        """Activate an existing off-topic name."""
+        await self.toggle_ot_name_activity(ctx, name, True)
+
+    @otname_group.command(name='deactivate', aliases=('blacklist',))
+    @has_any_role(*MODERATION_ROLES)
+    async def de_activate_ot_name(self, ctx: Context, name: OffTopicName) -> None:
+        """Deactivate a specific off-topic name."""
+        await self.toggle_ot_name_activity(ctx, name, False)
+
+    @otname_group.command(name='reroll')
+    @has_any_role(*MODERATION_ROLES)
+    async def re_roll_command(self, ctx: Context, ot_channel_index: Optional[int] = None) -> None:
+        """
+        Re-roll an off-topic name for a specific off-topic channel and deactivate the current name.
+
+        ot_channel_index: [0, 1, 2, ...]
+        """
+        if ot_channel_index is not None:
+            try:
+                channel = self.bot.get_channel(CHANNELS[ot_channel_index])
+            except IndexError:
+                await ctx.send(f":x: No off-topic channel found with index {ot_channel_index}.")
+                return
+        elif ctx.channel.id in CHANNELS:
+            channel = ctx.channel
+
+        else:
+            await ctx.send("Please specify channel for which the off-topic name should be re-rolled.")
+            return
+
+        old_channel_name = channel.name
+        old_ot_name = old_channel_name[NAME_START_INDEX:]  # ot1-name-of-ot -> name-of-ot
+
+        await self.de_activate_ot_name(ctx, old_ot_name)
+
+        response = await self.bot.api_client.get(
+            'bot/off-topic-channel-names', params={'random_items': 1}
+        )
+        try:
+            new_channel_name = response[0]
+        except IndexError:
+            await ctx.send("Out of active off-topic names. Add new names to reroll.")
+            return
+
+        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)
+            )
+            log.info(
+                f"{ctx.author} Off-topic channel re-named from `{old_ot_name}` "
+                f"to `{new_channel_name}`."
+            )
+
+            await ctx.message.reply(
+                f":ok_hand: Off-topic channel re-named from `{old_ot_name}` "
+                f"to `{new_channel_name}`. "
+            )
+
+        try:
+            await asyncio.wait_for(rename_channel(), 3)
+        except asyncio.TimeoutError:
+            # Channel rename endpoint rate limited. The task was cancelled by asyncio.
+            btn_yes = Button(label="Yes", style=ButtonStyle.success)
+            btn_no = Button(label="No", style=ButtonStyle.danger)
+
+            embed = Embed(
+                title=random.choice(NEGATIVE_REPLIES),
+                description=(
+                    "Re-naming the channel is being rate-limited. "
+                    "Would you like to schedule an asyncio task to rename the channel within the current bot session ?"
+                ),
+                colour=Colour.blurple()
+            )
+
+            async def btn_call_back(schedule: bool, interaction: Interaction) -> None:
+                if ctx.author != interaction.user:
+                    log.info("User is not author, skipping.")
+                    return
+                message = interaction.message
+
+                embed.description = (
+                    "Scheduled a channel re-name process within the current bot session."
+                    if schedule
+                    else
+                    "Channel not re-named due to rate limit. Please try again later."
+                )
+                await message.edit(embed=embed, view=None)
+
+                if schedule:
+                    await rename_channel()
+
+            btn_yes.callback = partial(btn_call_back, True)
+            btn_no.callback = partial(btn_call_back, False)
+
+            view = View()
+            view.add_item(btn_yes)
+            view.add_item(btn_no)
+
+            await ctx.message.reply(embed=embed, view=view)
+
+    @otname_group.group(name='list', aliases=('l',), invoke_without_command=True)
     @has_any_role(*MODERATION_ROLES)
     async def list_command(self, ctx: Context) -> None:
         """
@@ -117,17 +256,19 @@ async def list_command(self, ctx: Context) -> None:
 
         Restricted to Moderator and above to not spoil the surprise.
         """
-        result = await self.bot.api_client.get('bot/off-topic-channel-names')
-        lines = sorted(f"• {name}" for name in result)
-        embed = Embed(
-            title=f"Known off-topic names (`{len(result)}` total)",
-            colour=Colour.blue()
-        )
-        if result:
-            await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
-        else:
-            embed.description = "Hmmm, seems like there's nothing here yet."
-            await ctx.send(embed=embed)
+        await self.active_otnames_command(ctx)
+
+    @list_command.command(name='active', aliases=('a',))
+    @has_any_role(*MODERATION_ROLES)
+    async def active_otnames_command(self, ctx: Context) -> None:
+        """List active off-topic channel names."""
+        await self.list_ot_names(ctx, True)
+
+    @list_command.command(name='deactivated', aliases=('d',))
+    @has_any_role(*MODERATION_ROLES)
+    async def deactivated_otnames_command(self, ctx: Context) -> None:
+        """List deactivated off-topic channel names."""
+        await self.list_ot_names(ctx, False)
 
     @otname_group.command(name='search', aliases=('s',))
     @has_any_role(*MODERATION_ROLES)


Test patch: