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)