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/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..a8433c9 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 @@ -30,7 +31,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" @@ -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/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", }, ] 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.", diff --git a/src/discord/staff/usercleanup.py b/src/discord/staff/usercleanup.py new file mode 100644 index 0000000..3d643b7 --- /dev/null +++ b/src/discord/staff/usercleanup.py @@ -0,0 +1,269 @@ +import asyncio +import logging +from asyncio.locks import Event + +import discord +from discord import AllowedMentions, Member, app_commands, ui +from discord.errors import 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, + DISCORD_LONG_TERM_RATE_LIMIT, + EMOJI_LOADING, + ROLE_MR, + ROLE_STAFF, + ROLE_VIP, +) +from src.discord.staffcommands import Confirm + + +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, + initiator: discord.User | discord.Member, + ): + super().__init__(timeout=None) + self.initiator = initiator + self.cancel_flag = asyncio.Event() + + 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, + _: discord.Interaction, + button: discord.ui.Button[Self], + ) -> None: + button.disabled = True + button.label = "Cancelling ..." + self.cancel_flag.set() + + def get_cancel_event(self) -> Event: + return self.cancel_flag + + +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.", + ) + + 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_kicked = 0 + members_failed: list[Member] = [] + lock = asyncio.Lock() + + cancel_event = cancel_progress_view.get_cancel_event() + end_progress_event = asyncio.Event() + + async def progress_updater(end_signal: asyncio.Event): + if end_signal.is_set(): + return + while True: + async with lock: + 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}" + final_message = asyncio.create_task( + interaction.edit_original_response( + content=progress_message, + allowed_mentions=AllowedMentions.none(), + view=cancel_progress_view, + ), + ) + 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 + + 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 + + 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, + ): + 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 + break + if not member_predicate(member): + continue + 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:", + embed=embed_message, + ) + 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", + ) + kicked_count += 1 + 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 += ( + extra_processed if extra_processed else len(member_chunk) + ) + members_kicked += kicked_count + members_failed.extend(failed_members) + if cancel_event.is_set(): + break + + end_progress_event.set() + await ui_updater + + users_without_member_role = sum( + [ + 1 + async for member in interaction.guild.fetch_members(limit=None) + if member_predicate(member) + ], + ) + + progress_message = "Operation completed" + 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: + 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_without_member_role, + member_role.name, + ) + ) + + return await interaction.edit_original_response( + content=progress_message, + allowed_mentions=AllowedMentions.none(), + view=None, + ) + + +async def setup(bot: PiBot): + await bot.add_cog(UserCleanup(bot))