Merge pull request #3 from mdiluz/integration-tests

Failed attempt at adding integration tests, but there were some other benefits I suppose
This commit is contained in:
Marc Di Luzio 2024-08-13 23:10:23 +01:00 committed by GitHub
commit 71147e963e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 410 additions and 333 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ __pycache__
config.json config.json
state.json state.json
.venv .venv
.coverage

View file

@ -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. 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 ## 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 * Implement a .json file upgrade test
* Track if matches were successful * Track if matches were successful
* Improve the weirdo * Improve the weirdo

53
py/cogs/match_button.py Normal file
View file

@ -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<min>[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)

210
py/cogs/matchy_cog.py Normal file
View file

@ -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 :)")

32
py/cogs/owner_cog.py Normal file
View file

@ -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()

34
py/cogs/owner_cog_test.py Normal file
View file

@ -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")

View file

@ -60,9 +60,9 @@ def _migrate_to_v1(d: dict):
# Owners moved to History in v1 # Owners moved to History in v1
# Note: owners will be required to be re-added to the state.json # Note: owners will be required to be re-added to the state.json
owners = d.pop(_Key._OWNERS) owners = d.pop(_Key._OWNERS)
logger.warn( logger.warning(
"Migration removed owners from config, these must be re-added to the state.json") "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 # Set of migration functions to apply
@ -126,7 +126,7 @@ def _load_from_file(file: str = _FILE) -> _Config:
loaded = files.load(file) loaded = files.load(file)
_migrate(loaded) _migrate(loaded)
else: else:
logger.warn("No %s file found, bot cannot run!", file) logger.warning("No %s file found, bot cannot run!", file)
return _Config(loaded) return _Config(loaded)

View file

@ -1,8 +1,10 @@
"""Utility functions for matchy""" """Utility functions for matchy"""
import logging import logging
import discord
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
import state from state import State, save_to_file, ts_to_datetime
import util
import config import config
@ -100,7 +102,7 @@ def get_member_group_eligibility_score(member: Member,
def attempt_create_groups(matchees: list[Member], def attempt_create_groups(matchees: list[Member],
current_state: state.State, state: State,
oldest_relevant_ts: datetime, oldest_relevant_ts: datetime,
per_group: int) -> tuple[bool, list[list[Member]]]: per_group: int) -> tuple[bool, list[list[Member]]]:
"""History aware group matching""" """History aware group matching"""
@ -115,10 +117,10 @@ def attempt_create_groups(matchees: list[Member],
while matchees_left: while matchees_left:
# Get the next matchee to place # Get the next matchee to place
matchee = matchees_left.pop() 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 relevant_matches = [int(id) for id, ts
in matchee_matches.items() 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 # Try every single group from the current group onwards
# Progressing through the groups like this ensures we slowly fill them up with compatible people # 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], def members_to_groups(matchees: list[Member],
st: state.State = state.State(), state: State = State(),
per_group: int = 3, per_group: int = 3,
allow_fallback: bool = False) -> list[list[Member]]: allow_fallback: bool = False) -> list[list[Member]]:
"""Generate the groups from the set of matchees""" """Generate the groups from the set of matchees"""
@ -176,14 +178,14 @@ def members_to_groups(matchees: list[Member],
return [] return []
# Walk from the start of history until now trying to match up groups # 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 # Attempt with each starting matchee
for shifted_matchees in iterate_all_shifts(matchees): for shifted_matchees in iterate_all_shifts(matchees):
attempts += 1 attempts += 1
groups = attempt_create_groups( 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 # 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)): 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 # Simply assert false, this should never happen
# And should be caught by tests # And should be caught by tests
assert False 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)

View file

@ -397,13 +397,10 @@ def test_auth_scopes():
tmp_state = state.State() tmp_state = state.State()
id = "1" id = "1"
tmp_state.set_user_scope(id, state.AuthScope.OWNER) assert not tmp_state.get_user_has_scope(id, state.AuthScope.MATCHER)
assert tmp_state.get_user_has_scope(id, state.AuthScope.OWNER)
assert tmp_state.get_user_has_scope(id, state.AuthScope.MATCHER)
id = "2" id = "2"
tmp_state.set_user_scope(id, state.AuthScope.MATCHER) 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) assert tmp_state.get_user_has_scope(id, state.AuthScope.MATCHER)
tmp_state.validate() tmp_state.validate()

View file

@ -3,19 +3,14 @@
""" """
import logging import logging
import discord import discord
from discord import app_commands from discord.ext import commands
from discord.ext import commands, tasks
from datetime import datetime, timedelta, time
import matching
import state
import config import config
import re import state
import util 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 = logging.getLogger("matchy")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@ -29,312 +24,13 @@ bot = commands.Bot(command_prefix='$',
@bot.event @bot.event
async def setup_hook(): async def setup_hook():
bot.add_dynamic_items(DynamicGroupButton) await bot.add_cog(MatchyCog(bot, State))
await bot.add_cog(OwnerCog(bot))
@bot.event @bot.event
async def on_ready(): async def on_ready():
"""Bot is ready and connected""" logger.info("Logged in as %s", bot.user.name)
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<min>[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__": if __name__ == "__main__":

View file

@ -13,6 +13,10 @@ logger = logging.getLogger("state")
logger.setLevel(logging.INFO) 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 # Warning: Changing any of the below needs proper thought to ensure backwards compatibility
_VERSION = 4 _VERSION = 4
@ -73,7 +77,6 @@ _MIGRATIONS = [
class AuthScope(str): class AuthScope(str):
"""Various auth scopes""" """Various auth scopes"""
OWNER = "owner"
MATCHER = "matcher" MATCHER = "matcher"
@ -235,7 +238,7 @@ class State():
""" """
user = self._users.get(str(id), {}) user = self._users.get(str(id), {})
scopes = user.get(_Key.SCOPES, []) 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): 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""" """Set a user as active (or not) on a given channel"""
@ -373,7 +376,7 @@ def _migrate(dict: dict):
dict[_Key.VERSION] = _VERSION 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 Load the state from a file
Apply any required migrations Apply any required migrations
@ -393,6 +396,6 @@ def load_from_file(file: str) -> State:
return st 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""" """Saves the state out to a file"""
files.save(file, state.dict_internal_copy) files.save(file, state.dict_internal_copy)

View file

@ -21,7 +21,7 @@ def format_day(time: datetime) -> str:
return f"{day} {num}" 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""" """Format a list into a human readable format of foo, bar and bob"""
if len(list) > 1: if len(list) > 1:
return f"{', '.join(list[:-1])} and {list[-1]}" return f"{', '.join(list[:-1])} and {list[-1]}"

View file

@ -5,6 +5,7 @@ attrs==24.2.0
autopep8==2.3.1 autopep8==2.3.1
coverage==7.6.1 coverage==7.6.1
discord.py==2.4.0 discord.py==2.4.0
dpytest==0.7.0
flake8==7.1.1 flake8==7.1.1
frozenlist==1.4.1 frozenlist==1.4.1
gitdb==4.0.11 gitdb==4.0.11
@ -19,6 +20,7 @@ pluggy==1.5.0
pycodestyle==2.12.1 pycodestyle==2.12.1
pyflakes==3.2.0 pyflakes==3.2.0
pytest==8.3.2 pytest==8.3.2
pytest-asyncio==0.23.8
pytest-cov==5.0.0 pytest-cov==5.0.0
schema==0.7.7 schema==0.7.7
smmap==5.0.1 smmap==5.0.1