From c4a55ff3188eb5634a24e13a38e794accba69266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sun, 14 Jul 2024 03:59:15 +0100 Subject: [PATCH 01/12] add no general context menu and supporting features --- cogs/api.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/cogs/api.py b/cogs/api.py index d858af5b..42143669 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -21,6 +21,7 @@ from asyncpg import Record, Connection from cogs.reminder import Timer from cogs.dpy import DPYExclusive + from cogs.reminder import Reminder as ReminderCog, Timer DISCORD_API_ID = 81384788765712384 @@ -33,6 +34,7 @@ DISCORD_PY_JP_STAFF_ROLE = 490320652230852629 DISCORD_PY_PROF_ROLE = 381978395270971407 DISCORD_PY_HELPER_ROLE = 558559632637952010 +DISCORD_PY_NO_GENERAL_ROLE = 1258249274899169290 # DISCORD_PY_HELP_CHANNELS = (381965515721146390, 738572311107469354, 985299059441025044) DISCORD_PY_HELP_CHANNEL = 985299059441025044 @@ -56,6 +58,10 @@ def is_discord_py_helper(member: discord.Member) -> bool: return member._roles.has(DISCORD_PY_HELPER_ROLE) +def can_use_no_general(interaction: discord.Interaction) -> bool: + # Using `ban_members` over `manage_roles` since Documentation Manager has that + return interaction.user.guild_permissions.ban_members or interaction.user.get_role(DISCORD_PY_HELPER_ROLE) # type: ignore # interaction.user is a Member + def can_use_block(): def predicate(ctx: GuildContext) -> bool: @@ -168,6 +174,13 @@ async def convert(self, ctx: GuildContext, argument: str): return user +class CreateHelpThreadModal(discord.ui.Modal, title="Create help thread"): + thread_name = discord.ui.TextInput(label="Thread title", placeholder="Name for the help thread...", min_length=15, max_length=100) + + def __init__(self) -> None: + super().__init__(custom_id="dpy-create-thread-modal") + + class RepositoryExample(NamedTuple): path: str url: str @@ -186,6 +199,8 @@ class API(commands.Cog): def __init__(self, bot: RoboDanny): self.bot: RoboDanny = bot self.issue = re.compile(r'##(?P[0-9]+)') + self.create_thread_context = discord.app_commands.ContextMenu(name="Create help thread", callback=self.create_thread_callback) + self.bot.tree.add_command(self.create_thread_context, guild=discord.Object(id=DISCORD_PY_GUILD)) @property def display_emoji(self) -> discord.PartialEmoji: @@ -200,6 +215,74 @@ async def on_member_join(self, member: discord.Member): role = discord.Object(id=USER_BOTS_ROLE) await member.add_roles(role) + async def cog_unload(self) -> None: + self.bot.tree.remove_command( + self.create_thread_context.name, + guild=discord.Object(id=DISCORD_PY_GUILD), + type=self.create_thread_context.type + ) + + async def _attempt_general_block(self, moderator: discord.Member, member: discord.Member) -> None: + reminder: Optional[ReminderCog] = self.bot.get_cog('Reminder') # type: ignore # type downcasting + if not reminder: + return # we can't apply the timed role. + + await member.add_roles(discord.Object(id=DISCORD_PY_NO_GENERAL_ROLE), reason="Rule 16 - requesting help in general.") + + now = discord.utils.utcnow() + await reminder.create_timer( + now + datetime.timedelta(hours=1), + "general_block", + moderator.id, + member.id, + created=now + ) + + @commands.Cog.listener() + async def on_general_block_timer_complete(self, timer: Timer) -> None: + moderator_id, member_id = timer.args + await self.bot.wait_until_ready() + + guild = self.bot.get_guild(DISCORD_PY_GUILD) + if guild is None: + # RIP + return + + member = await self.bot.get_or_fetch_member(guild, member_id) + if member is None: + # They left the guild + return + + moderator = await self.bot.get_or_fetch_member(guild, moderator_id) + if moderator is None: + try: + moderator = await self.bot.fetch_user(moderator_id) + except discord.HTTPException: + moderator = f'Mod ID: {moderator_id}' + else: + moderator = f'{moderator} (ID: {moderator_id})' + + reason = f'Automatic removal of role from timer made on {timer.created_at} by {moderator}.' + await member.remove_roles(discord.Object(id=DISCORD_PY_NO_GENERAL_ROLE), reason=reason) + + + async def create_thread_callback(self, interaction: discord.Interaction, message: discord.Message) -> None: + modal = CreateHelpThreadModal() + await interaction.response.send_modal(modal) + _waited = await modal.wait() + if _waited: + return # we return on timeout, rather than proceeding + + forum: discord.ForumChannel = await interaction.guild.get_channel(DISCORD_PY_HELP_FORUM) # type: ignore # can only be executed from the guild + thread, _ = await forum.create_thread( + name=modal.thread_name.value, + content=message.content, + files=[await attachment.to_file() for attachment in message.attachments] + ) + await thread.send(f'This thread was created on behalf of {message.author}. Please continue your discussion for help in here.') + + await self._attempt_general_block(interaction.user, message.author) # type: ignore # we know it's a member here due to aforemention guild guard + def parse_object_inv(self, stream: SphinxObjectFileReader, url: str) -> dict[str, str]: # key: URL # n.b.: key doesn't have `discord` or `discord.ext.commands` namespaces From 3e1a62d49df6d8c21d2596bbc6b799636e56003d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sun, 14 Jul 2024 04:03:17 +0100 Subject: [PATCH 02/12] use check --- cogs/api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cogs/api.py b/cogs/api.py index 42143669..59a4d739 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -58,9 +58,9 @@ def is_discord_py_helper(member: discord.Member) -> bool: return member._roles.has(DISCORD_PY_HELPER_ROLE) -def can_use_no_general(interaction: discord.Interaction) -> bool: +def can_use_no_general(member: discord.Member) -> bool: # Using `ban_members` over `manage_roles` since Documentation Manager has that - return interaction.user.guild_permissions.ban_members or interaction.user.get_role(DISCORD_PY_HELPER_ROLE) # type: ignore # interaction.user is a Member + return member.guild_permissions.ban_members or member._roles.has(DISCORD_PY_HELPER_ROLE) def can_use_block(): @@ -267,6 +267,9 @@ async def on_general_block_timer_complete(self, timer: Timer) -> None: async def create_thread_callback(self, interaction: discord.Interaction, message: discord.Message) -> None: + if not can_use_no_general(interaction.user): # type: ignore # discord.Member since we're guild guarded + return await interaction.response.send_message('Sorry, this command is not available to you!') + modal = CreateHelpThreadModal() await interaction.response.send_modal(modal) _waited = await modal.wait() From 415fa32a5005c263da074eeb132dc56dda9a21ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sun, 14 Jul 2024 05:21:05 +0100 Subject: [PATCH 03/12] single quotes --- cogs/api.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cogs/api.py b/cogs/api.py index 59a4d739..08aaf252 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -174,11 +174,11 @@ async def convert(self, ctx: GuildContext, argument: str): return user -class CreateHelpThreadModal(discord.ui.Modal, title="Create help thread"): - thread_name = discord.ui.TextInput(label="Thread title", placeholder="Name for the help thread...", min_length=15, max_length=100) +class CreateHelpThreadModal(discord.ui.Modal, title='Create help thread'): + thread_name = discord.ui.TextInput(label='Thread title', placeholder='Name for the help thread...', min_length=15, max_length=100) def __init__(self) -> None: - super().__init__(custom_id="dpy-create-thread-modal") + super().__init__(custom_id='dpy-create-thread-modal') class RepositoryExample(NamedTuple): @@ -199,7 +199,7 @@ class API(commands.Cog): def __init__(self, bot: RoboDanny): self.bot: RoboDanny = bot self.issue = re.compile(r'##(?P[0-9]+)') - self.create_thread_context = discord.app_commands.ContextMenu(name="Create help thread", callback=self.create_thread_callback) + self.create_thread_context = discord.app_commands.ContextMenu(name='Create help thread', callback=self.create_thread_callback) self.bot.tree.add_command(self.create_thread_context, guild=discord.Object(id=DISCORD_PY_GUILD)) @property @@ -227,12 +227,12 @@ async def _attempt_general_block(self, moderator: discord.Member, member: discor if not reminder: return # we can't apply the timed role. - await member.add_roles(discord.Object(id=DISCORD_PY_NO_GENERAL_ROLE), reason="Rule 16 - requesting help in general.") + await member.add_roles(discord.Object(id=DISCORD_PY_NO_GENERAL_ROLE), reason='Rule 16 - requesting help in general.') now = discord.utils.utcnow() await reminder.create_timer( now + datetime.timedelta(hours=1), - "general_block", + 'general_block', moderator.id, member.id, created=now From 9c3f6035708f179a91cdfc1963cd5d053b876000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sun, 14 Jul 2024 14:34:27 +0100 Subject: [PATCH 04/12] implement Modal.stop in override --- cogs/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cogs/api.py b/cogs/api.py index 08aaf252..bf698b43 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -180,6 +180,9 @@ class CreateHelpThreadModal(discord.ui.Modal, title='Create help thread'): def __init__(self) -> None: super().__init__(custom_id='dpy-create-thread-modal') + async def on_submit(self, interaction: discord.Interaction) -> None: + self.stop() + class RepositoryExample(NamedTuple): path: str From 4899eb9742139de6ef290edef2f81f5dbc819dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sun, 14 Jul 2024 14:34:54 +0100 Subject: [PATCH 05/12] cog_unload is actually a maybecoro --- cogs/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/api.py b/cogs/api.py index bf698b43..31a54fdf 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -218,7 +218,7 @@ async def on_member_join(self, member: discord.Member): role = discord.Object(id=USER_BOTS_ROLE) await member.add_roles(role) - async def cog_unload(self) -> None: + def cog_unload(self) -> None: self.bot.tree.remove_command( self.create_thread_context.name, guild=discord.Object(id=DISCORD_PY_GUILD), From ce1861581c32d199ab1604d8bde9fe44e7d2e5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sun, 14 Jul 2024 14:35:17 +0100 Subject: [PATCH 06/12] actually mention the target and add them to the thread --- cogs/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/api.py b/cogs/api.py index 31a54fdf..d0c9d495 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -285,7 +285,7 @@ async def create_thread_callback(self, interaction: discord.Interaction, message content=message.content, files=[await attachment.to_file() for attachment in message.attachments] ) - await thread.send(f'This thread was created on behalf of {message.author}. Please continue your discussion for help in here.') + await thread.send(f'This thread was created on behalf of {message.author.mention}. Please continue your discussion for help in here.') await self._attempt_general_block(interaction.user, message.author) # type: ignore # we know it's a member here due to aforemention guild guard From 4fb57b74b6091786dae8d4a750cb6d7f127d71b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Mon, 5 Aug 2024 12:23:18 +0100 Subject: [PATCH 07/12] ask if we should apply the mute --- cogs/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cogs/api.py b/cogs/api.py index d0c9d495..a2b6a14c 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -176,6 +176,7 @@ async def convert(self, ctx: GuildContext, argument: str): class CreateHelpThreadModal(discord.ui.Modal, title='Create help thread'): thread_name = discord.ui.TextInput(label='Thread title', placeholder='Name for the help thread...', min_length=15, max_length=100) + should_mute = discord.ui.TextInput(label='Apply mute?', default="Yes", min_length=2, max_length=3) def __init__(self) -> None: super().__init__(custom_id='dpy-create-thread-modal') @@ -287,7 +288,8 @@ async def create_thread_callback(self, interaction: discord.Interaction, message ) await thread.send(f'This thread was created on behalf of {message.author.mention}. Please continue your discussion for help in here.') - await self._attempt_general_block(interaction.user, message.author) # type: ignore # we know it's a member here due to aforemention guild guard + if modal.should_mute.value.lower() == "yes": + await self._attempt_general_block(interaction.user, message.author) # type: ignore # we know it's a member here due to aforemention guild guard def parse_object_inv(self, stream: SphinxObjectFileReader, url: str) -> dict[str, str]: # key: URL From 6f05ba5f6480fa5d6c1286cec19ac1ac63a87619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Mon, 5 Aug 2024 13:28:33 +0100 Subject: [PATCH 08/12] Update cogs/api.py Co-authored-by: Leonardo --- cogs/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/api.py b/cogs/api.py index a2b6a14c..65743dcf 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -175,7 +175,7 @@ async def convert(self, ctx: GuildContext, argument: str): class CreateHelpThreadModal(discord.ui.Modal, title='Create help thread'): - thread_name = discord.ui.TextInput(label='Thread title', placeholder='Name for the help thread...', min_length=15, max_length=100) + thread_name = discord.ui.TextInput(label='Thread title', placeholder='Name for the help thread...', min_length=20, max_length=100) should_mute = discord.ui.TextInput(label='Apply mute?', default="Yes", min_length=2, max_length=3) def __init__(self) -> None: From 95f9600a0273edb41be0369a5a1d75c78b8731e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sat, 10 Aug 2024 00:21:49 +0100 Subject: [PATCH 09/12] cleanup and bug fixes --- cogs/api.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cogs/api.py b/cogs/api.py index 65743dcf..1849515d 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -183,6 +183,7 @@ def __init__(self) -> None: async def on_submit(self, interaction: discord.Interaction) -> None: self.stop() + await interaction.response.send_message('Thread created now.', ephemeral=True) class RepositoryExample(NamedTuple): @@ -226,12 +227,17 @@ def cog_unload(self) -> None: type=self.create_thread_context.type ) - async def _attempt_general_block(self, moderator: discord.Member, member: discord.Member) -> None: + async def _attempt_general_block(self, moderator: discord.Member, member: Union[discord.User, discord.Member]) -> None: reminder: Optional[ReminderCog] = self.bot.get_cog('Reminder') # type: ignore # type downcasting if not reminder: return # we can't apply the timed role. - await member.add_roles(discord.Object(id=DISCORD_PY_NO_GENERAL_ROLE), reason='Rule 16 - requesting help in general.') + resolved = moderator.guild.get_member(member.id) if isinstance(member, discord.User) else member + + if not resolved: + return # left the guild(?) + + await resolved.add_roles(discord.Object(id=DISCORD_PY_NO_GENERAL_ROLE), reason='Rule 16 - requesting help in general.') now = discord.utils.utcnow() await reminder.create_timer( @@ -276,11 +282,10 @@ async def create_thread_callback(self, interaction: discord.Interaction, message modal = CreateHelpThreadModal() await interaction.response.send_modal(modal) - _waited = await modal.wait() - if _waited: + if await modal.wait(): return # we return on timeout, rather than proceeding - forum: discord.ForumChannel = await interaction.guild.get_channel(DISCORD_PY_HELP_FORUM) # type: ignore # can only be executed from the guild + forum: discord.ForumChannel = interaction.guild.get_channel(DISCORD_PY_HELP_CHANNEL) # pyright: ignore[reportAssignmentType,reportOptionalMemberAccess] # we know the type via ID and that guild is present thread, _ = await forum.create_thread( name=modal.thread_name.value, content=message.content, @@ -289,7 +294,7 @@ async def create_thread_callback(self, interaction: discord.Interaction, message await thread.send(f'This thread was created on behalf of {message.author.mention}. Please continue your discussion for help in here.') if modal.should_mute.value.lower() == "yes": - await self._attempt_general_block(interaction.user, message.author) # type: ignore # we know it's a member here due to aforemention guild guard + await self._attempt_general_block(interaction.user, message.author) # pyright: ignore[reportArgumentType] # can only be executed from the guild def parse_object_inv(self, stream: SphinxObjectFileReader, url: str) -> dict[str, str]: # key: URL From 832f987eb395ff17b555d4e729b1476c23984af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Mon, 9 Sep 2024 21:12:04 +0100 Subject: [PATCH 10/12] correct predicate usage --- cogs/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/api.py b/cogs/api.py index 1849515d..cbbdb279 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -60,7 +60,7 @@ def is_discord_py_helper(member: discord.Member) -> bool: def can_use_no_general(member: discord.Member) -> bool: # Using `ban_members` over `manage_roles` since Documentation Manager has that - return member.guild_permissions.ban_members or member._roles.has(DISCORD_PY_HELPER_ROLE) + return member.guild_permissions.ban_members or is_discord_py_helper(member) def can_use_block(): From d796278590ebde9b56cf9e9c51bb4f83095c3de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Mon, 9 Sep 2024 21:12:24 +0100 Subject: [PATCH 11/12] Show interaction errors on View --- cogs/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cogs/api.py b/cogs/api.py index cbbdb279..9fe78ffc 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -181,6 +181,9 @@ class CreateHelpThreadModal(discord.ui.Modal, title='Create help thread'): def __init__(self) -> None: super().__init__(custom_id='dpy-create-thread-modal') + async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + await interaction.response.send_message(f'Sorry, something went wrong.\n\n{error}', ephemeral=True) + async def on_submit(self, interaction: discord.Interaction) -> None: self.stop() await interaction.response.send_message('Thread created now.', ephemeral=True) From 7b57df8c6f49e958256ca2594998aa3663606f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Mon, 9 Sep 2024 21:12:49 +0100 Subject: [PATCH 12/12] add tempnogeneral command --- cogs/api.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/cogs/api.py b/cogs/api.py index 9fe78ffc..449cce12 100644 --- a/cogs/api.py +++ b/cogs/api.py @@ -240,11 +240,13 @@ async def _attempt_general_block(self, moderator: discord.Member, member: Union[ if not resolved: return # left the guild(?) - await resolved.add_roles(discord.Object(id=DISCORD_PY_NO_GENERAL_ROLE), reason='Rule 16 - requesting help in general.') - now = discord.utils.utcnow() + then = now + datetime.timedelta(hours=1) + + await resolved.add_roles(discord.Object(id=DISCORD_PY_NO_GENERAL_ROLE), reason=f'Temp no general role by {moderator} (ID: {moderator.id}) until {discord.utils.format_dt(then), "F"}') + await reminder.create_timer( - now + datetime.timedelta(hours=1), + then, 'general_block', moderator.id, member.id, @@ -694,6 +696,32 @@ async def tempblock(self, ctx: GuildContext, duration: time.FutureTime, *, membe else: await ctx.send(f'Blocked {member} for {time.format_relative(duration.dt)}.') + @commands.command(name="tempnogeneral", aliases=["tempng"]) + @can_use_tempblock() + async def temp_no_general(self, ctx: GuildContext, duration: time.FutureTime, *, member: discord.Member): + """Temporarily blocks a user from your the general category of channels. + + The duration can be a a short time form, e.g. 30d or a more human + duration such as "until thursday at 3PM" or a more concrete time + such as "2017-12-31". + + Note that times are in UTC. + """ + + if member.top_role >= ctx.author.top_role: + return + + created_at = ctx.message.created_at + if is_discord_py_helper(ctx.author) and duration.dt > (created_at + datetime.timedelta(minutes=60)): + return await ctx.send('Helpers can only block for up to an hour.') + + try: + await self._attempt_general_block(ctx.author, member) + except: + await ctx.send('\N{THUMBS DOWN SIGN}') + else: + await ctx.send(f'Blocked {member} for {time.format_relative(duration.dt)}.') + @commands.Cog.listener() async def on_tempblock_timer_complete(self, timer: Timer): guild_id, mod_id, channel_id, member_id = timer.args