From 271d154bf76ce26043d64a4614f1c79bf0ebfea2 Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Mon, 11 Aug 2025 01:36:03 +0000 Subject: [PATCH 01/14] chore: Remove `/confirm`, unconfirmed role, and welcome channel --- src/discord/censor.py | 6 -- src/discord/globals.py | 1 - src/discord/logger.py | 152 ++++++++++++++++++++++------------------- 3 files changed, 82 insertions(+), 77 deletions(-) diff --git a/src/discord/censor.py b/src/discord/censor.py index ee0732b..47aad9e 100644 --- a/src/discord/censor.py +++ b/src/discord/censor.py @@ -18,7 +18,6 @@ CATEGORY_STAFF, CHANNEL_SUPPORT, DISCORD_INVITE_ENDINGS, - ROLE_UC, ) if TYPE_CHECKING: @@ -241,11 +240,6 @@ async def on_message_edit(self, before: discord.Message, after: discord.Message) @commands.Cog.listener() async def on_member_join(self, member: discord.Member): - # Give new user confirmed role - unconfirmed_role = discord.utils.get(member.guild.roles, name=ROLE_UC) - assert isinstance(unconfirmed_role, discord.Role) - await member.add_roles(unconfirmed_role) - # Check to see if user's name is innapropriate name = member.name if await self.censor_needed(name): diff --git a/src/discord/globals.py b/src/discord/globals.py index 155ed56..a959c97 100644 --- a/src/discord/globals.py +++ b/src/discord/globals.py @@ -30,7 +30,6 @@ ROLE_AT = "All Invitationals" ROLE_GAMES = "Games" ROLE_MR = "Member" -ROLE_UC = "Unconfirmed" ROLE_DIV_A = "Division A" ROLE_DIV_B = "Division B" ROLE_DIV_C = "Division C" diff --git a/src/discord/logger.py b/src/discord/logger.py index ea3a219..9c23fec 100644 --- a/src/discord/logger.py +++ b/src/discord/logger.py @@ -2,6 +2,7 @@ Logs actions that happened on the Scioly.org Discord server to specific information buckets, such as a Discord channel or database log. """ + from __future__ import annotations import logging @@ -19,7 +20,6 @@ CHANNEL_EDITEDM, CHANNEL_LEAVE, CHANNEL_LOUNGE, - ROLE_UC, ) if TYPE_CHECKING: @@ -54,9 +54,11 @@ async def send_to_dm_log(self, message: discord.Message): # Create an embed containing the direct message info and send it to the log channel message_embed = discord.Embed( title=":speech_balloon: Incoming Direct Message to Pi-Bot", - description=message.content - if len(message.content) > 0 - else "This message contained no content.", + description=( + message.content + if len(message.content) > 0 + else "This message contained no content." + ), color=discord.Color.brand_green(), ) message_embed.add_field( @@ -72,11 +74,13 @@ async def send_to_dm_log(self, message: discord.Message): ) message_embed.add_field( name="Attachments", - value=" | ".join( - [f"**{a.filename}**: [Link]({a.url})" for a in message.attachments], - ) - if len(message.attachments) > 0 - else "None", + value=( + " | ".join( + [f"**{a.filename}**: [Link]({a.url})" for a in message.attachments], + ) + if len(message.attachments) > 0 + else "None" + ), inline=True, ) await dm_channel.send(embed=message_embed) @@ -117,16 +121,9 @@ async def on_member_remove(self, member: discord.Member): member.guild.text_channels, name=CHANNEL_LEAVE, ) - unconfirmed_role = discord.utils.get(member.guild.roles, name=ROLE_UC) assert isinstance(leave_channel, discord.TextChannel) - assert isinstance(unconfirmed_role, discord.Role) - if unconfirmed_role in member.roles: - unconfirmed_statement = ":white_check_mark:" - embed = discord.Embed(color=discord.Color.yellow()) - else: - unconfirmed_statement = ":x:" - embed = discord.Embed(color=discord.Color.brand_red()) + embed = discord.Embed(color=discord.Color.brand_red()) embed.title = "Member Leave" @@ -141,7 +138,6 @@ async def on_member_remove(self, member: discord.Member): ) embed.add_field(name="Joined At", value=joined_at) - embed.add_field(name="Unconfirmed", value=unconfirmed_statement) await leave_channel.send(embed=embed) @commands.Cog.listener() @@ -382,35 +378,41 @@ async def log_edit_message_payload(self, payload): }, { "name": "Attachments", - "value": " | ".join( - [ - f"**{a.filename}**: [Link]({a.url})" - for a in message.attachments - ], - ) - if len(message.attachments) > 0 - else "None", + "value": ( + " | ".join( + [ + f"**{a.filename}**: [Link]({a.url})" + for a in message.attachments + ], + ) + if len(message.attachments) > 0 + else "None" + ), "inline": "False", }, { "name": "Past Content", - "value": message.content[:1024] - if len(message.content) > 0 - else "None", + "value": ( + message.content[:1024] if len(message.content) > 0 else "None" + ), "inline": "False", }, { "name": "New Content", - "value": message_now.content[:1024] - if len(message_now.content) > 0 - else "None", + "value": ( + message_now.content[:1024] + if len(message_now.content) > 0 + else "None" + ), "inline": "False", }, { "name": "Embed", - "value": "\n".join([str(e.to_dict()) for e in message.embeds]) - if len(message.embeds) > 0 - else "None", + "value": ( + "\n".join([str(e.to_dict()) for e in message.embeds]) + if len(message.embeds) > 0 + else "None" + ), "inline": "False", }, ] @@ -454,37 +456,43 @@ async def log_edit_message_payload(self, payload): }, { "name": "Edited At", - "value": discord.utils.format_dt(message_now.edited_at, "R") - if message_now.edited_at is not None - else "Never", + "value": ( + discord.utils.format_dt(message_now.edited_at, "R") + if message_now.edited_at is not None + else "Never" + ), "inline": True, }, { "name": "New Content", - "value": message_now.content[:1024] - if len(message_now.content) > 0 - else "None", + "value": ( + message_now.content[:1024] + if len(message_now.content) > 0 + else "None" + ), "inline": "False", }, { "name": "Current Attachments", - "value": " | ".join( - [ - f"**{a.filename}**: [Link]({a.url})" - for a in message_now.attachments - ], - ) - if len(message_now.attachments) > 0 - else "None", + "value": ( + " | ".join( + [ + f"**{a.filename}**: [Link]({a.url})" + for a in message_now.attachments + ], + ) + if len(message_now.attachments) > 0 + else "None" + ), "inline": "False", }, { "name": "Current Embed", - "value": "\n".join([str(e.to_dict()) for e in message_now.embeds])[ - :1024 - ] - if len(message_now.embeds) > 0 - else "None", + "value": ( + "\n".join([str(e.to_dict()) for e in message_now.embeds])[:1024] + if len(message_now.embeds) > 0 + else "None" + ), "inline": "False", }, ] @@ -551,30 +559,34 @@ async def log_delete_message_payload(self, payload): }, { "name": "Attachments", - "value": " | ".join( - [ - f"**{a.filename}**: [Link]({a.url})" - for a in message.attachments - ], - ) - if len(message.attachments) > 0 - else "None", + "value": ( + " | ".join( + [ + f"**{a.filename}**: [Link]({a.url})" + for a in message.attachments + ], + ) + if len(message.attachments) > 0 + else "None" + ), "inline": "False", }, { "name": "Content", - "value": str(message.content)[:1024] - if len(message.content) > 0 - else "None", + "value": ( + str(message.content)[:1024] + if len(message.content) > 0 + else "None" + ), "inline": "False", }, { "name": "Embed", - "value": "\n".join([str(e.to_dict()) for e in message.embeds])[ - :1024 - ] - if len(message.embeds) > 0 - else "None", + "value": ( + "\n".join([str(e.to_dict()) for e in message.embeds])[:1024] + if len(message.embeds) > 0 + else "None" + ), "inline": "False", }, ] From b5633752c4aecc9b153acd2a3a85de362ec41d81 Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Mon, 18 Aug 2025 04:40:03 +0000 Subject: [PATCH 02/14] Add default invite prefix variable --- src/discord/globals.py | 3 ++- src/discord/membercommands.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/discord/globals.py b/src/discord/globals.py index a959c97..4674ca8 100644 --- a/src/discord/globals.py +++ b/src/discord/globals.py @@ -8,6 +8,7 @@ ############## # CONSTANTS ############## +DISCORD_DEFAULT_INVITE_ENDING = "scioly" DISCORD_INVITE_ENDINGS = [ "9Z5zKtV", "C9PGV6h", @@ -16,7 +17,7 @@ "gh3aXbq", "skGQXd4", "RnkqUbK", - "scioly", + DISCORD_DEFAULT_INVITE_ENDING, ] # Roles diff --git a/src/discord/membercommands.py b/src/discord/membercommands.py index 5da2e33..81f90a0 100644 --- a/src/discord/membercommands.py +++ b/src/discord/membercommands.py @@ -2,6 +2,7 @@ Functionality for most member commands. These commands frequently help members manage their state on the server, including allowing them to change their roles or subscriptions. """ + from __future__ import annotations import datetime @@ -22,6 +23,7 @@ CATEGORY_STAFF, CHANNEL_INVITATIONALS, CHANNEL_UNSELFMUTE, + DISCORD_DEFAULT_INVITE_ENDING, ROLE_LH, ROLE_MR, ROLE_SELFMUTE, @@ -407,7 +409,9 @@ async def invite(self, interaction: discord.Interaction): Args: interaction (discord.Interaction): The interaction sent by Discord. """ - await interaction.response.send_message("https://discord.gg/C9PGV6h") + await interaction.response.send_message( + f"https://discord.gg/{DISCORD_DEFAULT_INVITE_ENDING}", + ) @app_commands.command( description="Returns a link to the Scioly.org forums.", From 6a02e150d0bdfdb076f8202ed8b9ecb6d09d9877 Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Mon, 18 Aug 2025 04:26:41 +0000 Subject: [PATCH 03/14] Add unconfirmed cleanup batch kick command To help facilitate in migrating unconfirmed pre-onboarding users to use the new onboarding feature once they join. --- bot.py | 1 + src/discord/staff/usercleanup.py | 233 +++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/discord/staff/usercleanup.py diff --git a/bot.py b/bot.py index 0d9a0ae..e1076d3 100644 --- a/bot.py +++ b/bot.py @@ -201,6 +201,7 @@ async def setup_hook(self) -> None: "src.discord.staff.censor", "src.discord.staff.tags", "src.discord.staff.events", + "src.discord.staff.usercleanup", "src.discord.embed", "src.discord.membercommands", "src.discord.devtools", diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py new file mode 100644 index 0000000..18680c7 --- /dev/null +++ b/src/discord/staff/usercleanup.py @@ -0,0 +1,233 @@ +import asyncio +import logging +from collections.abc import Sequence + +import discord +from discord import AllowedMentions, Member, Role, app_commands, ui +from discord.errors import Forbidden, HTTPException +from discord.ext import commands +from typing_extensions import Self + +from bot import PiBot +from env import env +from src.discord.globals import ( + DISCORD_DEFAULT_INVITE_ENDING, + EMOJI_LOADING, + ROLE_STAFF, + ROLE_VIP, +) + + +class UnconfirmedCleanupCancel(ui.View): + def __init__( + self, + interaction: discord.Interaction, + initiator: discord.User | discord.Member, + unconfirmed_role: Role, + total_member_count: int, + members: Sequence[Member], + ): + super().__init__(timeout=None) + self.initiator = initiator + self.cancel_flag = asyncio.Event() + self.task = asyncio.create_task( + self.task_handler( + interaction, + self.cancel_flag, + unconfirmed_role, + total_member_count, + members, + ), + ) + + async def interaction_check( + self, + interaction: discord.Interaction[discord.Client], + ) -> bool: + if interaction.user == self.initiator: + return True + await interaction.response.send_message( + f"The command was initiated by {self.initiator.mention}", + ephemeral=True, + ) + return False + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.red) + async def cancel( + self, + interaction: discord.Interaction, + button: discord.ui.Button[Self], + ) -> None: + button.disabled = True + button.label = "Cancelling ..." + self.cancel_flag.set() + + async def handle_done(): + await interaction.edit_original_response(content="Cancelled") + + self.task.add_done_callback(lambda _: asyncio.create_task(handle_done())) + + async def task_handler( + self, + interaction: discord.Interaction[discord.Client], + cancel_event: asyncio.Event, + unconfirmed_role: Role, + total_member_count: int, + members: Sequence[Member], + ): + if not interaction.command: + raise Exception("Handler was not invoked via command") + if not interaction.guild: + raise Exception("Command should be invoked within a server") + chunk_size = 100 + members_processed = 0 + members_failed: list[Member] = [] + lock = asyncio.Lock() + + end_event = asyncio.Event() + + async def progress_updater(end_signal: asyncio.Event): + while not end_signal.is_set(): + async with lock: + progress_message = "{} {}/~{} users processed".format( + EMOJI_LOADING, + members_processed, + total_member_count, + ) + for failed_member in members_failed: + progress_message += f"\n{failed_member.mention}" + final_message = asyncio.create_task( + interaction.edit_original_response( + content=progress_message, + view=self, + allowed_mentions=AllowedMentions.none(), + ), + ) + await asyncio.sleep(1) + + if final_message: + await final_message + + ui_updater = asyncio.create_task(progress_updater(end_event)) + + for member_chunk in discord.utils.as_chunks(members, chunk_size): + failed_members: list[Member] = [] + extra_processed = None + for i, member in enumerate(member_chunk): + if cancel_event.is_set(): + extra_processed = i + break + if unconfirmed_role in member.roles: + try: + await member.kick( + reason="Server cleanup. Please rejoin the server if available (discord.gg/{})".format( + DISCORD_DEFAULT_INVITE_ENDING, + ), + ) + except (Forbidden, HTTPException) as e: + logging.warning( + "{}: Failed to kick user @{}: {}", + interaction.command.qualified_name, + member.name, + e, + ) + failed_members.append(member) + + async with lock: + members_processed += ( + extra_processed if extra_processed else len(member_chunk) + ) + members_failed.extend(failed_members) + if cancel_event.is_set(): + break + + end_event.set() + await ui_updater + + users_with_unconfirmed_role = sum( + [ + 1 + async for member in interaction.guild.fetch_members(limit=None) + if unconfirmed_role in member.roles + ], + ) + + role_deletion_err = None + if users_with_unconfirmed_role == 0: + try: + await unconfirmed_role.delete( + reason="Deleted via `/cleanup unconfirmed`: No longer needed", + ) + except (Forbidden, HTTPException) as e: + role_deletion_err = e + + progress_message = "Completed" + if cancel_event.is_set(): + progress_message = "Cancelled" + for failed_member in members_failed: + progress_message += f"\n{failed_member.mention}" + if users_with_unconfirmed_role > 0: + progress_message += "\nThere exist {} user(s) that still have the {} role. Role was not be deleted.".format( + users_with_unconfirmed_role, + unconfirmed_role.name, + ) + else: + if role_deletion_err: + progress_message += "\nRole deletion encountered an error: {}".format( + role_deletion_err, + ) + else: + progress_message += "\nRole was deleted successfully" + + return await interaction.edit_original_response( + content=progress_message, + allowed_mentions=AllowedMentions.none(), + ) + + +class UserCleanup(commands.Cog): + def __init__(self, bot: PiBot): + self.bot = bot + + cleanup_command_group = app_commands.Group( + name="cleanup", + description="Staff commands to help facilitate easy tidying", + guild_ids=env.slash_command_guilds, + default_permissions=discord.Permissions(manage_messages=True), + ) + + @cleanup_command_group.command( + name="unconfirmed", + description="Kicks any person with the old Unconfirmed role with. Meant to be run one time.", + ) + @app_commands.checks.has_any_role(ROLE_STAFF, ROLE_VIP) + async def remove_unconfirmed_users(self, interaction: discord.Interaction): + if not interaction.command: + raise Exception("Handler was not invoked via command") + if not interaction.guild: + raise Exception("Command should be invoked within a server") + + role_name = "Unconfirmed (old)" + unconfirmed_role = discord.utils.get(interaction.guild.roles, name=role_name) + + if not unconfirmed_role: + raise Exception( + "Command has already removed `{}`, implying this command has already been run. No operation was performed on this command.".format( + role_name, + ), + ) + + await interaction.response.send_message( + view=UnconfirmedCleanupCancel( + interaction, + interaction.user, + unconfirmed_role, + total_member_count=interaction.guild.member_count + or len(interaction.guild.members), + members=interaction.guild.members, + ), + ) + + +async def setup(bot: PiBot): + await bot.add_cog(UserCleanup(bot)) From d8a629fd9598efb7874ea1355cc9b9394dab471c Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Tue, 19 Aug 2025 03:18:14 +0000 Subject: [PATCH 04/14] Reimplement to assume unconfirmed role has been removed --- src/discord/staff/usercleanup.py | 46 +++++++++++--------------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 18680c7..a9656fd 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -13,6 +13,7 @@ from src.discord.globals import ( DISCORD_DEFAULT_INVITE_ENDING, EMOJI_LOADING, + ROLE_MR, ROLE_STAFF, ROLE_VIP, ) @@ -23,7 +24,7 @@ def __init__( self, interaction: discord.Interaction, initiator: discord.User | discord.Member, - unconfirmed_role: Role, + member_role: Role, total_member_count: int, members: Sequence[Member], ): @@ -34,7 +35,7 @@ def __init__( self.task_handler( interaction, self.cancel_flag, - unconfirmed_role, + member_role, total_member_count, members, ), @@ -71,7 +72,7 @@ async def task_handler( self, interaction: discord.Interaction[discord.Client], cancel_event: asyncio.Event, - unconfirmed_role: Role, + member_role: Role, total_member_count: int, members: Sequence[Member], ): @@ -117,7 +118,7 @@ async def progress_updater(end_signal: asyncio.Event): if cancel_event.is_set(): extra_processed = i break - if unconfirmed_role in member.roles: + if member_role not in member.roles: try: await member.kick( reason="Server cleanup. Please rejoin the server if available (discord.gg/{})".format( @@ -148,36 +149,22 @@ async def progress_updater(end_signal: asyncio.Event): [ 1 async for member in interaction.guild.fetch_members(limit=None) - if unconfirmed_role in member.roles + if member_role not in member.roles ], ) - role_deletion_err = None - if users_with_unconfirmed_role == 0: - try: - await unconfirmed_role.delete( - reason="Deleted via `/cleanup unconfirmed`: No longer needed", - ) - except (Forbidden, HTTPException) as e: - role_deletion_err = e - progress_message = "Completed" if cancel_event.is_set(): progress_message = "Cancelled" for failed_member in members_failed: progress_message += f"\n{failed_member.mention}" if users_with_unconfirmed_role > 0: - progress_message += "\nThere exist {} user(s) that still have the {} role. Role was not be deleted.".format( - users_with_unconfirmed_role, - unconfirmed_role.name, - ) - else: - if role_deletion_err: - progress_message += "\nRole deletion encountered an error: {}".format( - role_deletion_err, + progress_message += ( + "\nThere exist {} user(s) that does not have the {} role".format( + users_with_unconfirmed_role, + member_role.name, ) - else: - progress_message += "\nRole was deleted successfully" + ) return await interaction.edit_original_response( content=progress_message, @@ -207,21 +194,18 @@ async def remove_unconfirmed_users(self, interaction: discord.Interaction): if not interaction.guild: raise Exception("Command should be invoked within a server") - role_name = "Unconfirmed (old)" - unconfirmed_role = discord.utils.get(interaction.guild.roles, name=role_name) + member_role = discord.utils.get(interaction.guild.roles, name=ROLE_MR) - if not unconfirmed_role: + if not member_role: raise Exception( - "Command has already removed `{}`, implying this command has already been run. No operation was performed on this command.".format( - role_name, - ), + f"Could not find role `{ROLE_MR}`. Please make sure it exists and the bot has adequate permissions.", ) await interaction.response.send_message( view=UnconfirmedCleanupCancel( interaction, interaction.user, - unconfirmed_role, + member_role, total_member_count=interaction.guild.member_count or len(interaction.guild.members), members=interaction.guild.members, From 5bd29b96792b3d4645491d455de4763dae1dd410 Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Tue, 19 Aug 2025 03:37:43 +0000 Subject: [PATCH 05/14] Add doc comments --- src/discord/staff/usercleanup.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index a9656fd..58351fc 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -20,6 +20,14 @@ class UnconfirmedCleanupCancel(ui.View): + """ + A View for showing progress on cleaning up unconfirmed users. + + Includes a cancellation button to cancel operation early. Note that this + entire operation is not atomic and any users that were kicked prior to + cancellation will not be reverted. + """ + def __init__( self, interaction: discord.Interaction, @@ -76,6 +84,11 @@ async def task_handler( total_member_count: int, members: Sequence[Member], ): + """ + In charge of processing all users to kick. Passes message rendering to another async task. + + Can be cancelled via button action. If cancelled, the coroutine is gracefully terminated. + """ if not interaction.command: raise Exception("Handler was not invoked via command") if not interaction.guild: @@ -189,6 +202,14 @@ def __init__(self, bot: PiBot): ) @app_commands.checks.has_any_role(ROLE_STAFF, ROLE_VIP) async def remove_unconfirmed_users(self, interaction: discord.Interaction): + """ + Kicks any users that do not have the Member role in the current server + the command was invoked. + + Includes a cancellation button to cancel operation early. Note that this + entire operation is not atomic and any users that were kicked prior to + cancellation will not be reverted. + """ if not interaction.command: raise Exception("Handler was not invoked via command") if not interaction.guild: From 00ee2f8d65fc3a909eb1fa43f8c9b87b3e6e47bf Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Tue, 19 Aug 2025 03:44:43 +0000 Subject: [PATCH 06/14] Add long term Discord rate limit global constant --- src/discord/globals.py | 1 + src/discord/staff/usercleanup.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/discord/globals.py b/src/discord/globals.py index 4674ca8..a8433c9 100644 --- a/src/discord/globals.py +++ b/src/discord/globals.py @@ -116,6 +116,7 @@ DISCORD_AUTOCOMPLETE_MAX_ENTRIES = 25 # The maximum number of options that can be passed into a discord.ui.Select DISCORD_SELECT_MAX_OPTIONS = 20 +DISCORD_LONG_TERM_RATE_LIMIT = 1 # 5 requests / 5 seconds, so might as well keep 1 request / 1 second for long running tasks ############## # VARIABLES diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 58351fc..d30eab7 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -12,6 +12,7 @@ from env import env from src.discord.globals import ( DISCORD_DEFAULT_INVITE_ENDING, + DISCORD_LONG_TERM_RATE_LIMIT, EMOJI_LOADING, ROLE_MR, ROLE_STAFF, @@ -117,7 +118,7 @@ async def progress_updater(end_signal: asyncio.Event): allowed_mentions=AllowedMentions.none(), ), ) - await asyncio.sleep(1) + await asyncio.sleep(DISCORD_LONG_TERM_RATE_LIMIT) if final_message: await final_message From 75d0d98af4831d0aefe51d5bbf78f066e366956c Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Wed, 10 Sep 2025 06:54:53 +0000 Subject: [PATCH 07/14] Add bot permission check --- src/discord/staff/usercleanup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index d30eab7..5c4375d 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -4,7 +4,7 @@ import discord from discord import AllowedMentions, Member, Role, app_commands, ui -from discord.errors import Forbidden, HTTPException +from discord.errors import HTTPException from discord.ext import commands from typing_extensions import Self @@ -139,7 +139,7 @@ async def progress_updater(end_signal: asyncio.Event): DISCORD_DEFAULT_INVITE_ENDING, ), ) - except (Forbidden, HTTPException) as e: + except HTTPException as e: logging.warning( "{}: Failed to kick user @{}: {}", interaction.command.qualified_name, @@ -202,6 +202,7 @@ def __init__(self, bot: PiBot): description="Kicks any person with the old Unconfirmed role with. Meant to be run one time.", ) @app_commands.checks.has_any_role(ROLE_STAFF, ROLE_VIP) + @app_commands.checks.bot_has_permissions(kick_members=True, send_messages=True) async def remove_unconfirmed_users(self, interaction: discord.Interaction): """ Kicks any users that do not have the Member role in the current server From 854457c4319d6c5181c715c9b7a647e51a8ffccd Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Wed, 10 Sep 2025 06:55:48 +0000 Subject: [PATCH 08/14] Add condition to ignore bots --- src/discord/staff/usercleanup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 5c4375d..34c35aa 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -132,6 +132,9 @@ async def progress_updater(end_signal: asyncio.Event): if cancel_event.is_set(): extra_processed = i break + if member.bot: + # Ignore all bots + continue if member_role not in member.roles: try: await member.kick( From d120fda9b061788bc813f8cadf300284e9491f1b Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Wed, 10 Sep 2025 06:56:56 +0000 Subject: [PATCH 09/14] Add basic interval ratelimiting --- src/discord/staff/usercleanup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 34c35aa..8a12697 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -150,6 +150,7 @@ async def progress_updater(end_signal: asyncio.Event): e, ) failed_members.append(member) + await asyncio.sleep(DISCORD_LONG_TERM_RATE_LIMIT) async with lock: members_processed += ( From e3f717d2f85f185858ed7705e63f2baac3d356ea Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Wed, 10 Sep 2025 06:58:19 +0000 Subject: [PATCH 10/14] Include sending message to kicked user Provides information to the kicked user as to why they were kicked and how to rejoin. The kicked user is not gauranteed to get the DM; they have the option to reject all DMs from specific servers. --- src/discord/staff/usercleanup.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 8a12697..3795207 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -137,13 +137,33 @@ async def progress_updater(end_signal: asyncio.Event): continue if member_role not in member.roles: try: - await member.kick( - reason="Server cleanup. Please rejoin the server if available (discord.gg/{})".format( + # We cannot send a message to the user after they are + # kicked, so we must send one first before we call + # kick() + await member.send( + ( + "Notice from the Scioly.org server: You were kicked " + "from the Scioly.org server since you did not " + "fill out the onboarding survey fully. You are " + "free to rejoin the server at your earliest " + "convenience (https://discord.gg/{})" + ).format( DISCORD_DEFAULT_INVITE_ENDING, ), ) except HTTPException as e: logging.warning( + "{}: Could not send message notify user @{}: {}", + interaction.command.qualified_name, + member.name, + e, + ) + try: + await member.kick( + reason="Server cleanup - Did not fill out onboarding survey", + ) + except HTTPException as e: + logging.error( "{}: Failed to kick user @{}: {}", interaction.command.qualified_name, member.name, From f23dfd5697b3da960e608fd710b278c9f21432ed Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Sat, 13 Sep 2025 09:04:41 +0000 Subject: [PATCH 11/14] chore: Refactor implementation to be inline Uses existing Confirm view to simpify code flow. --- src/discord/staff/usercleanup.py | 241 ++++++++++++++----------------- 1 file changed, 112 insertions(+), 129 deletions(-) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 3795207..0b76a35 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -1,9 +1,9 @@ import asyncio import logging -from collections.abc import Sequence +from asyncio.locks import Event import discord -from discord import AllowedMentions, Member, Role, app_commands, ui +from discord import AllowedMentions, Member, app_commands, ui from discord.errors import HTTPException from discord.ext import commands from typing_extensions import Self @@ -18,6 +18,7 @@ ROLE_STAFF, ROLE_VIP, ) +from src.discord.staffcommands import Confirm class UnconfirmedCleanupCancel(ui.View): @@ -31,24 +32,11 @@ class UnconfirmedCleanupCancel(ui.View): def __init__( self, - interaction: discord.Interaction, initiator: discord.User | discord.Member, - member_role: Role, - total_member_count: int, - members: Sequence[Member], ): super().__init__(timeout=None) self.initiator = initiator self.cancel_flag = asyncio.Event() - self.task = asyncio.create_task( - self.task_handler( - interaction, - self.cancel_flag, - member_role, - total_member_count, - members, - ), - ) async def interaction_check( self, @@ -65,41 +53,78 @@ async def interaction_check( @discord.ui.button(label="Cancel", style=discord.ButtonStyle.red) async def cancel( self, - interaction: discord.Interaction, + _: discord.Interaction, button: discord.ui.Button[Self], ) -> None: button.disabled = True button.label = "Cancelling ..." self.cancel_flag.set() - async def handle_done(): - await interaction.edit_original_response(content="Cancelled") + def get_cancel_event(self) -> Event: + return self.cancel_flag - self.task.add_done_callback(lambda _: asyncio.create_task(handle_done())) - async def task_handler( - self, - interaction: discord.Interaction[discord.Client], - cancel_event: asyncio.Event, - member_role: Role, - total_member_count: int, - members: Sequence[Member], - ): +class UserCleanup(commands.Cog): + def __init__(self, bot: PiBot): + self.bot = bot + + cleanup_command_group = app_commands.Group( + name="cleanup", + description="Staff commands to help facilitate easy tidying", + guild_ids=env.slash_command_guilds, + default_permissions=discord.Permissions(manage_messages=True), + ) + + @cleanup_command_group.command( + name="unconfirmed", + description="Kicks any person with the old Unconfirmed role with. Meant to be run one time.", + ) + @app_commands.checks.has_any_role(ROLE_STAFF, ROLE_VIP) + @app_commands.checks.bot_has_permissions(kick_members=True, send_messages=True) + async def remove_unconfirmed_users(self, interaction: discord.Interaction): """ - In charge of processing all users to kick. Passes message rendering to another async task. + Kicks any users that do not have the Member role in the current server + the command was invoked. - Can be cancelled via button action. If cancelled, the coroutine is gracefully terminated. + Includes a cancellation button to cancel operation early. Note that this + entire operation is not atomic and any users that were kicked prior to + cancellation will not be reverted. """ if not interaction.command: raise Exception("Handler was not invoked via command") if not interaction.guild: raise Exception("Command should be invoked within a server") + + member_role = discord.utils.get(interaction.guild.roles, name=ROLE_MR) + + if not member_role: + raise Exception( + f"Could not find role `{ROLE_MR}`. Please make sure it exists and the bot has adequate permissions.", + ) + + view = Confirm( + interaction.user, + "Cleanup operation was cancelled. All unconfirmed users should still be on the server.", + ) + await interaction.response.send_message( + "Please confirm that you want to purge all non-members from the server.", + view=view, + ) + + await view.wait() + + total_member_count = interaction.guild.member_count or len( + interaction.guild.members, + ) + + cancel_progress_view = UnconfirmedCleanupCancel(interaction.user) chunk_size = 100 members_processed = 0 members_failed: list[Member] = [] lock = asyncio.Lock() - end_event = asyncio.Event() + cancel_event = cancel_progress_view.get_cancel_event() + end_progress_event = asyncio.Event() async def progress_updater(end_signal: asyncio.Event): while not end_signal.is_set(): @@ -114,8 +139,8 @@ async def progress_updater(end_signal: asyncio.Event): final_message = asyncio.create_task( interaction.edit_original_response( content=progress_message, - view=self, allowed_mentions=AllowedMentions.none(), + view=cancel_progress_view, ), ) await asyncio.sleep(DISCORD_LONG_TERM_RATE_LIMIT) @@ -123,54 +148,58 @@ async def progress_updater(end_signal: asyncio.Event): if final_message: await final_message - ui_updater = asyncio.create_task(progress_updater(end_event)) + ui_updater = asyncio.create_task(progress_updater(end_progress_event)) + + def member_predicate(member: discord.Member) -> bool: + return not member.bot and member_role not in member.roles - for member_chunk in discord.utils.as_chunks(members, chunk_size): + for member_chunk in discord.utils.as_chunks( + interaction.guild.members, + chunk_size, + ): failed_members: list[Member] = [] extra_processed = None for i, member in enumerate(member_chunk): if cancel_event.is_set(): extra_processed = i break - if member.bot: - # Ignore all bots + if not member_predicate(member): continue - if member_role not in member.roles: - try: - # We cannot send a message to the user after they are - # kicked, so we must send one first before we call - # kick() - await member.send( - ( - "Notice from the Scioly.org server: You were kicked " - "from the Scioly.org server since you did not " - "fill out the onboarding survey fully. You are " - "free to rejoin the server at your earliest " - "convenience (https://discord.gg/{})" - ).format( - DISCORD_DEFAULT_INVITE_ENDING, - ), - ) - except HTTPException as e: - logging.warning( - "{}: Could not send message notify user @{}: {}", - interaction.command.qualified_name, - member.name, - e, - ) - try: - await member.kick( - reason="Server cleanup - Did not fill out onboarding survey", - ) - except HTTPException as e: - logging.error( - "{}: Failed to kick user @{}: {}", - interaction.command.qualified_name, - member.name, - e, - ) - failed_members.append(member) - await asyncio.sleep(DISCORD_LONG_TERM_RATE_LIMIT) + try: + # We cannot send a message to the user after they are + # kicked, so we must send one first before we call + # kick() + await member.send( + ( + "Notice from the Scioly.org server: You were kicked " + "from the Scioly.org server since you did not " + "fill out the onboarding survey fully. You are " + "free to rejoin the server at your earliest " + "convenience (https://discord.gg/{})" + ).format( + DISCORD_DEFAULT_INVITE_ENDING, + ), + ) + except HTTPException as e: + logging.warning( + "{}: Could not send message notify user @{}: {}", + interaction.command.qualified_name, + member.name, + e, + ) + try: + await member.kick( + reason="Server cleanup - Did not fill out onboarding survey", + ) + except HTTPException as e: + logging.error( + "{}: Failed to kick user @{}: {}", + interaction.command.qualified_name, + member.name, + e, + ) + failed_members.append(member) + await asyncio.sleep(DISCORD_LONG_TERM_RATE_LIMIT) async with lock: members_processed += ( @@ -180,26 +209,29 @@ async def progress_updater(end_signal: asyncio.Event): if cancel_event.is_set(): break - end_event.set() + end_progress_event.set() await ui_updater - users_with_unconfirmed_role = sum( + users_without_member_role = sum( [ 1 async for member in interaction.guild.fetch_members(limit=None) - if member_role not in member.roles + if member_predicate(member) ], ) - progress_message = "Completed" + progress_message = "Operation completed" if cancel_event.is_set(): - progress_message = "Cancelled" + progress_message = "Cancelled by initiator" + progress_message += f"\nProcessed {members_processed} members" + if members_failed: + progress_message += "\nFailed to process the following members:" for failed_member in members_failed: - progress_message += f"\n{failed_member.mention}" - if users_with_unconfirmed_role > 0: + progress_message += f"\n- {failed_member.mention}" + if users_without_member_role > 0: progress_message += ( "\nThere exist {} user(s) that does not have the {} role".format( - users_with_unconfirmed_role, + users_without_member_role, member_role.name, ) ) @@ -207,56 +239,7 @@ async def progress_updater(end_signal: asyncio.Event): return await interaction.edit_original_response( content=progress_message, allowed_mentions=AllowedMentions.none(), - ) - - -class UserCleanup(commands.Cog): - def __init__(self, bot: PiBot): - self.bot = bot - - cleanup_command_group = app_commands.Group( - name="cleanup", - description="Staff commands to help facilitate easy tidying", - guild_ids=env.slash_command_guilds, - default_permissions=discord.Permissions(manage_messages=True), - ) - - @cleanup_command_group.command( - name="unconfirmed", - description="Kicks any person with the old Unconfirmed role with. Meant to be run one time.", - ) - @app_commands.checks.has_any_role(ROLE_STAFF, ROLE_VIP) - @app_commands.checks.bot_has_permissions(kick_members=True, send_messages=True) - async def remove_unconfirmed_users(self, interaction: discord.Interaction): - """ - Kicks any users that do not have the Member role in the current server - the command was invoked. - - Includes a cancellation button to cancel operation early. Note that this - entire operation is not atomic and any users that were kicked prior to - cancellation will not be reverted. - """ - if not interaction.command: - raise Exception("Handler was not invoked via command") - if not interaction.guild: - raise Exception("Command should be invoked within a server") - - member_role = discord.utils.get(interaction.guild.roles, name=ROLE_MR) - - if not member_role: - raise Exception( - f"Could not find role `{ROLE_MR}`. Please make sure it exists and the bot has adequate permissions.", - ) - - await interaction.response.send_message( - view=UnconfirmedCleanupCancel( - interaction, - interaction.user, - member_role, - total_member_count=interaction.guild.member_count - or len(interaction.guild.members), - members=interaction.guild.members, - ), + view=None, ) From 5126a3ebf3627543db4f468fc2cbe534a4347f78 Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Sat, 13 Sep 2025 09:10:46 +0000 Subject: [PATCH 12/14] feat: Add kick count to progress and final report --- src/discord/staff/usercleanup.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 0b76a35..3b1282d 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -120,6 +120,7 @@ async def remove_unconfirmed_users(self, interaction: discord.Interaction): cancel_progress_view = UnconfirmedCleanupCancel(interaction.user) chunk_size = 100 members_processed = 0 + members_kicked = 0 members_failed: list[Member] = [] lock = asyncio.Lock() @@ -129,10 +130,14 @@ async def remove_unconfirmed_users(self, interaction: discord.Interaction): async def progress_updater(end_signal: asyncio.Event): while not end_signal.is_set(): async with lock: - progress_message = "{} {}/~{} users processed".format( - EMOJI_LOADING, - members_processed, - total_member_count, + progress_message = ( + "{} {}/~{} users processed\n{}/{} users kicked".format( + EMOJI_LOADING, + members_processed, + total_member_count, + members_kicked, + members_processed, + ) ) for failed_member in members_failed: progress_message += f"\n{failed_member.mention}" @@ -159,6 +164,7 @@ def member_predicate(member: discord.Member) -> bool: ): failed_members: list[Member] = [] extra_processed = None + kicked_count = 0 for i, member in enumerate(member_chunk): if cancel_event.is_set(): extra_processed = i @@ -191,6 +197,7 @@ def member_predicate(member: discord.Member) -> bool: await member.kick( reason="Server cleanup - Did not fill out onboarding survey", ) + kicked_count += 1 except HTTPException as e: logging.error( "{}: Failed to kick user @{}: {}", @@ -205,6 +212,7 @@ def member_predicate(member: discord.Member) -> bool: members_processed += ( extra_processed if extra_processed else len(member_chunk) ) + members_kicked += kicked_count members_failed.extend(failed_members) if cancel_event.is_set(): break @@ -224,6 +232,7 @@ def member_predicate(member: discord.Member) -> bool: if cancel_event.is_set(): progress_message = "Cancelled by initiator" progress_message += f"\nProcessed {members_processed} members" + progress_message += f"\nKicked {members_kicked} members" if members_failed: progress_message += "\nFailed to process the following members:" for failed_member in members_failed: From 275c13a674a4886f9ffcfce01e423cd751baf53e Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Sat, 13 Sep 2025 09:22:48 +0000 Subject: [PATCH 13/14] fix: Remove use of `asyncio.sleep` in favor of using `asyncio.wait_for` --- src/discord/staff/usercleanup.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 3b1282d..882ed30 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -128,7 +128,9 @@ async def remove_unconfirmed_users(self, interaction: discord.Interaction): end_progress_event = asyncio.Event() async def progress_updater(end_signal: asyncio.Event): - while not end_signal.is_set(): + if end_signal.is_set(): + return + while True: async with lock: progress_message = ( "{} {}/~{} users processed\n{}/{} users kicked".format( @@ -148,7 +150,14 @@ async def progress_updater(end_signal: asyncio.Event): view=cancel_progress_view, ), ) - await asyncio.sleep(DISCORD_LONG_TERM_RATE_LIMIT) + try: + await asyncio.wait_for( + end_signal.wait(), + timeout=DISCORD_LONG_TERM_RATE_LIMIT, + ) + break + except asyncio.TimeoutError: + pass if final_message: await final_message From 82d18475afc708c1730c5a884d311e04b27e9142 Mon Sep 17 00:00:00 2001 From: Jareth Gomes Date: Sat, 13 Sep 2025 09:24:12 +0000 Subject: [PATCH 14/14] fix: Use embed for DM message Keeps consistency in formatting with other notices that Pi-Bot sends to the user. --- src/discord/staff/usercleanup.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py index 882ed30..d0f9cae 100644 --- a/src/discord/staff/usercleanup.py +++ b/src/discord/staff/usercleanup.py @@ -167,6 +167,17 @@ async def progress_updater(end_signal: asyncio.Event): def member_predicate(member: discord.Member) -> bool: return not member.bot and member_role not in member.roles + embed_message = discord.Embed( + title="You have been kicked in the Scioly.org server.", + color=discord.Color.brand_red(), + description=( + "You were kicked from the Scioly.org server since " + "you did not fill out the onboarding survey " + "fully. You are free to rejoin the server at " + "your earliest convenience " + f"(https://discord.gg/{DISCORD_DEFAULT_INVITE_ENDING})", + ), + ) for member_chunk in discord.utils.as_chunks( interaction.guild.members, chunk_size, @@ -185,15 +196,8 @@ def member_predicate(member: discord.Member) -> bool: # kicked, so we must send one first before we call # kick() await member.send( - ( - "Notice from the Scioly.org server: You were kicked " - "from the Scioly.org server since you did not " - "fill out the onboarding survey fully. You are " - "free to rejoin the server at your earliest " - "convenience (https://discord.gg/{})" - ).format( - DISCORD_DEFAULT_INVITE_ENDING, - ), + "Notice from the Scioly.org server:", + embed=embed_message, ) except HTTPException as e: logging.warning(