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 diff --git a/README.md b/README.md index d510c1c..4f89f6f 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ 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)?) +* 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 * Improve the weirdo diff --git a/py/cogs/match_button.py b/py/cogs/match_button.py new file mode 100644 index 0000000..ba46518 --- /dev/null +++ b/py/cogs/match_button.py @@ -0,0 +1,53 @@ +""" +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/cogs/matchy_cog.py b/py/cogs/matchy_cog.py new file mode 100644 index 0000000..b7dd0ee --- /dev/null +++ b/py/cogs/matchy_cog.py @@ -0,0 +1,210 @@ +""" +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 cogs.match_button as 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() + 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!") + + @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}" + msg += " 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/cogs/owner_cog.py b/py/cogs/owner_cog.py new file mode 100644 index 0000000..06b677b --- /dev/null +++ b/py/cogs/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() diff --git a/py/cogs/owner_cog_test.py b/py/cogs/owner_cog_test.py new file mode 100644 index 0000000..7fdbd70 --- /dev/null +++ b/py/cogs/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/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) 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/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/matchy.py b/py/matchy.py index 2071a58..63f3f09 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 +from cogs.matchy_cog import MatchyCog +from cogs.owner_cog import OwnerCog +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,312 +24,13 @@ bot = commands.Bot(command_prefix='$', @bot.event async def setup_hook(): - bot.add_dynamic_items(DynamicGroupButton) + await bot.add_cog(MatchyCog(bot, State)) + await bot.add_cog(OwnerCog(bot)) @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!") - - -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(STATE_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() - - -@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) + logger.info("Logged in as %s", bot.user.name) if __name__ == "__main__": diff --git a/py/state.py b/py/state.py index 35a2366..81aafae 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 @@ -73,7 +77,6 @@ _MIGRATIONS = [ class AuthScope(str): """Various auth scopes""" - OWNER = "owner" MATCHER = "matcher" @@ -235,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""" @@ -373,7 +376,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 +396,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) 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]}" 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