Add a very basic ownership validation for the owner cog
This commit is contained in:
		
							parent
							
								
									f2bfa6d33c
								
							
						
					
					
						commit
						f25dc8db35
					
				
					 5 changed files with 37 additions and 264 deletions
				
			
		|  | @ -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. | 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)?) | * 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 | ||||||
|  |  | ||||||
							
								
								
									
										207
									
								
								py/cog.py
									
										
									
									
									
								
							
							
						
						
									
										207
									
								
								py/cog.py
									
										
									
									
									
								
							|  | @ -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 :)") |  | ||||||
|  | @ -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}!") |  | ||||||
							
								
								
									
										34
									
								
								py/owner_cog_test.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								py/owner_cog_test.py
									
										
									
									
									
										Normal 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") | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue