diff --git a/README.md b/README.md index d510c1c..11dedbf 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ 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)?) +* 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/cog.py b/py/cog.py deleted file mode 100644 index ff43320..0000000 --- a/py/cog.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Matchy bot cog -""" -import logging -import discord -from discord import app_commands -from discord.ext import commands, tasks -from datetime import datetime, timedelta, time - -import match_button -import matching -from state import State, save_to_file, AuthScope -import util - -logger = logging.getLogger("cog") -logger.setLevel(logging.INFO) - - -class MatchyCog(commands.Cog): - def __init__(self, bot: commands.Bot, state: State): - self.bot = bot - self.state = state - - @commands.Cog.listener() - async def on_ready(self): - """Bot is ready and connected""" - self.run_hourly_tasks.start() - activity = discord.Game("/join") - await self.bot.change_presence(status=discord.Status.online, activity=activity) - logger.info("Bot is up and ready!") - - @app_commands.command(description="Join the matchees for this channel") - @commands.guild_only() - async def join(self, interaction: discord.Interaction): - logger.info("Handling /join in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - self.state.set_user_active_in_channel( - interaction.user.id, interaction.channel.id) - save_to_file(self.state) - await interaction.response.send_message( - f"Roger roger {interaction.user.mention}!\n" - + f"Added you to {interaction.channel.mention}!", - ephemeral=True, silent=True) - - @app_commands.command(description="Leave the matchees for this channel") - @commands.guild_only() - async def leave(self, interaction: discord.Interaction): - logger.info("Handling /leave in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - self.state.set_user_active_in_channel( - interaction.user.id, interaction.channel.id, False) - save_to_file(self.state) - await interaction.response.send_message( - f"No worries {interaction.user.mention}. Come back soon :)", ephemeral=True, silent=True) - - @app_commands.command(description="Pause your matching in this channel for a number of days") - @commands.guild_only() - @app_commands.describe(days="Days to pause for (defaults to 7)") - async def pause(self, interaction: discord.Interaction, days: int | None = None): - logger.info("Handling /pause in %s %s from %s with days=%s", - interaction.guild.name, interaction.channel, interaction.user.name, days) - - if days is None: # Default to a week - days = 7 - until = datetime.now() + timedelta(days=days) - self.state.set_user_paused_in_channel( - interaction.user.id, interaction.channel.id, until) - save_to_file(self.state) - await interaction.response.send_message( - f"Sure thing {interaction.user.mention}!\n" - + f"Paused you until {util.format_day(until)}!", - ephemeral=True, silent=True) - - @app_commands.command(description="List the matchees for this channel") - @commands.guild_only() - async def list(self, interaction: discord.Interaction): - logger.info("Handling /list command in %s %s from %s", - interaction.guild.name, interaction.channel, interaction.user.name) - - matchees = matching.get_matchees_in_channel(self.state, interaction.channel) - mentions = [m.mention for m in matchees] - msg = "Current matchees in this channel:\n" + \ - f"{util.format_list(mentions)}" - - tasks = self.state.get_channel_match_tasks(interaction.channel.id) - for (day, hour, min) in tasks: - next_run = util.get_next_datetime(day, hour) - date_str = util.format_day(next_run) - msg += f"\nNext scheduled for {date_str} at {hour:02d}:00" - msg += f" with {min} members per group" - - await interaction.response.send_message(msg, ephemeral=True, silent=True) - - @app_commands.command(description="Schedule a match in this channel (UTC)") - @commands.guild_only() - @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)", - weekday="Day of the week to run this (defaults 0, Monday)", - hour="Hour in the day (defaults to 9 utc)", - cancel="Cancel the scheduled match at this time") - async def schedule(self, - interaction: discord.Interaction, - members_min: int | None = None, - weekday: int | None = None, - hour: int | None = None, - cancel: bool = False): - """Schedule a match using the input parameters""" - - # Set all the defaults - if not members_min: - members_min = 3 - if weekday is None: - weekday = 0 - if hour is None: - hour = 9 - channel_id = str(interaction.channel.id) - - # Bail if not a matcher - if not self.state.get_user_has_scope(interaction.user.id, AuthScope.MATCHER): - await interaction.response.send_message("You'll need the 'matcher' scope to schedule a match", - ephemeral=True, silent=True) - return - - # Add the scheduled task and save - success = self.state.set_channel_match_task( - channel_id, members_min, weekday, hour, not cancel) - save_to_file(self.state) - - # Let the user know what happened - if not cancel: - logger.info("Scheduled new match task in %s with min %s weekday %s hour %s", - channel_id, members_min, weekday, hour) - next_run = util.get_next_datetime(weekday, hour) - date_str = util.format_day(next_run) - - await interaction.response.send_message( - f"Done :) Next run will be on {date_str} at {hour:02d}:00\n" - + "Cancel this by re-sending the command with cancel=True", - ephemeral=True, silent=True) - - elif success: - logger.info("Removed task in %s on weekday %s hour %s", - channel_id, weekday, hour) - await interaction.response.send_message( - f"Done :) Schedule on day {weekday} and hour {hour} removed!", ephemeral=True, silent=True) - - else: - await interaction.response.send_message( - f"No schedule for this channel on day {weekday} and hour {hour} found :(", ephemeral=True, silent=True) - - @app_commands.command(description="Match up matchees") - @commands.guild_only() - @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)") - async def match(self, interaction: discord.Interaction, members_min: int | None = None): - """Match groups of channel members""" - - logger.info("Handling request '/match group_min=%s", members_min) - logger.info("User %s from %s in #%s", interaction.user, - interaction.guild.name, interaction.channel.name) - - # Sort out the defaults, if not specified they'll come in as None - if not members_min: - members_min = 3 - - # Grab the groups - groups = matching.active_members_to_groups( - self.state, interaction.channel, members_min) - - # Let the user know when there's nobody to match - if not groups: - await interaction.response.send_message("Nobody to match up :(", ephemeral=True, silent=True) - return - - # Post about all the groups with a button to send to the channel - groups_list = '\n'.join( - ", ".join([m.mention for m in g]) for g in groups) - msg = f"Roger! I've generated example groups for ya:\n\n{groups_list}" - view = discord.utils.MISSING - - if self.state.get_user_has_scope(interaction.user.id, AuthScope.MATCHER): - # Otherwise set up the button - msg += "\n\nClick the button to match up groups and send them to the channel.\n" - view = discord.ui.View(timeout=None) - view.add_item(match_button.DynamicGroupButton(members_min)) - else: - # Let a non-matcher know why they don't have the button - msg += f"\n\nYou'll need the {AuthScope.MATCHER}" - + " scope to post this to the channel, sorry!" - - await interaction.response.send_message(msg, ephemeral=True, silent=True, view=view) - - logger.info("Done.") - - @tasks.loop(time=[time(hour=h) for h in range(24)]) - async def run_hourly_tasks(self): - """Run any hourly tasks we have""" - for (channel, min) in self.state.get_active_match_tasks(): - logger.info("Scheduled match task triggered in %s", channel) - msg_channel = self.bot.get_channel(int(channel)) - await matching.match_groups_in_channel(self.state, msg_channel, min) - - for (channel, _) in self.state.get_active_match_tasks(datetime.now() + timedelta(days=1)): - logger.info("Reminding about scheduled task in %s", channel) - msg_channel = self.bot.get_channel(int(channel)) - await msg_channel.send("Arf arf! just a reminder I'll be doin a matcherino in here in T-24hrs!" - + "\nUse /join if you haven't already, or /pause if you want to skip a week :)") diff --git a/py/matchy_test.py b/py/matchy_test.py deleted file mode 100644 index 35a2cc8..0000000 --- a/py/matchy_test.py +++ /dev/null @@ -1,56 +0,0 @@ -import pytest -import discord -from discord.ext import commands -from unittest.mock import AsyncMock, MagicMock - -# Import your bot instance and commands module -from my_bot import bot - -@pytest.fixture -def bot_instance(): - # Setup a test bot instance - bot = commands.Bot(command_prefix="!") - - # Mock bot's guild and channel - guild = MagicMock() - guild.id = 1234567890 - - channel = MagicMock() - channel.id = 9876543210 - channel.guild = guild - channel.send = AsyncMock() # Mock send method - - bot.add_guild(guild) - bot.add_cog(MyCog(bot)) # Example of adding a cog - - return bot, channel - -@pytest.mark.asyncio -async def test_hello_command(bot_instance): - bot, channel = bot_instance - - # Simulate sending a message - message = MagicMock() - message.content = "!hello" - message.channel = channel - message.author = MagicMock() - message.author.bot = False # Ensure the author is not a bot - - # Dispatch the message to trigger the command - await bot.process_commands(message) - - # Check if the bot sent a response - channel.send.assert_called_once_with("Hello, World!") - -@pytest.mark.asyncio -async def test_on_member_join(bot_instance): - bot, channel = bot_instance - - # Simulate a member joining - member = MagicMock() - member.guild = channel.guild - - await bot.on_member_join(member) - - # Check if the bot welcomed the new member - channel.send.assert_called_once_with(f"Welcome {member.mention} to {channel.guild.name}!") diff --git a/py/owner_cog_test.py b/py/owner_cog_test.py new file mode 100644 index 0000000..7fdbd70 --- /dev/null +++ b/py/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/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