From 723fa5dfba3c7b5802c755b5c2e19dbcf60442ad Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 20:12:48 +0100 Subject: [PATCH 01/10] Pull out the majority of functionality into a cog --- py/cog.py | 207 ++++++++++++++++++++++++++++++++ py/match_button.py | 54 +++++++++ py/matching.py | 62 ++++++++-- py/matchy.py | 291 ++------------------------------------------- py/matchy_cog.py | 208 ++++++++++++++++++++++++++++++++ py/matchy_test.py | 56 +++++++++ py/state.py | 8 +- 7 files changed, 596 insertions(+), 290 deletions(-) create mode 100644 py/cog.py create mode 100644 py/match_button.py create mode 100644 py/matchy_cog.py create mode 100644 py/matchy_test.py diff --git a/py/cog.py b/py/cog.py new file mode 100644 index 0000000..ff43320 --- /dev/null +++ b/py/cog.py @@ -0,0 +1,207 @@ +""" +Matchy bot cog +""" +import logging +import discord +from discord import app_commands +from discord.ext import commands, tasks +from datetime import datetime, timedelta, time + +import match_button +import matching +from state import State, save_to_file, AuthScope +import util + +logger = logging.getLogger("cog") +logger.setLevel(logging.INFO) + + +class MatchyCog(commands.Cog): + def __init__(self, bot: commands.Bot, state: State): + self.bot = bot + self.state = state + + @commands.Cog.listener() + async def on_ready(self): + """Bot is ready and connected""" + self.run_hourly_tasks.start() + activity = discord.Game("/join") + await self.bot.change_presence(status=discord.Status.online, activity=activity) + logger.info("Bot is up and ready!") + + @app_commands.command(description="Join the matchees for this channel") + @commands.guild_only() + async def join(self, interaction: discord.Interaction): + logger.info("Handling /join in %s %s from %s", + interaction.guild.name, interaction.channel, interaction.user.name) + + self.state.set_user_active_in_channel( + interaction.user.id, interaction.channel.id) + save_to_file(self.state) + await interaction.response.send_message( + f"Roger roger {interaction.user.mention}!\n" + + f"Added you to {interaction.channel.mention}!", + ephemeral=True, silent=True) + + @app_commands.command(description="Leave the matchees for this channel") + @commands.guild_only() + async def leave(self, interaction: discord.Interaction): + logger.info("Handling /leave in %s %s from %s", + interaction.guild.name, interaction.channel, interaction.user.name) + + self.state.set_user_active_in_channel( + interaction.user.id, interaction.channel.id, False) + save_to_file(self.state) + await interaction.response.send_message( + f"No worries {interaction.user.mention}. Come back soon :)", ephemeral=True, silent=True) + + @app_commands.command(description="Pause your matching in this channel for a number of days") + @commands.guild_only() + @app_commands.describe(days="Days to pause for (defaults to 7)") + async def pause(self, interaction: discord.Interaction, days: int | None = None): + logger.info("Handling /pause in %s %s from %s with days=%s", + interaction.guild.name, interaction.channel, interaction.user.name, days) + + if days is None: # Default to a week + days = 7 + until = datetime.now() + timedelta(days=days) + self.state.set_user_paused_in_channel( + interaction.user.id, interaction.channel.id, until) + save_to_file(self.state) + await interaction.response.send_message( + f"Sure thing {interaction.user.mention}!\n" + + f"Paused you until {util.format_day(until)}!", + ephemeral=True, silent=True) + + @app_commands.command(description="List the matchees for this channel") + @commands.guild_only() + async def list(self, interaction: discord.Interaction): + logger.info("Handling /list command in %s %s from %s", + interaction.guild.name, interaction.channel, interaction.user.name) + + matchees = matching.get_matchees_in_channel(self.state, interaction.channel) + mentions = [m.mention for m in matchees] + msg = "Current matchees in this channel:\n" + \ + f"{util.format_list(mentions)}" + + tasks = self.state.get_channel_match_tasks(interaction.channel.id) + for (day, hour, min) in tasks: + next_run = util.get_next_datetime(day, hour) + date_str = util.format_day(next_run) + msg += f"\nNext scheduled for {date_str} at {hour:02d}:00" + msg += f" with {min} members per group" + + await interaction.response.send_message(msg, ephemeral=True, silent=True) + + @app_commands.command(description="Schedule a match in this channel (UTC)") + @commands.guild_only() + @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)", + weekday="Day of the week to run this (defaults 0, Monday)", + hour="Hour in the day (defaults to 9 utc)", + cancel="Cancel the scheduled match at this time") + async def schedule(self, + interaction: discord.Interaction, + members_min: int | None = None, + weekday: int | None = None, + hour: int | None = None, + cancel: bool = False): + """Schedule a match using the input parameters""" + + # Set all the defaults + if not members_min: + members_min = 3 + if weekday is None: + weekday = 0 + if hour is None: + hour = 9 + channel_id = str(interaction.channel.id) + + # Bail if not a matcher + if not self.state.get_user_has_scope(interaction.user.id, AuthScope.MATCHER): + await interaction.response.send_message("You'll need the 'matcher' scope to schedule a match", + ephemeral=True, silent=True) + return + + # Add the scheduled task and save + success = self.state.set_channel_match_task( + channel_id, members_min, weekday, hour, not cancel) + save_to_file(self.state) + + # Let the user know what happened + if not cancel: + logger.info("Scheduled new match task in %s with min %s weekday %s hour %s", + channel_id, members_min, weekday, hour) + next_run = util.get_next_datetime(weekday, hour) + date_str = util.format_day(next_run) + + await interaction.response.send_message( + f"Done :) Next run will be on {date_str} at {hour:02d}:00\n" + + "Cancel this by re-sending the command with cancel=True", + ephemeral=True, silent=True) + + elif success: + logger.info("Removed task in %s on weekday %s hour %s", + channel_id, weekday, hour) + await interaction.response.send_message( + f"Done :) Schedule on day {weekday} and hour {hour} removed!", ephemeral=True, silent=True) + + else: + await interaction.response.send_message( + f"No schedule for this channel on day {weekday} and hour {hour} found :(", ephemeral=True, silent=True) + + @app_commands.command(description="Match up matchees") + @commands.guild_only() + @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)") + async def match(self, interaction: discord.Interaction, members_min: int | None = None): + """Match groups of channel members""" + + logger.info("Handling request '/match group_min=%s", members_min) + logger.info("User %s from %s in #%s", interaction.user, + interaction.guild.name, interaction.channel.name) + + # Sort out the defaults, if not specified they'll come in as None + if not members_min: + members_min = 3 + + # Grab the groups + groups = matching.active_members_to_groups( + self.state, interaction.channel, members_min) + + # Let the user know when there's nobody to match + if not groups: + await interaction.response.send_message("Nobody to match up :(", ephemeral=True, silent=True) + return + + # Post about all the groups with a button to send to the channel + groups_list = '\n'.join( + ", ".join([m.mention for m in g]) for g in groups) + msg = f"Roger! I've generated example groups for ya:\n\n{groups_list}" + view = discord.utils.MISSING + + if self.state.get_user_has_scope(interaction.user.id, AuthScope.MATCHER): + # Otherwise set up the button + msg += "\n\nClick the button to match up groups and send them to the channel.\n" + view = discord.ui.View(timeout=None) + view.add_item(match_button.DynamicGroupButton(members_min)) + else: + # Let a non-matcher know why they don't have the button + msg += f"\n\nYou'll need the {AuthScope.MATCHER}" + + " scope to post this to the channel, sorry!" + + await interaction.response.send_message(msg, ephemeral=True, silent=True, view=view) + + logger.info("Done.") + + @tasks.loop(time=[time(hour=h) for h in range(24)]) + async def run_hourly_tasks(self): + """Run any hourly tasks we have""" + for (channel, min) in self.state.get_active_match_tasks(): + logger.info("Scheduled match task triggered in %s", channel) + msg_channel = self.bot.get_channel(int(channel)) + await matching.match_groups_in_channel(self.state, msg_channel, min) + + for (channel, _) in self.state.get_active_match_tasks(datetime.now() + timedelta(days=1)): + logger.info("Reminding about scheduled task in %s", channel) + msg_channel = self.bot.get_channel(int(channel)) + await msg_channel.send("Arf arf! just a reminder I'll be doin a matcherino in here in T-24hrs!" + + "\nUse /join if you haven't already, or /pause if you want to skip a week :)") diff --git a/py/match_button.py b/py/match_button.py new file mode 100644 index 0000000..d9705e3 --- /dev/null +++ b/py/match_button.py @@ -0,0 +1,54 @@ +""" +Class for a button that matches groups in a channel +""" +import logging +import discord +import re + +import state +import matching + +logger = logging.getLogger("match_button") +logger.setLevel(logging.INFO) + +# Increment when adjusting the custom_id so we don't confuse old users +_MATCH_BUTTON_CUSTOM_ID_VERSION = 1 +_MATCH_BUTTON_CUSTOM_ID_PREFIX = f'match:v{ + _MATCH_BUTTON_CUSTOM_ID_VERSION}:' + + +class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button], + template=_MATCH_BUTTON_CUSTOM_ID_PREFIX + r'min:(?P[0-9]+)'): + """ + Describes a simple button that lets the user trigger a match + """ + + def __init__(self, min: int) -> None: + super().__init__( + discord.ui.Button( + label='Match Groups!', + style=discord.ButtonStyle.blurple, + custom_id=_MATCH_BUTTON_CUSTOM_ID_PREFIX + f'min:{min}', + ) + ) + self.min: int = min + self.state = state.load_from_file() + + # This is called when the button is clicked and the custom_id matches the template. + @classmethod + async def from_custom_id(cls, intrctn: discord.Interaction, item: discord.ui.Button, match: re.Match[str], /): + min = int(match['min']) + return cls(min) + + async def callback(self, intrctn: discord.Interaction) -> None: + """Match up people when the button is pressed""" + + logger.info("Handling button press min=%s", self.min) + logger.info("User %s from %s in #%s", intrctn.user, + intrctn.guild.name, intrctn.channel.name) + + # Let the user know we've recieved the message + await intrctn.response.send_message(content="Matchy is matching matchees...", ephemeral=True) + + # Perform the match + await matching.match_groups_in_channel(self.state, intrctn.channel, self.min) diff --git a/py/matching.py b/py/matching.py index a3f08fd..49aa4d3 100644 --- a/py/matching.py +++ b/py/matching.py @@ -1,8 +1,10 @@ """Utility functions for matchy""" import logging +import discord from datetime import datetime, timedelta from typing import Protocol, runtime_checkable -import state +from state import State, save_to_file, ts_to_datetime +import util import config @@ -100,7 +102,7 @@ def get_member_group_eligibility_score(member: Member, def attempt_create_groups(matchees: list[Member], - current_state: state.State, + state: State, oldest_relevant_ts: datetime, per_group: int) -> tuple[bool, list[list[Member]]]: """History aware group matching""" @@ -115,10 +117,10 @@ def attempt_create_groups(matchees: list[Member], while matchees_left: # Get the next matchee to place matchee = matchees_left.pop() - matchee_matches = current_state.get_user_matches(matchee.id) + matchee_matches = state.get_user_matches(matchee.id) relevant_matches = [int(id) for id, ts in matchee_matches.items() - if state.ts_to_datetime(ts) >= oldest_relevant_ts] + if ts_to_datetime(ts) >= oldest_relevant_ts] # Try every single group from the current group onwards # Progressing through the groups like this ensures we slowly fill them up with compatible people @@ -164,7 +166,7 @@ def iterate_all_shifts(list: list): def members_to_groups(matchees: list[Member], - st: state.State = state.State(), + state: State = State(), per_group: int = 3, allow_fallback: bool = False) -> list[list[Member]]: """Generate the groups from the set of matchees""" @@ -176,14 +178,14 @@ def members_to_groups(matchees: list[Member], return [] # Walk from the start of history until now trying to match up groups - for oldest_relevant_datetime in st.get_history_timestamps(matchees) + [datetime.now()]: + for oldest_relevant_datetime in state.get_history_timestamps(matchees) + [datetime.now()]: # Attempt with each starting matchee for shifted_matchees in iterate_all_shifts(matchees): attempts += 1 groups = attempt_create_groups( - shifted_matchees, st, oldest_relevant_datetime, per_group) + shifted_matchees, state, oldest_relevant_datetime, per_group) # Fail the match if our groups aren't big enough if num_groups <= 1 or (groups and all(len(g) >= per_group for g in groups)): @@ -198,3 +200,49 @@ def members_to_groups(matchees: list[Member], # Simply assert false, this should never happen # And should be caught by tests assert False + + +async def match_groups_in_channel(state: State, channel: discord.channel, min: int): + """Match up the groups in a given channel""" + groups = active_members_to_groups(state, channel, min) + + # Send the groups + for group in groups: + + message = await channel.send( + f"Matched up {util.format_list([m.mention for m in group])}!") + + # Set up a thread for this match if the bot has permissions to do so + if channel.permissions_for(channel.guild.me).create_public_threads: + await channel.create_thread( + name=util.format_list([m.display_name for m in group]), + message=message, + reason="Creating a matching thread") + + # Close off with a message + await channel.send("That's all folks, happy matching and remember - DFTBA!") + + # Save the groups to the history + state.log_groups(groups) + save_to_file(state) + + logger.info("Done! Matched into %s groups.", len(groups)) + + +def get_matchees_in_channel(state: State, channel: discord.channel): + """Fetches the matchees in a channel""" + # Reactivate any unpaused users + state.reactivate_users(channel.id) + + # Gather up the prospective matchees + return [m for m in channel.members if state.get_user_active_in_channel(m.id, channel.id)] + + +def active_members_to_groups(state: State, channel: discord.channel, min_members: int): + """Helper to create groups from channel members""" + + # Gather up the prospective matchees + matchees = get_matchees_in_channel(state, channel) + + # Create our groups! + return members_to_groups(matchees, state, min_members, allow_fallback=True) diff --git a/py/matchy.py b/py/matchy.py index 2071a58..5a609a3 100755 --- a/py/matchy.py +++ b/py/matchy.py @@ -3,19 +3,14 @@ """ import logging import discord -from discord import app_commands -from discord.ext import commands, tasks -from datetime import datetime, timedelta, time -import matching -import state +from discord.ext import commands import config -import re -import util +import state +import cog +import match_button +State = state.load_from_file() -STATE_FILE = "state.json" - -State = state.load_from_file(STATE_FILE) logger = logging.getLogger("matchy") logger.setLevel(logging.INFO) @@ -29,16 +24,14 @@ bot = commands.Bot(command_prefix='$', @bot.event async def setup_hook(): - bot.add_dynamic_items(DynamicGroupButton) + await bot.add_cog(cog.MatchyCog(bot, State)) + # TODO: This line feels like it should be in the cog? + bot.add_dynamic_items(match_button.DynamicGroupButton) @bot.event async def on_ready(): - """Bot is ready and connected""" - run_hourly_tasks.start() - activity = discord.Game("/join") - await bot.change_presence(status=discord.Status.online, activity=activity) - logger.info("Bot is up and ready!") + logger.info("Logged in as %s", bot.user.name) def owner_only(ctx: commands.Context) -> bool: @@ -53,7 +46,7 @@ async def sync(ctx: commands.Context): """Handle sync command""" msg = await ctx.reply("Reloading state...", ephemeral=True) global State - State = state.load_from_file(STATE_FILE) + State = state.load_from_file() logger.info("Reloaded state") await msg.edit(content="Syncing commands...") @@ -73,270 +66,6 @@ async def close(ctx: commands.Context): await bot.close() -@bot.tree.command(description="Join the matchees for this channel") -@commands.guild_only() -async def join(interaction: discord.Interaction): - logger.info("Handling /join in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - State.set_user_active_in_channel( - interaction.user.id, interaction.channel.id) - state.save_to_file(State, STATE_FILE) - await interaction.response.send_message( - f"Roger roger {interaction.user.mention}!\n" - + f"Added you to {interaction.channel.mention}!", - ephemeral=True, silent=True) - - -@bot.tree.command(description="Leave the matchees for this channel") -@commands.guild_only() -async def leave(interaction: discord.Interaction): - logger.info("Handling /leave in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - State.set_user_active_in_channel( - interaction.user.id, interaction.channel.id, False) - state.save_to_file(State, STATE_FILE) - await interaction.response.send_message( - f"No worries {interaction.user.mention}. Come back soon :)", ephemeral=True, silent=True) - - -@bot.tree.command(description="Pause your matching in this channel for a number of days") -@commands.guild_only() -@app_commands.describe(days="Days to pause for (defaults to 7)") -async def pause(interaction: discord.Interaction, days: int | None = None): - logger.info("Handling /pause in %s %s from %s with days=%s", - interaction.guild.name, interaction.channel, interaction.user.name, days) - - if days is None: # Default to a week - days = 7 - until = datetime.now() + timedelta(days=days) - State.set_user_paused_in_channel( - interaction.user.id, interaction.channel.id, until) - state.save_to_file(State, STATE_FILE) - await interaction.response.send_message( - f"Sure thing {interaction.user.mention}!\n" - + f"Paused you until {util.format_day(until)}!", - ephemeral=True, silent=True) - - -@bot.tree.command(description="List the matchees for this channel") -@commands.guild_only() -async def list(interaction: discord.Interaction): - logger.info("Handling /list command in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - matchees = get_matchees_in_channel(interaction.channel) - mentions = [m.mention for m in matchees] - msg = "Current matchees in this channel:\n" + \ - f"{util.format_list(mentions)}" - - tasks = State.get_channel_match_tasks(interaction.channel.id) - for (day, hour, min) in tasks: - next_run = util.get_next_datetime(day, hour) - date_str = util.format_day(next_run) - msg += f"\nNext scheduled for {date_str} at {hour:02d}:00" - msg += f" with {min} members per group" - - await interaction.response.send_message(msg, ephemeral=True, silent=True) - - -@bot.tree.command(description="Schedule a match in this channel (UTC)") -@commands.guild_only() -@app_commands.describe(members_min="Minimum matchees per match (defaults to 3)", - weekday="Day of the week to run this (defaults 0, Monday)", - hour="Hour in the day (defaults to 9 utc)", - cancel="Cancel the scheduled match at this time") -async def schedule(interaction: discord.Interaction, - members_min: int | None = None, - weekday: int | None = None, - hour: int | None = None, - cancel: bool = False): - """Schedule a match using the input parameters""" - - # Set all the defaults - if not members_min: - members_min = 3 - if weekday is None: - weekday = 0 - if hour is None: - hour = 9 - channel_id = str(interaction.channel.id) - - # Bail if not a matcher - if not State.get_user_has_scope(interaction.user.id, state.AuthScope.MATCHER): - await interaction.response.send_message("You'll need the 'matcher' scope to schedule a match", - ephemeral=True, silent=True) - return - - # Add the scheduled task and save - success = State.set_channel_match_task( - channel_id, members_min, weekday, hour, not cancel) - state.save_to_file(State, STATE_FILE) - - # Let the user know what happened - if not cancel: - logger.info("Scheduled new match task in %s with min %s weekday %s hour %s", - channel_id, members_min, weekday, hour) - next_run = util.get_next_datetime(weekday, hour) - date_str = util.format_day(next_run) - - await interaction.response.send_message( - f"Done :) Next run will be on {date_str} at {hour:02d}:00\n" - + "Cancel this by re-sending the command with cancel=True", - ephemeral=True, silent=True) - - elif success: - logger.info("Removed task in %s on weekday %s hour %s", - channel_id, weekday, hour) - await interaction.response.send_message( - f"Done :) Schedule on day {weekday} and hour {hour} removed!", ephemeral=True, silent=True) - - else: - await interaction.response.send_message( - f"No schedule for this channel on day {weekday} and hour {hour} found :(", ephemeral=True, silent=True) - - -@bot.tree.command(description="Match up matchees") -@commands.guild_only() -@app_commands.describe(members_min="Minimum matchees per match (defaults to 3)") -async def match(interaction: discord.Interaction, members_min: int | None = None): - """Match groups of channel members""" - - logger.info("Handling request '/match group_min=%s", members_min) - logger.info("User %s from %s in #%s", interaction.user, - interaction.guild.name, interaction.channel.name) - - # Sort out the defaults, if not specified they'll come in as None - if not members_min: - members_min = 3 - - # Grab the groups - groups = active_members_to_groups(interaction.channel, members_min) - - # Let the user know when there's nobody to match - if not groups: - await interaction.response.send_message("Nobody to match up :(", ephemeral=True, silent=True) - return - - # Post about all the groups with a button to send to the channel - groups_list = '\n'.join(", ".join([m.mention for m in g]) for g in groups) - msg = f"Roger! I've generated example groups for ya:\n\n{groups_list}" - view = discord.utils.MISSING - - if State.get_user_has_scope(interaction.user.id, state.AuthScope.MATCHER): - # Otherwise set up the button - msg += "\n\nClick the button to match up groups and send them to the channel.\n" - view = discord.ui.View(timeout=None) - view.add_item(DynamicGroupButton(members_min)) - else: - # Let a non-matcher know why they don't have the button - msg += f"\n\nYou'll need the {state.AuthScope.MATCHER}" - + " scope to post this to the channel, sorry!" - - await interaction.response.send_message(msg, ephemeral=True, silent=True, view=view) - - logger.info("Done.") - - -# Increment when adjusting the custom_id so we don't confuse old users -_MATCH_BUTTON_CUSTOM_ID_VERSION = 1 -_MATCH_BUTTON_CUSTOM_ID_PREFIX = f'match:v{_MATCH_BUTTON_CUSTOM_ID_VERSION}:' - - -class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button], - template=_MATCH_BUTTON_CUSTOM_ID_PREFIX + r'min:(?P[0-9]+)'): - def __init__(self, min: int) -> None: - super().__init__( - discord.ui.Button( - label='Match Groups!', - style=discord.ButtonStyle.blurple, - custom_id=_MATCH_BUTTON_CUSTOM_ID_PREFIX + f'min:{min}', - ) - ) - self.min: int = min - - # This is called when the button is clicked and the custom_id matches the template. - @classmethod - async def from_custom_id(cls, intrctn: discord.Interaction, item: discord.ui.Button, match: re.Match[str], /): - min = int(match['min']) - return cls(min) - - async def callback(self, intrctn: discord.Interaction) -> None: - """Match up people when the button is pressed""" - - logger.info("Handling button press min=%s", self.min) - logger.info("User %s from %s in #%s", intrctn.user, - intrctn.guild.name, intrctn.channel.name) - - # Let the user know we've recieved the message - await intrctn.response.send_message(content="Matchy is matching matchees...", ephemeral=True) - - # Perform the match - await match_groups_in_channel(intrctn.channel, self.min) - - -async def match_groups_in_channel(channel: discord.channel, min: int): - """Match up the groups in a given channel""" - groups = active_members_to_groups(channel, min) - - # Send the groups - for group in groups: - - message = await channel.send( - f"Matched up {util.format_list([m.mention for m in group])}!") - - # Set up a thread for this match if the bot has permissions to do so - if channel.permissions_for(channel.guild.me).create_public_threads: - await channel.create_thread( - name=util.format_list([m.display_name for m in group]), - message=message, - reason="Creating a matching thread") - - # Close off with a message - await channel.send("That's all folks, happy matching and remember - DFTBA!") - - # Save the groups to the history - State.log_groups(groups) - state.save_to_file(State, STATE_FILE) - - logger.info("Done! Matched into %s groups.", len(groups)) - - -@tasks.loop(time=[time(hour=h) for h in range(24)]) -async def run_hourly_tasks(): - """Run any hourly tasks we have""" - for (channel, min) in State.get_active_match_tasks(): - logger.info("Scheduled match task triggered in %s", channel) - msg_channel = bot.get_channel(int(channel)) - await match_groups_in_channel(msg_channel, min) - - for (channel, _) in State.get_active_match_tasks(datetime.now() + timedelta(days=1)): - logger.info("Reminding about scheduled task in %s", channel) - msg_channel = bot.get_channel(int(channel)) - await msg_channel.send("Arf arf! just a reminder I'll be doin a matcherino in here in T-24hrs!" - + "\nUse /join if you haven't already, or /pause if you want to skip a week :)") - - -def get_matchees_in_channel(channel: discord.channel): - """Fetches the matchees in a channel""" - # Reactivate any unpaused users - State.reactivate_users(channel.id) - - # Gather up the prospective matchees - return [m for m in channel.members if State.get_user_active_in_channel(m.id, channel.id)] - - -def active_members_to_groups(channel: discord.channel, min_members: int): - """Helper to create groups from channel members""" - - # Gather up the prospective matchees - matchees = get_matchees_in_channel(channel) - - # Create our groups! - return matching.members_to_groups(matchees, State, min_members, allow_fallback=True) - - if __name__ == "__main__": handler = logging.StreamHandler() bot.run(config.Config.token, log_handler=handler, root_logger=True) diff --git a/py/matchy_cog.py b/py/matchy_cog.py new file mode 100644 index 0000000..3398e97 --- /dev/null +++ b/py/matchy_cog.py @@ -0,0 +1,208 @@ +""" +Matchy bot cog +""" +import logging +import discord +from discord import app_commands +from discord.ext import commands, tasks +from datetime import datetime, timedelta, time + +import match_button +import matching +from state import State, save_to_file, AuthScope +import util + +logger = logging.getLogger("cog") +logger.setLevel(logging.INFO) + + +class MatchyCog(commands.Cog): + def __init__(self, bot: commands.Bot, state: State): + self.bot = bot + self.state = state + + @commands.Cog.listener() + async def on_ready(self): + """Bot is ready and connected""" + self.run_hourly_tasks.start() + activity = discord.Game("/join") + await self.bot.change_presence(status=discord.Status.online, activity=activity) + logger.info("Bot is up and ready!") + + @app_commands.command(description="Join the matchees for this channel") + @commands.guild_only() + async def join(self, interaction: discord.Interaction): + logger.info("Handling /join in %s %s from %s", + interaction.guild.name, interaction.channel, interaction.user.name) + + self.state.set_user_active_in_channel( + interaction.user.id, interaction.channel.id) + save_to_file(self.state) + await interaction.response.send_message( + f"Roger roger {interaction.user.mention}!\n" + + f"Added you to {interaction.channel.mention}!", + ephemeral=True, silent=True) + + @app_commands.command(description="Leave the matchees for this channel") + @commands.guild_only() + async def leave(self, interaction: discord.Interaction): + logger.info("Handling /leave in %s %s from %s", + interaction.guild.name, interaction.channel, interaction.user.name) + + self.state.set_user_active_in_channel( + interaction.user.id, interaction.channel.id, False) + save_to_file(self.state) + await interaction.response.send_message( + f"No worries {interaction.user.mention}. Come back soon :)", ephemeral=True, silent=True) + + @app_commands.command(description="Pause your matching in this channel for a number of days") + @commands.guild_only() + @app_commands.describe(days="Days to pause for (defaults to 7)") + async def pause(self, interaction: discord.Interaction, days: int | None = None): + logger.info("Handling /pause in %s %s from %s with days=%s", + interaction.guild.name, interaction.channel, interaction.user.name, days) + + if days is None: # Default to a week + days = 7 + until = datetime.now() + timedelta(days=days) + self.state.set_user_paused_in_channel( + interaction.user.id, interaction.channel.id, until) + save_to_file(self.state) + await interaction.response.send_message( + f"Sure thing {interaction.user.mention}!\n" + + f"Paused you until {util.format_day(until)}!", + ephemeral=True, silent=True) + + @app_commands.command(description="List the matchees for this channel") + @commands.guild_only() + async def list(self, interaction: discord.Interaction): + logger.info("Handling /list command in %s %s from %s", + interaction.guild.name, interaction.channel, interaction.user.name) + + matchees = matching.get_matchees_in_channel(self.state, interaction.channel) + mentions = [m.mention for m in matchees] + msg = "Current matchees in this channel:\n" + \ + f"{util.format_list(mentions)}" + + tasks = self.state.get_channel_match_tasks(interaction.channel.id) + for (day, hour, min) in tasks: + next_run = util.get_next_datetime(day, hour) + date_str = util.format_day(next_run) + msg += f"\nNext scheduled for {date_str} at {hour:02d}:00" + msg += f" with {min} members per group" + + await interaction.response.send_message(msg, ephemeral=True, silent=True) + + @app_commands.command(description="Schedule a match in this channel (UTC)") + @commands.guild_only() + @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)", + weekday="Day of the week to run this (defaults 0, Monday)", + hour="Hour in the day (defaults to 9 utc)", + cancel="Cancel the scheduled match at this time") + async def schedule(self, + interaction: discord.Interaction, + members_min: int | None = None, + weekday: int | None = None, + hour: int | None = None, + cancel: bool = False): + """Schedule a match using the input parameters""" + + # Set all the defaults + if not members_min: + members_min = 3 + if weekday is None: + weekday = 0 + if hour is None: + hour = 9 + channel_id = str(interaction.channel.id) + + # Bail if not a matcher + if not self.state.get_user_has_scope(interaction.user.id, AuthScope.MATCHER): + await interaction.response.send_message("You'll need the 'matcher' scope to schedule a match", + ephemeral=True, silent=True) + return + + # Add the scheduled task and save + success = self.state.set_channel_match_task( + channel_id, members_min, weekday, hour, not cancel) + save_to_file(self.state) + + # Let the user know what happened + if not cancel: + logger.info("Scheduled new match task in %s with min %s weekday %s hour %s", + channel_id, members_min, weekday, hour) + next_run = util.get_next_datetime(weekday, hour) + date_str = util.format_day(next_run) + + await interaction.response.send_message( + f"Done :) Next run will be on {date_str} at {hour:02d}:00\n" + + "Cancel this by re-sending the command with cancel=True", + ephemeral=True, silent=True) + + elif success: + logger.info("Removed task in %s on weekday %s hour %s", + channel_id, weekday, hour) + await interaction.response.send_message( + f"Done :) Schedule on day {weekday} and hour {hour} removed!", ephemeral=True, silent=True) + + else: + await interaction.response.send_message( + f"No schedule for this channel on day {weekday} and hour {hour} found :(", ephemeral=True, silent=True) + + @app_commands.command(description="Match up matchees") + @commands.guild_only() + @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)") + async def match(self, interaction: discord.Interaction, members_min: int | None = None): + """Match groups of channel members""" + + logger.info("Handling request '/match group_min=%s", members_min) + logger.info("User %s from %s in #%s", interaction.user, + interaction.guild.name, interaction.channel.name) + + # Sort out the defaults, if not specified they'll come in as None + if not members_min: + members_min = 3 + + # Grab the groups + groups = matching.active_members_to_groups( + self.state, interaction.channel, members_min) + + # Let the user know when there's nobody to match + if not groups: + await interaction.response.send_message("Nobody to match up :(", ephemeral=True, silent=True) + return + + # Post about all the groups with a button to send to the channel + groups_list = '\n'.join( + ", ".join([m.mention for m in g]) for g in groups) + msg = f"Roger! I've generated example groups for ya:\n\n{groups_list}" + view = discord.utils.MISSING + + if self.state.get_user_has_scope(interaction.user.id, AuthScope.MATCHER): + # Otherwise set up the button + msg += "\n\nClick the button to match up groups and send them to the channel.\n" + view = discord.ui.View(timeout=None) + view.add_item(match_button.DynamicGroupButton(members_min)) + else: + # Let a non-matcher know why they don't have the button + msg += f"\n\nYou'll need the {AuthScope.MATCHER}" + + " scope to post this to the channel, sorry!" + + await interaction.response.send_message(msg, ephemeral=True, silent=True, view=view) + + logger.info("Done.") + + @tasks.loop(time=[time(hour=h) for h in range(24)]) + async def run_hourly_tasks(self): + """Run any hourly tasks we have""" + + for (channel, min) in self.state.get_active_match_tasks(): + logger.info("Scheduled match task triggered in %s", channel) + msg_channel = self.bot.get_channel(int(channel)) + await matching.match_groups_in_channel(self.state, msg_channel, min) + + for (channel, _) in self.state.get_active_match_tasks(datetime.now() + timedelta(days=1)): + logger.info("Reminding about scheduled task in %s", channel) + msg_channel = self.bot.get_channel(int(channel)) + await msg_channel.send("Arf arf! just a reminder I'll be doin a matcherino in here in T-24hrs!" + + "\nUse /join if you haven't already, or /pause if you want to skip a week :)") diff --git a/py/matchy_test.py b/py/matchy_test.py new file mode 100644 index 0000000..35a2cc8 --- /dev/null +++ b/py/matchy_test.py @@ -0,0 +1,56 @@ +import pytest +import discord +from discord.ext import commands +from unittest.mock import AsyncMock, MagicMock + +# Import your bot instance and commands module +from my_bot import bot + +@pytest.fixture +def bot_instance(): + # Setup a test bot instance + bot = commands.Bot(command_prefix="!") + + # Mock bot's guild and channel + guild = MagicMock() + guild.id = 1234567890 + + channel = MagicMock() + channel.id = 9876543210 + channel.guild = guild + channel.send = AsyncMock() # Mock send method + + bot.add_guild(guild) + bot.add_cog(MyCog(bot)) # Example of adding a cog + + return bot, channel + +@pytest.mark.asyncio +async def test_hello_command(bot_instance): + bot, channel = bot_instance + + # Simulate sending a message + message = MagicMock() + message.content = "!hello" + message.channel = channel + message.author = MagicMock() + message.author.bot = False # Ensure the author is not a bot + + # Dispatch the message to trigger the command + await bot.process_commands(message) + + # Check if the bot sent a response + channel.send.assert_called_once_with("Hello, World!") + +@pytest.mark.asyncio +async def test_on_member_join(bot_instance): + bot, channel = bot_instance + + # Simulate a member joining + member = MagicMock() + member.guild = channel.guild + + await bot.on_member_join(member) + + # Check if the bot welcomed the new member + channel.send.assert_called_once_with(f"Welcome {member.mention} to {channel.guild.name}!") diff --git a/py/state.py b/py/state.py index 35a2366..74a5acb 100644 --- a/py/state.py +++ b/py/state.py @@ -13,6 +13,10 @@ logger = logging.getLogger("state") logger.setLevel(logging.INFO) +# Location of the default state file +_STATE_FILE = "state.json" + + # Warning: Changing any of the below needs proper thought to ensure backwards compatibility _VERSION = 4 @@ -373,7 +377,7 @@ def _migrate(dict: dict): dict[_Key.VERSION] = _VERSION -def load_from_file(file: str) -> State: +def load_from_file(file: str = _STATE_FILE) -> State: """ Load the state from a file Apply any required migrations @@ -393,6 +397,6 @@ def load_from_file(file: str) -> State: return st -def save_to_file(state: State, file: str): +def save_to_file(state: State, file: str = _STATE_FILE): """Saves the state out to a file""" files.save(file, state.dict_internal_copy) From f4f6df90fbf6e17a2bfeaf72bff922ea1dec4176 Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 20:28:10 +0100 Subject: [PATCH 02/10] Pull out the owner cog parts Also make them use is_owner() rather than custom auth --- py/matchy.py | 38 ++++---------------------------------- py/owner_cog.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 34 deletions(-) create mode 100644 py/owner_cog.py diff --git a/py/matchy.py b/py/matchy.py index 5a609a3..490ef6c 100755 --- a/py/matchy.py +++ b/py/matchy.py @@ -6,7 +6,8 @@ import discord from discord.ext import commands import config import state -import cog +from matchy_cog import MatchyCog +from owner_cog import OwnerCog import match_button State = state.load_from_file() @@ -24,7 +25,8 @@ bot = commands.Bot(command_prefix='$', @bot.event async def setup_hook(): - await bot.add_cog(cog.MatchyCog(bot, State)) + await bot.add_cog(MatchyCog(bot, State)) + await bot.add_cog(OwnerCog(bot)) # TODO: This line feels like it should be in the cog? bot.add_dynamic_items(match_button.DynamicGroupButton) @@ -34,38 +36,6 @@ async def on_ready(): logger.info("Logged in as %s", bot.user.name) -def owner_only(ctx: commands.Context) -> bool: - """Checks the author is an owner""" - return State.get_user_has_scope(ctx.message.author.id, state.AuthScope.OWNER) - - -@bot.command() -@commands.dm_only() -@commands.check(owner_only) -async def sync(ctx: commands.Context): - """Handle sync command""" - msg = await ctx.reply("Reloading state...", ephemeral=True) - global State - State = state.load_from_file() - logger.info("Reloaded state") - - await msg.edit(content="Syncing commands...") - synced = await bot.tree.sync() - logger.info("Synced %s command(s)", len(synced)) - - await msg.edit(content="Done!") - - -@bot.command() -@commands.dm_only() -@commands.check(owner_only) -async def close(ctx: commands.Context): - """Handle restart command""" - await ctx.reply("Closing bot...", ephemeral=True) - logger.info("Closing down the bot") - await bot.close() - - if __name__ == "__main__": handler = logging.StreamHandler() bot.run(config.Config.token, log_handler=handler, root_logger=True) diff --git a/py/owner_cog.py b/py/owner_cog.py new file mode 100644 index 0000000..06b677b --- /dev/null +++ b/py/owner_cog.py @@ -0,0 +1,32 @@ +""" +Owner bot cog +""" +import logging +from discord.ext import commands + +logger = logging.getLogger("owner") +logger.setLevel(logging.INFO) + + +class OwnerCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command() + @commands.dm_only() + @commands.is_owner() + async def sync(self, ctx: commands.Context): + """Handle sync command""" + msg = await ctx.reply(content="Syncing commands...", ephemeral=True) + synced = await self.bot.tree.sync() + logger.info("Synced %s command(s)", len(synced)) + await msg.edit(content="Done!") + + @commands.command() + @commands.dm_only() + @commands.is_owner() + async def close(self, ctx: commands.Context): + """Handle restart command""" + await ctx.reply("Closing bot...", ephemeral=True) + logger.info("Closing down the bot") + await self.bot.close() From f2bfa6d33c552d42b90ace8b014d803697afb3f1 Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 20:29:26 +0100 Subject: [PATCH 03/10] Remove the OWNER scope as it's no longer used --- py/matching_test.py | 5 +---- py/state.py | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/py/matching_test.py b/py/matching_test.py index f9942de..29e6e2b 100644 --- a/py/matching_test.py +++ b/py/matching_test.py @@ -397,13 +397,10 @@ def test_auth_scopes(): tmp_state = state.State() id = "1" - tmp_state.set_user_scope(id, state.AuthScope.OWNER) - assert tmp_state.get_user_has_scope(id, state.AuthScope.OWNER) - assert tmp_state.get_user_has_scope(id, state.AuthScope.MATCHER) + assert not tmp_state.get_user_has_scope(id, state.AuthScope.MATCHER) id = "2" tmp_state.set_user_scope(id, state.AuthScope.MATCHER) - assert not tmp_state.get_user_has_scope(id, state.AuthScope.OWNER) assert tmp_state.get_user_has_scope(id, state.AuthScope.MATCHER) tmp_state.validate() diff --git a/py/state.py b/py/state.py index 74a5acb..81aafae 100644 --- a/py/state.py +++ b/py/state.py @@ -77,7 +77,6 @@ _MIGRATIONS = [ class AuthScope(str): """Various auth scopes""" - OWNER = "owner" MATCHER = "matcher" @@ -239,7 +238,7 @@ class State(): """ user = self._users.get(str(id), {}) scopes = user.get(_Key.SCOPES, []) - return AuthScope.OWNER in scopes or scope in scopes + return scope in scopes def set_user_active_in_channel(self, id: str, channel_id: str, active: bool = True): """Set a user as active (or not) on a given channel""" From f25dc8db350cdaf53b2d2df42bf5c1db7e771db4 Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 22:34:02 +0100 Subject: [PATCH 04/10] Add a very basic ownership validation for the owner cog --- README.md | 2 +- py/cog.py | 207 ------------------------------------------- py/matchy_test.py | 56 ------------ py/owner_cog_test.py | 34 +++++++ requirements.txt | 2 + 5 files changed, 37 insertions(+), 264 deletions(-) delete mode 100644 py/cog.py delete mode 100644 py/matchy_test.py create mode 100644 py/owner_cog_test.py diff --git a/README.md b/README.md index d510c1c..11dedbf 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ while ./scripts/run.py; end State is stored locally in a `state.json` file. This will be created by the bot. This stores historical information on users, maching schedules, user auth scopes and more. See [`py/state.py`](py/state.py) for schema information if you need to inspect it. ## TODO -* Write integration tests (maybe with [dpytest](https://dpytest.readthedocs.io/en/latest/tutorials/getting_started.html)?) +* Rethink the matcher scope, seems like maybe this could be simpler or removed * Implement a .json file upgrade test * Track if matches were successful * Improve the weirdo diff --git a/py/cog.py b/py/cog.py deleted file mode 100644 index ff43320..0000000 --- a/py/cog.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Matchy bot cog -""" -import logging -import discord -from discord import app_commands -from discord.ext import commands, tasks -from datetime import datetime, timedelta, time - -import match_button -import matching -from state import State, save_to_file, AuthScope -import util - -logger = logging.getLogger("cog") -logger.setLevel(logging.INFO) - - -class MatchyCog(commands.Cog): - def __init__(self, bot: commands.Bot, state: State): - self.bot = bot - self.state = state - - @commands.Cog.listener() - async def on_ready(self): - """Bot is ready and connected""" - self.run_hourly_tasks.start() - activity = discord.Game("/join") - await self.bot.change_presence(status=discord.Status.online, activity=activity) - logger.info("Bot is up and ready!") - - @app_commands.command(description="Join the matchees for this channel") - @commands.guild_only() - async def join(self, interaction: discord.Interaction): - logger.info("Handling /join in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - self.state.set_user_active_in_channel( - interaction.user.id, interaction.channel.id) - save_to_file(self.state) - await interaction.response.send_message( - f"Roger roger {interaction.user.mention}!\n" - + f"Added you to {interaction.channel.mention}!", - ephemeral=True, silent=True) - - @app_commands.command(description="Leave the matchees for this channel") - @commands.guild_only() - async def leave(self, interaction: discord.Interaction): - logger.info("Handling /leave in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - self.state.set_user_active_in_channel( - interaction.user.id, interaction.channel.id, False) - save_to_file(self.state) - await interaction.response.send_message( - f"No worries {interaction.user.mention}. Come back soon :)", ephemeral=True, silent=True) - - @app_commands.command(description="Pause your matching in this channel for a number of days") - @commands.guild_only() - @app_commands.describe(days="Days to pause for (defaults to 7)") - async def pause(self, interaction: discord.Interaction, days: int | None = None): - logger.info("Handling /pause in %s %s from %s with days=%s", - interaction.guild.name, interaction.channel, interaction.user.name, days) - - if days is None: # Default to a week - days = 7 - until = datetime.now() + timedelta(days=days) - self.state.set_user_paused_in_channel( - interaction.user.id, interaction.channel.id, until) - save_to_file(self.state) - await interaction.response.send_message( - f"Sure thing {interaction.user.mention}!\n" - + f"Paused you until {util.format_day(until)}!", - ephemeral=True, silent=True) - - @app_commands.command(description="List the matchees for this channel") - @commands.guild_only() - async def list(self, interaction: discord.Interaction): - logger.info("Handling /list command in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - matchees = matching.get_matchees_in_channel(self.state, interaction.channel) - mentions = [m.mention for m in matchees] - msg = "Current matchees in this channel:\n" + \ - f"{util.format_list(mentions)}" - - tasks = self.state.get_channel_match_tasks(interaction.channel.id) - for (day, hour, min) in tasks: - next_run = util.get_next_datetime(day, hour) - date_str = util.format_day(next_run) - msg += f"\nNext scheduled for {date_str} at {hour:02d}:00" - msg += f" with {min} members per group" - - await interaction.response.send_message(msg, ephemeral=True, silent=True) - - @app_commands.command(description="Schedule a match in this channel (UTC)") - @commands.guild_only() - @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)", - weekday="Day of the week to run this (defaults 0, Monday)", - hour="Hour in the day (defaults to 9 utc)", - cancel="Cancel the scheduled match at this time") - async def schedule(self, - interaction: discord.Interaction, - members_min: int | None = None, - weekday: int | None = None, - hour: int | None = None, - cancel: bool = False): - """Schedule a match using the input parameters""" - - # Set all the defaults - if not members_min: - members_min = 3 - if weekday is None: - weekday = 0 - if hour is None: - hour = 9 - channel_id = str(interaction.channel.id) - - # Bail if not a matcher - if not self.state.get_user_has_scope(interaction.user.id, AuthScope.MATCHER): - await interaction.response.send_message("You'll need the 'matcher' scope to schedule a match", - ephemeral=True, silent=True) - return - - # Add the scheduled task and save - success = self.state.set_channel_match_task( - channel_id, members_min, weekday, hour, not cancel) - save_to_file(self.state) - - # Let the user know what happened - if not cancel: - logger.info("Scheduled new match task in %s with min %s weekday %s hour %s", - channel_id, members_min, weekday, hour) - next_run = util.get_next_datetime(weekday, hour) - date_str = util.format_day(next_run) - - await interaction.response.send_message( - f"Done :) Next run will be on {date_str} at {hour:02d}:00\n" - + "Cancel this by re-sending the command with cancel=True", - ephemeral=True, silent=True) - - elif success: - logger.info("Removed task in %s on weekday %s hour %s", - channel_id, weekday, hour) - await interaction.response.send_message( - f"Done :) Schedule on day {weekday} and hour {hour} removed!", ephemeral=True, silent=True) - - else: - await interaction.response.send_message( - f"No schedule for this channel on day {weekday} and hour {hour} found :(", ephemeral=True, silent=True) - - @app_commands.command(description="Match up matchees") - @commands.guild_only() - @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)") - async def match(self, interaction: discord.Interaction, members_min: int | None = None): - """Match groups of channel members""" - - logger.info("Handling request '/match group_min=%s", members_min) - logger.info("User %s from %s in #%s", interaction.user, - interaction.guild.name, interaction.channel.name) - - # Sort out the defaults, if not specified they'll come in as None - if not members_min: - members_min = 3 - - # Grab the groups - groups = matching.active_members_to_groups( - self.state, interaction.channel, members_min) - - # Let the user know when there's nobody to match - if not groups: - await interaction.response.send_message("Nobody to match up :(", ephemeral=True, silent=True) - return - - # Post about all the groups with a button to send to the channel - groups_list = '\n'.join( - ", ".join([m.mention for m in g]) for g in groups) - msg = f"Roger! I've generated example groups for ya:\n\n{groups_list}" - view = discord.utils.MISSING - - if self.state.get_user_has_scope(interaction.user.id, AuthScope.MATCHER): - # Otherwise set up the button - msg += "\n\nClick the button to match up groups and send them to the channel.\n" - view = discord.ui.View(timeout=None) - view.add_item(match_button.DynamicGroupButton(members_min)) - else: - # Let a non-matcher know why they don't have the button - msg += f"\n\nYou'll need the {AuthScope.MATCHER}" - + " scope to post this to the channel, sorry!" - - await interaction.response.send_message(msg, ephemeral=True, silent=True, view=view) - - logger.info("Done.") - - @tasks.loop(time=[time(hour=h) for h in range(24)]) - async def run_hourly_tasks(self): - """Run any hourly tasks we have""" - for (channel, min) in self.state.get_active_match_tasks(): - logger.info("Scheduled match task triggered in %s", channel) - msg_channel = self.bot.get_channel(int(channel)) - await matching.match_groups_in_channel(self.state, msg_channel, min) - - for (channel, _) in self.state.get_active_match_tasks(datetime.now() + timedelta(days=1)): - logger.info("Reminding about scheduled task in %s", channel) - msg_channel = self.bot.get_channel(int(channel)) - await msg_channel.send("Arf arf! just a reminder I'll be doin a matcherino in here in T-24hrs!" - + "\nUse /join if you haven't already, or /pause if you want to skip a week :)") diff --git a/py/matchy_test.py b/py/matchy_test.py deleted file mode 100644 index 35a2cc8..0000000 --- a/py/matchy_test.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -import discord -from discord.ext import commands -from unittest.mock import AsyncMock, MagicMock - -# Import your bot instance and commands module -from my_bot import bot - -@pytest.fixture -def bot_instance(): - # Setup a test bot instance - bot = commands.Bot(command_prefix="!") - - # Mock bot's guild and channel - guild = MagicMock() - guild.id = 1234567890 - - channel = MagicMock() - channel.id = 9876543210 - channel.guild = guild - channel.send = AsyncMock() # Mock send method - - bot.add_guild(guild) - bot.add_cog(MyCog(bot)) # Example of adding a cog - - return bot, channel - -@pytest.mark.asyncio -async def test_hello_command(bot_instance): - bot, channel = bot_instance - - # Simulate sending a message - message = MagicMock() - message.content = "!hello" - message.channel = channel - message.author = MagicMock() - message.author.bot = False # Ensure the author is not a bot - - # Dispatch the message to trigger the command - await bot.process_commands(message) - - # Check if the bot sent a response - channel.send.assert_called_once_with("Hello, World!") - -@pytest.mark.asyncio -async def test_on_member_join(bot_instance): - bot, channel = bot_instance - - # Simulate a member joining - member = MagicMock() - member.guild = channel.guild - - await bot.on_member_join(member) - - # Check if the bot welcomed the new member - channel.send.assert_called_once_with(f"Welcome {member.mention} to {channel.guild.name}!") diff --git a/py/owner_cog_test.py b/py/owner_cog_test.py new file mode 100644 index 0000000..7fdbd70 --- /dev/null +++ b/py/owner_cog_test.py @@ -0,0 +1,34 @@ +import discord +import discord.ext.commands as commands +import pytest +import pytest_asyncio +import discord.ext.test as dpytest + +from owner_cog import OwnerCog + +# Primarily borrowing from https://dpytest.readthedocs.io/en/latest/tutorials/using_pytest.html +# TODO: Test more somehow, though it seems like dpytest is pretty incomplete + + +@pytest_asyncio.fixture +async def bot(): + # Setup + intents = discord.Intents.default() + intents.members = True + intents.message_content = True + b = commands.Bot(command_prefix="$", + intents=intents) + await b._async_setup_hook() + await b.add_cog(OwnerCog(b)) + dpytest.configure(b) + yield b + await dpytest.empty_queue() + + +@pytest.mark.asyncio +async def test_must_be_owner(bot): + with pytest.raises(commands.errors.NotOwner): + await dpytest.message("$sync") + + with pytest.raises(commands.errors.NotOwner): + await dpytest.message("$close") diff --git a/requirements.txt b/requirements.txt index cdc2eb2..9cc1b39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ attrs==24.2.0 autopep8==2.3.1 coverage==7.6.1 discord.py==2.4.0 +dpytest==0.7.0 flake8==7.1.1 frozenlist==1.4.1 gitdb==4.0.11 @@ -19,6 +20,7 @@ pluggy==1.5.0 pycodestyle==2.12.1 pyflakes==3.2.0 pytest==8.3.2 +pytest-asyncio==0.23.8 pytest-cov==5.0.0 schema==0.7.7 smmap==5.0.1 From 93337ebde97c2b7054b2354daa23b00b686f3352 Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 22:42:32 +0100 Subject: [PATCH 05/10] Add the coverage file to the ignore list --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2296932..0c813ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ config.json state.json -.venv \ No newline at end of file +.venv +.coverage \ No newline at end of file From d3cdec3965279cb61ab99b361010b0aa4afecde7 Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 22:42:52 +0100 Subject: [PATCH 06/10] Move the cogs into a subdir --- py/{ => cogs}/match_button.py | 0 py/{ => cogs}/matchy_cog.py | 0 py/{ => cogs}/owner_cog.py | 0 py/{ => cogs}/owner_cog_test.py | 0 py/matchy.py | 6 +++--- py/util.py | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) rename py/{ => cogs}/match_button.py (100%) rename py/{ => cogs}/matchy_cog.py (100%) rename py/{ => cogs}/owner_cog.py (100%) rename py/{ => cogs}/owner_cog_test.py (100%) diff --git a/py/match_button.py b/py/cogs/match_button.py similarity index 100% rename from py/match_button.py rename to py/cogs/match_button.py diff --git a/py/matchy_cog.py b/py/cogs/matchy_cog.py similarity index 100% rename from py/matchy_cog.py rename to py/cogs/matchy_cog.py diff --git a/py/owner_cog.py b/py/cogs/owner_cog.py similarity index 100% rename from py/owner_cog.py rename to py/cogs/owner_cog.py diff --git a/py/owner_cog_test.py b/py/cogs/owner_cog_test.py similarity index 100% rename from py/owner_cog_test.py rename to py/cogs/owner_cog_test.py diff --git a/py/matchy.py b/py/matchy.py index 490ef6c..e0a688a 100755 --- a/py/matchy.py +++ b/py/matchy.py @@ -6,9 +6,9 @@ import discord from discord.ext import commands import config import state -from matchy_cog import MatchyCog -from owner_cog import OwnerCog -import match_button +from cogs.matchy_cog import MatchyCog +from cogs.owner_cog import OwnerCog +import cogs.match_button as match_button State = state.load_from_file() diff --git a/py/util.py b/py/util.py index 4d3973c..9b28f6d 100644 --- a/py/util.py +++ b/py/util.py @@ -21,7 +21,7 @@ def format_day(time: datetime) -> str: return f"{day} {num}" -def format_list(list) -> str: +def format_list(list: list) -> str: """Format a list into a human readable format of foo, bar and bob""" if len(list) > 1: return f"{', '.join(list[:-1])} and {list[-1]}" From d856b74d1cb3002132b7a68fb47783d45c2d5d28 Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 23:00:43 +0100 Subject: [PATCH 07/10] Fix the format breaking the match print again --- py/cogs/matchy_cog.py | 10 ++++++---- py/matchy.py | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/py/cogs/matchy_cog.py b/py/cogs/matchy_cog.py index 3398e97..b7dd0ee 100644 --- a/py/cogs/matchy_cog.py +++ b/py/cogs/matchy_cog.py @@ -7,7 +7,7 @@ from discord import app_commands from discord.ext import commands, tasks from datetime import datetime, timedelta, time -import match_button +import cogs.match_button as match_button import matching from state import State, save_to_file, AuthScope import util @@ -25,6 +25,7 @@ class MatchyCog(commands.Cog): async def on_ready(self): """Bot is ready and connected""" self.run_hourly_tasks.start() + self.bot.add_dynamic_items(match_button.DynamicGroupButton) activity = discord.Game("/join") await self.bot.change_presence(status=discord.Status.online, activity=activity) logger.info("Bot is up and ready!") @@ -79,7 +80,8 @@ class MatchyCog(commands.Cog): logger.info("Handling /list command in %s %s from %s", interaction.guild.name, interaction.channel, interaction.user.name) - matchees = matching.get_matchees_in_channel(self.state, interaction.channel) + matchees = matching.get_matchees_in_channel( + self.state, interaction.channel) mentions = [m.mention for m in matchees] msg = "Current matchees in this channel:\n" + \ f"{util.format_list(mentions)}" @@ -186,7 +188,7 @@ class MatchyCog(commands.Cog): else: # Let a non-matcher know why they don't have the button msg += f"\n\nYou'll need the {AuthScope.MATCHER}" - + " scope to post this to the channel, sorry!" + msg += " scope to post this to the channel, sorry!" await interaction.response.send_message(msg, ephemeral=True, silent=True, view=view) @@ -195,7 +197,7 @@ class MatchyCog(commands.Cog): @tasks.loop(time=[time(hour=h) for h in range(24)]) async def run_hourly_tasks(self): """Run any hourly tasks we have""" - + for (channel, min) in self.state.get_active_match_tasks(): logger.info("Scheduled match task triggered in %s", channel) msg_channel = self.bot.get_channel(int(channel)) diff --git a/py/matchy.py b/py/matchy.py index e0a688a..c40559c 100755 --- a/py/matchy.py +++ b/py/matchy.py @@ -27,8 +27,6 @@ bot = commands.Bot(command_prefix='$', async def setup_hook(): await bot.add_cog(MatchyCog(bot, State)) await bot.add_cog(OwnerCog(bot)) - # TODO: This line feels like it should be in the cog? - bot.add_dynamic_items(match_button.DynamicGroupButton) @bot.event From f65497b91e31f89157b9add0d769e99d74ec349e Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 23:03:21 +0100 Subject: [PATCH 08/10] Add a small TODO entry --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 11dedbf..4f89f6f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ while ./scripts/run.py; end State is stored locally in a `state.json` file. This will be created by the bot. This stores historical information on users, maching schedules, user auth scopes and more. See [`py/state.py`](py/state.py) for schema information if you need to inspect it. ## TODO +* Implement better tests to the discordy parts of the codebase * Rethink the matcher scope, seems like maybe this could be simpler or removed * Implement a .json file upgrade test * Track if matches were successful From 682d31afe418b4bef7836c2328fd0834beeb8191 Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 23:08:01 +0100 Subject: [PATCH 09/10] Use logger.warning to shut up deprecation warnings --- py/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/py/config.py b/py/config.py index d10dc28..6f5ec19 100644 --- a/py/config.py +++ b/py/config.py @@ -60,9 +60,9 @@ def _migrate_to_v1(d: dict): # Owners moved to History in v1 # Note: owners will be required to be re-added to the state.json owners = d.pop(_Key._OWNERS) - logger.warn( + logger.warning( "Migration removed owners from config, these must be re-added to the state.json") - logger.warn("Owners: %s", owners) + logger.warning("Owners: %s", owners) # Set of migration functions to apply @@ -126,7 +126,7 @@ def _load_from_file(file: str = _FILE) -> _Config: loaded = files.load(file) _migrate(loaded) else: - logger.warn("No %s file found, bot cannot run!", file) + logger.warning("No %s file found, bot cannot run!", file) return _Config(loaded) From 9131fa48cae6ed8fae047c1613704d855680aa79 Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Tue, 13 Aug 2024 23:08:50 +0100 Subject: [PATCH 10/10] Fix a couple of linting errors --- py/cogs/match_button.py | 3 +-- py/matchy.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/py/cogs/match_button.py b/py/cogs/match_button.py index d9705e3..ba46518 100644 --- a/py/cogs/match_button.py +++ b/py/cogs/match_button.py @@ -13,8 +13,7 @@ logger.setLevel(logging.INFO) # Increment when adjusting the custom_id so we don't confuse old users _MATCH_BUTTON_CUSTOM_ID_VERSION = 1 -_MATCH_BUTTON_CUSTOM_ID_PREFIX = f'match:v{ - _MATCH_BUTTON_CUSTOM_ID_VERSION}:' +_MATCH_BUTTON_CUSTOM_ID_PREFIX = f'match:v{_MATCH_BUTTON_CUSTOM_ID_VERSION}:' class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button], diff --git a/py/matchy.py b/py/matchy.py index c40559c..63f3f09 100755 --- a/py/matchy.py +++ b/py/matchy.py @@ -8,7 +8,6 @@ import config import state from cogs.matchy_cog import MatchyCog from cogs.owner_cog import OwnerCog -import cogs.match_button as match_button State = state.load_from_file()