-
-
Notifications
You must be signed in to change notification settings - Fork 751
Subscribe with buttons #1868
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Subscribe with buttons #1868
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
8680df2
Move handle_role_change to a util file
ChrisLovering 0465db9
Remove the subscribe command from the verification cog
ChrisLovering 5df26ba
Add self assignable roles to config
ChrisLovering 4f70109
Add an interactive subscribe command
ChrisLovering 4c98287
Ensure the user interacting is still in guild before changing roles
ChrisLovering 1d7765c
Add 10s member cooldown to subscribe command
ChrisLovering 9a3be9e
Stop listening for events when message is deleted
ChrisLovering b748d13
Use new get_logger helper util
ChrisLovering 8b10983
Delete the subscribe message after 5 minutes
ChrisLovering 7f22abf
Allow roles to be assignable over multiple months
ChrisLovering 19eef3e
Sort unavailable self-assignable roles to the end of the list
ChrisLovering 005af3b
Swap remove and unavailable colours for subscribe command
ChrisLovering 57c1b8e
Add lock emoji to highlight unavailable self-assignable roles
ChrisLovering aecb093
Subscribe command replies to invocation to keep context
ChrisLovering 04a58c4
Merge branch 'main' into subscribe-with-buttons
ChrisLovering File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| import calendar | ||
| import operator | ||
| import typing as t | ||
| from dataclasses import dataclass | ||
|
|
||
| import arrow | ||
| import discord | ||
| from discord.ext import commands | ||
| from discord.interactions import Interaction | ||
|
|
||
| from bot import constants | ||
| from bot.bot import Bot | ||
| from bot.decorators import in_whitelist | ||
| from bot.log import get_logger | ||
| from bot.utils import checks, members, scheduling | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class AssignableRole: | ||
| """ | ||
| A role that can be assigned to a user. | ||
|
|
||
| months_available is a tuple that signifies what months the role should be | ||
| self-assignable, using None for when it should always be available. | ||
| """ | ||
|
|
||
| role_id: int | ||
| months_available: t.Optional[tuple[int]] | ||
| name: t.Optional[str] = None # This gets populated within Subscribe.init_cog() | ||
|
|
||
| def is_currently_available(self) -> bool: | ||
| """Check if the role is available for the current month.""" | ||
| if self.months_available is None: | ||
| return True | ||
| return arrow.utcnow().month in self.months_available | ||
|
|
||
| def get_readable_available_months(self) -> str: | ||
| """Get a readable string of the months the role is available.""" | ||
| if self.months_available is None: | ||
| return f"{self.name} is always available." | ||
|
|
||
| # Join the months together with comma separators, but use "and" for the final seperator. | ||
| month_names = [calendar.month_name[month] for month in self.months_available] | ||
| available_months_str = ", ".join(month_names[:-1]) + f" and {month_names[-1]}" | ||
| return f"{self.name} can only be assigned during {available_months_str}." | ||
|
|
||
|
|
||
| ASSIGNABLE_ROLES = ( | ||
| AssignableRole(constants.Roles.announcements, None), | ||
| AssignableRole(constants.Roles.pyweek_announcements, None), | ||
| AssignableRole(constants.Roles.lovefest, (1, 2)), | ||
| AssignableRole(constants.Roles.advent_of_code, (11, 12)), | ||
| ) | ||
|
|
||
| ITEMS_PER_ROW = 3 | ||
| DELETE_MESSAGE_AFTER = 300 # Seconds | ||
|
|
||
| log = get_logger(__name__) | ||
|
|
||
|
|
||
| class RoleButtonView(discord.ui.View): | ||
| """A list of SingleRoleButtons to show to the member.""" | ||
|
|
||
| def __init__(self, member: discord.Member): | ||
| super().__init__() | ||
| self.interaction_owner = member | ||
|
|
||
| async def interaction_check(self, interaction: Interaction) -> bool: | ||
| """Ensure that the user clicking the button is the member who invoked the command.""" | ||
| if interaction.user != self.interaction_owner: | ||
| await interaction.response.send_message( | ||
| ":x: This is not your command to react to!", | ||
| ephemeral=True | ||
| ) | ||
| return False | ||
| return True | ||
|
|
||
|
|
||
| class SingleRoleButton(discord.ui.Button): | ||
|
ChrisLovering marked this conversation as resolved.
|
||
| """A button that adds or removes a role from the member depending on it's current state.""" | ||
|
|
||
| ADD_STYLE = discord.ButtonStyle.success | ||
| REMOVE_STYLE = discord.ButtonStyle.red | ||
| UNAVAILABLE_STYLE = discord.ButtonStyle.secondary | ||
| LABEL_FORMAT = "{action} role {role_name}." | ||
| CUSTOM_ID_FORMAT = "subscribe-{role_id}" | ||
|
ChrisLovering marked this conversation as resolved.
|
||
|
|
||
| def __init__(self, role: AssignableRole, assigned: bool, row: int): | ||
| if role.is_currently_available(): | ||
| style = self.REMOVE_STYLE if assigned else self.ADD_STYLE | ||
| label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name) | ||
| else: | ||
| style = self.UNAVAILABLE_STYLE | ||
| label = f"🔒 {role.name}" | ||
|
|
||
| super().__init__( | ||
| style=style, | ||
| label=label, | ||
| custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.role_id), | ||
| row=row, | ||
| ) | ||
| self.role = role | ||
| self.assigned = assigned | ||
|
|
||
| async def callback(self, interaction: Interaction) -> None: | ||
| """Update the member's role and change button text to reflect current text.""" | ||
| if isinstance(interaction.user, discord.User): | ||
| log.trace("User %s is not a member", interaction.user) | ||
| await interaction.message.delete() | ||
| self.view.stop() | ||
| return | ||
|
|
||
| if not self.role.is_currently_available(): | ||
| await interaction.response.send_message(self.role.get_readable_available_months(), ephemeral=True) | ||
| return | ||
|
|
||
| await members.handle_role_change( | ||
|
ChrisLovering marked this conversation as resolved.
|
||
| interaction.user, | ||
| interaction.user.remove_roles if self.assigned else interaction.user.add_roles, | ||
| discord.Object(self.role.role_id), | ||
| ) | ||
|
|
||
| self.assigned = not self.assigned | ||
| await self.update_view(interaction) | ||
| await interaction.response.send_message( | ||
| self.LABEL_FORMAT.format(action="Added" if self.assigned else "Removed", role_name=self.role.name), | ||
| ephemeral=True, | ||
| ) | ||
|
|
||
| async def update_view(self, interaction: Interaction) -> None: | ||
| """Updates the original interaction message with a new view object with the updated buttons.""" | ||
| self.style = self.REMOVE_STYLE if self.assigned else self.ADD_STYLE | ||
| self.label = self.LABEL_FORMAT.format(action="Remove" if self.assigned else "Add", role_name=self.role.name) | ||
| try: | ||
| await interaction.message.edit(view=self.view) | ||
| except discord.NotFound: | ||
| log.debug("Subscribe message for %s removed before buttons could be updated", interaction.user) | ||
| self.view.stop() | ||
|
|
||
|
|
||
| class Subscribe(commands.Cog): | ||
| """Cog to allow user to self-assign & remove the roles present in ASSIGNABLE_ROLES.""" | ||
|
|
||
| def __init__(self, bot: Bot): | ||
| self.bot = bot | ||
| self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop) | ||
| self.assignable_roles: list[AssignableRole] = [] | ||
| self.guild: discord.Guild = None | ||
|
|
||
| async def init_cog(self) -> None: | ||
| """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names.""" | ||
| await self.bot.wait_until_guild_available() | ||
|
|
||
| self.guild = self.bot.get_guild(constants.Guild.id) | ||
|
|
||
| for role in ASSIGNABLE_ROLES: | ||
| discord_role = self.guild.get_role(role.role_id) | ||
| if discord_role is None: | ||
| log.warning("Could not resolve %d to a role in the guild, skipping.", role.role_id) | ||
| continue | ||
| self.assignable_roles.append( | ||
| AssignableRole( | ||
| role_id=role.role_id, | ||
| months_available=role.months_available, | ||
| name=discord_role.name, | ||
| ) | ||
| ) | ||
| # Sort unavailable roles to the end of the list | ||
| self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True) | ||
|
|
||
| @commands.cooldown(1, 10, commands.BucketType.member) | ||
| @commands.command(name="subscribe") | ||
| @in_whitelist(channels=(constants.Channels.bot_commands,)) | ||
| async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args | ||
| """Display the member's current state for each role, and allow them to add/remove the roles.""" | ||
| await self.init_task | ||
|
|
||
| button_view = RoleButtonView(ctx.author) | ||
| author_roles = [role.id for role in ctx.author.roles] | ||
| for index, role in enumerate(self.assignable_roles): | ||
| row = index // ITEMS_PER_ROW | ||
| button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row)) | ||
|
|
||
| await ctx.reply( | ||
| "Click the buttons below to add or remove your roles!", | ||
| view=button_view, | ||
| delete_after=DELETE_MESSAGE_AFTER, | ||
| ) | ||
|
|
||
| # This cannot be static (must have a __func__ attribute). | ||
| async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: | ||
| """Check for & ignore any InWhitelistCheckFailure.""" | ||
| if isinstance(error, checks.InWhitelistCheckFailure): | ||
| error.handled = True | ||
|
|
||
|
|
||
| def setup(bot: Bot) -> None: | ||
| """Load the Subscribe cog.""" | ||
| if len(ASSIGNABLE_ROLES) > ITEMS_PER_ROW*5: # Discord limits views to 5 rows of buttons. | ||
| log.error("Too many roles for 5 rows, not loading the Subscribe cog.") | ||
| else: | ||
| bot.add_cog(Subscribe(bot)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.