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
This commit is contained in:
Marc Di Luzio 2024-08-12 23:00:49 +01:00
parent 522f89cff9
commit 07485ceb8d
4 changed files with 244 additions and 44 deletions

View file

@ -1,5 +1,5 @@
"""Very simple config loading library""" """Very simple config loading library"""
from schema import Schema, And, Use, Optional from schema import Schema, Use, Optional
import files import files
import os import os
import logging import logging
@ -32,19 +32,19 @@ class _Key():
_SCHEMA = Schema( _SCHEMA = Schema(
{ {
# The current version # The current version
_Key.VERSION: And(Use(int)), _Key.VERSION: Use(int),
# Discord bot token # Discord bot token
_Key.TOKEN: And(Use(str)), _Key.TOKEN: Use(str),
# Settings for the match algorithmn, see matching.py for explanations on usage # Settings for the match algorithmn, see matching.py for explanations on usage
Optional(_Key.MATCH): { Optional(_Key.MATCH): {
Optional(_Key.SCORE_FACTORS): { Optional(_Key.SCORE_FACTORS): {
Optional(_Key.REPEAT_ROLE): And(Use(int)), Optional(_Key.REPEAT_ROLE): Use(int),
Optional(_Key.REPEAT_MATCH): And(Use(int)), Optional(_Key.REPEAT_MATCH): Use(int),
Optional(_Key.EXTRA_MEMBER): And(Use(int)), Optional(_Key.EXTRA_MEMBER): Use(int),
Optional(_Key.UPPER_THRESHOLD): And(Use(int)), Optional(_Key.UPPER_THRESHOLD): Use(int),
} }
} }
} }

View file

@ -4,7 +4,8 @@
import logging import logging
import discord import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands, tasks
import datetime
import matching import matching
import state import state
import config import config
@ -34,9 +35,10 @@ async def setup_hook():
@bot.event @bot.event
async def on_ready(): async def on_ready():
"""Bot is ready and connected""" """Bot is ready and connected"""
logger.info("Bot is up and ready!") run_hourly_tasks.start()
activity = discord.Game("/join") activity = discord.Game("/join")
await bot.change_presence(status=discord.Status.online, activity=activity) await bot.change_presence(status=discord.Status.online, activity=activity)
logger.info("Bot is up and ready!")
def owner_only(ctx: commands.Context) -> bool: 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") @bot.tree.command(description="List the matchees for this channel")
@commands.guild_only() @commands.guild_only()
async def list(interaction: discord.Interaction): async def list(interaction: discord.Interaction):
matchees = get_matchees_in_channel(interaction.channel) matchees = get_matchees_in_channel(interaction.channel)
mentions = [m.mention for m in matchees] mentions = [m.mention for m in matchees]
msg = "Current matchees in this channel:\n" + \ msg = "Current matchees in this channel:\n" + \
f"{', '.join(mentions[:-1])} and {mentions[-1]}" 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) 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") @bot.tree.command(description="Match up matchees")
@commands.guild_only() @commands.guild_only()
@app_commands.describe(members_min="Minimum matchees per match (defaults to 3)") @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""" """Match groups of channel members"""
logger.info("Handling request '/match group_min=%s", members_min) logger.info("Handling request '/match group_min=%s", members_min)
@ -191,23 +258,30 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button],
# Let the user know we've recieved the message # Let the user know we've recieved the message
await intrctn.response.send_message(content="Matchy is matching matchees...", ephemeral=True) 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)
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 # Send the groups
for idx, group in enumerate(groups): for group in groups:
message = await intrctn.channel.send( message = await channel.send(
f"Matched up {util.format_list([m.mention for m in group])}!") 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 # 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: if channel.permissions_for(channel.guild.me).create_public_threads:
await intrctn.channel.create_thread( await channel.create_thread(
name=f"{util.format_list([m.display_name for m in group])}", name=f"{util.format_list(
[m.display_name for m in group])}",
message=message, message=message,
reason="Creating a matching thread") reason="Creating a matching thread")
# Close off with a message # Close off with a message
await intrctn.channel.send("That's all folks, happy matching and remember - DFTBA!") await channel.send("That's all folks, happy matching and remember - DFTBA!")
# Save the groups to the history # Save the groups to the history
State.log_groups(groups) State.log_groups(groups)
@ -216,6 +290,15 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button],
logger.info("Done! Matched into %s groups.", len(groups)) 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): def get_matchees_in_channel(channel: discord.channel):
"""Fetches the matchees in a channel""" """Fetches the matchees in a channel"""
# Reactivate any unpaused users # Reactivate any unpaused users

View file

@ -1,7 +1,8 @@
"""Store bot state""" """Store bot state"""
import os import os
from datetime import datetime, timedelta 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 from typing import Protocol
import files import files
import copy import copy
@ -13,7 +14,7 @@ logger.setLevel(logging.INFO)
# Warning: Changing any of the below needs proper thought to ensure backwards compatibility # Warning: Changing any of the below needs proper thought to ensure backwards compatibility
_VERSION = 2 _VERSION = 3
def _migrate_to_v1(d: dict): 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) 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 # Set of migration functions to apply
_MIGRATIONS = [ _MIGRATIONS = [
_migrate_to_v1, _migrate_to_v1,
_migrate_to_v2 _migrate_to_v2,
_migrate_to_v3,
] ]
@ -66,16 +73,24 @@ class AuthScope(str):
class _Key(str): class _Key(str):
"""Various keys used in the schema""" """Various keys used in the schema"""
VERSION = "version"
HISTORY = "history" HISTORY = "history"
GROUPS = "groups" GROUPS = "groups"
MEMBERS = "members" MEMBERS = "members"
USERS = "users" USERS = "users"
SCOPES = "scopes" SCOPES = "scopes"
MATCHES = "matches" MATCHES = "matches"
ACTIVE = "active" ACTIVE = "active"
CHANNELS = "channels" CHANNELS = "channels"
REACTIVATE = "reactivate" REACTIVATE = "reactivate"
VERSION = "version"
TASKS = "tasks"
MATCH_TASKS = "match_tasks"
MEMBERS_MIN = "members_min"
WEEKDAY = "weekdays"
HOUR = "hours"
# Unused # Unused
_MATCHEES = "matchees" _MATCHEES = "matchees"
@ -88,39 +103,54 @@ _TIME_FORMAT_OLD = "%a %b %d %H:%M:%S %Y"
_SCHEMA = Schema( _SCHEMA = Schema(
{ {
# The current version # The current version
_Key.VERSION: And(Use(int)), _Key.VERSION: Use(int),
Optional(_Key.HISTORY): { _Key.HISTORY: {
# A datetime # A datetime
Optional(str): { Optional(str): {
_Key.GROUPS: [ _Key.GROUPS: [
{ {
_Key.MEMBERS: [ _Key.MEMBERS: [
# The ID of each matchee in the match # 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(str): {
Optional(_Key.SCOPES): And(Use(list[str])), Optional(_Key.SCOPES): Use(list[str]),
Optional(_Key.MATCHES): { Optional(_Key.MATCHES): {
# Matchee ID and Datetime pair # Matchee ID and Datetime pair
Optional(str): And(Use(str)) Optional(str): Use(str)
}, },
Optional(_Key.CHANNELS): { Optional(_Key.CHANNELS): {
# The channel ID # The channel ID
Optional(str): { Optional(str): {
# Whether the user is signed up in this channel # 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 # 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 = { _EMPTY_DICT = {
_Key.HISTORY: {}, _Key.HISTORY: {},
_Key.USERS: {}, _Key.USERS: {},
_Key.TASKS: {},
_Key.VERSION: _VERSION _Key.VERSION: _VERSION
} }
assert _SCHEMA.validate(_EMPTY_DICT) assert _SCHEMA.validate(_EMPTY_DICT)
@ -254,6 +285,68 @@ class State():
if reactivate and datetime.now() > ts_to_datetime(reactivate): if reactivate and datetime.now() > ts_to_datetime(reactivate):
channel[_Key.ACTIVE] = True 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 @property
def dict_internal_copy(self) -> dict: def dict_internal_copy(self) -> dict:
"""Only to be used to get the internal dict as a copy""" """Only to be used to get the internal dict as a copy"""
@ -267,6 +360,10 @@ class State():
def _users(self) -> dict[str]: def _users(self) -> dict[str]:
return self._dict[_Key.USERS] 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): def _set_user_channel_prop(self, id: str, channel_id: str, key: str, value):
"""Set a user channel property helper""" """Set a user channel property helper"""
with self._safe_wrap() as safe_state: with self._safe_wrap() as safe_state:

View file

@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
def get_day_with_suffix(day): 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') return str(day) + {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
def format_today(): def format_today() -> str:
"""Format the current datetime""" """Format the current datetime"""
now = datetime.now() return format_day(datetime.now())
num = get_day_with_suffix(now.day)
day = now.strftime("%a")
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}" return f"{day} {num}"
@ -22,3 +26,19 @@ def format_list(list) -> str:
if len(list) > 1: if len(list) > 1:
return f"{', '.join(list[:-1])} and {list[-1]}" return f"{', '.join(list[:-1])} and {list[-1]}"
return list[0] if list else '' 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