From 07485ceb8d31c004542262c0e879d028a46cd17d Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Mon, 12 Aug 2024 23:00:49 +0100 Subject: [PATCH] Implement /schedule Allows matchers to schedule repeated weekly runs on a given weekday and hour Can schedule multiple runs Scheduled runs can be cancelled with cancel:True in the command /list also shows any scheduled commands in that channel --- py/config.py | 14 +++--- py/matchy.py | 123 ++++++++++++++++++++++++++++++++++++++++++--------- py/state.py | 121 +++++++++++++++++++++++++++++++++++++++++++++----- py/util.py | 30 ++++++++++--- 4 files changed, 244 insertions(+), 44 deletions(-) diff --git a/py/config.py b/py/config.py index 5f6de44..0722a8f 100644 --- a/py/config.py +++ b/py/config.py @@ -1,5 +1,5 @@ """Very simple config loading library""" -from schema import Schema, And, Use, Optional +from schema import Schema, Use, Optional import files import os import logging @@ -32,19 +32,19 @@ class _Key(): _SCHEMA = Schema( { # The current version - _Key.VERSION: And(Use(int)), + _Key.VERSION: Use(int), # Discord bot token - _Key.TOKEN: And(Use(str)), + _Key.TOKEN: Use(str), # Settings for the match algorithmn, see matching.py for explanations on usage Optional(_Key.MATCH): { Optional(_Key.SCORE_FACTORS): { - Optional(_Key.REPEAT_ROLE): And(Use(int)), - Optional(_Key.REPEAT_MATCH): And(Use(int)), - Optional(_Key.EXTRA_MEMBER): And(Use(int)), - Optional(_Key.UPPER_THRESHOLD): And(Use(int)), + Optional(_Key.REPEAT_ROLE): Use(int), + Optional(_Key.REPEAT_MATCH): Use(int), + Optional(_Key.EXTRA_MEMBER): Use(int), + Optional(_Key.UPPER_THRESHOLD): Use(int), } } } diff --git a/py/matchy.py b/py/matchy.py index c5ad950..4f91fc4 100755 --- a/py/matchy.py +++ b/py/matchy.py @@ -4,7 +4,8 @@ import logging import discord from discord import app_commands -from discord.ext import commands +from discord.ext import commands, tasks +import datetime import matching import state import config @@ -34,9 +35,10 @@ async def setup_hook(): @bot.event async def on_ready(): """Bot is ready and connected""" - logger.info("Bot is up and ready!") + 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: @@ -109,17 +111,82 @@ async def pause(interaction: discord.Interaction, days: int = None): @bot.tree.command(description="List the matchees for this channel") @commands.guild_only() async def list(interaction: discord.Interaction): + matchees = get_matchees_in_channel(interaction.channel) mentions = [m.mention for m in matchees] msg = "Current matchees in this channel:\n" + \ f"{', '.join(mentions[:-1])} and {mentions[-1]}" + + 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 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): +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) @@ -191,29 +258,45 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button], # Let the user know we've recieved the message await intrctn.response.send_message(content="Matchy is matching matchees...", ephemeral=True) - groups = active_members_to_groups(intrctn.channel, self.min) + # Perform the match + await match_groups_in_channel(intrctn.channel, self.min) - # Send the groups - for idx, group in enumerate(groups): - message = await intrctn.channel.send( - f"Matched up {util.format_list([m.mention for m in group])}!") +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) - # Set up a thread for this match if the bot has permissions to do so - if intrctn.channel.permissions_for(intrctn.guild.me).create_public_threads: - await intrctn.channel.create_thread( - name=f"{util.format_list([m.display_name for m in group])}", - message=message, - reason="Creating a matching thread") + # Send the groups + for group in groups: - # Close off with a message - await intrctn.channel.send("That's all folks, happy matching and remember - DFTBA!") + message = await channel.send( + f"Matched up {util.format_list([m.mention for m in group])}!") - # Save the groups to the history - State.log_groups(groups) - state.save_to_file(State, STATE_FILE) + # 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=f"{util.format_list( + [m.display_name for m in group])}", + message=message, + reason="Creating a matching thread") - logger.info("Done! Matched into %s groups.", len(groups)) + # 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=[datetime.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_channel_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) def get_matchees_in_channel(channel: discord.channel): diff --git a/py/state.py b/py/state.py index 453a76e..50d556d 100644 --- a/py/state.py +++ b/py/state.py @@ -1,7 +1,8 @@ """Store bot state""" import os from datetime import datetime, timedelta -from schema import Schema, And, Use, Optional +from schema import Schema, Use, Optional +from collections.abc import Generator from typing import Protocol import files import copy @@ -13,7 +14,7 @@ logger.setLevel(logging.INFO) # Warning: Changing any of the below needs proper thought to ensure backwards compatibility -_VERSION = 2 +_VERSION = 3 def _migrate_to_v1(d: dict): @@ -51,10 +52,16 @@ def _migrate_to_v2(d: dict): channel[_Key.REACTIVATE] = old_to_new_ts(old_ts) +def _migrate_to_v3(d: dict): + """v3 simply added the tasks entry""" + d[_Key.TASKS] = {} + + # Set of migration functions to apply _MIGRATIONS = [ _migrate_to_v1, - _migrate_to_v2 + _migrate_to_v2, + _migrate_to_v3, ] @@ -66,16 +73,24 @@ class AuthScope(str): class _Key(str): """Various keys used in the schema""" + VERSION = "version" + HISTORY = "history" GROUPS = "groups" MEMBERS = "members" + USERS = "users" SCOPES = "scopes" MATCHES = "matches" ACTIVE = "active" CHANNELS = "channels" REACTIVATE = "reactivate" - VERSION = "version" + + TASKS = "tasks" + MATCH_TASKS = "match_tasks" + MEMBERS_MIN = "members_min" + WEEKDAY = "weekdays" + HOUR = "hours" # Unused _MATCHEES = "matchees" @@ -88,39 +103,54 @@ _TIME_FORMAT_OLD = "%a %b %d %H:%M:%S %Y" _SCHEMA = Schema( { # The current version - _Key.VERSION: And(Use(int)), + _Key.VERSION: Use(int), - Optional(_Key.HISTORY): { + _Key.HISTORY: { # A datetime Optional(str): { _Key.GROUPS: [ { _Key.MEMBERS: [ # The ID of each matchee in the match - And(Use(int)) + Use(int) ] } ] } }, - Optional(_Key.USERS): { + + _Key.USERS: { + # User ID as string Optional(str): { - Optional(_Key.SCOPES): And(Use(list[str])), + Optional(_Key.SCOPES): Use(list[str]), Optional(_Key.MATCHES): { # Matchee ID and Datetime pair - Optional(str): And(Use(str)) + Optional(str): Use(str) }, Optional(_Key.CHANNELS): { # The channel ID Optional(str): { # Whether the user is signed up in this channel - _Key.ACTIVE: And(Use(bool)), + _Key.ACTIVE: Use(bool), # A timestamp for when to re-activate the user - Optional(_Key.REACTIVATE): And(Use(str)), + Optional(_Key.REACTIVATE): Use(str), } } } }, + + _Key.TASKS: { + # Channel ID as string + Optional(str): { + Optional(_Key.MATCH_TASKS): [ + { + _Key.MEMBERS_MIN: Use(int), + _Key.WEEKDAY: Use(int), + _Key.HOUR: Use(int), + } + ] + } + } } ) @@ -128,6 +158,7 @@ _SCHEMA = Schema( _EMPTY_DICT = { _Key.HISTORY: {}, _Key.USERS: {}, + _Key.TASKS: {}, _Key.VERSION: _VERSION } assert _SCHEMA.validate(_EMPTY_DICT) @@ -254,6 +285,68 @@ class State(): if reactivate and datetime.now() > ts_to_datetime(reactivate): channel[_Key.ACTIVE] = True + def get_active_channel_match_tasks(self) -> Generator[str, int]: + """ + Get any currently active match tasks + returns list of channel,members_min pairs + """ + now = datetime.now() + weekday = now.weekday() + hour = now.hour + + for channel, tasks in self._tasks.items(): + for match in tasks.get(_Key.MATCH_TASKS, []): + if match[_Key.WEEKDAY] == weekday and match[_Key.HOUR] == hour: + yield (channel, match[_Key.MEMBERS_MIN]) + + def get_channel_match_tasks(self, channel_id: str) -> Generator[int, int, int]: + """ + Get all match tasks for the channel + """ + all_tasks = ( + tasks.get(_Key.MATCH_TASKS, []) + for channel, tasks in self._tasks.items() + if str(channel) == str(channel_id) + ) + for tasks in all_tasks: + for task in tasks: + yield (task[_Key.WEEKDAY], task[_Key.HOUR], task[_Key.MEMBERS_MIN]) + + def set_channel_match_task(self, channel_id: str, members_min: int, weekday: int, hour: int, set: bool) -> bool: + """Set up a match task on a channel""" + with self._safe_wrap() as safe_state: + channel = safe_state._tasks.get(str(channel_id), {}) + matches = channel.get(_Key.MATCH_TASKS, []) + + found = False + for match in matches: + # Specifically check for the combination of weekday and hour + if match[_Key.WEEKDAY] == weekday and match[_Key.HOUR] == hour: + found = True + if set: + match[_Key.MEMBERS_MIN] = members_min + else: + matches.remove(match) + + # Return true as we've successfully changed the data in place + return True + + # If we didn't find it, add it to the schedule + if not found and set: + matches.append({ + _Key.MEMBERS_MIN: members_min, + _Key.WEEKDAY: weekday, + _Key.HOUR: hour, + }) + + # Roll back out, saving the entries in case they're new + channel[_Key.MATCH_TASKS] = matches + safe_state._tasks[str(channel_id)] = channel + return True + + # We did not manage to remove the schedule (or add it? though that should be impossible) + return False + @property def dict_internal_copy(self) -> dict: """Only to be used to get the internal dict as a copy""" @@ -267,6 +360,10 @@ class State(): def _users(self) -> dict[str]: return self._dict[_Key.USERS] + @property + def _tasks(self) -> dict[str]: + return self._dict[_Key.TASKS] + def _set_user_channel_prop(self, id: str, channel_id: str, key: str, value): """Set a user channel property helper""" with self._safe_wrap() as safe_state: diff --git a/py/util.py b/py/util.py index 7b8036b..4d3973c 100644 --- a/py/util.py +++ b/py/util.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta def get_day_with_suffix(day): @@ -9,11 +9,15 @@ def get_day_with_suffix(day): return str(day) + {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') -def format_today(): +def format_today() -> str: """Format the current datetime""" - now = datetime.now() - num = get_day_with_suffix(now.day) - day = now.strftime("%a") + return format_day(datetime.now()) + + +def format_day(time: datetime) -> str: + """Format the a given datetime""" + num = get_day_with_suffix(time.day) + day = time.strftime("%a") return f"{day} {num}" @@ -22,3 +26,19 @@ def format_list(list) -> str: if len(list) > 1: return f"{', '.join(list[:-1])} and {list[-1]}" return list[0] if list else '' + + +def get_next_datetime(weekday, hour) -> datetime: + """Get the next datetime for the given weekday and hour""" + now = datetime.now() + days_until_next_week = (weekday - now.weekday() + 7) % 7 + + # Account for when we're already beyond the time now + if days_until_next_week == 0 and now.hour >= hour: + days_until_next_week = 7 + + # Calculate the next datetime + next_date = now + timedelta(days=days_until_next_week) + next_date.replace(hour=hour) + + return next_date