From b86d03a84f24ae62a6690c05232dff395e1793ee Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 18:36:29 +0200 Subject: [PATCH 01/55] Using override from typing_extensions instead of typing --- classes/pycord_bot.py | 3 ++- requirements.txt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index df4219b..86bc9c5 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -1,7 +1,7 @@ from datetime import datetime from logging import Logger from pathlib import Path -from typing import Any, Dict, List, override +from typing import Any, Dict, List from zoneinfo import ZoneInfo from bson import ObjectId @@ -9,6 +9,7 @@ from discord import Attachment, File, Guild, TextChannel, User from libbot.cache.classes import CacheMemcached, CacheRedis from libbot.cache.manager import create_cache_client from libbot.pycord.classes import PycordBot as LibPycordBot +from typing_extensions import override from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser from modules.database import col_events, col_users diff --git a/requirements.txt b/requirements.txt index 91130da..fac428f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ apscheduler~=3.11.0 async_pymongo==0.1.11 libbot[speed,pycord,cache]==4.1.0 mongodb-migrations==1.3.1 -pytz~=2025.1 \ No newline at end of file +pytz~=2025.1 +typing_extensions>=4.11.0 \ No newline at end of file From e6036d033ed6a14d61486334a7dcab2cfc655aa8 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 18:58:14 +0200 Subject: [PATCH 02/55] Fixed missing astimezone() call --- cogs/cog_event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 744fca8..44ce8f3 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -154,8 +154,8 @@ class CogEvent(Cog): await pycord_event.update( self.bot.cache, - starts=start_date, - ends=end_date, + starts=start_date.astimezone(ZoneInfo("UTC")), + ends=end_date.astimezone(ZoneInfo("UTC")), name=pycord_event.name if name is None else name, thumbnail=pycord_event.thumbnail if thumbnail is None else processed_media[0], ) From 3e9edf91d5d235edbfa734556590d81da5dab62e Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 19:05:15 +0200 Subject: [PATCH 03/55] Added a message to admins when not stages are defined for the event to be started --- classes/pycord_bot.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 86bc9c5..5538ce2 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -68,13 +68,20 @@ class PycordBot(LibPycordBot): # Process each event for event in events: + guild: Guild = self.get_guild(event.guild_id) + pycord_guild: PycordGuild = await self.find_guild(guild) + if len(event.stage_ids) == 0: # TODO Make a nice message for management logger.error("Could not start the event %s: no event stages are defined.", event._id) - continue - guild: Guild = self.get_guild(event.guild_id) - pycord_guild: PycordGuild = await self.find_guild(guild) + await self.notify_admins( + guild, + pycord_guild, + f"Could not start the event **{event.name}**: no event stages are defined.", + ) + + continue first_stage: PycordEventStage = await self.find_event_stage(event.stage_ids[0]) # Get list of participants From 94c4cdbf65c44eb9044df067388ce591e622309c Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 19:16:34 +0200 Subject: [PATCH 04/55] Registering after the event already began will now create a channel --- classes/pycord_bot.py | 3 +++ cogs/cog_register.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 5538ce2..4be5a39 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -81,7 +81,10 @@ class PycordBot(LibPycordBot): f"Could not start the event **{event.name}**: no event stages are defined.", ) + await event.cancel(self.cache) + continue + first_stage: PycordEventStage = await self.find_event_stage(event.stage_ids[0]) # Get list of participants diff --git a/cogs/cog_register.py b/cogs/cog_register.py index c61a77a..803f155 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -1,3 +1,6 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + from bson.errors import InvalidId from discord import ApplicationContext, Cog, option, slash_command from discord.utils import basic_autocomplete @@ -56,6 +59,9 @@ class CogRegister(Cog): f"You are now registered for the event **{pycord_event.name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!" ) + if pycord_event.starts < datetime.now(tz=ZoneInfo("UTC")): + await user.setup_event_channel(self.bot, ctx.guild, guild, event, cache=self.bot.cache) + def setup(bot: PycordBot) -> None: bot.add_cog(CogRegister(bot)) From 9981143f87e665e7c9d209c41f00088839534a54 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 19:21:24 +0200 Subject: [PATCH 05/55] Fixed sending message to user without an event channel --- classes/pycord_bot.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 4be5a39..ac489a0 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -145,6 +145,14 @@ class PycordBot(LibPycordBot): users: List[PycordUser] = await self._get_event_participants(event._id) for user in users: + if str(event._id) not in user.event_channels: + logger.warning( + "User %s participated in the event %s but did not have a channel. End message will not be sent and permissions will not be updated.", + user.id, + event._id, + ) + continue + # Send a notification about event start user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)]) From 3b8da61b470591ed66084d89e696c2c9a9cce3a0 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 19:27:15 +0200 Subject: [PATCH 06/55] Fixed event not being set as ended correctly --- classes/pycord_bot.py | 8 +++++++- classes/pycord_event.py | 8 ++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index ac489a0..592521c 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -63,7 +63,11 @@ class PycordBot(LibPycordBot): async def _process_events_start(self) -> None: # Get events to start events: List[PycordEvent] = await self._get_events( - {"starts": datetime.now(tz=ZoneInfo("UTC")).replace(second=0, microsecond=0)} + { + "starts": datetime.now(tz=ZoneInfo("UTC")).replace(second=0, microsecond=0), + "is_cancelled": False, + "ended": None, + } ) # Process each event @@ -171,6 +175,8 @@ class PycordBot(LibPycordBot): f"Event **{event.name}** has ended! Users can no longer submit their answers.", ) + await event.end(cache=self.cache) + @staticmethod async def _get_events(query: Dict[str, Any]) -> List[PycordEvent]: events: List[PycordEvent] = [] diff --git a/classes/pycord_event.py b/classes/pycord_event.py index 8878945..9c64ba9 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -268,6 +268,10 @@ class PycordEvent: async def cancel(self, cache: Optional[Cache] = None): await self._set(cache, is_cancelled=True) + # # TODO Add documentation + async def end(self, cache: Optional[Cache] = None) -> None: + await self._set(cache, ended=datetime.now(tz=ZoneInfo("UTC"))) + async def _update_event_stage_order( self, bot: Any, @@ -325,7 +329,7 @@ class PycordEvent: # # TODO Add documentation # def get_localized_start_date(self, tz: str | timezone | ZoneInfo) -> datetime: # return self.starts.replace(tzinfo=tz) - # - # # TODO Add documentation + + # TODO Add documentation # def get_localized_end_date(self, tz: str | timezone | ZoneInfo) -> datetime: # return self.ends.replace(tzinfo=tz) From 2dac6a471483510dc09f5910e9722954210f433d Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 19:32:49 +0200 Subject: [PATCH 07/55] Fixed the goddamn timezone for event start check --- cogs/cog_register.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 803f155..1390484 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -59,7 +59,7 @@ class CogRegister(Cog): f"You are now registered for the event **{pycord_event.name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!" ) - if pycord_event.starts < datetime.now(tz=ZoneInfo("UTC")): + if pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) < datetime.now(tz=ZoneInfo("UTC")): await user.setup_event_channel(self.bot, ctx.guild, guild, event, cache=self.bot.cache) From 64cd7b3bff717ae035a4f541a13a536354a47b50 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 19:37:53 +0200 Subject: [PATCH 08/55] Fixed a typo in argument name --- cogs/cog_register.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 1390484..fc9a0a0 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -60,7 +60,7 @@ class CogRegister(Cog): ) if pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) < datetime.now(tz=ZoneInfo("UTC")): - await user.setup_event_channel(self.bot, ctx.guild, guild, event, cache=self.bot.cache) + await user.setup_event_channel(self.bot, ctx.guild, guild, pycord_event, cache=self.bot.cache) def setup(bot: PycordBot) -> None: From e45a56835a48f26ce691e94338662e63f9ee642a Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 20:01:52 +0200 Subject: [PATCH 09/55] Fixed messages about not created channels --- classes/pycord_bot.py | 25 +++++++++++++++++---- classes/pycord_user.py | 6 ++++-- cogs/cog_register.py | 49 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 592521c..29b3a2e 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -98,10 +98,27 @@ class PycordBot(LibPycordBot): await user._set(self.cache, current_event_id=event._id, current_stage_id=first_stage._id) # Create a channel for each participant - await user.setup_event_channel(self, guild, pycord_guild, event, cache=self.cache) + user_channel: TextChannel | None = await user.setup_event_channel( + self, guild, pycord_guild, event, cache=self.cache + ) - # Send a notification about event start - user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)]) + if user_channel is None: + logger.error( + "Event channel was not created for user %s from guild %s and event %s after registration.", + user.id, + guild.id, + event._id, + ) + + discord_user: User = self.get_user(user.id) + + await self.notify_admins( + guild, + pycord_guild, + f"Event channel could not be created for user **{discord_user.display_name}** ({discord_user.mention}) and event **{event.name}**.", + ) + + continue thumbnail: File | None = ( None @@ -109,8 +126,8 @@ class PycordBot(LibPycordBot): else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"]) ) + # Send a notification about event start # TODO Make a nice message - # TODO Also send a thumbnail, event info and short explanation on how to play await user_channel.send( f"Event **{event.name}** is starting!\n\nUse slash command `/guess` to suggest your answers to each event stage.", file=thumbnail, diff --git a/classes/pycord_user.py b/classes/pycord_user.py index 011b985..fa1b5dd 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -270,9 +270,9 @@ class PycordUser: pycord_guild: "PycordGuild", pycord_event: "PycordEvent", cache: Optional[Cache] = None, - ): + ) -> TextChannel | None: if str(pycord_event._id) in self.event_channels.keys(): - return + return None discord_member: Member | None = guild.get_member(self.id) discord_category: GuildChannel | None = bot.get_channel(pycord_guild.category_id) @@ -316,6 +316,8 @@ class PycordUser: await self.set_event_channel(pycord_event._id, channel.id, cache=cache) + return channel + async def lock_event_channel( self, guild: Guild, diff --git a/cogs/cog_register.py b/cogs/cog_register.py index fc9a0a0..1934077 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -1,13 +1,18 @@ from datetime import datetime +from logging import Logger +from pathlib import Path +from typing import List from zoneinfo import ZoneInfo from bson.errors import InvalidId -from discord import ApplicationContext, Cog, option, slash_command +from discord import ApplicationContext, Cog, option, slash_command, TextChannel, File from discord.utils import basic_autocomplete -from classes import PycordEvent, PycordGuild, PycordUser +from classes import PycordEvent, PycordGuild, PycordUser, PycordEventStage from classes.pycord_bot import PycordBot -from modules.utils import autocomplete_active_events, get_unix_timestamp +from modules.utils import autocomplete_active_events, get_unix_timestamp, get_logger + +logger: Logger = get_logger(__name__) class CogRegister(Cog): @@ -60,7 +65,43 @@ class CogRegister(Cog): ) if pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) < datetime.now(tz=ZoneInfo("UTC")): - await user.setup_event_channel(self.bot, ctx.guild, guild, pycord_event, cache=self.bot.cache) + user_channel: TextChannel = await user.setup_event_channel( + self.bot, ctx.guild, guild, pycord_event, cache=self.bot.cache + ) + + if user_channel is None: + logger.error( + "Event channel was not created for user %s from guild %s and event %s after registration.", + ctx.author.id, + guild.id, + pycord_event._id, + ) + + await self.bot.notify_admins( + ctx.guild, + guild, + f"Event channel could not be created for user **{ctx.author.display_name}** ({ctx.author.mention}) and event **{pycord_event.name}**.", + ) + + return + + thumbnail: File | None = ( + None + if pycord_event.thumbnail is None + else File(Path(f"data/{pycord_event.thumbnail['id']}"), pycord_event.thumbnail["filename"]) + ) + + # TODO Make a nice message + await user_channel.send( + f"Event **{pycord_event.name}** has already started!\n\nUse slash command `/guess` to suggest your answers to each event stage.", + file=thumbnail, + ) + + first_stage: PycordEventStage = await self.bot.find_event_stage(pycord_event.stage_ids[0]) + + first_stage_files: List[File] | None = first_stage.get_media_files() + + await user_channel.send(f"First stage...\n\n{first_stage.question}", files=first_stage_files) def setup(bot: PycordBot) -> None: From 6b143d8a2d1decee89324379e77e57e54b20fa99 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 26 Apr 2025 21:31:35 +0200 Subject: [PATCH 10/55] Implemented activities --- cogs/cog_utility.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ config_example.json | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 cogs/cog_utility.py diff --git a/cogs/cog_utility.py b/cogs/cog_utility.py new file mode 100644 index 0000000..33695f8 --- /dev/null +++ b/cogs/cog_utility.py @@ -0,0 +1,47 @@ +from logging import Logger + +from discord import Activity, ActivityType, Cog +from discord.ext import commands + +from classes.pycord_bot import PycordBot +from modules.utils import get_logger + +logger: Logger = get_logger(__name__) + + +class CogUtility(Cog): + def __init__(self, bot: PycordBot): + self.bot: PycordBot = bot + + @commands.Cog.listener() + async def on_ready(self) -> None: + """Listener for the event when bot connects to Discord and becomes "ready".""" + logger.info("Logged in as %s", self.bot.user) + + activity_enabled: bool = self.bot.config["bot"]["status"]["enabled"] + activity_type: str = self.bot.config["bot"]["status"]["activity_type"] + activity_message: str = self.bot.config["bot"]["status"]["activity_text"] + + if not activity_enabled: + return + + if activity_type == "playing": + await self.bot.change_presence(activity=Activity(type=ActivityType.playing, name=activity_message)) + elif activity_type == "watching": + await self.bot.change_presence(activity=Activity(type=ActivityType.watching, name=activity_message)) + elif activity_type == "listening": + await self.bot.change_presence(activity=Activity(type=ActivityType.listening, name=activity_message)) + elif activity_type == "streaming": + await self.bot.change_presence(activity=Activity(type=ActivityType.streaming, name=activity_message)) + elif activity_type == "competing": + await self.bot.change_presence(activity=Activity(type=ActivityType.competing, name=activity_message)) + elif activity_type == "custom": + await self.bot.change_presence(activity=Activity(type=ActivityType.custom, name=activity_message)) + else: + return + + logger.info("Set activity type to %s with message %s", activity_type, activity_message) + + +def setup(bot: PycordBot) -> None: + bot.add_cog(CogUtility(bot)) diff --git a/config_example.json b/config_example.json index a6dfc60..a5e8549 100644 --- a/config_example.json +++ b/config_example.json @@ -12,7 +12,7 @@ "timezone": "UTC", "status": { "enabled": true, - "activity_type": 0, + "activity_type": "playing", "activity_text": "The Game Of Life" } }, From 638658af752dc157d885ae5299ce1771cda183d9 Mon Sep 17 00:00:00 2001 From: Profitroll Date: Sun, 27 Apr 2025 12:39:30 +0200 Subject: [PATCH 11/55] Added more custom exceptions and prepared everything for documentation --- classes/errors/__init__.py | 10 ++++- classes/errors/discord.py | 28 +++++++++++++ classes/errors/pycord_event.py | 20 +++++++++ classes/errors/pycord_event_stage.py | 17 ++++++++ classes/errors/pycord_user.py | 33 +++++++++++++++ classes/pycord_bot.py | 5 ++- classes/pycord_event.py | 15 ++++--- classes/pycord_event_stage.py | 9 ++-- classes/pycord_guild.py | 1 + classes/pycord_user.py | 61 +++++++++++----------------- 10 files changed, 146 insertions(+), 53 deletions(-) create mode 100644 classes/errors/discord.py create mode 100644 classes/errors/pycord_event.py create mode 100644 classes/errors/pycord_event_stage.py diff --git a/classes/errors/__init__.py b/classes/errors/__init__.py index baa580e..a0533c9 100644 --- a/classes/errors/__init__.py +++ b/classes/errors/__init__.py @@ -1,2 +1,10 @@ +from pycord_event import EventNotFoundError +from pycord_event_stage import EventStageNotFoundError, EventStageMissingSequenceError +from .discord import DiscordGuildMemberNotFoundError, DiscordCategoryNotFoundError, DiscordChannelNotFoundError from .pycord_guild import GuildNotFoundError -from .pycord_user import UserNotFoundError +from .pycord_user import ( + UserNotFoundError, + UserAlreadyRegisteredForEventError, + UserNotRegisteredForEventError, + UserAlreadyCompletedEventError, +) diff --git a/classes/errors/discord.py b/classes/errors/discord.py new file mode 100644 index 0000000..49a5b44 --- /dev/null +++ b/classes/errors/discord.py @@ -0,0 +1,28 @@ +class DiscordGuildMemberNotFoundError(Exception): + """Member was not found in a discord guild""" + + def __init__(self, user_id: int, guild_id: int) -> None: + self.user_id: int = user_id + self.guild_id: int = guild_id + + super().__init__(f"Member with id {self.user_id} was not found in guild with id {self.guild_id}") + + +class DiscordCategoryNotFoundError(Exception): + """Category was not found in a discord guild""" + + def __init__(self, category_id: int, guild_id: int) -> None: + self.category_id: int = category_id + self.guild_id: int = guild_id + + super().__init__(f"Category with id {self.category_id} was not found in guild with id {self.guild_id}") + + +class DiscordChannelNotFoundError(Exception): + """Channel was not found in a discord guild""" + + def __init__(self, channel_id: int, guild_id: int) -> None: + self.channel_id: int = channel_id + self.guild_id: int = guild_id + + super().__init__(f"Channel with id {self.channel_id} was not found in guild with id {self.guild_id}") diff --git a/classes/errors/pycord_event.py b/classes/errors/pycord_event.py new file mode 100644 index 0000000..4d1ee03 --- /dev/null +++ b/classes/errors/pycord_event.py @@ -0,0 +1,20 @@ +from typing import Optional + +from bson import ObjectId + + +class EventNotFoundError(Exception): + """PycordEvent could not find event with such an ID in the database""" + + def __init__(self, event_id: Optional[str | ObjectId] = None, event_name: Optional[str] = None) -> None: + self.event_id = event_id + self.event_name = event_name + + if self.event_id is None and self.event_name is None: + raise AttributeError("Either event id or name must be provided") + + super().__init__( + f"Event with id {self.event_id} was not found" + if event_id is not None + else f"Event with name {self.event_name} was not found" + ) diff --git a/classes/errors/pycord_event_stage.py b/classes/errors/pycord_event_stage.py new file mode 100644 index 0000000..4e3d519 --- /dev/null +++ b/classes/errors/pycord_event_stage.py @@ -0,0 +1,17 @@ +from bson import ObjectId + + +class EventStageNotFoundError(Exception): + """PycordEventStage could not find event with such an ID in the database""" + + def __init__(self, stage_id: str | ObjectId) -> None: + self.stage_id = stage_id + + super().__init__(f"Stage with id {self.stage_id} was not found") + + +class EventStageMissingSequenceError(Exception): + """No sequence is provided for the event stage""" + + def __init__(self) -> None: + super().__init__("Stage does not have a defined sequence") diff --git a/classes/errors/pycord_user.py b/classes/errors/pycord_user.py index 87b10ae..85e0167 100644 --- a/classes/errors/pycord_user.py +++ b/classes/errors/pycord_user.py @@ -1,3 +1,6 @@ +from bson import ObjectId + + class UserNotFoundError(Exception): """PycordUser could not find user with such an ID in the database""" @@ -6,3 +9,33 @@ class UserNotFoundError(Exception): self.guild_id: int = guild_id super().__init__(f"User with id {self.user_id} was not found in guild {self.guild_id}") + + +class UserAlreadyRegisteredForEventError(Exception): + """PycordUser is already registered for the provided event""" + + def __init__(self, user_id: int, event_id: str | ObjectId) -> None: + self.user_id: int = user_id + self.event_id: str | ObjectId = event_id + + super().__init__(f"User with id {self.user_id} is already registered for the event {self.event_id}") + + +class UserNotRegisteredForEventError(Exception): + """PycordUser is not registered for the provided event""" + + def __init__(self, user_id: int, event_id: str | ObjectId) -> None: + self.user_id: int = user_id + self.event_id: str | ObjectId = event_id + + super().__init__(f"User with id {self.user_id} is not registered for the event {self.event_id}") + + +class UserAlreadyCompletedEventError(Exception): + """PycordUser already completed the provided event""" + + def __init__(self, user_id: int, event_id: str | ObjectId) -> None: + self.user_id: int = user_id + self.event_id: str | ObjectId = event_id + + super().__init__(f"User with id {self.user_id} already completed the event {self.event_id}") diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 29b3a2e..c08c4d7 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -12,6 +12,7 @@ from libbot.pycord.classes import PycordBot as LibPycordBot from typing_extensions import override from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser +from classes.errors import EventStageMissingSequenceError from modules.database import col_events, col_users from modules.utils import get_logger @@ -283,8 +284,7 @@ class PycordBot(LibPycordBot): # event: PycordEvent = await self.find_event(event_id=kwargs["event_id"]) if "sequence" not in kwargs: - # TODO Create a nicer exception - raise RuntimeError("Stage must have a defined sequence") + raise EventStageMissingSequenceError() event_stage: PycordEventStage = await PycordEventStage.create(**kwargs, cache=self.cache) @@ -306,6 +306,7 @@ class PycordBot(LibPycordBot): async def find_event_stage(self, stage_id: str | ObjectId) -> PycordEventStage: return await PycordEventStage.from_id(stage_id, cache=self.cache) + # TODO Add documentation @staticmethod async def process_attachments(attachments: List[Attachment]) -> List[Dict[str, Any]]: processed_attachments: List[Dict[str, Any]] = [] diff --git a/classes/pycord_event.py b/classes/pycord_event.py index 9c64ba9..95c9599 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -9,6 +9,7 @@ from discord import Bot from libbot.cache.classes import Cache from pymongo.results import InsertOneResult +from classes.errors import EventNotFoundError from modules.database import col_events from modules.utils import get_logger, restore_from_cache @@ -70,16 +71,14 @@ class PycordEvent: ) if db_entry is None: - raise RuntimeError(f"Event {event_id} not found") - - # TODO Add a unique exception - # raise EventNotFoundError(event_id) + raise EventNotFoundError(event_id=event_id) if cache is not None: cache.set_json(f"{cls.__short_name__}_{event_id}", db_entry) return cls(**db_entry) + # TODO Add documentation @classmethod async def from_name(cls, event_name: str, cache: Optional[Cache] = None) -> "PycordEvent": # TODO Add sorting by creation date or something. @@ -87,16 +86,14 @@ class PycordEvent: db_entry: Dict[str, Any] | None = await cls.__collection__.find_one({"name": event_name}) if db_entry is None: - raise RuntimeError(f"Event with name {event_name} not found") - - # TODO Add a unique exception - # raise EventNotFoundError(event_name) + raise EventNotFoundError(event_name=event_name) if cache is not None: cache.set_json(f"{cls.__short_name__}_{db_entry['_id']}", db_entry) return cls(**db_entry) + # TODO Add documentation @classmethod async def create( cls, @@ -217,6 +214,7 @@ class PycordEvent: "stage_ids": self.stage_ids, } + # TODO Add documentation @staticmethod def get_defaults() -> Dict[str, Any]: return { @@ -232,6 +230,7 @@ class PycordEvent: "stage_ids": [], } + # TODO Add documentation @staticmethod def get_default_value(key: str) -> Any: if key not in PycordEvent.get_defaults(): diff --git a/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index bceb989..416f6eb 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -10,6 +10,7 @@ from discord import File from libbot.cache.classes import Cache from pymongo.results import InsertOneResult +from classes.errors import EventStageNotFoundError from modules.database import col_stages from modules.utils import get_logger, restore_from_cache @@ -66,16 +67,14 @@ class PycordEventStage: ) if db_entry is None: - raise RuntimeError(f"Event stage {stage_id} not found") - - # TODO Add a unique exception - # raise EventStageNotFoundError(event_id) + raise EventStageNotFoundError(stage_id) if cache is not None: cache.set_json(f"{cls.__short_name__}_{stage_id}", db_entry) return cls(**db_entry) + # TODO Add documentation @classmethod async def create( cls, @@ -193,6 +192,7 @@ class PycordEventStage: "media": self.media, } + # TODO Add documentation @staticmethod def get_defaults() -> Dict[str, Any]: return { @@ -206,6 +206,7 @@ class PycordEventStage: "media": [], } + # TODO Add documentation @staticmethod def get_default_value(key: str) -> Any: if key not in PycordEventStage.get_defaults(): diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index 771a601..5355976 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -147,6 +147,7 @@ class PycordGuild: "timezone": self.timezone, } + # TODO Add documentation @staticmethod def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]: return { diff --git a/classes/pycord_user.py b/classes/pycord_user.py index fa1b5dd..026f9f6 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -17,7 +17,15 @@ from discord.abc import GuildChannel from libbot.cache.classes import Cache from pymongo.results import InsertOneResult -from classes.errors.pycord_user import UserNotFoundError +from classes.errors import ( + UserNotFoundError, + UserAlreadyRegisteredForEventError, + UserAlreadyCompletedEventError, + UserNotRegisteredForEventError, + DiscordGuildMemberNotFoundError, + DiscordCategoryNotFoundError, + DiscordChannelNotFoundError, +) from modules.database import col_users from modules.utils import get_logger, restore_from_cache @@ -192,6 +200,7 @@ class PycordUser: cache.delete(self._get_cache_key()) + # TODO Add documentation @staticmethod def get_defaults(user_id: Optional[int] = None, guild_id: Optional[int] = None) -> Dict[str, Any]: return { @@ -205,6 +214,7 @@ class PycordUser: "completed_event_ids": [], } + # TODO Add documentation @staticmethod def get_default_value(key: str) -> Any: if key not in PycordUser.get_defaults(): @@ -226,10 +236,7 @@ class PycordUser: event_id: ObjectId = ObjectId(event_id) if isinstance(event_id, str) else event_id if event_id in self.registered_event_ids: - raise RuntimeError(f"User is already registered for event {event_id}") - - # TODO Add a unique exception - # raise UserAlreadyRegisteredForEventError(event_name) + raise UserAlreadyRegisteredForEventError(self.id, event_id) self.registered_event_ids.append(event_id) @@ -240,10 +247,7 @@ class PycordUser: event_id: ObjectId = ObjectId(event_id) if isinstance(event_id, str) else event_id if event_id not in self.registered_event_ids: - raise RuntimeError(f"User is not registered for event {event_id}") - - # TODO Add a unique exception - # raise UserNotRegisteredForEventError(event_name) + raise UserNotRegisteredForEventError(self.id, event_id) self.registered_event_ids.remove(event_id) @@ -254,15 +258,13 @@ class PycordUser: event_id: ObjectId = ObjectId(event_id) if isinstance(event_id, str) else event_id if event_id in self.completed_event_ids: - raise RuntimeError(f"User has already completed event {event_id}") - - # TODO Add a unique exception - # raise UserAlreadyCompletedEventError(event_name) + raise UserAlreadyCompletedEventError(self.id, event_id) self.completed_event_ids.append(event_id) await self._set(cache, completed_event_ids=self.completed_event_ids) + # TODO Add documentation async def setup_event_channel( self, bot: Bot, @@ -275,23 +277,13 @@ class PycordUser: return None discord_member: Member | None = guild.get_member(self.id) - discord_category: GuildChannel | None = bot.get_channel(pycord_guild.category_id) + discord_category: GuildChannel | None = bot.get_channel(pycord_guild.channel_id) if discord_member is None: - raise RuntimeError( - f"Discord guild member with ID {self.id} in guild with ID {guild.id} could not be found!" - ) - - # TODO Add a unique exception - # raise DiscordGuildMemberNotFoundError(self.id, guild.id) + raise DiscordGuildMemberNotFoundError(self.id, guild.id) if discord_category is None: - raise RuntimeError( - f"Discord category with ID {pycord_guild.category_id} in guild with ID {guild.id} could not be found!" - ) - - # TODO Add a unique exception - # raise DiscordCategoryNotFoundError(pycord_guild.category_id, guild.id) + raise DiscordCategoryNotFoundError(pycord_guild.channel_id, guild.id) permission_overwrites: Dict[Role | Member, PermissionOverwrite] = { guild.default_role: PermissionOverwrite( @@ -318,6 +310,7 @@ class PycordUser: return channel + # TODO Add documentation async def lock_event_channel( self, guild: Guild, @@ -331,20 +324,10 @@ class PycordUser: ) if discord_member is None: - raise RuntimeError( - f"Discord guild member with ID {self.id} in guild with ID {guild.id} could not be found!" - ) - - # TODO Add a unique exception - # raise DiscordGuildMemberNotFoundError(self.id, guild.id) + raise DiscordGuildMemberNotFoundError(self.id, guild.id) if discord_member is None: - raise RuntimeError( - f"Discord channel with ID {self.event_channels[str(event_id)]} in guild with ID {guild.id} could not be found!" - ) - - # TODO Add a unique exception - # raise DiscordChannelNotFoundError(self.event_channels[str(event_id)], guild.id) + raise DiscordChannelNotFoundError(self.event_channels[str(event_id)], guild.id) permission_overwrite: PermissionOverwrite = PermissionOverwrite( view_channel=not completely, @@ -375,8 +358,10 @@ class PycordUser: async def set_event_stage(self, stage_id: str | ObjectId | None, cache: Optional[Cache] = None) -> None: await self._set(cache, current_stage_id=stage_id if isinstance(stage_id, str) else ObjectId(stage_id)) + # TODO Add documentation async def jail(self, cache: Optional[Cache] = None) -> None: await self._set(cache, is_jailed=True) + # TODO Add documentation async def unjail(self, cache: Optional[Cache] = None) -> None: await self._set(cache, is_jailed=False) From 12a88d5a23811d6f6e68eb9e5509b36453a46cf7 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 27 Apr 2025 17:41:14 +0200 Subject: [PATCH 12/55] Fully documented and updated PycordEvent (#4) --- classes/errors/pycord_event.py | 14 +- classes/pycord_bot.py | 17 +- classes/pycord_event.py | 389 ++++++++++++++++++++++---------- cogs/cog_event.py | 4 +- modules/utils/datetime_utils.py | 4 +- 5 files changed, 292 insertions(+), 136 deletions(-) diff --git a/classes/errors/pycord_event.py b/classes/errors/pycord_event.py index 4d1ee03..84a2139 100644 --- a/classes/errors/pycord_event.py +++ b/classes/errors/pycord_event.py @@ -6,9 +6,15 @@ from bson import ObjectId class EventNotFoundError(Exception): """PycordEvent could not find event with such an ID in the database""" - def __init__(self, event_id: Optional[str | ObjectId] = None, event_name: Optional[str] = None) -> None: - self.event_id = event_id - self.event_name = event_name + def __init__( + self, + event_id: Optional[str | ObjectId] = None, + event_name: Optional[str] = None, + guild_id: Optional[int] = None, + ) -> None: + self.event_id: str | ObjectId | None = event_id + self.event_name: str | None = event_name + self.guild_id: int | None = guild_id if self.event_id is None and self.event_name is None: raise AttributeError("Either event id or name must be provided") @@ -16,5 +22,5 @@ class EventNotFoundError(Exception): super().__init__( f"Event with id {self.event_id} was not found" if event_id is not None - else f"Event with name {self.event_name} was not found" + else f"Event with name {self.event_name} was not found for the guild {self.guild_id}" ) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index c08c4d7..d93db4b 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -1,7 +1,7 @@ from datetime import datetime from logging import Logger from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from zoneinfo import ZoneInfo from bson import ObjectId @@ -293,14 +293,19 @@ class PycordBot(LibPycordBot): return event_stage # TODO Document this method - async def find_event(self, event_id: str | ObjectId | None = None, event_name: str | None = None) -> PycordEvent: - if event_id is None and event_name is None: - raise AttributeError("Either event's ID or name must be provided!") + async def find_event( + self, + event_id: Optional[str | ObjectId] = None, + event_name: Optional[str] = None, + guild_id: Optional[int] = None, + ) -> PycordEvent: + if event_id is None or (event_name is None and guild_id is None): + raise AttributeError("Either event ID or name with guild ID must be provided") if event_id is not None: return await PycordEvent.from_id(event_id, cache=self.cache) - else: - return await PycordEvent.from_name(event_name, cache=self.cache) + + return await PycordEvent.from_name(event_name, guild_id, cache=self.cache) # TODO Document this method async def find_event_stage(self, stage_id: str | ObjectId) -> PycordEventStage: diff --git a/classes/pycord_event.py b/classes/pycord_event.py index 95c9599..d8a47d3 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -1,12 +1,14 @@ +"""Module with class PycordEvent.""" + from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from logging import Logger from typing import Any, Dict, List, Optional from zoneinfo import ZoneInfo from bson import ObjectId -from discord import Bot from libbot.cache.classes import Cache +from pymongo import DESCENDING from pymongo.results import InsertOneResult from classes.errors import EventNotFoundError @@ -18,6 +20,22 @@ logger: Logger = get_logger(__name__) @dataclass class PycordEvent: + """Object representation of an event in the database. + + Attributes: + _id (ObjectId): ID of the event generated by the database. + name (str): Name of the event. + guild_id (int): Discord ID of the guild where the event takes place. + created (datetime): Date of event's creation in UTC. + ended (datetime | None): Date of the event's actual end in UTC. + is_cancelled (bool): Whether the event is cancelled. + creator_id (int): Discord ID of the creator. + starts (datetime): Date of the event's planned start in UTC. + ends (datetime): Date of the event's planned end in UTC. + thumbnail (Dict[str, Any] | None): Thumbnail to use for the event in format `{"id": thumbnail_id (int), "filename": thumbnail_filename (str)}`. + stage_ids (List[ObjectId]): Database ID's of the event's stages ordered in the completion order. + """ + __slots__ = ( "_id", "name", @@ -48,18 +66,18 @@ class PycordEvent: @classmethod async def from_id(cls, event_id: str | ObjectId, cache: Optional[Cache] = None) -> "PycordEvent": - """Find event in the database. + """Find the event by its ID and construct PycordEvent from database entry. Args: - event_id (str | ObjectId): Event's ID - cache (:obj:`Cache`, optional): Cache engine to get the cache from + event_id (str | ObjectId): ID of the event to look up. + cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache. Returns: - PycordEvent: Event object + PycordEvent: Object of the found event. Raises: - EventNotFoundError: Event was not found - InvalidId: Invalid event ID was provided + EventNotFoundError: Event with such ID does not exist. + InvalidId: Provided event ID is of invalid format. """ cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, event_id, cache=cache) @@ -78,22 +96,35 @@ class PycordEvent: return cls(**db_entry) - # TODO Add documentation @classmethod - async def from_name(cls, event_name: str, cache: Optional[Cache] = None) -> "PycordEvent": - # TODO Add sorting by creation date or something. - # Duplicate events should be avoided, latest active event should be returned. - db_entry: Dict[str, Any] | None = await cls.__collection__.find_one({"name": event_name}) + async def from_name(cls, event_name: str, guild_id: int, cache: Optional[Cache] = None) -> "PycordEvent": + """Find the event by its name and construct PycordEvent from database entry. + + If multiple events with the same name exist, the one with the greatest start date will be returned. + + Args: + event_name (str): Name of the event to look up. + guild_id (int): Discord ID of the guild where the event takes place. + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + + Returns: + PycordEvent: Object of the found event. + + Raises: + EventNotFoundError: Event with such name does not exist. + """ + db_entry: Dict[str, Any] | None = await cls.__collection__.find_one( + {"name": event_name, "guild_id": guild_id}, sort=[("starts", DESCENDING)] + ) if db_entry is None: - raise EventNotFoundError(event_name=event_name) + raise EventNotFoundError(event_name=event_name, guild_id=guild_id) if cache is not None: cache.set_json(f"{cls.__short_name__}_{db_entry['_id']}", db_entry) return cls(**db_entry) - # TODO Add documentation @classmethod async def create( cls, @@ -105,6 +136,22 @@ class PycordEvent: thumbnail: Dict[str, Any] | None, cache: Optional[Cache] = None, ) -> "PycordEvent": + """Create an event, write it to the database and return the constructed PycordEvent object. + + Creation date will be set to current time in UTC automatically. + + Args: + name (str): Name of the event. + guild_id (int): Guild ID where the event takes place. + creator_id (int): Discord ID of the event creator. + starts (datetime): Date when the event starts. Must be UTC. + ends (datetime): Date when the event ends. Must be UTC. + thumbnail (:obj:`Dict[str, Any]`, optional): Thumbnail to use for the event in format `{"id": thumbnail_id (int), "filename": thumbnail_filename (str)}`. + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + + Returns: + PycordEvent: Object of the created event. + """ db_entry: Dict[str, Any] = { "name": name, "guild_id": guild_id, @@ -128,15 +175,9 @@ class PycordEvent: return cls(**db_entry) async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: - """Set attribute data and save it into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - **kwargs (Any): Mapping of attribute names and respective values to be set - """ for key, value in kwargs.items(): if not hasattr(self, key): - raise AttributeError() + raise AttributeError(f"Attribute '{key}' does not exist in PycordEvent") setattr(self, key, value) @@ -147,17 +188,11 @@ class PycordEvent: logger.info("Set attributes of event %s to %s", self._id, kwargs) async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: - """Remove attribute data and save it into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - *args (str): List of attributes to remove - """ attributes: Dict[str, Any] = {} for key in args: if not hasattr(self, key): - raise AttributeError() + raise AttributeError(f"Attribute '{key}' does not exist in PycordEvent") default_value: Any = self.get_default_value(key) @@ -191,86 +226,6 @@ class PycordEvent: cache.delete(self._get_cache_key()) - def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: - """Convert PycordEvent object to a JSON representation. - - Args: - json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted - - Returns: - Dict[str, Any]: JSON representation of PycordEvent - """ - return { - "_id": self._id if not json_compatible else str(self._id), - "name": self.name, - "guild_id": self.guild_id, - "created": self.created, - "ended": self.ended, - "is_cancelled": self.is_cancelled, - "creator_id": self.creator_id, - "starts": self.starts, - "ends": self.ends, - "thumbnail": self.thumbnail, - "stage_ids": self.stage_ids, - } - - # TODO Add documentation - @staticmethod - def get_defaults() -> Dict[str, Any]: - return { - "name": None, - "guild_id": None, - "created": None, - "ended": None, - "is_cancelled": False, - "creator_id": None, - "starts": None, - "ends": None, - "thumbnail": None, - "stage_ids": [], - } - - # TODO Add documentation - @staticmethod - def get_default_value(key: str) -> Any: - if key not in PycordEvent.get_defaults(): - raise KeyError(f"There's no default value for key '{key}' in PycordEvent") - - return PycordEvent.get_defaults()[key] - - # TODO Add documentation - async def update( - self, - cache: Optional[Cache] = None, - **kwargs, - ): - await self._set(cache=cache, **kwargs) - - # TODO Add documentation - async def reset( - self, - cache: Optional[Cache] = None, - *args, - ): - await self._remove(cache, *args) - - async def purge(self, cache: Optional[Cache] = None) -> None: - """Completely remove event data from database. Currently only removes the event record from events collection. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - """ - await self.__collection__.delete_one({"_id": self._id}) - self._delete_cache(cache) - - # TODO Add documentation - async def cancel(self, cache: Optional[Cache] = None): - await self._set(cache, is_cancelled=True) - - # # TODO Add documentation - async def end(self, cache: Optional[Cache] = None) -> None: - await self._set(cache, ended=datetime.now(tz=ZoneInfo("UTC"))) - async def _update_event_stage_order( self, bot: Any, @@ -294,10 +249,139 @@ class PycordEvent: if stage_index != old_stage_index: await (await bot.find_event_stage(event_stage_id)).update(cache, sequence=stage_index) - # TODO Add documentation - async def insert_stage( - self, bot: Bot, event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None + def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: + """Convert the object to a JSON representation. + + Args: + json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted. + + Returns: + Dict[str, Any]: JSON representation of the object. + """ + return { + "_id": self._id if not json_compatible else str(self._id), + "name": self.name, + "guild_id": self.guild_id, + "created": self.created, + "ended": self.ended, + "is_cancelled": self.is_cancelled, + "creator_id": self.creator_id, + "starts": self.starts, + "ends": self.ends, + "thumbnail": self.thumbnail, + "stage_ids": self.stage_ids, + } + + @staticmethod + def get_defaults() -> Dict[str, Any]: + """Get default values for the object attributes. + + Returns: + Dict[str, Any]: Mapping of attributes and their respective values in format `{"attribute_name:" attribute_value}`. + """ + return { + "name": None, + "guild_id": None, + "created": None, + "ended": None, + "is_cancelled": False, + "creator_id": None, + "starts": None, + "ends": None, + "thumbnail": None, + "stage_ids": [], + } + + @staticmethod + def get_default_value(key: str) -> Any: + """Get default value of the attribute for the object. + + Args: + key (str): Name of the attribute. + + Returns: + Any: Default value of the attribute. + + Raises: + KeyError: There's no default value for the provided attribute. + """ + if key not in PycordEvent.get_defaults(): + raise KeyError(f"There's no default value for key '{key}' in PycordEvent") + + return PycordEvent.get_defaults()[key] + + async def update( + self, + cache: Optional[Cache] = None, + **kwargs, ) -> None: + """Update attribute(s) on the object and save the updated entry into the database. + + Args: + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + **kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update. + + Raises: + AttributeError: Provided attribute does not exist in the class. + """ + await self._set(cache=cache, **kwargs) + + async def reset( + self, + *args, + cache: Optional[Cache] = None, + ) -> None: + """Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. + + Args: + *args (str): List of attributes to remove. + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + + Raises: + AttributeError: Provided attribute does not exist in the class. + """ + await self._remove(*args, cache=cache) + + async def purge(self, cache: Optional[Cache] = None) -> None: + """Completely remove event data from database. Currently only removes the event record from events collection. + + Args: + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + """ + await self.__collection__.delete_one({"_id": self._id}) + self._delete_cache(cache) + + async def cancel(self, cache: Optional[Cache] = None) -> None: + """Cancel the event. + + Attribute `is_cancelled` will be set to `True`. + + Args: + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + """ + await self._set(cache, is_cancelled=True) + + async def end(self, cache: Optional[Cache] = None) -> None: + """End the event. + + Attribute `ended` will be set to the current date in UTC. + + Args: + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + """ + await self._set(cache, ended=datetime.now(tz=ZoneInfo("UTC"))) + + async def insert_stage( + self, bot: "PycordBot", event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None + ) -> None: + """Insert a stage at the provided index. + + Args: + bot (PycordBot): Bot object. + event_stage_id (ObjectId): Stage ID to be inserted. + index (int): Index to be inserted at. + cache: Cache engine that will be used to update the cache. + """ old_stage_ids: List[ObjectId] = self.stage_ids.copy() self.stage_ids.insert(index, event_stage_id) @@ -305,10 +389,17 @@ class PycordEvent: await self._set(cache, stage_ids=self.stage_ids) await self._update_event_stage_order(bot, old_stage_ids, cache=cache) - # TODO Add documentation async def reorder_stage( - self, bot: Any, event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None + self, bot: "PycordBot", event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None ) -> None: + """Reorder a stage to the provided index. + + Args: + bot (PycordBot): Bot object. + event_stage_id (ObjectId): Stage ID to be reordered. + index (int): Index to be reordered to. + cache: Cache engine that will be used to update the cache. + """ old_stage_ids: List[ObjectId] = self.stage_ids.copy() self.stage_ids.insert(index, self.stage_ids.pop(self.stage_ids.index(event_stage_id))) @@ -316,8 +407,14 @@ class PycordEvent: await self._set(cache, stage_ids=self.stage_ids) await self._update_event_stage_order(bot, old_stage_ids, cache=cache) - # TODO Add documentation - async def remove_stage(self, bot: Bot, event_stage_id: ObjectId, cache: Optional[Cache] = None) -> None: + async def remove_stage(self, bot: "PycordBot", event_stage_id: ObjectId, cache: Optional[Cache] = None) -> None: + """Remove a stage from the event. + + Args: + bot (PycordBot): Bot object. + event_stage_id (ObjectId): Stage ID to be reordered. + cache: Cache engine that will be used to update the cache. + """ old_stage_ids: List[ObjectId] = self.stage_ids.copy() self.stage_ids.pop(self.stage_ids.index(event_stage_id)) @@ -325,10 +422,58 @@ class PycordEvent: await self._set(cache, stage_ids=self.stage_ids) await self._update_event_stage_order(bot, old_stage_ids, cache=cache) - # # TODO Add documentation - # def get_localized_start_date(self, tz: str | timezone | ZoneInfo) -> datetime: - # return self.starts.replace(tzinfo=tz) + def get_start_date_utc(self) -> datetime: + """Get the event start date in UTC timezone. - # TODO Add documentation - # def get_localized_end_date(self, tz: str | timezone | ZoneInfo) -> datetime: - # return self.ends.replace(tzinfo=tz) + Returns: + datetime: Start date in UTC. + + Raises: + ValueError: Event does not have a start date. + """ + if self.starts is None: + raise ValueError("Event does not have a start date") + + return self.starts.replace(tzinfo=ZoneInfo("UTC")) + + def get_end_date_utc(self) -> datetime: + """Get the event end date in UTC timezone. + + Returns: + datetime: End date in UTC. + + Raises: + ValueError: Event does not have an end date. + """ + if self.ends is None: + raise ValueError("Event does not have an end date") + + return self.ends.replace(tzinfo=ZoneInfo("UTC")) + + def get_start_date_localized(self, tz: str | timezone | ZoneInfo) -> datetime: + """Get the event start date in the provided timezone. + + Returns: + datetime: Start date in the provided timezone. + + Raises: + ValueError: Event does not have a start date. + """ + if self.starts is None: + raise ValueError("Event does not have a start date") + + return self.starts.replace(tzinfo=tz) + + def get_end_date_localized(self, tz: str | timezone | ZoneInfo) -> datetime: + """Get the event end date in the provided timezone. + + Returns: + datetime: End date in the provided timezone. + + Raises: + ValueError: Event does not have an end date. + """ + if self.ends is None: + raise ValueError("Event does not have an end date") + + return self.ends.replace(tzinfo=tz) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 44ce8f3..35ff40b 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -242,8 +242,8 @@ class CogEvent(Cog): await ctx.respond("Event was not found.") return - starts_date: datetime = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) - ends_date: datetime = pycord_event.ends.replace(tzinfo=ZoneInfo("UTC")) + starts_date: datetime = pycord_event.get_start_date_utc() + ends_date: datetime = pycord_event.get_end_date_utc() stages: List[PycordEventStage] = await self.bot.get_event_stages(pycord_event) diff --git a/modules/utils/datetime_utils.py b/modules/utils/datetime_utils.py index 61b384c..a267962 100644 --- a/modules/utils/datetime_utils.py +++ b/modules/utils/datetime_utils.py @@ -3,5 +3,5 @@ from zoneinfo import ZoneInfo # TODO Add documentation -def get_unix_timestamp(date: datetime) -> int: - return int((date.replace(tzinfo=ZoneInfo("UTC"))).timestamp()) +def get_unix_timestamp(date: datetime, to_utc: bool = False) -> int: + return int((date if not to_utc else date.replace(tzinfo=ZoneInfo("UTC"))).timestamp()) From 9a5edbaa4dcb3800547c9be40c677780ccd2c1c1 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 27 Apr 2025 22:04:14 +0200 Subject: [PATCH 13/55] Introduced i18n for /event, /guess, /register and /unregister. Prepared other commands for i18n too --- cogs/cog_event.py | 124 +++++++++++++++++++++++++++++++++-------- cogs/cog_guess.py | 19 +++++-- cogs/cog_register.py | 13 ++++- cogs/cog_stage.py | 7 ++- cogs/cog_unregister.py | 23 ++++++-- locale/en-US.json | 124 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 273 insertions(+), 37 deletions(-) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 35ff40b..eeca8f5 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -11,6 +11,7 @@ from discord import ( ) from discord.ext.commands import Cog from discord.utils import basic_autocomplete +from libbot.i18n import in_every_locale, _ from classes import PycordEvent, PycordEventStage, PycordGuild from classes.pycord_bot import PycordBot @@ -28,18 +29,49 @@ class CogEvent(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot - # TODO Introduce i18n - command_group: SlashCommandGroup = SlashCommandGroup("event", "Event management") + command_group: SlashCommandGroup = SlashCommandGroup( + "event", + description=_("description", "commands", "event"), + description_localizations=in_every_locale("description", "commands", "event"), + ) - # TODO Introduce i18n @command_group.command( name="create", - description="Create new event", + description=_("description", "commands", "event_create"), + description_localizations=in_every_locale("description", "commands", "event_create"), + ) + @option( + "name", + description=_("description", "commands", "event_create", "options", "name"), + description_localizations=in_every_locale( + "description", "commands", "event_create", "options", "name" + ), + required=True, + ) + @option( + "start", + description=_("description", "commands", "event_create", "options", "start"), + description_localizations=in_every_locale( + "description", "commands", "event_create", "options", "start" + ), + required=True, + ) + @option( + "end", + description=_("description", "commands", "event_create", "options", "end"), + description_localizations=in_every_locale( + "description", "commands", "event_create", "options", "end" + ), + required=True, + ) + @option( + "thumbnail", + description=_("description", "commands", "event_create", "options", "thumbnail"), + description_localizations=in_every_locale( + "description", "commands", "event_create", "options", "thumbnail" + ), + required=False, ) - @option("name", description="Name of the event", required=True) - @option("start", description="Date when the event starts (DD.MM.YYYY HH:MM)", required=True) - @option("end", description="Date when the event ends (DD.MM.YYYY HH:MM)", required=True) - @option("thumbnail", description="Thumbnail of the event", required=False) async def command_event_create( self, ctx: ApplicationContext, @@ -89,21 +121,52 @@ class CogEvent(Cog): f"Event **{event.name}** has been created and will take place ." ) - # TODO Introduce i18n @command_group.command( name="edit", - description="Edit event", + description=_("description", "commands", "event_edit"), + description_localizations=in_every_locale("description", "commands", "event_edit"), ) @option( "event", - description="Name of the event", + description=_("description", "commands", "event_edit", "options", "event"), + description_localizations=in_every_locale( + "description", "commands", "event_edit", "options", "event" + ), autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) - @option("name", description="New name of the event", required=False) - @option("start", description="Date when the event starts (DD.MM.YYYY HH:MM)", required=False) - @option("end", description="Date when the event ends (DD.MM.YYYY HH:MM)", required=False) - @option("thumbnail", description="Thumbnail of the event", required=False) + @option( + "name", + description=_("description", "commands", "event_edit", "options", "name"), + description_localizations=in_every_locale( + "description", "commands", "event_edit", "options", "name" + ), + required=False, + ) + @option( + "start", + description=_("description", "commands", "event_edit", "options", "start"), + description_localizations=in_every_locale( + "description", "commands", "event_edit", "options", "start" + ), + required=False, + ) + @option( + "end", + description=_("description", "commands", "event_edit", "options", "end"), + description_localizations=in_every_locale( + "description", "commands", "event_edit", "options", "end" + ), + required=False, + ) + @option( + "thumbnail", + description=_("description", "commands", "event_edit", "options", "thumbnail"), + description_localizations=in_every_locale( + "description", "commands", "event_edit", "options", "thumbnail" + ), + required=False, + ) async def command_event_edit( self, ctx: ApplicationContext, @@ -139,7 +202,9 @@ class CogEvent(Cog): return try: - end_date: datetime = pycord_event.ends if end is None else datetime.strptime(end, "%d.%m.%Y %H:%M") + end_date: datetime = ( + pycord_event.ends if end is None else datetime.strptime(end, "%d.%m.%Y %H:%M") + ) end_date = end_date.replace(tzinfo=guild_timezone) except ValueError: # TODO Make a nice message @@ -167,18 +232,28 @@ class CogEvent(Cog): f"Event **{pycord_event.name}** has been updated and will take place ." ) - # TODO Introduce i18n @command_group.command( name="cancel", - description="Cancel event", + description=_("description", "commands", "event_cancel"), + description_localizations=in_every_locale("description", "commands", "event_cancel"), ) @option( "event", - description="Name of the event", + description=_("description", "commands", "event_cancel", "options", "event"), + description_localizations=in_every_locale( + "description", "commands", "event_cancel", "options", "event" + ), autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) - @option("confirm", description="Confirmation of the operation", required=False) + @option( + "confirm", + description=_("description", "commands", "event_cancel", "options", "confirm"), + description_localizations=in_every_locale( + "description", "commands", "event_cancel", "options", "confirm" + ), + required=False, + ) async def command_event_cancel( self, ctx: ApplicationContext, @@ -221,14 +296,17 @@ class CogEvent(Cog): # TODO Make a nice message await ctx.respond(f"Event **{pycord_event.name}** was cancelled.") - # TODO Introduce i18n @command_group.command( name="show", - description="Show the details about certain event", + description=_("description", "commands", "event_show"), + description_localizations=in_every_locale("description", "commands", "event_show"), ) @option( "event", - description="Name of the event", + description=_("description", "commands", "event_show", "options", "event"), + description_localizations=in_every_locale( + "description", "commands", "event_show", "options", "event" + ), autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index 7ce17b2..c08a936 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -3,6 +3,7 @@ from typing import List from bson import ObjectId from bson.errors import InvalidId from discord import ApplicationContext, Cog, File, option, slash_command +from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser from classes.pycord_bot import PycordBot @@ -14,12 +15,16 @@ class CogGuess(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot - # TODO Implement the command @slash_command( name="guess", - description="Propose an answer to the current event stage", + description=_("description", "commands", "guess"), + description_localizations=in_every_locale("description", "commands", "guess"), + ) + @option( + "answer", + description=_("description", "commands", "guess", "options", "answer"), + description_localizations=in_every_locale("description", "commands", "guess", "options", "answer"), ) - @option("answer", description="An answer to the current stage") async def command_guess(self, ctx: ApplicationContext, answer: str) -> None: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) @@ -31,7 +36,9 @@ class CogGuess(Cog): if user.is_jailed: # TODO Make a nice message - await ctx.respond("You are jailed and cannot interact with events. Please, contact the administrator.") + await ctx.respond( + "You are jailed and cannot interact with events. Please, contact the administrator." + ) return if user.current_event_id is None or user.current_stage_id is None: @@ -44,7 +51,9 @@ class CogGuess(Cog): stage: PycordEventStage = await self.bot.find_event_stage(user.current_stage_id) except (InvalidId, RuntimeError): # TODO Make a nice message - await ctx.respond("Your event could not be found. Please, report this issue to the event's management.") + await ctx.respond( + "Your event could not be found. Please, report this issue to the event's management." + ) return if ctx.channel_id != user.event_channels[str(event._id)]: diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 1934077..fc8679d 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -7,6 +7,7 @@ from zoneinfo import ZoneInfo from bson.errors import InvalidId from discord import ApplicationContext, Cog, option, slash_command, TextChannel, File from discord.utils import basic_autocomplete +from libbot.i18n import in_every_locale, _ from classes import PycordEvent, PycordGuild, PycordUser, PycordEventStage from classes.pycord_bot import PycordBot @@ -24,11 +25,15 @@ class CogRegister(Cog): # TODO Introduce i18n @slash_command( name="register", - description="Enter the selected event", + description=_("description", "commands", "register"), + description_localizations=in_every_locale("description", "commands", "register"), ) @option( "event", - description="Name of the event", + description=_("description", "commands", "register", "options", "event"), + description_localizations=in_every_locale( + "description", "commands", "register", "options", "event" + ), autocomplete=basic_autocomplete(autocomplete_active_events), ) async def command_register(self, ctx: ApplicationContext, event: str) -> None: @@ -49,7 +54,9 @@ class CogRegister(Cog): if user.is_jailed: # TODO Make a nice message - await ctx.respond("You are jailed and cannot interact with events. Please, contact the administrator.") + await ctx.respond( + "You are jailed and cannot interact with events. Please, contact the administrator." + ) return if pycord_event._id in user.registered_event_ids: diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 8096607..1c39fe2 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -4,6 +4,7 @@ from bson.errors import InvalidId from discord import ApplicationContext, Attachment, SlashCommandGroup, option from discord.ext.commands import Cog from discord.utils import basic_autocomplete +from libbot.i18n import in_every_locale, _ from classes import PycordEvent, PycordEventStage, PycordGuild from classes.pycord_bot import PycordBot @@ -21,7 +22,11 @@ class CogStage(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot - command_group: SlashCommandGroup = SlashCommandGroup("stage", "Event stage management") + command_group: SlashCommandGroup = SlashCommandGroup( + "stage", + description=_("description", "commands", "stage"), + description_localizations=in_every_locale("description", "commands", "stage"), + ) # TODO Introduce i18n # TODO Maybe add an option for order? diff --git a/cogs/cog_unregister.py b/cogs/cog_unregister.py index 65681e2..516d64c 100644 --- a/cogs/cog_unregister.py +++ b/cogs/cog_unregister.py @@ -1,6 +1,7 @@ from bson.errors import InvalidId from discord import ApplicationContext, Cog, option, slash_command from discord.utils import basic_autocomplete +from libbot.i18n import in_every_locale, _ from classes import PycordEvent, PycordGuild, PycordUser from classes.pycord_bot import PycordBot @@ -13,17 +14,27 @@ class CogUnregister(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot - # TODO Introduce i18n @slash_command( name="unregister", - description="Leave the selected event", + description=_("description", "commands", "unregister"), + description_localizations=in_every_locale("description", "commands", "unregister"), ) @option( "event", - description="Name of the event", + description=_("description", "commands", "unregister", "options", "event"), + description_localizations=in_every_locale( + "description", "commands", "unregister", "options", "event" + ), autocomplete=basic_autocomplete(autocomplete_user_registered_events), ) - @option("confirm", description="Confirmation of the operation", required=False) + @option( + "confirm", + description=_("description", "commands", "unregister", "options", "confirm"), + description_localizations=in_every_locale( + "description", "commands", "unregister", "options", "confirm" + ), + required=False, + ) async def command_unregister(self, ctx: ApplicationContext, event: str, confirm: bool = False) -> None: if not (await is_operation_confirmed(ctx, confirm)): return @@ -45,7 +56,9 @@ class CogUnregister(Cog): if user.is_jailed: # TODO Make a nice message - await ctx.respond("You are jailed and cannot interact with events. Please, contact the administrator.") + await ctx.respond( + "You are jailed and cannot interact with events. Please, contact the administrator." + ) return if pycord_event._id not in user.registered_event_ids: diff --git a/locale/en-US.json b/locale/en-US.json index 71d82f9..e3d1cd9 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -36,6 +36,130 @@ }, "config_show": { "description": "Show the guild's configuration" + }, + "event": { + "description": "Event management" + }, + "event_create": { + "description": "Create new event", + "options": { + "name": { + "description": "Name of the event" + }, + "start": { + "description": "Date when the event starts (DD.MM.YYYY HH:MM)" + }, + "end": { + "description": "Date when the event ends (DD.MM.YYYY HH:MM)" + }, + "thumbnail": { + "description": "Thumbnail of the event" + } + } + }, + "event_edit": { + "description": "Edit the event", + "options": { + "event": { + "description": "Name of the event" + }, + "name": { + "description": "New name of the event" + }, + "start": { + "description": "Date when the event starts (DD.MM.YYYY HH:MM)" + }, + "end": { + "description": "Date when the event ends (DD.MM.YYYY HH:MM)" + }, + "thumbnail": { + "description": "Thumbnail of the event" + } + } + }, + "event_cancel": { + "description": "Cancel the event", + "options": { + "event": { + "description": "Name of the event" + }, + "confirm": { + "description": "Confirmation of the operation" + } + } + }, + "event_show": { + "description": "Show details about the event", + "options": { + "event": { + "description": "Name of the event" + } + } + }, + "guess": { + "description": "Provide an answer to the current event stage", + "options": { + "answer": { + "description": "Answer to the current stage" + } + } + }, + "register": { + "description": "Register for the selected event", + "options": { + "event": { + "description": "Name of the event" + } + } + }, + "stage": { + "description": "Event stage management" + }, + "stage_add": { + "description": "", + "options": {} + }, + "stage_edit": { + "description": "", + "options": {} + }, + "stage_delete": { + "description": "", + "options": {} + }, + "unregister": { + "description": "Leave the selected event", + "options": { + "event": { + "description": "Name of the event" + }, + "confirm": { + "description": "Confirmation of the operation" + } + } + }, + "user": { + "description": "User management" + }, + "user_create_channel": { + "description": "", + "options": {} + }, + "user_update_channel": { + "description": "", + "options": {} + }, + "user_delete_channel": { + "description": "", + "options": {} + }, + "user_jail": { + "description": "", + "options": {} + }, + "user_unjail": { + "description": "", + "options": {} } } } From 923173ebe8eabd6e4a8d90fcba4691af1cd4ef48 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 27 Apr 2025 22:05:34 +0200 Subject: [PATCH 14/55] Added a stub for #11 and slightly improved typing --- classes/errors/pycord_event_stage.py | 2 +- classes/errors/pycord_guild.py | 2 +- cogs/cog_utility.py | 10 +++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/classes/errors/pycord_event_stage.py b/classes/errors/pycord_event_stage.py index 4e3d519..1d4b4d0 100644 --- a/classes/errors/pycord_event_stage.py +++ b/classes/errors/pycord_event_stage.py @@ -5,7 +5,7 @@ class EventStageNotFoundError(Exception): """PycordEventStage could not find event with such an ID in the database""" def __init__(self, stage_id: str | ObjectId) -> None: - self.stage_id = stage_id + self.stage_id: str | ObjectId = stage_id super().__init__(f"Stage with id {self.stage_id} was not found") diff --git a/classes/errors/pycord_guild.py b/classes/errors/pycord_guild.py index 21a89b7..83f1c48 100644 --- a/classes/errors/pycord_guild.py +++ b/classes/errors/pycord_guild.py @@ -2,6 +2,6 @@ class GuildNotFoundError(Exception): """PycordGuild could not find guild with such an ID in the database""" def __init__(self, guild_id: int) -> None: - self.guild_id = guild_id + self.guild_id: int = guild_id super().__init__(f"Guild with id {self.guild_id} was not found") diff --git a/cogs/cog_utility.py b/cogs/cog_utility.py index 33695f8..5f021c0 100644 --- a/cogs/cog_utility.py +++ b/cogs/cog_utility.py @@ -1,7 +1,6 @@ from logging import Logger -from discord import Activity, ActivityType, Cog -from discord.ext import commands +from discord import Activity, ActivityType, Cog, Member from classes.pycord_bot import PycordBot from modules.utils import get_logger @@ -13,7 +12,7 @@ class CogUtility(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot - @commands.Cog.listener() + @Cog.listener() async def on_ready(self) -> None: """Listener for the event when bot connects to Discord and becomes "ready".""" logger.info("Logged in as %s", self.bot.user) @@ -42,6 +41,11 @@ class CogUtility(Cog): logger.info("Set activity type to %s with message %s", activity_type, activity_message) + # TODO Implement #11 + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + pass + def setup(bot: PycordBot) -> None: bot.add_cog(CogUtility(bot)) From 11f0cc384a8d8293071fa0b05042568c68ce4bf1 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 27 Apr 2025 22:06:35 +0200 Subject: [PATCH 15/55] Changed line length to 108 in black --- classes/errors/__init__.py | 15 ++++++++++----- classes/errors/discord.py | 8 ++++++-- classes/pycord_bot.py | 8 ++++++-- classes/pycord_event.py | 12 +++++++++--- classes/pycord_user.py | 22 ++++++++++++++-------- cogs/cog_config.py | 16 ++++++++++++---- cogs/cog_event.py | 2 +- cogs/cog_register.py | 8 ++++---- cogs/cog_stage.py | 10 +++++++--- cogs/cog_unregister.py | 2 +- cogs/cog_user.py | 12 +++++++++--- cogs/cog_utility.py | 24 ++++++++++++++++++------ modules/utils/autocomplete_utils.py | 4 +++- pyproject.toml | 2 +- 14 files changed, 101 insertions(+), 44 deletions(-) diff --git a/classes/errors/__init__.py b/classes/errors/__init__.py index a0533c9..19e9902 100644 --- a/classes/errors/__init__.py +++ b/classes/errors/__init__.py @@ -1,10 +1,15 @@ from pycord_event import EventNotFoundError -from pycord_event_stage import EventStageNotFoundError, EventStageMissingSequenceError -from .discord import DiscordGuildMemberNotFoundError, DiscordCategoryNotFoundError, DiscordChannelNotFoundError +from pycord_event_stage import EventStageMissingSequenceError, EventStageNotFoundError + +from .discord import ( + DiscordCategoryNotFoundError, + DiscordChannelNotFoundError, + DiscordGuildMemberNotFoundError, +) from .pycord_guild import GuildNotFoundError from .pycord_user import ( - UserNotFoundError, - UserAlreadyRegisteredForEventError, - UserNotRegisteredForEventError, UserAlreadyCompletedEventError, + UserAlreadyRegisteredForEventError, + UserNotFoundError, + UserNotRegisteredForEventError, ) diff --git a/classes/errors/discord.py b/classes/errors/discord.py index 49a5b44..9b2efc0 100644 --- a/classes/errors/discord.py +++ b/classes/errors/discord.py @@ -15,7 +15,9 @@ class DiscordCategoryNotFoundError(Exception): self.category_id: int = category_id self.guild_id: int = guild_id - super().__init__(f"Category with id {self.category_id} was not found in guild with id {self.guild_id}") + super().__init__( + f"Category with id {self.category_id} was not found in guild with id {self.guild_id}" + ) class DiscordChannelNotFoundError(Exception): @@ -25,4 +27,6 @@ class DiscordChannelNotFoundError(Exception): self.channel_id: int = channel_id self.guild_id: int = guild_id - super().__init__(f"Channel with id {self.channel_id} was not found in guild with id {self.guild_id}") + super().__init__( + f"Channel with id {self.channel_id} was not found in guild with id {self.guild_id}" + ) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index d93db4b..02f0b38 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -55,7 +55,9 @@ class PycordBot(LibPycordBot): await super().close(**kwargs) async def _schedule_tasks(self) -> None: - self.scheduler.add_job(self._execute_event_controller, trigger="cron", minute="*/1", id="event_controller") + self.scheduler.add_job( + self._execute_event_controller, trigger="cron", minute="*/1", id="event_controller" + ) async def _execute_event_controller(self) -> None: await self._process_events_start() @@ -136,7 +138,9 @@ class PycordBot(LibPycordBot): first_stage_files: List[File] | None = first_stage.get_media_files() - await user_channel.send(f"First stage...\n\n{first_stage.question}", files=first_stage_files) + await user_channel.send( + f"First stage...\n\n{first_stage.question}", files=first_stage_files + ) # TODO Make a nice message await self.notify_admins( diff --git a/classes/pycord_event.py b/classes/pycord_event.py index d8a47d3..19c99d4 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -97,7 +97,9 @@ class PycordEvent: return cls(**db_entry) @classmethod - async def from_name(cls, event_name: str, guild_id: int, cache: Optional[Cache] = None) -> "PycordEvent": + async def from_name( + cls, event_name: str, guild_id: int, cache: Optional[Cache] = None + ) -> "PycordEvent": """Find the event by its name and construct PycordEvent from database entry. If multiple events with the same name exist, the one with the greatest start date will be returned. @@ -244,7 +246,9 @@ class PycordEvent: stage_index: int = self.stage_ids.index(event_stage_id) old_stage_index: int = old_stage_ids.index(event_stage_id) - logger.debug("Indexes for %s: was %s and is now %s", event_stage_id, old_stage_index, stage_index) + logger.debug( + "Indexes for %s: was %s and is now %s", event_stage_id, old_stage_index, stage_index + ) if stage_index != old_stage_index: await (await bot.find_event_stage(event_stage_id)).update(cache, sequence=stage_index) @@ -407,7 +411,9 @@ class PycordEvent: await self._set(cache, stage_ids=self.stage_ids) await self._update_event_stage_order(bot, old_stage_ids, cache=cache) - async def remove_stage(self, bot: "PycordBot", event_stage_id: ObjectId, cache: Optional[Cache] = None) -> None: + async def remove_stage( + self, bot: "PycordBot", event_stage_id: ObjectId, cache: Optional[Cache] = None + ) -> None: """Remove a stage from the event. Args: diff --git a/classes/pycord_user.py b/classes/pycord_user.py index 026f9f6..12c78e8 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -18,13 +18,13 @@ from libbot.cache.classes import Cache from pymongo.results import InsertOneResult from classes.errors import ( - UserNotFoundError, - UserAlreadyRegisteredForEventError, - UserAlreadyCompletedEventError, - UserNotRegisteredForEventError, - DiscordGuildMemberNotFoundError, DiscordCategoryNotFoundError, DiscordChannelNotFoundError, + DiscordGuildMemberNotFoundError, + UserAlreadyCompletedEventError, + UserAlreadyRegisteredForEventError, + UserNotFoundError, + UserNotRegisteredForEventError, ) from modules.database import col_users from modules.utils import get_logger, restore_from_cache @@ -122,8 +122,12 @@ class PycordUser: "guild_id": self.guild_id, "event_channels": self.event_channels, "is_jailed": self.is_jailed, - "current_event_id": (self.current_event_id if not json_compatible else str(self.current_event_id)), - "current_stage_id": (self.current_stage_id if not json_compatible else str(self.current_stage_id)), + "current_event_id": ( + self.current_event_id if not json_compatible else str(self.current_event_id) + ), + "current_stage_id": ( + self.current_stage_id if not json_compatible else str(self.current_stage_id) + ), "registered_event_ids": ( self.registered_event_ids if not json_compatible @@ -356,7 +360,9 @@ class PycordUser: # TODO Add documentation async def set_event_stage(self, stage_id: str | ObjectId | None, cache: Optional[Cache] = None) -> None: - await self._set(cache, current_stage_id=stage_id if isinstance(stage_id, str) else ObjectId(stage_id)) + await self._set( + cache, current_stage_id=stage_id if isinstance(stage_id, str) else ObjectId(stage_id) + ) # TODO Add documentation async def jail(self, cache: Optional[Cache] = None) -> None: diff --git a/cogs/cog_config.py b/cogs/cog_config.py index c5b8cd7..31e2bf5 100644 --- a/cogs/cog_config.py +++ b/cogs/cog_config.py @@ -36,14 +36,18 @@ class CogConfig(Cog): @option( "category", description=_("description", "commands", "config_set", "options", "category"), - description_localizations=in_every_locale("description", "commands", "config_set", "options", "category"), + description_localizations=in_every_locale( + "description", "commands", "config_set", "options", "category" + ), required=True, ) @option("channel", description="Text channel for admin notifications", required=True) @option( "timezone", description=_("description", "commands", "config_set", "options", "timezone"), - description_localizations=in_every_locale("description", "commands", "config_set", "options", "timezone"), + description_localizations=in_every_locale( + "description", "commands", "config_set", "options", "timezone" + ), autocomplete=basic_autocomplete(autocomplete_timezones), required=True, ) @@ -59,7 +63,9 @@ class CogConfig(Cog): try: timezone_parsed: ZoneInfo = ZoneInfo(timezone) except ZoneInfoNotFoundError: - await ctx.respond(self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone)) + await ctx.respond( + self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone) + ) return await guild.update( @@ -79,7 +85,9 @@ class CogConfig(Cog): @option( "confirm", description=_("description", "commands", "config_reset", "options", "confirm"), - description_localizations=in_every_locale("description", "commands", "config_reset", "options", "confirm"), + description_localizations=in_every_locale( + "description", "commands", "config_reset", "options", "confirm" + ), required=False, ) async def command_config_reset(self, ctx: ApplicationContext, confirm: bool = False) -> None: diff --git a/cogs/cog_event.py b/cogs/cog_event.py index eeca8f5..7e1f6d9 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -11,7 +11,7 @@ from discord import ( ) from discord.ext.commands import Cog from discord.utils import basic_autocomplete -from libbot.i18n import in_every_locale, _ +from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild from classes.pycord_bot import PycordBot diff --git a/cogs/cog_register.py b/cogs/cog_register.py index fc8679d..587192f 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -5,13 +5,13 @@ from typing import List from zoneinfo import ZoneInfo from bson.errors import InvalidId -from discord import ApplicationContext, Cog, option, slash_command, TextChannel, File +from discord import ApplicationContext, Cog, File, TextChannel, option, slash_command from discord.utils import basic_autocomplete -from libbot.i18n import in_every_locale, _ +from libbot.i18n import _, in_every_locale -from classes import PycordEvent, PycordGuild, PycordUser, PycordEventStage +from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser from classes.pycord_bot import PycordBot -from modules.utils import autocomplete_active_events, get_unix_timestamp, get_logger +from modules.utils import autocomplete_active_events, get_logger, get_unix_timestamp logger: Logger = get_logger(__name__) diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 1c39fe2..3fc2b07 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -4,7 +4,7 @@ from bson.errors import InvalidId from discord import ApplicationContext, Attachment, SlashCommandGroup, option from discord.ext.commands import Cog from discord.utils import basic_autocomplete -from libbot.i18n import in_every_locale, _ +from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild from classes.pycord_bot import PycordBot @@ -67,7 +67,9 @@ class CogStage(Cog): if not (await is_event_status_valid(ctx, pycord_event)): return - processed_media: List[Dict[str, Any]] = [] if media is None else await self.bot.process_attachments([media]) + processed_media: List[Dict[str, Any]] = ( + [] if media is None else await self.bot.process_attachments([media]) + ) event_stage: PycordEventStage = await self.bot.create_event_stage( event=pycord_event, @@ -146,7 +148,9 @@ class CogStage(Cog): await ctx.respond("Stage sequence out of range.") return - processed_media: List[Dict[str, Any]] = [] if media is None else await self.bot.process_attachments([media]) + processed_media: List[Dict[str, Any]] = ( + [] if media is None else await self.bot.process_attachments([media]) + ) if not (question is None and answer is None and media is None and remove_media is False): await event_stage.update( diff --git a/cogs/cog_unregister.py b/cogs/cog_unregister.py index 516d64c..16e8065 100644 --- a/cogs/cog_unregister.py +++ b/cogs/cog_unregister.py @@ -1,7 +1,7 @@ from bson.errors import InvalidId from discord import ApplicationContext, Cog, option, slash_command from discord.utils import basic_autocomplete -from libbot.i18n import in_every_locale, _ +from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordGuild, PycordUser from classes.pycord_bot import PycordBot diff --git a/cogs/cog_user.py b/cogs/cog_user.py index cbeeb40..ef86870 100644 --- a/cogs/cog_user.py +++ b/cogs/cog_user.py @@ -54,7 +54,9 @@ class CogUser(Cog): description="Selected user", ) @option("confirm", description="Confirmation of the operation", required=False) - async def command_user_delete_channel(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None: + async def command_user_delete_channel( + self, ctx: ApplicationContext, user: User, confirm: bool = False + ) -> None: await ctx.respond("Not implemented.") # TODO Introduce i18n @@ -81,7 +83,9 @@ class CogUser(Cog): await pycord_user.jail(self.bot.cache) # TODO Introduce i18n - await ctx.respond(f"User **{user.display_name}** has been jailed and cannot interact with events anymore.") + await ctx.respond( + f"User **{user.display_name}** has been jailed and cannot interact with events anymore." + ) # TODO Introduce i18n @command_group.command( @@ -107,7 +111,9 @@ class CogUser(Cog): await pycord_user.unjail(self.bot.cache) # TODO Introduce i18n - await ctx.respond(f"User **{user.display_name}** has been unjailed and can interact with events again.") + await ctx.respond( + f"User **{user.display_name}** has been unjailed and can interact with events again." + ) def setup(bot: PycordBot) -> None: diff --git a/cogs/cog_utility.py b/cogs/cog_utility.py index 5f021c0..f31a428 100644 --- a/cogs/cog_utility.py +++ b/cogs/cog_utility.py @@ -25,17 +25,29 @@ class CogUtility(Cog): return if activity_type == "playing": - await self.bot.change_presence(activity=Activity(type=ActivityType.playing, name=activity_message)) + await self.bot.change_presence( + activity=Activity(type=ActivityType.playing, name=activity_message) + ) elif activity_type == "watching": - await self.bot.change_presence(activity=Activity(type=ActivityType.watching, name=activity_message)) + await self.bot.change_presence( + activity=Activity(type=ActivityType.watching, name=activity_message) + ) elif activity_type == "listening": - await self.bot.change_presence(activity=Activity(type=ActivityType.listening, name=activity_message)) + await self.bot.change_presence( + activity=Activity(type=ActivityType.listening, name=activity_message) + ) elif activity_type == "streaming": - await self.bot.change_presence(activity=Activity(type=ActivityType.streaming, name=activity_message)) + await self.bot.change_presence( + activity=Activity(type=ActivityType.streaming, name=activity_message) + ) elif activity_type == "competing": - await self.bot.change_presence(activity=Activity(type=ActivityType.competing, name=activity_message)) + await self.bot.change_presence( + activity=Activity(type=ActivityType.competing, name=activity_message) + ) elif activity_type == "custom": - await self.bot.change_presence(activity=Activity(type=ActivityType.custom, name=activity_message)) + await self.bot.change_presence( + activity=Activity(type=ActivityType.custom, name=activity_message) + ) else: return diff --git a/modules/utils/autocomplete_utils.py b/modules/utils/autocomplete_utils.py index 502244f..06454a2 100644 --- a/modules/utils/autocomplete_utils.py +++ b/modules/utils/autocomplete_utils.py @@ -92,6 +92,8 @@ async def autocomplete_event_stages(ctx: AutocompleteContext) -> List[OptionChoi event_stages: List[OptionChoice] = [] async for result in col_stages.find(query).sort([("sequence", ASCENDING)]): - event_stages.append(OptionChoice(f"{result['sequence']+1} ({result['question']})", str(result["_id"]))) + event_stages.append( + OptionChoice(f"{result['sequence']+1} ({result['question']})", str(result["_id"])) + ) return event_stages diff --git a/pyproject.toml b/pyproject.toml index 0b2a344..8285e6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ readme = "README.md" requires-python = ">=3.11" [tool.black] -line-length = 118 +line-length = 108 target-version = ["py311", "py312", "py313"] [tool.isort] From d41d41663f76a3103c07e589e435019ecffe947f Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 27 Apr 2025 22:08:42 +0200 Subject: [PATCH 16/55] Fixed broken imports --- classes/errors/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/classes/errors/__init__.py b/classes/errors/__init__.py index 19e9902..d8fbaa6 100644 --- a/classes/errors/__init__.py +++ b/classes/errors/__init__.py @@ -1,11 +1,10 @@ -from pycord_event import EventNotFoundError -from pycord_event_stage import EventStageMissingSequenceError, EventStageNotFoundError - from .discord import ( DiscordCategoryNotFoundError, DiscordChannelNotFoundError, DiscordGuildMemberNotFoundError, ) +from .pycord_event import EventNotFoundError +from .pycord_event_stage import EventStageMissingSequenceError, EventStageNotFoundError from .pycord_guild import GuildNotFoundError from .pycord_user import ( UserAlreadyCompletedEventError, From 99653c6fe19abc917f3e853720af3ba2389d4147 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 27 Apr 2025 22:30:21 +0200 Subject: [PATCH 17/55] Improved (still partial) i18n for /stage and /user --- cogs/cog_stage.py | 122 +++++++++++++++++++++++++++++++++++++--------- cogs/cog_user.py | 110 +++++++++++++++++++++++++---------------- locale/en-US.json | 85 +++++++++++++++++++++++++++----- 3 files changed, 238 insertions(+), 79 deletions(-) diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 3fc2b07..4b3a573 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -28,21 +28,45 @@ class CogStage(Cog): description_localizations=in_every_locale("description", "commands", "stage"), ) - # TODO Introduce i18n # TODO Maybe add an option for order? @command_group.command( name="add", - description="Add new event stage", + description=_("description", "commands", "stage_add"), + description_localizations=in_every_locale("description", "commands", "stage_add"), ) @option( "event", - description="Name of the event", + description=_("description", "commands", "stage_add", "options", "event"), + description_localizations=in_every_locale( + "description", "commands", "stage_add", "options", "event" + ), autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) - @option("question", description="Question to be answered", required=True) - @option("answer", description="Answer to the stage's question", required=True) - @option("media", description="Media file to be attached", required=False) + @option( + "question", + description=_("description", "commands", "stage_add", "options", "question"), + description_localizations=in_every_locale( + "description", "commands", "stage_add", "options", "question" + ), + required=True, + ) + @option( + "answer", + description=_("description", "commands", "stage_add", "options", "answer"), + description_localizations=in_every_locale( + "description", "commands", "stage_add", "options", "answer" + ), + required=True, + ) + @option( + "media", + description=_("description", "commands", "stage_add", "options", "media"), + description_localizations=in_every_locale( + "description", "commands", "stage_add", "options", "media" + ), + required=False, + ) async def command_stage_add( self, ctx: ApplicationContext, @@ -85,30 +109,70 @@ class CogStage(Cog): # TODO Make a nice message await ctx.respond("Event stage has been created.") - # TODO Implement the command - # /stage edit @command_group.command( name="edit", - description="Edit the event stage", + description=_("description", "commands", "stage_edit"), + description_localizations=in_every_locale("description", "commands", "stage_edit"), ) @option( "event", - description="Name of the event", + description=_("description", "commands", "stage_edit", "options", "event"), + description_localizations=in_every_locale( + "description", "commands", "stage_edit", "options", "event" + ), autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) - # TODO Add autofill @option( "stage", - description="Stage to edit", + description=_("description", "commands", "stage_edit", "options", "stage"), + description_localizations=in_every_locale( + "description", "commands", "stage_edit", "options", "stage" + ), autocomplete=basic_autocomplete(autocomplete_event_stages), required=True, ) - @option("order", description="Number in the event stages' order", min_value=1, required=False) - @option("question", description="Question to be answered", required=False) - @option("answer", description="Answer to the stage's question", required=False) - @option("media", description="Media file to be attached", required=False) - @option("remove_media", description="Remove attached media", required=False) + @option( + "order", + description=_("description", "commands", "stage_edit", "options", "order"), + description_localizations=in_every_locale( + "description", "commands", "stage_edit", "options", "order" + ), + min_value=1, + required=False, + ) + @option( + "question", + description=_("description", "commands", "stage_edit", "options", "question"), + description_localizations=in_every_locale( + "description", "commands", "stage_edit", "options", "question" + ), + required=False, + ) + @option( + "answer", + description=_("description", "commands", "stage_edit", "options", "answer"), + description_localizations=in_every_locale( + "description", "commands", "stage_edit", "options", "answer" + ), + required=False, + ) + @option( + "media", + description=_("description", "commands", "stage_edit", "options", "media"), + description_localizations=in_every_locale( + "description", "commands", "stage_edit", "options", "media" + ), + required=False, + ) + @option( + "remove_media", + description=_("description", "commands", "stage_edit", "options", "remove_media"), + description_localizations=in_every_locale( + "description", "commands", "stage_edit", "options", "remove_media" + ), + required=False, + ) async def command_stage_edit( self, ctx: ApplicationContext, @@ -164,25 +228,37 @@ class CogStage(Cog): await ctx.respond("Event stage has been updated.") - # TODO Implement the command - # /stage delete @command_group.command( name="delete", - description="Delete the event stage", + description=_("description", "commands", "stage_delete"), + description_localizations=in_every_locale("description", "commands", "stage_delete"), ) @option( "event", - description="Name of the event", + description=_("description", "commands", "stage_delete", "options", "event"), + description_localizations=in_every_locale( + "description", "commands", "stage_delete", "options", "event" + ), autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) @option( "stage", - description="Stage to delete", + description=_("description", "commands", "stage_delete", "options", "stage"), + description_localizations=in_every_locale( + "description", "commands", "stage_delete", "options", "stage" + ), autocomplete=basic_autocomplete(autocomplete_event_stages), required=True, ) - @option("confirm", description="Confirmation of the operation", required=False) + @option( + "confirm", + description=_("description", "commands", "stage_delete", "options", "confirm"), + description_localizations=in_every_locale( + "description", "commands", "stage_delete", "options", "confirm" + ), + required=False, + ) async def command_stage_delete( self, ctx: ApplicationContext, event: str, stage: str, confirm: bool = False ) -> None: diff --git a/cogs/cog_user.py b/cogs/cog_user.py index ef86870..cc73f5b 100644 --- a/cogs/cog_user.py +++ b/cogs/cog_user.py @@ -5,6 +5,7 @@ from discord import ( option, ) from discord.ext.commands import Cog +from libbot.i18n import _, in_every_locale from classes import PycordUser from classes.pycord_bot import PycordBot @@ -17,58 +18,71 @@ class CogUser(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot - # TODO Introduce i18n - command_group: SlashCommandGroup = SlashCommandGroup("user", "User management") + command_group: SlashCommandGroup = SlashCommandGroup( + "user", + description=_("description", "commands", "user"), + description_localizations=in_every_locale("description", "commands", "user"), + ) # TODO Implement the command - @command_group.command( - name="create_channel", - description="Create channel for the user", - ) - @option( - "user", - description="Selected user", - ) - async def command_user_create_channel(self, ctx: ApplicationContext, user: User) -> None: - await ctx.respond("Not implemented.") + # @command_group.command( + # name="create_channel", + # description="Create channel for the user", + # ) + # @option( + # "user", + # description="Selected user", + # ) + # async def command_user_create_channel(self, ctx: ApplicationContext, user: User) -> None: + # await ctx.respond("Not implemented.") # TODO Implement the command - @command_group.command( - name="update_channel", - description="Update user's channel", - ) - @option( - "user", - description="Selected user", - ) - async def command_user_update_channel(self, ctx: ApplicationContext, user: User) -> None: - await ctx.respond("Not implemented.") + # @command_group.command( + # name="update_channel", + # description="Update user's channel", + # ) + # @option( + # "user", + # description="Selected user", + # ) + # async def command_user_update_channel(self, ctx: ApplicationContext, user: User) -> None: + # await ctx.respond("Not implemented.") # TODO Implement the command - @command_group.command( - name="delete_channel", - description="Delete user's channel", - ) - @option( - "user", - description="Selected user", - ) - @option("confirm", description="Confirmation of the operation", required=False) - async def command_user_delete_channel( - self, ctx: ApplicationContext, user: User, confirm: bool = False - ) -> None: - await ctx.respond("Not implemented.") + # @command_group.command( + # name="delete_channel", + # description="Delete user's channel", + # ) + # @option( + # "user", + # description="Selected user", + # ) + # @option("confirm", description="Confirmation of the operation", required=False) + # async def command_user_delete_channel( + # self, ctx: ApplicationContext, user: User, confirm: bool = False + # ) -> None: + # await ctx.respond("Not implemented.") - # TODO Introduce i18n @command_group.command( name="jail", - description="Jail the user", + description=_("description", "commands", "user_jail"), + description_localizations=in_every_locale("description", "commands", "user_jail"), ) @option( "user", - description="Selected user", + description=_("description", "commands", "user_jail", "options", "user"), + description_localizations=in_every_locale( + "description", "commands", "user_jail", "options", "user" + ), + ) + @option( + "confirm", + description=_("description", "commands", "user_jail", "options", "confirm"), + description_localizations=in_every_locale( + "description", "commands", "user_jail", "options", "confirm" + ), + required=False, ) - @option("confirm", description="Confirmation of the operation", required=False) async def command_user_jail(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None: if not (await is_operation_confirmed(ctx, confirm)): return @@ -87,16 +101,26 @@ class CogUser(Cog): f"User **{user.display_name}** has been jailed and cannot interact with events anymore." ) - # TODO Introduce i18n @command_group.command( name="unjail", - description="Unjail the user", + description=_("description", "commands", "user_unjail"), + description_localizations=in_every_locale("description", "commands", "user_unjail"), ) @option( "user", - description="Selected user", + description=_("description", "commands", "user_unjail", "options", "user"), + description_localizations=in_every_locale( + "description", "commands", "user_unjail", "options", "user" + ), + ) + @option( + "confirm", + description=_("description", "commands", "user_unjail", "options", "confirm"), + description_localizations=in_every_locale( + "description", "commands", "user_unjail", "options", "confirm" + ), + required=False, ) - @option("confirm", description="Confirmation of the operation", required=False) async def command_user_unjail(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None: if not (await is_operation_confirmed(ctx, confirm)): return diff --git a/locale/en-US.json b/locale/en-US.json index e3d1cd9..f7527c1 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -116,16 +116,61 @@ "description": "Event stage management" }, "stage_add": { - "description": "", - "options": {} + "description": "Add new event stage", + "options": { + "event": { + "description": "Name of the event" + }, + "question": { + "description": "Question to be answered" + }, + "answer": { + "description": "Answer to the stage's question" + }, + "media": { + "description": "Media file to be attached" + } + } }, "stage_edit": { - "description": "", - "options": {} + "description": "Edit the event stage", + "options": { + "event": { + "description": "Name of the event" + }, + "stage": { + "description": "Stage to edit" + }, + "order": { + "description": "Number in the event stages' order" + }, + "question": { + "description": "Question to be answered" + }, + "answer": { + "description": "Answer to the question" + }, + "media": { + "description": "Media file to be attached" + }, + "remove_media": { + "description": "Remove attached media" + } + } }, "stage_delete": { - "description": "", - "options": {} + "description": "Delete the event stage", + "options": { + "event": { + "description": "Name of the event" + }, + "stage": { + "description": "Stage to delete" + }, + "confirm": { + "description": "Confirmation of the operation" + } + } }, "unregister": { "description": "Leave the selected event", @@ -142,24 +187,38 @@ "description": "User management" }, "user_create_channel": { - "description": "", + "description": "Create channel for the user", "options": {} }, "user_update_channel": { - "description": "", + "description": "Update user's channel", "options": {} }, "user_delete_channel": { - "description": "", + "description": "Delete user's channel", "options": {} }, "user_jail": { - "description": "", - "options": {} + "description": "Jail the user", + "options": { + "user": { + "description": "Selected user" + }, + "confirm": { + "description": "Confirmation of the operation" + } + } }, "user_unjail": { - "description": "", - "options": {} + "description": "Unjail the user", + "options": { + "user": { + "description": "Selected user" + }, + "confirm": { + "description": "Confirmation of the operation" + } + } } } } From b212236b1027001d74340850c97d73762af7897e Mon Sep 17 00:00:00 2001 From: Profitroll Date: Mon, 28 Apr 2025 01:21:20 +0200 Subject: [PATCH 18/55] Fixed categories and some timestamps --- classes/pycord_bot.py | 2 +- classes/pycord_user.py | 2 +- cogs/cog_event.py | 10 ++++++---- cogs/cog_register.py | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 02f0b38..cda9992 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -303,7 +303,7 @@ class PycordBot(LibPycordBot): event_name: Optional[str] = None, guild_id: Optional[int] = None, ) -> PycordEvent: - if event_id is None or (event_name is None and guild_id is None): + if event_id is None and (event_name is None or guild_id is None): raise AttributeError("Either event ID or name with guild ID must be provided") if event_id is not None: diff --git a/classes/pycord_user.py b/classes/pycord_user.py index 12c78e8..b0d8f44 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -305,7 +305,7 @@ class PycordUser: channel: TextChannel = await guild.create_text_channel( f"{discord_member.name}_{shake_256(str(pycord_event._id).encode()).hexdigest(3)}", - category=discord_category, + category=discord_category.category, overwrites=permission_overwrites, reason=f"Event channel of {self.id} for event {pycord_event._id}", ) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 7e1f6d9..2014904 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -101,7 +101,8 @@ class CogEvent(Cog): ) return - await validate_event_validity(ctx, name, start_date, end_date, guild_timezone) + if not await validate_event_validity(ctx, name, start_date, end_date, guild_timezone): + return processed_media: List[Dict[str, Any]] = ( [] if thumbnail is None else await self.bot.process_attachments([thumbnail]) @@ -118,7 +119,7 @@ class CogEvent(Cog): # TODO Introduce i18n await ctx.respond( - f"Event **{event.name}** has been created and will take place ." + f"Event **{event.name}** has been created and will take place ." ) @command_group.command( @@ -211,7 +212,8 @@ class CogEvent(Cog): await ctx.respond("Could not parse the end date.") return - await validate_event_validity(ctx, name, start_date, end_date, guild_timezone) + if not await validate_event_validity(ctx, name, start_date, end_date, guild_timezone): + return processed_media: List[Dict[str, Any]] = ( [] if thumbnail is None else await self.bot.process_attachments([thumbnail]) @@ -229,7 +231,7 @@ class CogEvent(Cog): # TODO Make a nice message await ctx.respond( - f"Event **{pycord_event.name}** has been updated and will take place ." + f"Event **{pycord_event.name}** has been updated and will take place ." ) @command_group.command( diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 587192f..5416be3 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -68,7 +68,7 @@ class CogRegister(Cog): # TODO Make a nice message await ctx.respond( - f"You are now registered for the event **{pycord_event.name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!" + f"You are now registered for the event **{pycord_event.name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!" ) if pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) < datetime.now(tz=ZoneInfo("UTC")): From b9dbc9443b2d041d6bc51d88f56e20832ed847ef Mon Sep 17 00:00:00 2001 From: Profitroll Date: Mon, 28 Apr 2025 01:27:46 +0200 Subject: [PATCH 19/55] Fixed event validation --- modules/utils/event_utils.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/utils/event_utils.py b/modules/utils/event_utils.py index b40ae42..697e614 100644 --- a/modules/utils/event_utils.py +++ b/modules/utils/event_utils.py @@ -17,16 +17,16 @@ async def validate_event_validity( finish_date: datetime | None, guild_timezone: ZoneInfo, event_id: ObjectId | None = None, -) -> None: +) -> bool: if start_date > finish_date: # TODO Make a nice message await ctx.respond("Start date must be before finish date") - return + return False if start_date < datetime.now(tz=guild_timezone): # TODO Make a nice message await ctx.respond("Start date must not be in the past") - return + return False # TODO Add validation for concurrent events. # Only one event can take place at the same time. @@ -43,4 +43,6 @@ async def validate_event_validity( if (await col_events.find_one(query)) is not None: # TODO Make a nice message await ctx.respond("There can only be one active event with the same name") - return + return False + + return True From 679d026286940f27385d306b7732c11be9bce7dc Mon Sep 17 00:00:00 2001 From: Profitroll Date: Mon, 28 Apr 2025 01:33:24 +0200 Subject: [PATCH 20/55] Fixed category id not being provided --- classes/pycord_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/classes/pycord_user.py b/classes/pycord_user.py index b0d8f44..6f91082 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -281,7 +281,7 @@ class PycordUser: return None discord_member: Member | None = guild.get_member(self.id) - discord_category: GuildChannel | None = bot.get_channel(pycord_guild.channel_id) + discord_category: GuildChannel | None = bot.get_channel(pycord_guild.category_id) if discord_member is None: raise DiscordGuildMemberNotFoundError(self.id, guild.id) @@ -305,7 +305,7 @@ class PycordUser: channel: TextChannel = await guild.create_text_channel( f"{discord_member.name}_{shake_256(str(pycord_event._id).encode()).hexdigest(3)}", - category=discord_category.category, + category=discord_category, overwrites=permission_overwrites, reason=f"Event channel of {self.id} for event {pycord_event._id}", ) From 22139aa486f48adb3dc6f9a5489b534ea047d7f8 Mon Sep 17 00:00:00 2001 From: Profitroll Date: Mon, 28 Apr 2025 01:51:43 +0200 Subject: [PATCH 21/55] Fixed some event-related issues and guessing --- classes/pycord_user.py | 6 ++++++ cogs/cog_guess.py | 2 +- cogs/cog_register.py | 4 ++++ modules/utils/event_utils.py | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/classes/pycord_user.py b/classes/pycord_user.py index 6f91082..df7a303 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -364,6 +364,12 @@ class PycordUser: cache, current_stage_id=stage_id if isinstance(stage_id, str) else ObjectId(stage_id) ) + # TODO Add documentation + async def set_event(self, event_id: str | ObjectId | None, cache: Optional[Cache] = None) -> None: + await self._set( + cache, current_event_id=event_id if isinstance(event_id, str) else ObjectId(event_id) + ) + # TODO Add documentation async def jail(self, cache: Optional[Cache] = None) -> None: await self._set(cache, is_jailed=True) diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index c08a936..5ca36ba 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -101,7 +101,7 @@ class CogGuess(Cog): files=files, ) - await user.set_event_stage(next_stage._id, cache=self.bot.cache) + await user.set_event(next_stage._id, cache=self.bot.cache) await self.bot.notify_admins( ctx.guild, diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 5416be3..9962a85 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -72,6 +72,8 @@ class CogRegister(Cog): ) if pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) < datetime.now(tz=ZoneInfo("UTC")): + await user.set_event(pycord_event._id, cache=self.bot.cache) + user_channel: TextChannel = await user.setup_event_channel( self.bot, ctx.guild, guild, pycord_event, cache=self.bot.cache ) @@ -106,6 +108,8 @@ class CogRegister(Cog): first_stage: PycordEventStage = await self.bot.find_event_stage(pycord_event.stage_ids[0]) + await user.set_event_stage(first_stage._id, cache=self.bot.cache) + first_stage_files: List[File] | None = first_stage.get_media_files() await user_channel.send(f"First stage...\n\n{first_stage.question}", files=first_stage_files) diff --git a/modules/utils/event_utils.py b/modules/utils/event_utils.py index 697e614..0f1b0eb 100644 --- a/modules/utils/event_utils.py +++ b/modules/utils/event_utils.py @@ -18,7 +18,7 @@ async def validate_event_validity( guild_timezone: ZoneInfo, event_id: ObjectId | None = None, ) -> bool: - if start_date > finish_date: + if start_date >= finish_date: # TODO Make a nice message await ctx.respond("Start date must be before finish date") return False From c96cb167b54b6e2f2f518ebfd1dc0c7bd3b0e753 Mon Sep 17 00:00:00 2001 From: Profitroll Date: Mon, 28 Apr 2025 02:00:19 +0200 Subject: [PATCH 22/55] Fixed stage not being updated on guessing --- cogs/cog_guess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index 5ca36ba..c08a936 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -101,7 +101,7 @@ class CogGuess(Cog): files=files, ) - await user.set_event(next_stage._id, cache=self.bot.cache) + await user.set_event_stage(next_stage._id, cache=self.bot.cache) await self.bot.notify_admins( ctx.guild, From c4ebd1b891538ad6ce1276c367242a98d26c8958 Mon Sep 17 00:00:00 2001 From: profitroll Date: Mon, 28 Apr 2025 13:06:55 +0200 Subject: [PATCH 23/55] Added /status command --- classes/pycord_bot.py | 6 ++++++ cogs/cog_status.py | 42 ++++++++++++++++++++++++++++++++++++++ locale/en-US.json | 7 ++++++- main.py | 4 +++- modules/utils/__init__.py | 1 + modules/utils/git_utils.py | 27 ++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 cogs/cog_status.py create mode 100644 modules/utils/git_utils.py diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index cda9992..0bc25a6 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -20,6 +20,9 @@ logger: Logger = get_logger(__name__) class PycordBot(LibPycordBot): + __version__ = "0.1.0" + + started: datetime cache: CacheMemcached | CacheRedis | None = None def __init__(self, *args, **kwargs) -> None: @@ -48,6 +51,9 @@ class PycordBot(LibPycordBot): @override async def start(self, *args: Any, **kwargs: Any) -> None: await self._schedule_tasks() + + self.started = datetime.now(tz=ZoneInfo("UTC")) + await super().start(*args, **kwargs) @override diff --git a/cogs/cog_status.py b/cogs/cog_status.py new file mode 100644 index 0000000..d2a17a7 --- /dev/null +++ b/cogs/cog_status.py @@ -0,0 +1,42 @@ +from discord import ApplicationContext, Cog, slash_command +from libbot.i18n import _, in_every_locale + +from classes.pycord_bot import PycordBot +from modules.utils import get_current_commit, get_unix_timestamp + + +class CogStatus(Cog): + """Cog with the status command.""" + + def __init__(self, bot: PycordBot): + self.bot: PycordBot = bot + + @slash_command( + name="status", + description=_("description", "commands", "status"), + description_localizations=in_every_locale("description", "commands", "status"), + ) + async def command_status(self, ctx: ApplicationContext) -> None: + current_commit: str | None = await get_current_commit() + + if current_commit is None: + await ctx.respond( + self.bot._("status", "messages", locale=ctx.locale).format( + version=self.bot.__version__, + start_time=get_unix_timestamp(self.bot.started), + ) + ) + + return + + await ctx.respond( + self.bot._("status_git", "messages", locale=ctx.locale).format( + version=self.bot.__version__, + commit=current_commit if len(current_commit) < 10 else current_commit[:10], + start_time=get_unix_timestamp(self.bot.started), + ) + ) + + +def setup(bot: PycordBot) -> None: + bot.add_cog(CogStatus(bot)) diff --git a/locale/en-US.json b/locale/en-US.json index f7527c1..6df5eb7 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -6,7 +6,9 @@ "timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.", "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", - "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: {timezone}" + "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: {timezone}", + "status": "**QuizBot** v{version}\n\nUptime: since ", + "status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: since " }, "commands": { "config": { @@ -172,6 +174,9 @@ } } }, + "status": { + "description": "Get status of the bot" + }, "unregister": { "description": "Leave the selected event", "options": { diff --git a/main.py b/main.py index d86b1ed..db14de2 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +"""Main module with entry point that must be executed for the bot to start""" + import contextlib import logging.config from argparse import ArgumentParser @@ -39,7 +41,7 @@ with contextlib.suppress(ImportError): uvloop.install() -def main(): +def main() -> None: # Perform migration if command line argument was provided if args.migrate: migrate_database() diff --git a/modules/utils/__init__.py b/modules/utils/__init__.py index c3f0ed2..f15d19e 100644 --- a/modules/utils/__init__.py +++ b/modules/utils/__init__.py @@ -9,5 +9,6 @@ from .autocomplete_utils import ( from .cache_utils import restore_from_cache from .datetime_utils import get_unix_timestamp from .event_utils import validate_event_validity +from .git_utils import get_current_commit from .logging_utils import get_logger, get_logging_config from .validation_utils import is_event_status_valid, is_operation_confirmed diff --git a/modules/utils/git_utils.py b/modules/utils/git_utils.py new file mode 100644 index 0000000..3c61fb0 --- /dev/null +++ b/modules/utils/git_utils.py @@ -0,0 +1,27 @@ +from pathlib import Path + +import aiofiles + + +# TODO Add documentation +async def get_current_commit() -> str | None: + head_path: Path = Path(".git/HEAD") + + if not head_path.exists(): + return None + + async with aiofiles.open(head_path, "r", encoding="utf-8") as head_file: + head_content: str = (await head_file.read()).strip() + + if not head_content.startswith("ref:"): + return head_content + + head_ref_path: Path = Path(".git/").joinpath(" ".join(head_content.split(" ")[1:])) + + if not head_ref_path.exists(): + return None + + async with aiofiles.open(head_ref_path, "r", encoding="utf-8") as head_ref_file: + head_ref_content: str = (await head_ref_file.read()).strip() + + return head_ref_content From 2ccdd6406a940808415270d48d6ee7a3e181bf3a Mon Sep 17 00:00:00 2001 From: profitroll Date: Mon, 28 Apr 2025 14:20:06 +0200 Subject: [PATCH 24/55] Improved handling of larger event stages --- classes/pycord_bot.py | 31 ++++++++++++++++++++++------- classes/pycord_event_stage.py | 4 ++++ cogs/cog_event.py | 14 ++++++++++--- cogs/cog_guess.py | 9 +++++---- cogs/cog_register.py | 8 +++++++- cogs/cog_stage.py | 2 ++ locale/en-US.json | 4 ++-- modules/utils/autocomplete_utils.py | 9 ++++++--- 8 files changed, 61 insertions(+), 20 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 0bc25a6..067c9be 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -144,9 +144,13 @@ class PycordBot(LibPycordBot): first_stage_files: List[File] | None = first_stage.get_media_files() - await user_channel.send( - f"First stage...\n\n{first_stage.question}", files=first_stage_files - ) + question_chunks: List[str] = first_stage.get_question_chunked(2000) + question_chunks_length: int = len(question_chunks) + + for index, chunk in enumerate(question_chunks): + await user_channel.send( + chunk, files=None if index != question_chunks_length - 1 else first_stage_files + ) # TODO Make a nice message await self.notify_admins( @@ -158,7 +162,11 @@ class PycordBot(LibPycordBot): async def _process_events_end(self) -> None: # Get events to end events: List[PycordEvent] = await self._get_events( - {"ends": datetime.now(tz=ZoneInfo("UTC")).replace(second=0, microsecond=0)} + { + "ends": datetime.now(tz=ZoneInfo("UTC")).replace(second=0, microsecond=0), + "is_cancelled": False, + "ended": None, + } ) # Process each event @@ -169,8 +177,7 @@ class PycordBot(LibPycordBot): # TODO Make a nice message stages_string: str = "\n\n".join( - f"**Stage {stage.sequence+1}**\nQuestion: {stage.question}\nAnswer: ||{stage.answer}||" - for stage in stages + f"**Stage {stage.sequence+1}**\nAnswer: ||{stage.answer}||" for stage in stages ) # Get list of participants @@ -189,10 +196,20 @@ class PycordBot(LibPycordBot): user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)]) # TODO Make a nice message - await user_channel.send( + event_ended_string: str = ( f"Event **{event.name}** has ended! Stages and respective answers are listed below.\n\n{stages_string}" ) + chunk_size: int = 2000 + + event_info_chunks: List[str] = [ + event_ended_string[i : i + chunk_size] + for i in range(0, len(event_ended_string), chunk_size) + ] + + for chunk in event_info_chunks: + await user_channel.send(chunk) + # Lock each participant out await user.lock_event_channel(guild, event._id, channel=user_channel) diff --git a/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index 416f6eb..a3820cd 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -246,3 +246,7 @@ class PycordEventStage: if len(self.media) == 0 else [File(Path(f"data/{media['id']}"), media["filename"]) for media in self.media] ) + + # TODO Add documentation + def get_question_chunked(self, chunk_size: int) -> List[str]: + return [self.question[i : i + chunk_size] for i in range(0, len(self.question), chunk_size)] diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 2014904..75334c9 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -329,17 +329,25 @@ class CogEvent(Cog): # TODO Make a nice message stages_string: str = "\n\n".join( - f"**Stage {stage.sequence+1}**\nQuestion: {stage.question}\nAnswer: ||{stage.answer}||" - for stage in stages + f"**Stage {stage.sequence+1}**\nAnswer: ||{stage.answer}||" for stage in stages ) # TODO Show users registered for the event # TODO Introduce i18n - await ctx.respond( + event_info_string: str = ( f"**Event details**\n\nName: {pycord_event.name}\nStarts: \nEnds: \n\nStages:\n{stages_string}" ) + chunk_size: int = 2000 + + event_info_chunks: List[str] = [ + event_info_string[i : i + chunk_size] for i in range(0, len(event_info_string), chunk_size) + ] + + for chunk in event_info_chunks: + await ctx.respond(chunk) + def setup(bot: PycordBot) -> None: bot.add_cog(CogEvent(bot)) diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index c08a936..c472024 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -96,10 +96,11 @@ class CogGuess(Cog): files: List[File] | None = next_stage.get_media_files() - await ctx.respond( - f"Provided answer is correct! Next stage...\n\n{next_stage.question}", - files=files, - ) + next_question_chunks: List[str] = next_stage.get_question_chunked(2000) + next_question_chunks_length: int = len(next_question_chunks) + + for index, chunk in enumerate(next_question_chunks): + await ctx.respond(chunk, files=None if index != next_question_chunks_length - 1 else files) await user.set_event_stage(next_stage._id, cache=self.bot.cache) diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 9962a85..a90d4e3 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -112,7 +112,13 @@ class CogRegister(Cog): first_stage_files: List[File] | None = first_stage.get_media_files() - await user_channel.send(f"First stage...\n\n{first_stage.question}", files=first_stage_files) + question_chunks: List[str] = first_stage.get_question_chunked(2000) + question_chunks_length: int = len(question_chunks) + + for index, chunk in enumerate(question_chunks): + await user_channel.send( + chunk, files=None if index != question_chunks_length - 1 else first_stage_files + ) def setup(bot: PycordBot) -> None: diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 4b3a573..4a6e3e1 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -57,6 +57,7 @@ class CogStage(Cog): description_localizations=in_every_locale( "description", "commands", "stage_add", "options", "answer" ), + max_length=500, required=True, ) @option( @@ -155,6 +156,7 @@ class CogStage(Cog): description_localizations=in_every_locale( "description", "commands", "stage_edit", "options", "answer" ), + max_length=500, required=False, ) @option( diff --git a/locale/en-US.json b/locale/en-US.json index 6df5eb7..59731df 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -6,9 +6,9 @@ "timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.", "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", - "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: {timezone}", + "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`", "status": "**QuizBot** v{version}\n\nUptime: since ", - "status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: since " + "status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: up since " }, "commands": { "config": { diff --git a/modules/utils/autocomplete_utils.py b/modules/utils/autocomplete_utils.py index 06454a2..34bc2ed 100644 --- a/modules/utils/autocomplete_utils.py +++ b/modules/utils/autocomplete_utils.py @@ -29,7 +29,7 @@ async def autocomplete_active_events(ctx: AutocompleteContext) -> List[OptionCho query: Dict[str, Any] = { "ended": None, "ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, - "is_cancelled": {"$ne": True}, + "is_cancelled": False, } event_names: List[OptionChoice] = [] @@ -63,7 +63,7 @@ async def autocomplete_user_registered_events(ctx: AutocompleteContext) -> List[ "registered_events.ended": None, "registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, "registered_events.starts": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, - "registered_events.is_cancelled": {"$ne": True}, + "registered_events.is_cancelled": False, } }, ] @@ -93,7 +93,10 @@ async def autocomplete_event_stages(ctx: AutocompleteContext) -> List[OptionChoi async for result in col_stages.find(query).sort([("sequence", ASCENDING)]): event_stages.append( - OptionChoice(f"{result['sequence']+1} ({result['question']})", str(result["_id"])) + OptionChoice( + f"{result['sequence']+1} ({result['question'] if len(result['question']) < 50 else result['question'][:47] + '...'})", + str(result["_id"]), + ) ) return event_stages From 2e9ed41a2c45857d8a575c42d126b60dd8af29d8 Mon Sep 17 00:00:00 2001 From: Profitroll Date: Tue, 29 Apr 2025 00:58:00 +0200 Subject: [PATCH 25/55] Added Pycord speedups to requirements --- requirements.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fac428f..500a656 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,10 @@ +aiodns~=3.2.0 apscheduler~=3.11.0 async_pymongo==0.1.11 +brotlipy~=0.7.0 +faust-cchardet~=2.1.19 libbot[speed,pycord,cache]==4.1.0 mongodb-migrations==1.3.1 +msgspec~=0.19.0 pytz~=2025.1 -typing_extensions>=4.11.0 \ No newline at end of file +typing_extensions>=4.11.cl0 \ No newline at end of file From 112387115fdb271a244b0fded0de12a64a61cba8 Mon Sep 17 00:00:00 2001 From: Profitroll Date: Tue, 29 Apr 2025 01:04:50 +0200 Subject: [PATCH 26/55] Slightly improved hardcoded messages --- cogs/cog_event.py | 8 ++++++-- cogs/cog_guess.py | 9 +++++---- cogs/cog_stage.py | 2 +- cogs/cog_unregister.py | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 75334c9..8fda7d6 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -199,7 +199,9 @@ class CogEvent(Cog): start_date = start_date.replace(tzinfo=guild_timezone) except ValueError: # TODO Make a nice message - await ctx.respond("Could not parse the start date.") + await ctx.respond( + "Could not parse the start date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format." + ) return try: @@ -209,7 +211,9 @@ class CogEvent(Cog): end_date = end_date.replace(tzinfo=guild_timezone) except ValueError: # TODO Make a nice message - await ctx.respond("Could not parse the end date.") + await ctx.respond( + "Could not parse the end date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format." + ) return if not await validate_event_validity(ctx, name, start_date, end_date, guild_timezone): diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index c472024..7b8edf0 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -43,7 +43,9 @@ class CogGuess(Cog): if user.current_event_id is None or user.current_stage_id is None: # TODO Make a nice message - await ctx.respond("You have no ongoing events.") + await ctx.respond( + "You have no ongoing events. You can register for events using the `/register` command." + ) return try: @@ -51,9 +53,7 @@ class CogGuess(Cog): stage: PycordEventStage = await self.bot.find_event_stage(user.current_stage_id) except (InvalidId, RuntimeError): # TODO Make a nice message - await ctx.respond( - "Your event could not be found. Please, report this issue to the event's management." - ) + await ctx.respond("Your event could not be found. Please, contact the administrator.") return if ctx.channel_id != user.event_channels[str(event._id)]: @@ -90,6 +90,7 @@ class CogGuess(Cog): guild, f"User **{ctx.author.display_name}** ({ctx.author.mention}) has completed the event", ) + return next_stage: PycordEventStage = await self.bot.find_event_stage(next_stage_id) diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 4a6e3e1..67d03b9 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -294,7 +294,7 @@ class CogStage(Cog): await event_stage.purge(cache=self.bot.cache) # TODO Make a nice message - await ctx.respond("Okay.") + await ctx.respond("Event stage has been deleted.") def setup(bot: PycordBot) -> None: diff --git a/cogs/cog_unregister.py b/cogs/cog_unregister.py index 16e8065..d188fbd 100644 --- a/cogs/cog_unregister.py +++ b/cogs/cog_unregister.py @@ -70,7 +70,7 @@ class CogUnregister(Cog): # TODO Text channel must be locked and updated - await ctx.respond("Ok.") + await ctx.respond("You are no longer registered for this event.") def setup(bot: PycordBot) -> None: From 28d634084739b730cba83434683a5bd1ecf310bf Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 29 Apr 2025 12:18:33 +0200 Subject: [PATCH 27/55] Set min version of typing_extensions to 4.11.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 500a656..b63f5a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,4 @@ libbot[speed,pycord,cache]==4.1.0 mongodb-migrations==1.3.1 msgspec~=0.19.0 pytz~=2025.1 -typing_extensions>=4.11.cl0 \ No newline at end of file +typing_extensions>=4.11.0 \ No newline at end of file From 9d39b803f3675d0be2a9ce7f0fd6d8b925120f70 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 29 Apr 2025 13:50:38 +0200 Subject: [PATCH 28/55] Improved error handling; Introduced i18n for /register, /unregister and /guess --- classes/pycord_bot.py | 34 +++++++++++++++++++++++---- cogs/cog_config.py | 20 +++++++++++++--- cogs/cog_event.py | 27 +++++++++++++++------- cogs/cog_guess.py | 41 ++++++++++++++++++--------------- cogs/cog_register.py | 52 +++++++++++++++++++++++++++--------------- cogs/cog_stage.py | 29 ++++++++++++++++------- cogs/cog_unregister.py | 22 +++++++++--------- locale/en-US.json | 28 ++++++++++++++++++----- 8 files changed, 177 insertions(+), 76 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 067c9be..3c01dbb 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional from zoneinfo import ZoneInfo from bson import ObjectId +from bson.errors import InvalidId from discord import Attachment, File, Guild, TextChannel, User from libbot.cache.classes import CacheMemcached, CacheRedis from libbot.cache.manager import create_cache_client @@ -12,7 +13,12 @@ from libbot.pycord.classes import PycordBot as LibPycordBot from typing_extensions import override from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser -from classes.errors import EventStageMissingSequenceError +from classes.errors import ( + EventStageMissingSequenceError, + GuildNotFoundError, + EventStageNotFoundError, + EventNotFoundError, +) from modules.database import col_events, col_users from modules.utils import get_logger @@ -82,7 +88,12 @@ class PycordBot(LibPycordBot): # Process each event for event in events: guild: Guild = self.get_guild(event.guild_id) - pycord_guild: PycordGuild = await self.find_guild(guild) + + try: + pycord_guild: PycordGuild = await self.find_guild(guild) + except (InvalidId, GuildNotFoundError) as exc: + logger.error("Could not find guild %s (%s) due to: %s.", guild, guild.id, exc_info=exc) + continue if len(event.stage_ids) == 0: # TODO Make a nice message for management @@ -172,8 +183,23 @@ class PycordBot(LibPycordBot): # Process each event for event in events: guild: Guild = self.get_guild(event.guild_id) - pycord_guild: PycordGuild = await self.find_guild(guild) - stages: List[PycordEventStage] = await self.get_event_stages(event) + + try: + pycord_guild: PycordGuild = await self.find_guild(guild) + except (InvalidId, GuildNotFoundError) as exc: + logger.error("Could not find guild %s (%s) due to: %s.", guild, guild.id, exc_info=exc) + continue + + try: + stages: List[PycordEventStage] = await self.get_event_stages(event) + except (InvalidId, EventNotFoundError, EventStageNotFoundError) as exc: + logger.error( + "Could not event stages of the event %s (%s) due to: %s.", + event, + event._id, + exc_info=exc, + ) + continue # TODO Make a nice message stages_string: str = "\n\n".join( diff --git a/cogs/cog_config.py b/cogs/cog_config.py index 31e2bf5..c45d000 100644 --- a/cogs/cog_config.py +++ b/cogs/cog_config.py @@ -1,5 +1,6 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from bson.errors import InvalidId from discord import ( ApplicationContext, CategoryChannel, @@ -12,6 +13,7 @@ from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale from classes import PycordGuild +from classes.errors import GuildNotFoundError from classes.pycord_bot import PycordBot from modules.utils import autocomplete_timezones, is_operation_confirmed @@ -58,7 +60,11 @@ class CogConfig(Cog): channel: TextChannel, timezone: str, ) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return try: timezone_parsed: ZoneInfo = ZoneInfo(timezone) @@ -94,7 +100,11 @@ class CogConfig(Cog): if not (await is_operation_confirmed(ctx, confirm)): return - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return await guild.purge(self.bot.cache) @@ -106,7 +116,11 @@ class CogConfig(Cog): description_localizations=in_every_locale("description", "commands", "config_show"), ) async def command_config_show(self, ctx: ApplicationContext) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return if not guild.is_configured(): await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 8fda7d6..40e15b5 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -14,6 +14,7 @@ from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild +from classes.errors import GuildNotFoundError, EventNotFoundError from classes.pycord_bot import PycordBot from modules.utils import ( autocomplete_active_events, @@ -80,7 +81,11 @@ class CogEvent(Cog): end: str, thumbnail: Attachment = None, ) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return if not guild.is_configured(): await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) @@ -177,11 +182,15 @@ class CogEvent(Cog): end: str = None, thumbnail: Attachment = None, ) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) - except (InvalidId, RuntimeError): + except (InvalidId, EventNotFoundError): # TODO Make a nice message await ctx.respond("Event was not found.") return @@ -269,11 +278,15 @@ class CogEvent(Cog): if not (await is_operation_confirmed(ctx, confirm)): return - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) - except (InvalidId, RuntimeError): + except (InvalidId, EventNotFoundError): # TODO Make a nice message await ctx.respond("Event was not found.") return @@ -317,11 +330,9 @@ class CogEvent(Cog): required=True, ) async def command_event_show(self, ctx: ApplicationContext, event: str) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) - try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) - except (InvalidId, RuntimeError): + except (InvalidId, EventNotFoundError): # TODO Make a nice message await ctx.respond("Event was not found.") return diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index 7b8edf0..0925eb4 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -6,6 +6,7 @@ from discord import ApplicationContext, Cog, File, option, slash_command from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser +from classes.errors import EventNotFoundError, EventStageNotFoundError, GuildNotFoundError from classes.pycord_bot import PycordBot @@ -26,7 +27,11 @@ class CogGuess(Cog): description_localizations=in_every_locale("description", "commands", "guess", "options", "answer"), ) async def command_guess(self, ctx: ApplicationContext, answer: str) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return if not guild.is_configured(): await ctx.respond(self.bot._("guild_unconfigured", "messages", locale=ctx.locale)) @@ -35,30 +40,24 @@ class CogGuess(Cog): user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) if user.is_jailed: - # TODO Make a nice message - await ctx.respond( - "You are jailed and cannot interact with events. Please, contact the administrator." - ) + await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale)) return if user.current_event_id is None or user.current_stage_id is None: - # TODO Make a nice message - await ctx.respond( - "You have no ongoing events. You can register for events using the `/register` command." - ) + await ctx.respond(self.bot._("guess_unregistered", "messages", locale=ctx.locale)) return try: event: PycordEvent = await self.bot.find_event(event_id=user.current_event_id) stage: PycordEventStage = await self.bot.find_event_stage(user.current_stage_id) - except (InvalidId, RuntimeError): - # TODO Make a nice message - await ctx.respond("Your event could not be found. Please, contact the administrator.") + except (InvalidId, EventNotFoundError, EventStageNotFoundError): + await ctx.respond(self.bot._("guess_incorrect_event", "messages", locale=ctx.locale)) return if ctx.channel_id != user.event_channels[str(event._id)]: - # TODO Make a nice message - await ctx.respond("Usage outside own event channel is not allowed.", ephemeral=True) + await ctx.respond( + self.bot._("guess_incorrect_channel", "messages", locale=ctx.locale), ephemeral=True + ) return if answer.lower() != stage.answer.lower(): @@ -73,7 +72,6 @@ class CogGuess(Cog): ) if next_stage_id is None: - # TODO Make a nice message user.completed_event_ids.append(event._id) await user._set( @@ -83,12 +81,14 @@ class CogGuess(Cog): completed_event_ids=user.completed_event_ids, ) - await ctx.respond("Congratulations! You have completed the event!") + await ctx.respond(self.bot._("guess_completed_event", "messages", locale=ctx.locale)) await self.bot.notify_admins( ctx.guild, guild, - f"User **{ctx.author.display_name}** ({ctx.author.mention}) has completed the event", + self.bot._("admin_user_completed_event", "messages", locale=ctx.locale).format( + display_name=ctx.author.display_name, mention=ctx.author.mention + ), ) return @@ -108,7 +108,12 @@ class CogGuess(Cog): await self.bot.notify_admins( ctx.guild, guild, - f"User **{ctx.author.display_name}** ({ctx.author.mention}) has completed the stage {stage.sequence+1} of the event **{event.name}**.", + self.bot._("admin_user_completed_stage", "messages", locale=ctx.locale).format( + display_name=ctx.author.display_name, + mention=ctx.author.mention, + stage_sequence=stage.sequence + 1, + event_name=event.name, + ), ) diff --git a/cogs/cog_register.py b/cogs/cog_register.py index a90d4e3..34308a6 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -10,6 +10,7 @@ from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser +from classes.errors import EventNotFoundError, GuildNotFoundError from classes.pycord_bot import PycordBot from modules.utils import autocomplete_active_events, get_logger, get_unix_timestamp @@ -22,7 +23,6 @@ class CogRegister(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot - # TODO Introduce i18n @slash_command( name="register", description=_("description", "commands", "register"), @@ -37,13 +37,16 @@ class CogRegister(Cog): autocomplete=basic_autocomplete(autocomplete_active_events), ) async def command_register(self, ctx: ApplicationContext, event: str) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) - except (InvalidId, RuntimeError): - # TODO Make a nice message - await ctx.respond("Event was not found.") + except (InvalidId, EventNotFoundError): + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale)) return if not guild.is_configured(): @@ -53,25 +56,33 @@ class CogRegister(Cog): user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) if user.is_jailed: - # TODO Make a nice message - await ctx.respond( - "You are jailed and cannot interact with events. Please, contact the administrator." - ) + await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale)) return if pycord_event._id in user.registered_event_ids: - # TODO Make a nice message - await ctx.respond("You are already registered for this event.") + await ctx.respond(self.bot._("register_already_registered", "messages", locale=ctx.locale)) return await user.event_register(pycord_event._id, cache=self.bot.cache) - # TODO Make a nice message - await ctx.respond( - f"You are now registered for the event **{pycord_event.name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!" + event_ongoing: bool = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) < datetime.now( + tz=ZoneInfo("UTC") ) - if pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) < datetime.now(tz=ZoneInfo("UTC")): + registered_message: str = ( + self.bot._("register_success_ongoing", "messages", locale=ctx.locale).format( + event_name=pycord_event.name + ) + if event_ongoing + else self.bot._("register_success_scheduled", "messages", locale=ctx.locale).format( + event_name=pycord_event.name, + event_starts=get_unix_timestamp(pycord_event.starts, to_utc=True), + ) + ) + + await ctx.respond(registered_message) + + if event_ongoing: await user.set_event(pycord_event._id, cache=self.bot.cache) user_channel: TextChannel = await user.setup_event_channel( @@ -89,7 +100,11 @@ class CogRegister(Cog): await self.bot.notify_admins( ctx.guild, guild, - f"Event channel could not be created for user **{ctx.author.display_name}** ({ctx.author.mention}) and event **{pycord_event.name}**.", + self.bot._("admin_user_channel_creation_failed", "messages", locale=ctx.locale).format( + display_name=ctx.author.display_name, + mention=ctx.author.mention, + event_name=pycord_event.name, + ), ) return @@ -100,9 +115,10 @@ class CogRegister(Cog): else File(Path(f"data/{pycord_event.thumbnail['id']}"), pycord_event.thumbnail["filename"]) ) - # TODO Make a nice message await user_channel.send( - f"Event **{pycord_event.name}** has already started!\n\nUse slash command `/guess` to suggest your answers to each event stage.", + self.bot._("register_already_started", "messages", locale=ctx.locale).format( + event_name=pycord_event.name + ), file=thumbnail, ) diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 67d03b9..44a3449 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -7,6 +7,7 @@ from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild +from classes.errors import GuildNotFoundError, EventStageNotFoundError, EventNotFoundError from classes.pycord_bot import PycordBot from modules.utils import ( autocomplete_active_events, @@ -76,7 +77,11 @@ class CogStage(Cog): answer: str, media: Attachment = None, ) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return if not guild.is_configured(): await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) @@ -84,7 +89,7 @@ class CogStage(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) - except (InvalidId, RuntimeError): + except (InvalidId, EventStageNotFoundError): # TODO Make a nice message await ctx.respond("Event was not found.") return @@ -186,7 +191,11 @@ class CogStage(Cog): media: Attachment = None, remove_media: bool = False, ) -> None: - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return if not guild.is_configured(): await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) @@ -194,7 +203,7 @@ class CogStage(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) - except (InvalidId, RuntimeError): + except (InvalidId, EventNotFoundError): # TODO Make a nice message await ctx.respond("Event was not found.") return @@ -204,7 +213,7 @@ class CogStage(Cog): try: event_stage: PycordEventStage = await self.bot.find_event_stage(stage) - except (InvalidId, RuntimeError): + except (InvalidId, EventStageNotFoundError): # TODO Make a nice message await ctx.respond("Event stage was not found.") return @@ -267,7 +276,11 @@ class CogStage(Cog): if not (await is_operation_confirmed(ctx, confirm)): return - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return if not guild.is_configured(): await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) @@ -275,7 +288,7 @@ class CogStage(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) - except (InvalidId, RuntimeError): + except (InvalidId, EventNotFoundError): # TODO Make a nice message await ctx.respond("Event was not found.") return @@ -285,7 +298,7 @@ class CogStage(Cog): try: event_stage: PycordEventStage = await self.bot.find_event_stage(stage) - except (InvalidId, RuntimeError): + except (InvalidId, EventStageNotFoundError): # TODO Make a nice message await ctx.respond("Event stage was not found.") return diff --git a/cogs/cog_unregister.py b/cogs/cog_unregister.py index d188fbd..6def4b7 100644 --- a/cogs/cog_unregister.py +++ b/cogs/cog_unregister.py @@ -4,6 +4,7 @@ from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordGuild, PycordUser +from classes.errors import EventNotFoundError, GuildNotFoundError from classes.pycord_bot import PycordBot from modules.utils import autocomplete_user_registered_events, is_operation_confirmed @@ -39,13 +40,16 @@ class CogUnregister(Cog): if not (await is_operation_confirmed(ctx, confirm)): return - guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) - except (InvalidId, RuntimeError): - # TODO Make a nice message - await ctx.respond("Event was not found.") + except (InvalidId, EventNotFoundError): + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale)) return if not guild.is_configured(): @@ -55,22 +59,18 @@ class CogUnregister(Cog): user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) if user.is_jailed: - # TODO Make a nice message - await ctx.respond( - "You are jailed and cannot interact with events. Please, contact the administrator." - ) + await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale)) return if pycord_event._id not in user.registered_event_ids: - # TODO Make a nice message - await ctx.respond("You are not registered for this event.") + await ctx.respond(self.bot._("unregister_not_registered", "messages", locale=ctx.locale)) return await user.event_unregister(pycord_event._id, cache=self.bot.cache) # TODO Text channel must be locked and updated - await ctx.respond("You are no longer registered for this event.") + await ctx.respond(self.bot._("unregister_unregistered", "messages", locale=ctx.locale)) def setup(bot: PycordBot) -> None: diff --git a/locale/en-US.json b/locale/en-US.json index 59731df..592ffe9 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -1,14 +1,30 @@ { "messages": { - "operation_unconfirmed": "Operation not confirmed.", + "admin_user_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.", + "admin_user_completed_event": "User **{display_name}** ({mention}) has completed the event", + "admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.", + "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", + "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", + "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`", + "event_not_found": "Event was not found.", + "guess_completed_event": "Congratulations! You have completed the event!", + "guess_incorrect_channel": "Usage outside own event channel is not allowed.", + "guess_incorrect_event": "Your event could not be found. Please, contact the administrator.", + "guess_unregistered": "You have no ongoing events. You can register for events using the `/register` command.", "guild_unconfigured": "Guild is not configured. Please, report this to the administrator.", "guild_unconfigured_admin": "Guild is not configured. Please, configure it using `/config set`.", - "timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.", - "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", - "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", - "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`", + "jailed_error": "You are jailed and cannot interact with events. Please, contact the administrator.", + "operation_unconfirmed": "Operation not confirmed.", + "register_already_registered": "You are already registered for this event.", + "register_already_started": "Event **{event_name}** has already started!\n\nUse slash command `/guess` to suggest your answers to each event stage.", + "register_success_ongoing": "You are now registered for the event **{event_name}**.\n\nNew channel has been created for you and further instructions will are provided in it. Good luck!", + "register_success_scheduled": "You are now registered for the event **{event_name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!", "status": "**QuizBot** v{version}\n\nUptime: since ", - "status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: up since " + "status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: up since ", + "timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.", + "unexpected_error": "An unexpected error has occurred. Please, contact the administrator.", + "unregister_not_registered": "You are not registered for this event.", + "unregister_unregistered": "You are no longer registered for this event." }, "commands": { "config": { From efb9ae55ef3741d8c795d9c7ddbe9875706caba0 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 29 Apr 2025 13:53:38 +0200 Subject: [PATCH 29/55] Completion message sent to admins will now include the event name --- cogs/cog_guess.py | 2 +- locale/en-US.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index 0925eb4..d1e16eb 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -87,7 +87,7 @@ class CogGuess(Cog): ctx.guild, guild, self.bot._("admin_user_completed_event", "messages", locale=ctx.locale).format( - display_name=ctx.author.display_name, mention=ctx.author.mention + display_name=ctx.author.display_name, mention=ctx.author.mention, event_name=event.name ), ) diff --git a/locale/en-US.json b/locale/en-US.json index 592ffe9..333ac80 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -1,7 +1,7 @@ { "messages": { "admin_user_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.", - "admin_user_completed_event": "User **{display_name}** ({mention}) has completed the event", + "admin_user_completed_event": "User **{display_name}** ({mention}) has completed the event **{event_name}**", "admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.", "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", From 137ecffcf721961e60d5fe8d7effbecf0bec5ef4 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 29 Apr 2025 13:54:17 +0200 Subject: [PATCH 30/55] Optimized imports --- classes/pycord_bot.py | 6 +++--- cogs/cog_event.py | 2 +- cogs/cog_guess.py | 6 +++++- cogs/cog_stage.py | 6 +++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 3c01dbb..3c8e199 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -14,10 +14,10 @@ from typing_extensions import override from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser from classes.errors import ( - EventStageMissingSequenceError, - GuildNotFoundError, - EventStageNotFoundError, EventNotFoundError, + EventStageMissingSequenceError, + EventStageNotFoundError, + GuildNotFoundError, ) from modules.database import col_events, col_users from modules.utils import get_logger diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 40e15b5..0a0273d 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -14,7 +14,7 @@ from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild -from classes.errors import GuildNotFoundError, EventNotFoundError +from classes.errors import EventNotFoundError, GuildNotFoundError from classes.pycord_bot import PycordBot from modules.utils import ( autocomplete_active_events, diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index d1e16eb..df2d2da 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -6,7 +6,11 @@ from discord import ApplicationContext, Cog, File, option, slash_command from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser -from classes.errors import EventNotFoundError, EventStageNotFoundError, GuildNotFoundError +from classes.errors import ( + EventNotFoundError, + EventStageNotFoundError, + GuildNotFoundError, +) from classes.pycord_bot import PycordBot diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 44a3449..1e09690 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -7,7 +7,11 @@ from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale from classes import PycordEvent, PycordEventStage, PycordGuild -from classes.errors import GuildNotFoundError, EventStageNotFoundError, EventNotFoundError +from classes.errors import ( + EventNotFoundError, + EventStageNotFoundError, + GuildNotFoundError, +) from classes.pycord_bot import PycordBot from modules.utils import ( autocomplete_active_events, From 390145ca0e234eff680919b7a5c20eb20c93340e Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 29 Apr 2025 16:47:15 +0200 Subject: [PATCH 31/55] Added i18n to the jailing mechanism --- cogs/cog_event.py | 9 +++------ cogs/cog_register.py | 2 +- cogs/cog_stage.py | 9 +++------ cogs/cog_unregister.py | 2 +- cogs/cog_user.py | 26 ++++++++++++++++++-------- locale/en-US.json | 6 +++++- 6 files changed, 31 insertions(+), 23 deletions(-) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 0a0273d..fc788ec 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -191,8 +191,7 @@ class CogEvent(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) except (InvalidId, EventNotFoundError): - # TODO Make a nice message - await ctx.respond("Event was not found.") + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): @@ -287,8 +286,7 @@ class CogEvent(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) except (InvalidId, EventNotFoundError): - # TODO Make a nice message - await ctx.respond("Event was not found.") + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): @@ -333,8 +331,7 @@ class CogEvent(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) except (InvalidId, EventNotFoundError): - # TODO Make a nice message - await ctx.respond("Event was not found.") + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True) return starts_date: datetime = pycord_event.get_start_date_utc() diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 34308a6..d116785 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -46,7 +46,7 @@ class CogRegister(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) except (InvalidId, EventNotFoundError): - await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 1e09690..482d098 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -94,8 +94,7 @@ class CogStage(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) except (InvalidId, EventStageNotFoundError): - # TODO Make a nice message - await ctx.respond("Event was not found.") + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True) return if not (await is_event_status_valid(ctx, pycord_event)): @@ -208,8 +207,7 @@ class CogStage(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) except (InvalidId, EventNotFoundError): - # TODO Make a nice message - await ctx.respond("Event was not found.") + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True) return if not (await is_event_status_valid(ctx, pycord_event)): @@ -293,8 +291,7 @@ class CogStage(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) except (InvalidId, EventNotFoundError): - # TODO Make a nice message - await ctx.respond("Event was not found.") + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True) return if not (await is_event_status_valid(ctx, pycord_event)): diff --git a/cogs/cog_unregister.py b/cogs/cog_unregister.py index 6def4b7..4e18604 100644 --- a/cogs/cog_unregister.py +++ b/cogs/cog_unregister.py @@ -49,7 +49,7 @@ class CogUnregister(Cog): try: pycord_event: PycordEvent = await self.bot.find_event(event_id=event) except (InvalidId, EventNotFoundError): - await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): diff --git a/cogs/cog_user.py b/cogs/cog_user.py index cc73f5b..3e725cc 100644 --- a/cogs/cog_user.py +++ b/cogs/cog_user.py @@ -90,15 +90,20 @@ class CogUser(Cog): pycord_user: PycordUser = await self.bot.find_user(user, ctx.guild) if pycord_user.is_jailed: - # TODO Introduce i18n - await ctx.respond(f"User **{user.display_name}** is already jailed.") + await ctx.respond( + self.bot._("user_jail_already_jailed", "messages", locale=ctx.locale).format( + display_name=user.display_name + ), + ephemeral=True, + ) return await pycord_user.jail(self.bot.cache) - # TODO Introduce i18n await ctx.respond( - f"User **{user.display_name}** has been jailed and cannot interact with events anymore." + self.bot._("user_jail_successful", "messages", locale=ctx.locale).format( + display_name=user.display_name + ) ) @command_group.command( @@ -128,15 +133,20 @@ class CogUser(Cog): pycord_user: PycordUser = await self.bot.find_user(user, ctx.guild) if not pycord_user.is_jailed: - # TODO Introduce i18n - await ctx.respond(f"User **{user.display_name}** is not jailed.") + await ctx.respond( + self.bot._("user_unjail_not_jailed", "messages", locale=ctx.locale).format( + display_name=user.display_name + ), + ephemeral=True, + ) return await pycord_user.unjail(self.bot.cache) - # TODO Introduce i18n await ctx.respond( - f"User **{user.display_name}** has been unjailed and can interact with events again." + self.bot._("user_unjail_successful", "messages", locale=ctx.locale).format( + display_name=user.display_name + ) ) diff --git a/locale/en-US.json b/locale/en-US.json index 333ac80..2caf257 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -24,7 +24,11 @@ "timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.", "unexpected_error": "An unexpected error has occurred. Please, contact the administrator.", "unregister_not_registered": "You are not registered for this event.", - "unregister_unregistered": "You are no longer registered for this event." + "unregister_unregistered": "You are no longer registered for this event.", + "user_jail_already_jailed": "User **{display_name}** is already jailed.", + "user_jail_successful": "User **{display_name}** has been jailed and cannot interact with events anymore.", + "user_unjail_not_jailed": "User **{display_name}** is not jailed.", + "user_unjail_successful": "User **{display_name}** has been unjailed and can interact with events again." }, "commands": { "config": { From 80eae3f1b18d417824494568fc3972ad335293a0 Mon Sep 17 00:00:00 2001 From: profitroll Date: Fri, 2 May 2025 12:01:23 +0200 Subject: [PATCH 32/55] Closes #11 --- classes/pycord_bot.py | 45 ++++++++++++++- classes/pycord_user.py | 40 +++++++++++++- cogs/cog_register.py | 13 +---- cogs/cog_utility.py | 85 +++++++++++++++++++++++++++-- modules/utils/autocomplete_utils.py | 1 + 5 files changed, 165 insertions(+), 19 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 3c8e199..abe5e8c 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -18,6 +18,7 @@ from classes.errors import ( EventStageMissingSequenceError, EventStageNotFoundError, GuildNotFoundError, + DiscordGuildMemberNotFoundError, ) from modules.database import col_events, col_users from modules.utils import get_logger @@ -118,9 +119,25 @@ class PycordBot(LibPycordBot): await user._set(self.cache, current_event_id=event._id, current_stage_id=first_stage._id) # Create a channel for each participant - user_channel: TextChannel | None = await user.setup_event_channel( - self, guild, pycord_guild, event, cache=self.cache - ) + try: + user_channel: TextChannel | None = await user.setup_event_channel( + self, guild, pycord_guild, event, cache=self.cache + ) + except DiscordGuildMemberNotFoundError: + logger.error( + "Could not create and configure event channel for user %s in %s (event %s): user not found in the guild", + user.id, + guild.id, + event._id, + ) + + await self.notify_admins( + guild, + pycord_guild, + f"Event channel could not be created for user with ID `{user.id}` (<@{user.id}>) and event **{event.name}**: user was not found on the server.", + ) + + continue if user_channel is None: logger.error( @@ -374,3 +391,25 @@ class PycordBot(LibPycordBot): processed_attachments.append({"id": attachment.id, "filename": attachment.filename}) return processed_attachments + + # TODO Add documentation + async def send_stage_question( + self, + channel: TextChannel, + event: PycordEvent, + stage: Optional[PycordEventStage] = None, + use_first_stage: bool = False, + ) -> None: + stage: PycordEventStage = ( + stage + if not use_first_stage or stage is not None + else await self.find_event_stage(event.stage_ids[0]) + ) + + stage_files: List[File] | None = stage.get_media_files() + + question_chunks: List[str] = stage.get_question_chunked(2000) + question_chunks_length: int = len(question_chunks) + + for index, chunk in enumerate(question_chunks): + await channel.send(chunk, files=None if index != question_chunks_length - 1 else stage_files) diff --git a/classes/pycord_user.py b/classes/pycord_user.py index df7a303..c1a5218 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -275,9 +275,10 @@ class PycordUser: guild: Guild, pycord_guild: "PycordGuild", pycord_event: "PycordEvent", + ignore_exists: bool = False, cache: Optional[Cache] = None, ) -> TextChannel | None: - if str(pycord_event._id) in self.event_channels.keys(): + if not ignore_exists and str(pycord_event._id) in self.event_channels.keys(): return None discord_member: Member | None = guild.get_member(self.id) @@ -314,6 +315,43 @@ class PycordUser: return channel + # TODO Add documentation + async def fix_event_channel( + self, + bot: Bot, + guild: Guild, + pycord_guild: "PycordGuild", + pycord_event: "PycordEvent", + cache: Optional[Cache] = None, + ) -> TextChannel | None: + # Configure channel if not set + if str(pycord_event._id) not in self.event_channels.keys(): + return await self.setup_event_channel(bot, guild, pycord_guild, pycord_event, cache=cache) + + discord_member: Member | None = guild.get_member(self.id) + + if discord_member is None: + raise DiscordGuildMemberNotFoundError(self.id, guild.id) + + channel: TextChannel = guild.get_channel(self.event_channels[str(pycord_event._id)]) + + if channel is None: + return await self.setup_event_channel( + bot, guild, pycord_guild, pycord_event, ignore_exists=True, cache=cache + ) + + await channel.set_permissions( + discord_member, + overwrite=PermissionOverwrite( + view_channel=True, + send_messages=True, + use_application_commands=True, + ), + reason=f"Updated event channel of {self.id} for event {pycord_event._id}", + ) + + return channel + # TODO Add documentation async def lock_event_channel( self, diff --git a/cogs/cog_register.py b/cogs/cog_register.py index d116785..8fc0afd 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -1,11 +1,10 @@ from datetime import datetime from logging import Logger from pathlib import Path -from typing import List from zoneinfo import ZoneInfo from bson.errors import InvalidId -from discord import ApplicationContext, Cog, File, TextChannel, option, slash_command +from discord import ApplicationContext, Cog, TextChannel, option, slash_command, File from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale @@ -126,15 +125,7 @@ class CogRegister(Cog): await user.set_event_stage(first_stage._id, cache=self.bot.cache) - first_stage_files: List[File] | None = first_stage.get_media_files() - - question_chunks: List[str] = first_stage.get_question_chunked(2000) - question_chunks_length: int = len(question_chunks) - - for index, chunk in enumerate(question_chunks): - await user_channel.send( - chunk, files=None if index != question_chunks_length - 1 else first_stage_files - ) + await self.bot.send_stage_question(user_channel, pycord_event, first_stage) def setup(bot: PycordBot) -> None: diff --git a/cogs/cog_utility.py b/cogs/cog_utility.py index f31a428..64a5739 100644 --- a/cogs/cog_utility.py +++ b/cogs/cog_utility.py @@ -1,8 +1,17 @@ +from datetime import datetime from logging import Logger +from pathlib import Path +from typing import Any, Dict, List +from zoneinfo import ZoneInfo -from discord import Activity, ActivityType, Cog, Member +from bson import ObjectId +from bson.errors import InvalidId +from discord import Activity, ActivityType, Cog, Member, TextChannel, File +from classes import PycordEvent, PycordGuild, PycordUser +from classes.errors import GuildNotFoundError from classes.pycord_bot import PycordBot +from modules.database import col_users from modules.utils import get_logger logger: Logger = get_logger(__name__) @@ -53,10 +62,78 @@ class CogUtility(Cog): logger.info("Set activity type to %s with message %s", activity_type, activity_message) - # TODO Implement #11 - @Cog.listener() + @Cog.listener("on_member_join") async def on_member_join(self, member: Member) -> None: - pass + try: + guild: PycordGuild = await self.bot.find_guild(member.guild.id) + except (InvalidId, GuildNotFoundError) as exc: + logger.error( + "Could not process member join event for %s in %s due to: %s", + member.id, + member.guild.id, + exc, + ) + return + + user: PycordUser = await self.bot.find_user(member.id, member.guild.id) + events: List[PycordEvent] = [] + + pipeline: List[Dict[str, Any]] = [ + {"$match": {"id": user.id}}, + { + "$lookup": { + "from": "events", + "localField": "registered_event_ids", + "foreignField": "_id", + "as": "registered_events", + } + }, + { + "$match": { + "registered_events.ended": None, + "registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, + "registered_events.starts": {"$lt": datetime.now(tz=ZoneInfo("UTC"))}, + "registered_events.is_cancelled": False, + } + }, + ] + + async for result in col_users.aggregate(pipeline): + for registered_event in result["registered_events"]: + events.append(PycordEvent(**registered_event)) + + for event in events: + if user.current_event_id is not None and user.current_event_id != event._id: + continue + + if user.current_event_id is None: + await user.set_event(event._id, cache=self.bot.cache) + + channel: TextChannel | None = await user.fix_event_channel( + self.bot, member.guild, guild, event, cache=self.bot.cache + ) + + if channel is None: + continue + + thumbnail: File | None = ( + None + if event.thumbnail is None + else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"]) + ) + + await channel.send( + self.bot._("register_already_started", "messages").format(event_name=event.name), + file=thumbnail, + ) + + stage_id: ObjectId = ( + event.stage_ids[0] if user.current_stage_id is None else user.current_stage_id + ) + + await user.set_event_stage(stage_id, cache=self.bot.cache) + + await self.bot.send_stage_question(channel, event, await self.bot.find_event_stage(stage_id)) def setup(bot: PycordBot) -> None: diff --git a/modules/utils/autocomplete_utils.py b/modules/utils/autocomplete_utils.py index 34bc2ed..30056ba 100644 --- a/modules/utils/autocomplete_utils.py +++ b/modules/utils/autocomplete_utils.py @@ -50,6 +50,7 @@ async def autocomplete_user_registered_events(ctx: AutocompleteContext) -> List[ """Return list of active events user is registered in""" pipeline: List[Dict[str, Any]] = [ + {"$match": {"id": ctx.interaction.user.id}}, { "$lookup": { "from": "events", From 5507295b1bcc0e7d0ad9784c6da0dce91a6ac48d Mon Sep 17 00:00:00 2001 From: profitroll Date: Fri, 2 May 2025 12:20:24 +0200 Subject: [PATCH 33/55] Fixed handling of event dates (for #2) --- cogs/cog_event.py | 18 ++++++++++++++---- modules/utils/event_utils.py | 22 +++++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index fc788ec..0ae2806 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -106,7 +106,7 @@ class CogEvent(Cog): ) return - if not await validate_event_validity(ctx, name, start_date, end_date, guild_timezone): + if not await validate_event_validity(ctx, name, start_date, end_date, to_utc=True): return processed_media: List[Dict[str, Any]] = ( @@ -204,7 +204,12 @@ class CogEvent(Cog): start_date: datetime = ( pycord_event.starts if start is None else datetime.strptime(start, "%d.%m.%Y %H:%M") ) - start_date = start_date.replace(tzinfo=guild_timezone) + + start_date = ( + start_date.replace(tzinfo=ZoneInfo("UTC")) + if start is None + else start_date.replace(tzinfo=guild_timezone) + ) except ValueError: # TODO Make a nice message await ctx.respond( @@ -216,7 +221,12 @@ class CogEvent(Cog): end_date: datetime = ( pycord_event.ends if end is None else datetime.strptime(end, "%d.%m.%Y %H:%M") ) - end_date = end_date.replace(tzinfo=guild_timezone) + + end_date = ( + end_date.replace(tzinfo=ZoneInfo("UTC")) + if end is None + else end_date.replace(tzinfo=guild_timezone) + ) except ValueError: # TODO Make a nice message await ctx.respond( @@ -224,7 +234,7 @@ class CogEvent(Cog): ) return - if not await validate_event_validity(ctx, name, start_date, end_date, guild_timezone): + if not await validate_event_validity(ctx, name, start_date, end_date, to_utc=True): return processed_media: List[Dict[str, Any]] = ( diff --git a/modules/utils/event_utils.py b/modules/utils/event_utils.py index 0f1b0eb..efb1fd0 100644 --- a/modules/utils/event_utils.py +++ b/modules/utils/event_utils.py @@ -14,20 +14,28 @@ async def validate_event_validity( ctx: ApplicationContext, name: str, start_date: datetime | None, - finish_date: datetime | None, - guild_timezone: ZoneInfo, + end_date: datetime | None, event_id: ObjectId | None = None, + to_utc: bool = False, ) -> bool: - if start_date >= finish_date: - # TODO Make a nice message - await ctx.respond("Start date must be before finish date") - return False + start_date_internal: datetime = start_date.astimezone(ZoneInfo("UTC")) if to_utc else start_date + end_date_internal: datetime = end_date.astimezone(ZoneInfo("UTC")) if to_utc else end_date - if start_date < datetime.now(tz=guild_timezone): + if start_date_internal < datetime.now(tz=ZoneInfo("UTC")): # TODO Make a nice message await ctx.respond("Start date must not be in the past") return False + if end_date_internal < datetime.now(tz=ZoneInfo("UTC")): + # TODO Make a nice message + await ctx.respond("End date must not be in the past") + return False + + if start_date_internal >= end_date_internal: + # TODO Make a nice message + await ctx.respond("Start date must be before end date") + return False + # TODO Add validation for concurrent events. # Only one event can take place at the same time. query: Dict[str, Any] = { From 3dcae36dec6712b6a8dc1b26e72176ea64b6ad49 Mon Sep 17 00:00:00 2001 From: profitroll Date: Fri, 2 May 2025 14:07:00 +0200 Subject: [PATCH 34/55] Implemented "/user update_channels" (#10) --- cogs/cog_event.py | 33 ++++++------ cogs/cog_register.py | 2 +- cogs/cog_user.py | 99 ++++++++++++++++++++++++++++++++---- cogs/cog_utility.py | 2 +- locale/en-US.json | 2 +- modules/utils/event_utils.py | 8 +-- 6 files changed, 110 insertions(+), 36 deletions(-) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index 0ae2806..fb23875 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -24,6 +24,7 @@ from modules.utils import ( ) +# noinspection Mypy class CogEvent(Cog): """Cog with event management commands.""" @@ -94,11 +95,8 @@ class CogEvent(Cog): guild_timezone: ZoneInfo = ZoneInfo(guild.timezone) try: - start_date: datetime = datetime.strptime(start, "%d.%m.%Y %H:%M") - end_date: datetime = datetime.strptime(end, "%d.%m.%Y %H:%M") - - start_date = start_date.replace(tzinfo=guild_timezone) - end_date = end_date.replace(tzinfo=guild_timezone) + start_date: datetime = datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) + end_date: datetime = datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) except ValueError: # TODO Introduce i18n await ctx.respond( @@ -202,13 +200,9 @@ class CogEvent(Cog): try: start_date: datetime = ( - pycord_event.starts if start is None else datetime.strptime(start, "%d.%m.%Y %H:%M") - ) - - start_date = ( - start_date.replace(tzinfo=ZoneInfo("UTC")) + pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) if start is None - else start_date.replace(tzinfo=guild_timezone) + else datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) ) except ValueError: # TODO Make a nice message @@ -219,13 +213,9 @@ class CogEvent(Cog): try: end_date: datetime = ( - pycord_event.ends if end is None else datetime.strptime(end, "%d.%m.%Y %H:%M") - ) - - end_date = ( - end_date.replace(tzinfo=ZoneInfo("UTC")) + pycord_event.ends.replace(tzinfo=ZoneInfo("UTC")) if end is None - else end_date.replace(tzinfo=guild_timezone) + else datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) ) except ValueError: # TODO Make a nice message @@ -234,7 +224,14 @@ class CogEvent(Cog): ) return - if not await validate_event_validity(ctx, name, start_date, end_date, to_utc=True): + if not await validate_event_validity( + ctx, + pycord_event.name if name is None else name, + start_date, + end_date, + event_id=pycord_event._id, + to_utc=True, + ): return processed_media: List[Dict[str, Any]] = ( diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 8fc0afd..0f4b2f4 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -115,7 +115,7 @@ class CogRegister(Cog): ) await user_channel.send( - self.bot._("register_already_started", "messages", locale=ctx.locale).format( + self.bot._("notice_event_already_started", "messages", locale=ctx.locale).format( event_name=pycord_event.name ), file=thumbnail, diff --git a/cogs/cog_user.py b/cogs/cog_user.py index 3e725cc..fd5b385 100644 --- a/cogs/cog_user.py +++ b/cogs/cog_user.py @@ -1,14 +1,25 @@ +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any +from zoneinfo import ZoneInfo + +from bson import ObjectId +from bson.errors import InvalidId from discord import ( ApplicationContext, SlashCommandGroup, User, option, + File, + TextChannel, ) from discord.ext.commands import Cog from libbot.i18n import _, in_every_locale -from classes import PycordUser +from classes import PycordUser, PycordEvent, PycordGuild +from classes.errors import GuildNotFoundError from classes.pycord_bot import PycordBot +from modules.database import col_users from modules.utils import is_operation_confirmed @@ -37,16 +48,82 @@ class CogUser(Cog): # await ctx.respond("Not implemented.") # TODO Implement the command - # @command_group.command( - # name="update_channel", - # description="Update user's channel", - # ) - # @option( - # "user", - # description="Selected user", - # ) - # async def command_user_update_channel(self, ctx: ApplicationContext, user: User) -> None: - # await ctx.respond("Not implemented.") + @command_group.command( + name="update_channels", + description="Update user's event channels", + ) + @option( + "user", + description="Selected user", + ) + async def command_user_update_channels(self, ctx: ApplicationContext, user: User) -> None: + try: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + except (InvalidId, GuildNotFoundError): + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + return + + pycord_user: PycordUser = await self.bot.find_user(user.id, ctx.guild.id) + events: List[PycordEvent] = [] + + pipeline: List[Dict[str, Any]] = [ + {"$match": {"id": pycord_user.id}}, + { + "$lookup": { + "from": "events", + "localField": "registered_event_ids", + "foreignField": "_id", + "as": "registered_events", + } + }, + { + "$match": { + "registered_events.ended": None, + "registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, + "registered_events.starts": {"$lt": datetime.now(tz=ZoneInfo("UTC"))}, + "registered_events.is_cancelled": False, + } + }, + ] + + async for result in col_users.aggregate(pipeline): + for registered_event in result["registered_events"]: + events.append(PycordEvent(**registered_event)) + + for event in events: + if pycord_user.current_event_id is not None and pycord_user.current_event_id != event._id: + continue + + if pycord_user.current_event_id is None: + await pycord_user.set_event(event._id, cache=self.bot.cache) + + channel: TextChannel | None = await pycord_user.fix_event_channel( + self.bot, ctx.guild, guild, event, cache=self.bot.cache + ) + + if channel is None: + continue + + thumbnail: File | None = ( + None + if event.thumbnail is None + else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"]) + ) + + await channel.send( + self.bot._("notice_event_already_started", "messages").format(event_name=event.name), + file=thumbnail, + ) + + stage_id: ObjectId = ( + event.stage_ids[0] if pycord_user.current_stage_id is None else pycord_user.current_stage_id + ) + + await pycord_user.set_event_stage(stage_id, cache=self.bot.cache) + + await self.bot.send_stage_question(channel, event, await self.bot.find_event_stage(stage_id)) + + await ctx.respond("Channels were updated.") # TODO Implement the command # @command_group.command( diff --git a/cogs/cog_utility.py b/cogs/cog_utility.py index 64a5739..b8d63a7 100644 --- a/cogs/cog_utility.py +++ b/cogs/cog_utility.py @@ -123,7 +123,7 @@ class CogUtility(Cog): ) await channel.send( - self.bot._("register_already_started", "messages").format(event_name=event.name), + self.bot._("notice_event_already_started", "messages").format(event_name=event.name), file=thumbnail, ) diff --git a/locale/en-US.json b/locale/en-US.json index 2caf257..92f75a7 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -14,9 +14,9 @@ "guild_unconfigured": "Guild is not configured. Please, report this to the administrator.", "guild_unconfigured_admin": "Guild is not configured. Please, configure it using `/config set`.", "jailed_error": "You are jailed and cannot interact with events. Please, contact the administrator.", + "notice_event_already_started": "Event **{event_name}** has already started!\n\nUse slash command `/guess` to suggest your answers to each event stage.", "operation_unconfirmed": "Operation not confirmed.", "register_already_registered": "You are already registered for this event.", - "register_already_started": "Event **{event_name}** has already started!\n\nUse slash command `/guess` to suggest your answers to each event stage.", "register_success_ongoing": "You are now registered for the event **{event_name}**.\n\nNew channel has been created for you and further instructions will are provided in it. Good luck!", "register_success_scheduled": "You are now registered for the event **{event_name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!", "status": "**QuizBot** v{version}\n\nUptime: since ", diff --git a/modules/utils/event_utils.py b/modules/utils/event_utils.py index efb1fd0..4774bec 100644 --- a/modules/utils/event_utils.py +++ b/modules/utils/event_utils.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict +from typing import Any, Dict, Optional from zoneinfo import ZoneInfo from bson import ObjectId @@ -13,9 +13,9 @@ from modules.database import col_events async def validate_event_validity( ctx: ApplicationContext, name: str, - start_date: datetime | None, - end_date: datetime | None, - event_id: ObjectId | None = None, + start_date: datetime, + end_date: datetime, + event_id: Optional[ObjectId] = None, to_utc: bool = False, ) -> bool: start_date_internal: datetime = start_date.astimezone(ZoneInfo("UTC")) if to_utc else start_date From fa200ef92d7873d3a53bd9096fd7d603e5b8a34e Mon Sep 17 00:00:00 2001 From: profitroll Date: Fri, 2 May 2025 14:18:58 +0200 Subject: [PATCH 35/55] Introduced i18n for "/user update_channels" --- cogs/cog_user.py | 61 ++++++++++++++++++++++++++++++++++------------- locale/en-US.json | 14 +++++++---- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/cogs/cog_user.py b/cogs/cog_user.py index fd5b385..b50ace2 100644 --- a/cogs/cog_user.py +++ b/cogs/cog_user.py @@ -1,4 +1,5 @@ from datetime import datetime +from logging import Logger from pathlib import Path from typing import List, Dict, Any from zoneinfo import ZoneInfo @@ -20,7 +21,9 @@ from classes import PycordUser, PycordEvent, PycordGuild from classes.errors import GuildNotFoundError from classes.pycord_bot import PycordBot from modules.database import col_users -from modules.utils import is_operation_confirmed +from modules.utils import is_operation_confirmed, get_logger + +logger: Logger = get_logger(__name__) class CogUser(Cog): @@ -35,26 +38,17 @@ class CogUser(Cog): description_localizations=in_every_locale("description", "commands", "user"), ) - # TODO Implement the command - # @command_group.command( - # name="create_channel", - # description="Create channel for the user", - # ) - # @option( - # "user", - # description="Selected user", - # ) - # async def command_user_create_channel(self, ctx: ApplicationContext, user: User) -> None: - # await ctx.respond("Not implemented.") - - # TODO Implement the command @command_group.command( name="update_channels", - description="Update user's event channels", + description=_("description", "commands", "user_update_channels"), + description_localizations=in_every_locale("description", "commands", "user_update_channels"), ) @option( "user", - description="Selected user", + description=_("description", "commands", "user_update_channels", "options", "user"), + description_localizations=in_every_locale( + "description", "commands", "user_update_channels", "options", "user" + ), ) async def command_user_update_channels(self, ctx: ApplicationContext, user: User) -> None: try: @@ -101,6 +95,23 @@ class CogUser(Cog): self.bot, ctx.guild, guild, event, cache=self.bot.cache ) + try: + await self.bot.notify_admins( + ctx.guild, + guild, + self.bot._("admin_user_channel_fixed", "messages", locale=ctx.locale).format( + display_name=user.display_name, mention=user.mention, event_name=event.name + ), + ) + except Exception as exc: + logger.error( + "Could not notify admins that user %s got their event channel for %s fixed due to: %s", + user.id, + event._id, + exc, + exc_info=exc, + ) + if channel is None: continue @@ -123,7 +134,23 @@ class CogUser(Cog): await self.bot.send_stage_question(channel, event, await self.bot.find_event_stage(stage_id)) - await ctx.respond("Channels were updated.") + await ctx.respond( + self.bot._("user_channels_updated", "messages", locale=ctx.locale).format( + display_name=user.display_name + ) + ) + + # TODO Implement the command + # @command_group.command( + # name="create_channel", + # description="Create channel for the user", + # ) + # @option( + # "user", + # description="Selected user", + # ) + # async def command_user_create_channel(self, ctx: ApplicationContext, user: User) -> None: + # await ctx.respond("Not implemented.") # TODO Implement the command # @command_group.command( diff --git a/locale/en-US.json b/locale/en-US.json index 92f75a7..c5e2363 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -3,6 +3,7 @@ "admin_user_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.", "admin_user_completed_event": "User **{display_name}** ({mention}) has completed the event **{event_name}**", "admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.", + "admin_user_channel_fixed": "Fixed event channel of user **{display_name}** ({mention}) for the event **{event_name}**.", "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`", @@ -28,7 +29,8 @@ "user_jail_already_jailed": "User **{display_name}** is already jailed.", "user_jail_successful": "User **{display_name}** has been jailed and cannot interact with events anymore.", "user_unjail_not_jailed": "User **{display_name}** is not jailed.", - "user_unjail_successful": "User **{display_name}** has been unjailed and can interact with events again." + "user_unjail_successful": "User **{display_name}** has been unjailed and can interact with events again.", + "user_channels_updated": "Event channels of the user **{display_name}** were updated." }, "commands": { "config": { @@ -215,9 +217,13 @@ "description": "Create channel for the user", "options": {} }, - "user_update_channel": { - "description": "Update user's channel", - "options": {} + "user_update_channels": { + "description": "Update user's event channels", + "options": { + "user": { + "description": "Selected user" + } + } }, "user_delete_channel": { "description": "Delete user's channel", From 327dcba544449af78cce4ac83034f113a4239847 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 3 May 2025 00:33:49 +0200 Subject: [PATCH 36/55] Introduced i18n to CogEvent --- cogs/cog_event.py | 55 +++++++++++++++++++++++++---------------------- locale/en-US.json | 15 ++++++++++--- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/cogs/cog_event.py b/cogs/cog_event.py index fb23875..e8641ed 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -24,7 +24,6 @@ from modules.utils import ( ) -# noinspection Mypy class CogEvent(Cog): """Cog with event management commands.""" @@ -98,10 +97,7 @@ class CogEvent(Cog): start_date: datetime = datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) end_date: datetime = datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) except ValueError: - # TODO Introduce i18n - await ctx.respond( - "Could not parse start and end dates. Please, make sure these are provided in `DD.MM.YYYY HH:MM` format." - ) + await ctx.respond(self.bot._("event_dates_parsing_failed", "messages", locale=ctx.locale)) return if not await validate_event_validity(ctx, name, start_date, end_date, to_utc=True): @@ -120,9 +116,10 @@ class CogEvent(Cog): thumbnail=processed_media[0] if thumbnail else None, ) - # TODO Introduce i18n await ctx.respond( - f"Event **{event.name}** has been created and will take place ." + self.bot._("event_created", "messages", locale=ctx.locale).format( + event_name=event.name, start_time=get_unix_timestamp(event.starts, to_utc=True) + ) ) @command_group.command( @@ -205,10 +202,7 @@ class CogEvent(Cog): else datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) ) except ValueError: - # TODO Make a nice message - await ctx.respond( - "Could not parse the start date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format." - ) + await ctx.respond(self.bot._("event_start_date_parsing_failed", "messages", locale=ctx.locale)) return try: @@ -218,10 +212,7 @@ class CogEvent(Cog): else datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) ) except ValueError: - # TODO Make a nice message - await ctx.respond( - "Could not parse the end date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format." - ) + await ctx.respond(self.bot._("event_end_date_parsing_failed", "messages", locale=ctx.locale)) return if not await validate_event_validity( @@ -248,9 +239,11 @@ class CogEvent(Cog): # TODO Notify participants about time changes - # TODO Make a nice message await ctx.respond( - f"Event **{pycord_event.name}** has been updated and will take place ." + self.bot._("event_updated", "messages", locale=ctx.locale).format( + event_name=pycord_event.name, + start_time=get_unix_timestamp(pycord_event.starts, to_utc=True), + ) ) @command_group.command( @@ -309,16 +302,22 @@ class CogEvent(Cog): or end_date <= datetime.now(tz=ZoneInfo("UTC")) or start_date <= datetime.now(tz=ZoneInfo("UTC")) ): - # TODO Make a nice message - await ctx.respond("Finished or ongoing events cannot be cancelled.") + await ctx.respond( + self.bot._("event_not_editable", "messages", locale=ctx.locale).format( + event_name=pycord_event.name + ) + ) return await pycord_event.cancel() # TODO Notify participants about cancellation - # TODO Make a nice message - await ctx.respond(f"Event **{pycord_event.name}** was cancelled.") + await ctx.respond( + self.bot._("event_cancelled", "messages", locale=ctx.locale).format( + event_name=pycord_event.name + ) + ) @command_group.command( name="show", @@ -346,16 +345,20 @@ class CogEvent(Cog): stages: List[PycordEventStage] = await self.bot.get_event_stages(pycord_event) - # TODO Make a nice message stages_string: str = "\n\n".join( - f"**Stage {stage.sequence+1}**\nAnswer: ||{stage.answer}||" for stage in stages + self.bot._("stage_entry", "messages", locale=ctx.locale).format( + sequence=stage.sequence + 1, answer=stage.answer + ) + for stage in stages ) # TODO Show users registered for the event - # TODO Introduce i18n - event_info_string: str = ( - f"**Event details**\n\nName: {pycord_event.name}\nStarts: \nEnds: \n\nStages:\n{stages_string}" + event_info_string: str = self.bot._("event_details", "messages", locale=ctx.locale).format( + event_name=pycord_event.name, + start_time=get_unix_timestamp(starts_date), + end_time=get_unix_timestamp(ends_date), + stages=stages_string, ) chunk_size: int = 2000 diff --git a/locale/en-US.json b/locale/en-US.json index c5e2363..7c84b59 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -1,13 +1,21 @@ { "messages": { "admin_user_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.", + "admin_user_channel_fixed": "Fixed event channel of user **{display_name}** ({mention}) for the event **{event_name}**.", "admin_user_completed_event": "User **{display_name}** ({mention}) has completed the event **{event_name}**", "admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.", - "admin_user_channel_fixed": "Fixed event channel of user **{display_name}** ({mention}) for the event **{event_name}**.", "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`", + "event_cancelled": "Event **{event_name}** was cancelled.", + "event_created": "Event **{event_name}** has been created and will take place .", + "event_dates_parsing_failed": "Could not parse start and end dates. Please, make sure these are provided in `DD.MM.YYYY HH:MM` format.", + "event_details": "**Event details**\n\nName: {event_name}\nStarts: \nEnds: \n\nStages:\n{stages}", + "event_end_date_parsing_failed": "Could not parse the end date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format.", + "event_not_editable": "Finished or ongoing events cannot be cancelled.", "event_not_found": "Event was not found.", + "event_start_date_parsing_failed": "Could not parse the start date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format.", + "event_updated": "Event **{event_name}** has been updated and will take place .", "guess_completed_event": "Congratulations! You have completed the event!", "guess_incorrect_channel": "Usage outside own event channel is not allowed.", "guess_incorrect_event": "Your event could not be found. Please, contact the administrator.", @@ -20,17 +28,18 @@ "register_already_registered": "You are already registered for this event.", "register_success_ongoing": "You are now registered for the event **{event_name}**.\n\nNew channel has been created for you and further instructions will are provided in it. Good luck!", "register_success_scheduled": "You are now registered for the event **{event_name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!", + "stage_entry": "**Stage {sequence}**\nAnswer: ||{answer}||", "status": "**QuizBot** v{version}\n\nUptime: since ", "status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: up since ", "timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.", "unexpected_error": "An unexpected error has occurred. Please, contact the administrator.", "unregister_not_registered": "You are not registered for this event.", "unregister_unregistered": "You are no longer registered for this event.", + "user_channels_updated": "Event channels of the user **{display_name}** were updated.", "user_jail_already_jailed": "User **{display_name}** is already jailed.", "user_jail_successful": "User **{display_name}** has been jailed and cannot interact with events anymore.", "user_unjail_not_jailed": "User **{display_name}** is not jailed.", - "user_unjail_successful": "User **{display_name}** has been unjailed and can interact with events again.", - "user_channels_updated": "Event channels of the user **{display_name}** were updated." + "user_unjail_successful": "User **{display_name}** has been unjailed and can interact with events again." }, "commands": { "config": { From 34a506466db1e454744eafd57f5e8cea3d8c66f6 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 3 May 2025 00:38:13 +0200 Subject: [PATCH 37/55] Introduced i18n to CogStage --- cogs/cog_stage.py | 17 ++++++----------- locale/en-US.json | 5 +++++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 482d098..d219bda 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -115,8 +115,7 @@ class CogStage(Cog): media=[] if media is None else processed_media, ) - # TODO Make a nice message - await ctx.respond("Event stage has been created.") + await ctx.respond(self.bot._("stage_created", "messages", locale=ctx.locale)) @command_group.command( name="edit", @@ -216,13 +215,11 @@ class CogStage(Cog): try: event_stage: PycordEventStage = await self.bot.find_event_stage(stage) except (InvalidId, EventStageNotFoundError): - # TODO Make a nice message - await ctx.respond("Event stage was not found.") + await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale)) return if order is not None and order > len(pycord_event.stage_ids): - # TODO Make a nice message - await ctx.respond("Stage sequence out of range.") + await ctx.respond(self.bot._("stage_sequence_out_of_range", "messages", locale=ctx.locale)) return processed_media: List[Dict[str, Any]] = ( @@ -239,7 +236,7 @@ class CogStage(Cog): if order is not None and order - 1 != event_stage.sequence: await pycord_event.reorder_stage(self.bot, event_stage._id, order - 1, cache=self.bot.cache) - await ctx.respond("Event stage has been updated.") + await ctx.respond(self.bot._("stage_updated", "messages", locale=ctx.locale)) @command_group.command( name="delete", @@ -300,15 +297,13 @@ class CogStage(Cog): try: event_stage: PycordEventStage = await self.bot.find_event_stage(stage) except (InvalidId, EventStageNotFoundError): - # TODO Make a nice message - await ctx.respond("Event stage was not found.") + await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale)) return await pycord_event.remove_stage(self.bot, event_stage._id, cache=self.bot.cache) await event_stage.purge(cache=self.bot.cache) - # TODO Make a nice message - await ctx.respond("Event stage has been deleted.") + await ctx.respond(self.bot._("stage_deleted", "messages", locale=ctx.locale)) def setup(bot: PycordBot) -> None: diff --git a/locale/en-US.json b/locale/en-US.json index 7c84b59..dfdcaa9 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -28,7 +28,12 @@ "register_already_registered": "You are already registered for this event.", "register_success_ongoing": "You are now registered for the event **{event_name}**.\n\nNew channel has been created for you and further instructions will are provided in it. Good luck!", "register_success_scheduled": "You are now registered for the event **{event_name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts . Good luck!", + "stage_created": "Event stage has been created.", + "stage_deleted": "Event stage has been deleted.", "stage_entry": "**Stage {sequence}**\nAnswer: ||{answer}||", + "stage_not_found": "Event stage was not found.", + "stage_sequence_out_of_range": "Stage sequence out of range.", + "stage_updated": "Event stage has been updated.", "status": "**QuizBot** v{version}\n\nUptime: since ", "status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: up since ", "timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.", From aa2f90e1c5a86f6f37d2cb7e13b7eff5ac19fa80 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 3 May 2025 00:45:22 +0200 Subject: [PATCH 38/55] Introduced i18n to utility modules --- locale/en-US.json | 6 ++++++ modules/utils/event_utils.py | 13 +++++-------- modules/utils/validation_utils.py | 7 +++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/locale/en-US.json b/locale/en-US.json index dfdcaa9..7e3ff12 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -11,10 +11,16 @@ "event_created": "Event **{event_name}** has been created and will take place .", "event_dates_parsing_failed": "Could not parse start and end dates. Please, make sure these are provided in `DD.MM.YYYY HH:MM` format.", "event_details": "**Event details**\n\nName: {event_name}\nStarts: \nEnds: \n\nStages:\n{stages}", + "event_end_before_start": "Start date must be before end date", "event_end_date_parsing_failed": "Could not parse the end date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format.", + "event_end_past": "End date must not be in the past", + "event_is_cancelled": "This event was cancelled.", + "event_name_duplicate": "There can only be one active event with the same name", "event_not_editable": "Finished or ongoing events cannot be cancelled.", "event_not_found": "Event was not found.", + "event_ongoing_not_editable": "Ongoing events cannot be modified.", "event_start_date_parsing_failed": "Could not parse the start date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format.", + "event_start_past": "Start date must not be in the past", "event_updated": "Event **{event_name}** has been updated and will take place .", "guess_completed_event": "Congratulations! You have completed the event!", "guess_incorrect_channel": "Usage outside own event channel is not allowed.", diff --git a/modules/utils/event_utils.py b/modules/utils/event_utils.py index 4774bec..28500ab 100644 --- a/modules/utils/event_utils.py +++ b/modules/utils/event_utils.py @@ -6,6 +6,7 @@ from bson import ObjectId from discord import ( ApplicationContext, ) +from libbot.i18n import _ from modules.database import col_events @@ -22,18 +23,15 @@ async def validate_event_validity( end_date_internal: datetime = end_date.astimezone(ZoneInfo("UTC")) if to_utc else end_date if start_date_internal < datetime.now(tz=ZoneInfo("UTC")): - # TODO Make a nice message - await ctx.respond("Start date must not be in the past") + await ctx.respond(_("event_start_past", "messages", locale=ctx.locale)) return False if end_date_internal < datetime.now(tz=ZoneInfo("UTC")): - # TODO Make a nice message - await ctx.respond("End date must not be in the past") + await ctx.respond(_("event_end_past", "messages", locale=ctx.locale)) return False if start_date_internal >= end_date_internal: - # TODO Make a nice message - await ctx.respond("Start date must be before end date") + await ctx.respond(_("event_end_before_start", "messages", locale=ctx.locale)) return False # TODO Add validation for concurrent events. @@ -49,8 +47,7 @@ async def validate_event_validity( query["_id"] = {"$ne": event_id} if (await col_events.find_one(query)) is not None: - # TODO Make a nice message - await ctx.respond("There can only be one active event with the same name") + await ctx.respond(_("event_name_duplicate", "messages", locale=ctx.locale)) return False return True diff --git a/modules/utils/validation_utils.py b/modules/utils/validation_utils.py index f7f330f..924bf06 100644 --- a/modules/utils/validation_utils.py +++ b/modules/utils/validation_utils.py @@ -2,6 +2,7 @@ from datetime import datetime from zoneinfo import ZoneInfo from discord import ApplicationContext +from libbot.i18n import _ async def is_operation_confirmed(ctx: ApplicationContext, confirm: bool) -> bool: @@ -17,8 +18,7 @@ async def is_event_status_valid( event: "PycordEvent", ) -> bool: if event.is_cancelled: - # TODO Make a nice message - await ctx.respond("This event was cancelled.") + await ctx.respond(_("event_is_cancelled", "messages", locale=ctx.locale)) return False if ( @@ -26,8 +26,7 @@ async def is_event_status_valid( <= datetime.now(tz=ZoneInfo("UTC")) <= event.ends.replace(tzinfo=ZoneInfo("UTC")) ): - # TODO Make a nice message - await ctx.respond("Ongoing events cannot be modified.") + await ctx.respond(_("event_ongoing_not_editable", "messages", locale=ctx.locale)) return False return True From 2d9bf1cfb9cc8d9c5b786e3d5e0bebe0e3a2236b Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 3 May 2025 00:55:13 +0200 Subject: [PATCH 39/55] Introduced prefer_emojis for PycordGuild --- classes/pycord_guild.py | 3 +++ cogs/cog_config.py | 20 +++++++++++++++++++- cogs/cog_guess.py | 8 +++++--- locale/en-US.json | 6 +++++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index 5355976..1eb639f 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -26,6 +26,7 @@ class PycordGuild: channel_id: int | None category_id: int | None timezone: str + prefer_emojis: bool @classmethod async def from_id( @@ -145,6 +146,7 @@ class PycordGuild: "channel_id": self.channel_id, "category_id": self.category_id, "timezone": self.timezone, + "prefer_emojis": self.prefer_emojis, } # TODO Add documentation @@ -155,6 +157,7 @@ class PycordGuild: "channel_id": None, "category_id": None, "timezone": "UTC", + "prefer_emojis": False, } @staticmethod diff --git a/cogs/cog_config.py b/cogs/cog_config.py index c45d000..e22e7a9 100644 --- a/cogs/cog_config.py +++ b/cogs/cog_config.py @@ -43,7 +43,14 @@ class CogConfig(Cog): ), required=True, ) - @option("channel", description="Text channel for admin notifications", required=True) + @option( + "channel", + description=_("description", "commands", "config_set", "options", "channel"), + description_localizations=in_every_locale( + "description", "commands", "config_set", "options", "channel" + ), + required=True, + ) @option( "timezone", description=_("description", "commands", "config_set", "options", "timezone"), @@ -53,12 +60,21 @@ class CogConfig(Cog): autocomplete=basic_autocomplete(autocomplete_timezones), required=True, ) + @option( + "prefer_emojis", + description=_("description", "commands", "config_set", "options", "prefer_emojis"), + description_localizations=in_every_locale( + "description", "commands", "config_set", "options", "prefer_emojis" + ), + required=True, + ) async def command_config_set( self, ctx: ApplicationContext, category: CategoryChannel, channel: TextChannel, timezone: str, + prefer_emojis: bool, ) -> None: try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) @@ -79,6 +95,7 @@ class CogConfig(Cog): channel_id=channel.id, category_id=category.id, timezone=str(timezone_parsed), + prefer_emojis=prefer_emojis, ) await ctx.respond(self.bot._("config_set", "messages", locale=ctx.locale)) @@ -131,6 +148,7 @@ class CogConfig(Cog): channel_id=guild.channel_id, category_id=guild.category_id, timezone=guild.timezone, + prefer_emojis=guild.prefer_emojis, ) ) diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index df2d2da..61b6785 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -65,9 +65,11 @@ class CogGuess(Cog): return if answer.lower() != stage.answer.lower(): - # TODO Make a nice message - # await ctx.respond("Provided answer is wrong.") - await ctx.respond(self.bot.config["emojis"]["guess_wrong"]) + await ctx.respond( + self.bot.config["emojis"]["guess_wrong"] + if guild.prefer_emojis + else self.bot._("guess_incorrect", "messages", locale=ctx.locale) + ) return next_stage_index = stage.sequence + 1 diff --git a/locale/en-US.json b/locale/en-US.json index 7e3ff12..ffcc0c2 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -6,7 +6,7 @@ "admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.", "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", - "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`", + "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`\nPrefer emojis: `{prefer_emojis}`", "event_cancelled": "Event **{event_name}** was cancelled.", "event_created": "Event **{event_name}** has been created and will take place .", "event_dates_parsing_failed": "Could not parse start and end dates. Please, make sure these are provided in `DD.MM.YYYY HH:MM` format.", @@ -23,6 +23,7 @@ "event_start_past": "Start date must not be in the past", "event_updated": "Event **{event_name}** has been updated and will take place .", "guess_completed_event": "Congratulations! You have completed the event!", + "guess_incorrect": "Provided answer is wrong.", "guess_incorrect_channel": "Usage outside own event channel is not allowed.", "guess_incorrect_event": "Your event could not be found. Please, contact the administrator.", "guess_unregistered": "You have no ongoing events. You can register for events using the `/register` command.", @@ -67,6 +68,9 @@ }, "timezone": { "description": "Timezone in which events take place" + }, + "prefer_emojis": { + "description": "Prefer emojis over text messages where available" } } }, From f2e01e3b85575d346574b848164d2151fc11da4a Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 3 May 2025 01:07:24 +0200 Subject: [PATCH 40/55] Fixed i18n for PycordBot --- classes/pycord_bot.py | 34 ++++++++++++++++++---------------- locale/en-US.json | 7 +++++++ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index abe5e8c..1238588 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -97,13 +97,12 @@ class PycordBot(LibPycordBot): continue if len(event.stage_ids) == 0: - # TODO Make a nice message for management logger.error("Could not start the event %s: no event stages are defined.", event._id) await self.notify_admins( guild, pycord_guild, - f"Could not start the event **{event.name}**: no event stages are defined.", + self.bot._("admin_event_no_stages_defined", "messages").format(event_name=event.name), ) await event.cancel(self.cache) @@ -134,7 +133,9 @@ class PycordBot(LibPycordBot): await self.notify_admins( guild, pycord_guild, - f"Event channel could not be created for user with ID `{user.id}` (<@{user.id}>) and event **{event.name}**: user was not found on the server.", + self.bot._("admin_channel_creation_failed_no_user", "messages").format( + user_id=user.id, event_name=event.name + ), ) continue @@ -152,7 +153,11 @@ class PycordBot(LibPycordBot): await self.notify_admins( guild, pycord_guild, - f"Event channel could not be created for user **{discord_user.display_name}** ({discord_user.mention}) and event **{event.name}**.", + self.bot._("admin_channel_creation_failed", "messages").format( + display_name=discord_user.display_name, + mention=discord_user.mention, + event_name=event.name, + ), ) continue @@ -163,10 +168,8 @@ class PycordBot(LibPycordBot): else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"]) ) - # Send a notification about event start - # TODO Make a nice message await user_channel.send( - f"Event **{event.name}** is starting!\n\nUse slash command `/guess` to suggest your answers to each event stage.", + self.bot._("event_is_starting", "messages").format(event_name=event.name), file=thumbnail, ) @@ -180,11 +183,10 @@ class PycordBot(LibPycordBot): chunk, files=None if index != question_chunks_length - 1 else first_stage_files ) - # TODO Make a nice message await self.notify_admins( guild, pycord_guild, - f"Event **{event.name}** has started! Users have gotten their channels and can already start submitting their answers.", + self.bot._("admin_event_started", "messages").format(event_name=event.name), ) async def _process_events_end(self) -> None: @@ -218,9 +220,11 @@ class PycordBot(LibPycordBot): ) continue - # TODO Make a nice message stages_string: str = "\n\n".join( - f"**Stage {stage.sequence+1}**\nAnswer: ||{stage.answer}||" for stage in stages + self.bot._("stage_entry", "messages").format( + sequence=stage.sequence + 1, answer=stage.answer + ) + for stage in stages ) # Get list of participants @@ -238,9 +242,8 @@ class PycordBot(LibPycordBot): # Send a notification about event start user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)]) - # TODO Make a nice message - event_ended_string: str = ( - f"Event **{event.name}** has ended! Stages and respective answers are listed below.\n\n{stages_string}" + event_ended_string: str = self.bot._("event_ended", "messages").format( + event_name=event.name, stages=stages_string ) chunk_size: int = 2000 @@ -256,11 +259,10 @@ class PycordBot(LibPycordBot): # Lock each participant out await user.lock_event_channel(guild, event._id, channel=user_channel) - # TODO Make a nice message await self.notify_admins( guild, pycord_guild, - f"Event **{event.name}** has ended! Users can no longer submit their answers.", + self.bot._("admin_event_ended", "messages").format(event_name=event.name), ) await event.end(cache=self.cache) diff --git a/locale/en-US.json b/locale/en-US.json index ffcc0c2..5a49b88 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -1,5 +1,10 @@ { "messages": { + "admin_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.", + "admin_channel_creation_failed_no_user": "Event channel could not be created for user with ID `{user_id}` (<@{user_id}>) and event **{event_name}**: user was not found on the server.", + "admin_event_ended": "Event **{event_name}** has ended! Users can no longer submit their answers.", + "admin_event_no_stages_defined": "Could not start the event **{event_name}**: no event stages are defined.", + "admin_event_started": "Event **{event_name}** has started! Users have gotten their channels and can already start submitting their answers.", "admin_user_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.", "admin_user_channel_fixed": "Fixed event channel of user **{display_name}** ({mention}) for the event **{event_name}**.", "admin_user_completed_event": "User **{display_name}** ({mention}) has completed the event **{event_name}**", @@ -14,7 +19,9 @@ "event_end_before_start": "Start date must be before end date", "event_end_date_parsing_failed": "Could not parse the end date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format.", "event_end_past": "End date must not be in the past", + "event_ended": "Event **{event_name}** has ended! Stages and respective answers are listed below.\n\n{stages}", "event_is_cancelled": "This event was cancelled.", + "event_is_starting": "Event **{event_name}** is starting!\n\nUse slash command `/guess` to suggest your answers to each event stage.", "event_name_duplicate": "There can only be one active event with the same name", "event_not_editable": "Finished or ongoing events cannot be cancelled.", "event_not_found": "Event was not found.", From 798f5ac52951e8340277b478f5f63b6e5549c310 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 3 May 2025 01:09:11 +0200 Subject: [PATCH 41/55] Fixed prefer_emojis missing from __slots__ in PycordGuild --- classes/pycord_guild.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index 1eb639f..611d17d 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -17,7 +17,7 @@ logger: Logger = get_logger(__name__) class PycordGuild: """Dataclass of DB entry of a guild""" - __slots__ = ("_id", "id", "channel_id", "category_id", "timezone") + __slots__ = ("_id", "id", "channel_id", "category_id", "timezone", "prefer_emojis") __short_name__ = "guild" __collection__ = col_guilds From 44e144801d34e113849c6bf9cb36babed9322d2a Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 3 May 2025 17:00:21 +0200 Subject: [PATCH 42/55] Added installation and usage instructions --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/README.md b/README.md index 647df6a..19a4c6a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,54 @@ # QuizBot +Open source Discord bot for quizzes and quest-like events. + +## Installation + +### Dependencies + +- [Python 3.11+](https://www.python.org) +- [MongoDB](https://www.mongodb.com) +- [Redis](https://redis.io)/[Valkey](https://valkey.io) or [Memcached](https://memcached.org) (used for caching, + optional) +- [Git](https://git-scm.com) (only if installing from source) + +### Installation from release + +1. Download the release archive from [Releases](https://git.end-play.xyz/profitroll/QuizBot/releases) +2. Unpack the archive to a folder of your choice +3. Go to the project folder +4. Create a virtual environment: `python3 -m venv .venv` +5. Activate virtual environment: + - Linux: `source .venv/bin/activate` + - Windows (cmd): `.venv/bin/activate.bat` + - Windows (PowerShell): `.venv/bin/activate.ps1` +6. Install requirements: `pip install -r requirements.txt` +7. Copy example config to a real file: `cp config_example.json config.json` +8. Configure the bot (see [Configuration](#configuration)) +9. Start the bot: `python main.py` + +### Installation from source + +1. Clone the repository: `git clone https://git.end-play.xyz/profitroll/QuizBot.git` +2. Go to the project's folder: `cd QuizBot` +3. Continue from step 4 of [Installation from release](#installation-from-release) + +## Configuration + +TODO + +## Upgrading + +TODO + +## Usage + +1. Invite the bot to your server with permissions `137707834448` and `applications.commands` scope. + You can also use the following URL template to invite your bot after replacing `CLIENT_ID` with you bot's client + ID: + `https://discord.com/oauth2/authorize?client_id=CLIENT_ID&permissions=137707834448&integration_type=0&scope=applications.commands+bot` +2. Go to "Server Settings > Integrations > QuizBot" and disable access to admin commands for you default role. + Only admins should have access to following commands: `/config`, `/event`, `/stage` and `/user`. + Allowing access to `/status` is not recommended, however won't do any harm if done so. +3. Configure bot for usage on your server using `/config set` providing all the necessary arguments. + Timezones are compatible with summer time (e.g. `CET` will be interpreted as `CEST` during summer time). \ No newline at end of file From 498d822e09a96675367a19fdf64da7be0baf2395 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sat, 3 May 2025 17:00:45 +0200 Subject: [PATCH 43/55] Fixed wrong _ being used --- classes/pycord_bot.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 1238588..c084326 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -102,7 +102,7 @@ class PycordBot(LibPycordBot): await self.notify_admins( guild, pycord_guild, - self.bot._("admin_event_no_stages_defined", "messages").format(event_name=event.name), + self._("admin_event_no_stages_defined", "messages").format(event_name=event.name), ) await event.cancel(self.cache) @@ -133,7 +133,7 @@ class PycordBot(LibPycordBot): await self.notify_admins( guild, pycord_guild, - self.bot._("admin_channel_creation_failed_no_user", "messages").format( + self._("admin_channel_creation_failed_no_user", "messages").format( user_id=user.id, event_name=event.name ), ) @@ -153,7 +153,7 @@ class PycordBot(LibPycordBot): await self.notify_admins( guild, pycord_guild, - self.bot._("admin_channel_creation_failed", "messages").format( + self._("admin_channel_creation_failed", "messages").format( display_name=discord_user.display_name, mention=discord_user.mention, event_name=event.name, @@ -169,7 +169,7 @@ class PycordBot(LibPycordBot): ) await user_channel.send( - self.bot._("event_is_starting", "messages").format(event_name=event.name), + self._("event_is_starting", "messages").format(event_name=event.name), file=thumbnail, ) @@ -186,7 +186,7 @@ class PycordBot(LibPycordBot): await self.notify_admins( guild, pycord_guild, - self.bot._("admin_event_started", "messages").format(event_name=event.name), + self._("admin_event_started", "messages").format(event_name=event.name), ) async def _process_events_end(self) -> None: @@ -221,9 +221,7 @@ class PycordBot(LibPycordBot): continue stages_string: str = "\n\n".join( - self.bot._("stage_entry", "messages").format( - sequence=stage.sequence + 1, answer=stage.answer - ) + self._("stage_entry", "messages").format(sequence=stage.sequence + 1, answer=stage.answer) for stage in stages ) @@ -242,7 +240,7 @@ class PycordBot(LibPycordBot): # Send a notification about event start user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)]) - event_ended_string: str = self.bot._("event_ended", "messages").format( + event_ended_string: str = self._("event_ended", "messages").format( event_name=event.name, stages=stages_string ) @@ -262,7 +260,7 @@ class PycordBot(LibPycordBot): await self.notify_admins( guild, pycord_guild, - self.bot._("admin_event_ended", "messages").format(event_name=event.name), + self._("admin_event_ended", "messages").format(event_name=event.name), ) await event.end(cache=self.cache) From 134533e34215641fad3bc40dc8ec6b4a5b7c8877 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 4 May 2025 00:48:19 +0200 Subject: [PATCH 44/55] Added instructions for upgrading from release and source --- README.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 19a4c6a..46f9085 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ Open source Discord bot for quizzes and quest-like events. ### Installation from release 1. Download the release archive from [Releases](https://git.end-play.xyz/profitroll/QuizBot/releases) -2. Unpack the archive to a folder of your choice -3. Go to the project folder +2. Unpack the archive to a directory of your choice +3. Go to the project's directory 4. Create a virtual environment: `python3 -m venv .venv` -5. Activate virtual environment: +5. Activate the virtual environment: - Linux: `source .venv/bin/activate` - Windows (cmd): `.venv/bin/activate.bat` - Windows (PowerShell): `.venv/bin/activate.ps1` @@ -26,11 +26,13 @@ Open source Discord bot for quizzes and quest-like events. 7. Copy example config to a real file: `cp config_example.json config.json` 8. Configure the bot (see [Configuration](#configuration)) 9. Start the bot: `python main.py` +10. The bot can be stopped by a keyboard interrupt (`Ctrl+C`) and a virtual environment can be deactivated using + `deactivate` ### Installation from source 1. Clone the repository: `git clone https://git.end-play.xyz/profitroll/QuizBot.git` -2. Go to the project's folder: `cd QuizBot` +2. Go to the project's directory: `cd QuizBot` 3. Continue from step 4 of [Installation from release](#installation-from-release) ## Configuration @@ -39,7 +41,29 @@ TODO ## Upgrading -TODO +### Upgrading from release + +Installing over the older version is not supported. Fresh installation is necessary to prevent data corruption. + +1. Make a backup of the project's directory. Some of the old files will be reused +2. Follow the [Installation from release](#installation-from-release) from the beginning and stop before 7th step +3. Copy file `config.json` and directory `data` from the backup you made into the new installation's directory +4. While still in the virtual environment, migrate the database: `python main.py --migrate` + +After these steps are performed, the bot is ready to be started and used. + +### Upgrading from source + +1. Make a backup of the project's directory +2. Go to the project's directory +3. Update the project: `git pull` +4. Activate the virtual environment: + - Linux: `source .venv/bin/activate` + - Windows (cmd): `.venv/bin/activate.bat` + - Windows (PowerShell): `.venv/bin/activate.ps1` +5. Migrate the database: `python main.py --migrate` + +After these steps are performed, the bot is ready to be started and used. ## Usage @@ -51,4 +75,4 @@ TODO Only admins should have access to following commands: `/config`, `/event`, `/stage` and `/user`. Allowing access to `/status` is not recommended, however won't do any harm if done so. 3. Configure bot for usage on your server using `/config set` providing all the necessary arguments. - Timezones are compatible with summer time (e.g. `CET` will be interpreted as `CEST` during summer time). \ No newline at end of file + Timezones are compatible with daylight saving time (e.g. `CET` will be interpreted as `CEST` during daylight saving). \ No newline at end of file From 6b5a276f00072eef9cbb5a7985ef66cbeb10b852 Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 4 May 2025 01:07:28 +0200 Subject: [PATCH 45/55] Documented PycordGuild and partially documented PycordEventStage (#4) --- classes/pycord_event.py | 4 +- classes/pycord_event_stage.py | 65 ++++++++++++++------ classes/pycord_guild.py | 108 +++++++++++++++++----------------- 3 files changed, 104 insertions(+), 73 deletions(-) diff --git a/classes/pycord_event.py b/classes/pycord_event.py index 19c99d4..b272a4c 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -317,7 +317,7 @@ class PycordEvent: async def update( self, cache: Optional[Cache] = None, - **kwargs, + **kwargs: Any, ) -> None: """Update attribute(s) on the object and save the updated entry into the database. @@ -332,7 +332,7 @@ class PycordEvent: async def reset( self, - *args, + *args: str, cache: Optional[Cache] = None, ) -> None: """Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. diff --git a/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index a3820cd..95ee2a9 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -45,17 +45,18 @@ class PycordEventStage: @classmethod async def from_id(cls, stage_id: str | ObjectId, cache: Optional[Cache] = None) -> "PycordEventStage": - """Find event stage in the database. + """Find the event stage by its ID and construct PycordEventStage from database entry. Args: - stage_id (str | ObjectId): Stage's ID - cache (:obj:`Cache`, optional): Cache engine to get the cache from + stage_id (str | ObjectId): ID of the event stage to look up. + cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache. Returns: - PycordEventStage: Event stage object + PycordEventStage: Object of the found event stage. Raises: - EventStageNotFoundError: Event stage was not found + EventStageNotFoundError: Event stage with such ID does not exist. + InvalidId: Provided event stage ID is of invalid format. """ cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, stage_id, cache=cache) @@ -172,13 +173,13 @@ class PycordEventStage: cache.delete(self._get_cache_key()) def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: - """Convert PycordEventStage object to a JSON representation. + """Convert the object to a JSON representation. Args: - json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted + json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted. Returns: - Dict[str, Any]: JSON representation of PycordEventStage + Dict[str, Any]: JSON representation of the object. """ return { "_id": self._id if not json_compatible else str(self._id), @@ -192,9 +193,13 @@ class PycordEventStage: "media": self.media, } - # TODO Add documentation @staticmethod def get_defaults() -> Dict[str, Any]: + """Get default values for the object attributes. + + Returns: + Dict[str, Any]: Mapping of attributes and their respective values in format `{"attribute_name:" attribute_value}`. + """ return { "event_id": None, "guild_id": None, @@ -206,29 +211,55 @@ class PycordEventStage: "media": [], } - # TODO Add documentation @staticmethod def get_default_value(key: str) -> Any: + """Get default value of the attribute for the object. + + Args: + key (str): Name of the attribute. + + Returns: + Any: Default value of the attribute. + + Raises: + KeyError: There's no default value for the provided attribute. + """ if key not in PycordEventStage.get_defaults(): raise KeyError(f"There's no default value for key '{key}' in PycordEventStage") return PycordEventStage.get_defaults()[key] - # TODO Add documentation async def update( self, cache: Optional[Cache] = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: + """Update attribute(s) on the object and save the updated entry into the database. + + Args: + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + **kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update. + + Raises: + AttributeError: Provided attribute does not exist in the class. + """ await self._set(cache=cache, **kwargs) - # TODO Add documentation async def reset( self, + *args: str, cache: Optional[Cache] = None, - *args, - ): - await self._remove(cache, *args) + ) -> None: + """Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. + + Args: + *args (str): List of attributes to remove. + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + + Raises: + AttributeError: Provided attribute does not exist in the class. + """ + await self._remove(*args, cache=cache) async def purge(self, cache: Optional[Cache] = None) -> None: """Completely remove event stage data from database. Currently only removes the event stage record from events collection. diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index 611d17d..87b3854 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -32,18 +32,18 @@ class PycordGuild: async def from_id( cls, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None ) -> "PycordGuild": - """Find guild in database and create new record if guild does not exist. + """Find the guild by its ID and construct PycordEventStage from database entry. Args: - guild_id (int): User's Discord ID - allow_creation (:obj:`bool`, optional): Create new guild record if none found in the database - cache (:obj:`Cache`, optional): Cache engine to get the cache from + guild_id (int): ID of the guild to look up. + allow_creation (:obj:`bool`, optional): Create a new record if none found in the database. + cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache. Returns: - PycordGuild: User object + PycordGuild: Object of the found or newly created guild. Raises: - GuildNotFoundError: User was not found and creation was not allowed + GuildNotFoundError: Guild with such ID does not exist and creation was not allowed. """ cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, guild_id, cache=cache) @@ -68,12 +68,6 @@ class PycordGuild: return cls(**db_entry) async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: - """Set attribute data and save it into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - **kwargs (Any): Mapping of attribute names and respective values to be set - """ for key, value in kwargs.items(): if not hasattr(self, key): raise AttributeError() @@ -87,12 +81,6 @@ class PycordGuild: logger.info("Set attributes of guild %s to %s", self.id, kwargs) async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: - """Remove attribute data and save it into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - *args (str): List of attributes to remove - """ attributes: Dict[str, Any] = {} for key in args: @@ -132,13 +120,13 @@ class PycordGuild: cache.delete(self._get_cache_key()) def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: - """Convert PycordGuild object to a JSON representation. + """Convert the object to a JSON representation. Args: - json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted + json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted. Returns: - Dict[str, Any]: JSON representation of PycordGuild + Dict[str, Any]: JSON representation of the object. """ return { "_id": self._id if not json_compatible else str(self._id), @@ -149,9 +137,13 @@ class PycordGuild: "prefer_emojis": self.prefer_emojis, } - # TODO Add documentation @staticmethod def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]: + """Get default values for the object attributes. + + Returns: + Dict[str, Any]: Mapping of attributes and their respective values in format `{"attribute_name:" attribute_value}`. + """ return { "id": guild_id, "channel_id": None, @@ -162,26 +154,53 @@ class PycordGuild: @staticmethod def get_default_value(key: str) -> Any: + """Get default value of the attribute for the object. + + Args: + key (str): Name of the attribute. + + Returns: + Any: Default value of the attribute. + + Raises: + KeyError: There's no default value for the provided attribute. + """ if key not in PycordGuild.get_defaults(): raise KeyError(f"There's no default value for key '{key}' in PycordGuild") return PycordGuild.get_defaults()[key] - # TODO Add documentation async def update( self, cache: Optional[Cache] = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: + """Update attribute(s) on the object and save the updated entry into the database. + + Args: + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + **kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update. + + Raises: + AttributeError: Provided attribute does not exist in the class. + """ await self._set(cache=cache, **kwargs) - # TODO Add documentation async def reset( self, + *args: str, cache: Optional[Cache] = None, - *args, - ): - await self._remove(cache, *args) + ) -> None: + """Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. + + Args: + *args (str): List of attributes to remove. + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + + Raises: + AttributeError: Provided attribute does not exist in the class. + """ + await self._remove(*args, cache=cache) async def purge(self, cache: Optional[Cache] = None) -> None: """Completely remove guild data from database. Currently only removes the guild record from guilds collection. @@ -195,35 +214,16 @@ class PycordGuild: logger.info("Purged guild %s (%s) from the database", self.id, self._id) - # TODO Add documentation def is_configured(self) -> bool: + """Return whether all attributes required for bot's use on the server are set. + + Returns: + bool: `True` if yes and `False` if not. + """ return ( (self.id is not None) and (self.channel_id is not None) and (self.category_id is not None) and (self.timezone is not None) + and (self.prefer_emojis is not None) ) - - # TODO Add documentation - async def set_channel(self, channel_id: Optional[int] = None, cache: Optional[Cache] = None) -> None: - await self._set(cache, channel_id=channel_id) - - # TODO Add documentation - async def reset_channel(self, cache: Optional[Cache] = None) -> None: - await self._remove(cache, "channel_id") - - # TODO Add documentation - async def set_category(self, category_id: Optional[int] = None, cache: Optional[Cache] = None) -> None: - await self._set(cache, category_id=category_id) - - # TODO Add documentation - async def reset_category(self, cache: Optional[Cache] = None) -> None: - await self._remove(cache, "category_id") - - # TODO Add documentation - async def set_timezone(self, timezone: str, cache: Optional[Cache] = None) -> None: - await self._set(cache, timezone=timezone) - - # TODO Add documentation - async def reset_timezone(self, cache: Optional[Cache] = None) -> None: - await self._remove(cache, "timezone") From 9d562e2e9d71e30374a42d8184ca99aa52d1e50a Mon Sep 17 00:00:00 2001 From: profitroll Date: Sun, 4 May 2025 22:30:54 +0200 Subject: [PATCH 46/55] Closes #14 --- classes/pycord_bot.py | 90 ++++++++++++++++++++++++++++++++++------- classes/pycord_guild.py | 22 +++++++--- classes/pycord_user.py | 7 +--- cogs/cog_config.py | 23 ++++++++--- locale/en-US.json | 13 ++++-- 5 files changed, 121 insertions(+), 34 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index c084326..86da158 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -189,6 +189,12 @@ class PycordBot(LibPycordBot): self._("admin_event_started", "messages").format(event_name=event.name), ) + await self.notify_users( + guild, + pycord_guild, + self._("event_started", "messages").format(event_name=event.name), + ) + async def _process_events_end(self) -> None: # Get events to end events: List[PycordEvent] = await self._get_events( @@ -228,6 +234,17 @@ class PycordBot(LibPycordBot): # Get list of participants users: List[PycordUser] = await self._get_event_participants(event._id) + event_ended_string: str = self._("event_ended", "messages").format( + event_name=event.name, stages=stages_string + ) + + chunk_size: int = 2000 + + event_info_chunks: List[str] = [ + event_ended_string[i : i + chunk_size] + for i in range(0, len(event_ended_string), chunk_size) + ] + for user in users: if str(event._id) not in user.event_channels: logger.warning( @@ -240,30 +257,58 @@ class PycordBot(LibPycordBot): # Send a notification about event start user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)]) - event_ended_string: str = self._("event_ended", "messages").format( - event_name=event.name, stages=stages_string - ) - - chunk_size: int = 2000 - - event_info_chunks: List[str] = [ - event_ended_string[i : i + chunk_size] - for i in range(0, len(event_ended_string), chunk_size) - ] - for chunk in event_info_chunks: await user_channel.send(chunk) # Lock each participant out await user.lock_event_channel(guild, event._id, channel=user_channel) + await event.end(cache=self.cache) + await self.notify_admins( guild, pycord_guild, self._("admin_event_ended", "messages").format(event_name=event.name), ) - await event.end(cache=self.cache) + await self._notify_general_channel_event_end(guild, pycord_guild, event, stages) + + async def _notify_general_channel_event_end( + self, guild: Guild, pycord_guild: PycordGuild, event: PycordEvent, stages: List[PycordEventStage] + ) -> None: + event_ended_string: str = self._("event_ended_short", "messages").format(event_name=event.name) + + await self.notify_users( + guild, + pycord_guild, + event_ended_string, + ) + + chunk_size: int = 2000 + + for stage in stages: + header_full: str = self._("stage_entry_header", "messages").format( + sequence=stage.sequence + 1, question=stage.question + ) + + header_chunks: List[str] = [ + header_full[i : i + chunk_size] for i in range(0, len(header_full), chunk_size) + ] + header_chunks_length: int = len(header_chunks) + + files: List[File] | None = stage.get_media_files() + + for index, chunk in enumerate(header_chunks): + await self.notify_users( + guild, + pycord_guild, + chunk, + files=None if index != header_chunks_length - 1 else files, + ) + + await self.notify_users( + guild, pycord_guild, self._("stage_entry_footer", "messages").format(answer=stage.answer) + ) @staticmethod async def _get_events(query: Dict[str, Any]) -> List[PycordEvent]: @@ -290,18 +335,35 @@ class PycordBot(LibPycordBot): # TODO Add documentation @staticmethod async def notify_admins(guild: Guild, pycord_guild: PycordGuild, message: str) -> None: - management_channel: TextChannel | None = guild.get_channel(pycord_guild.channel_id) + management_channel: TextChannel | None = guild.get_channel(pycord_guild.management_channel_id) if management_channel is None: logger.error( "Discord channel with ID %s in guild with ID %s could not be found!", - pycord_guild.channel_id, + pycord_guild.management_channel_id, guild.id, ) return await management_channel.send(message) + # TODO Add documentation + @staticmethod + async def notify_users( + guild: Guild, pycord_guild: PycordGuild, message: str, files: Optional[List[File]] = None + ) -> None: + general_channel: TextChannel | None = guild.get_channel(pycord_guild.general_channel_id) + + if general_channel is None: + logger.error( + "Discord channel with ID %s in guild with ID %s could not be found!", + pycord_guild.general_channel_id, + guild.id, + ) + return + + await general_channel.send(message, files=files) + async def find_user(self, user: int | User, guild: int | Guild) -> PycordUser: """Find User by its ID or User object. diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index 87b3854..bfcfb3a 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -17,13 +17,22 @@ logger: Logger = get_logger(__name__) class PycordGuild: """Dataclass of DB entry of a guild""" - __slots__ = ("_id", "id", "channel_id", "category_id", "timezone", "prefer_emojis") + __slots__ = ( + "_id", + "id", + "general_channel_id", + "management_channel_id", + "category_id", + "timezone", + "prefer_emojis", + ) __short_name__ = "guild" __collection__ = col_guilds _id: ObjectId id: int - channel_id: int | None + general_channel_id: int | None + management_channel_id: int | None category_id: int | None timezone: str prefer_emojis: bool @@ -131,7 +140,8 @@ class PycordGuild: return { "_id": self._id if not json_compatible else str(self._id), "id": self.id, - "channel_id": self.channel_id, + "general_channel_id": self.general_channel_id, + "management_channel_id": self.management_channel_id, "category_id": self.category_id, "timezone": self.timezone, "prefer_emojis": self.prefer_emojis, @@ -146,7 +156,8 @@ class PycordGuild: """ return { "id": guild_id, - "channel_id": None, + "general_channel_id": None, + "management_channel_id": None, "category_id": None, "timezone": "UTC", "prefer_emojis": False, @@ -222,7 +233,8 @@ class PycordGuild: """ return ( (self.id is not None) - and (self.channel_id is not None) + and (self.general_channel_id is not None) + and (self.management_channel_id is not None) and (self.category_id is not None) and (self.timezone is not None) and (self.prefer_emojis is not None) diff --git a/classes/pycord_user.py b/classes/pycord_user.py index c1a5218..3473a5b 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -60,11 +60,6 @@ class PycordUser: registered_event_ids: List[ObjectId] completed_event_ids: List[ObjectId] - # TODO Review the redesign - # event_channel_ids: { - # "%event_id%": %channel_id% - # } - @classmethod async def from_id( cls, user_id: int, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None @@ -288,7 +283,7 @@ class PycordUser: raise DiscordGuildMemberNotFoundError(self.id, guild.id) if discord_category is None: - raise DiscordCategoryNotFoundError(pycord_guild.channel_id, guild.id) + raise DiscordCategoryNotFoundError(pycord_guild.category_id, guild.id) permission_overwrites: Dict[Role | Member, PermissionOverwrite] = { guild.default_role: PermissionOverwrite( diff --git a/cogs/cog_config.py b/cogs/cog_config.py index e22e7a9..69f69b6 100644 --- a/cogs/cog_config.py +++ b/cogs/cog_config.py @@ -44,10 +44,18 @@ class CogConfig(Cog): required=True, ) @option( - "channel", - description=_("description", "commands", "config_set", "options", "channel"), + "general_channel", + description=_("description", "commands", "config_set", "options", "general_channel"), description_localizations=in_every_locale( - "description", "commands", "config_set", "options", "channel" + "description", "commands", "config_set", "options", "general_channel" + ), + required=True, + ) + @option( + "management_channel", + description=_("description", "commands", "config_set", "options", "management_channel"), + description_localizations=in_every_locale( + "description", "commands", "config_set", "options", "management_channel" ), required=True, ) @@ -72,7 +80,8 @@ class CogConfig(Cog): self, ctx: ApplicationContext, category: CategoryChannel, - channel: TextChannel, + general_channel: TextChannel, + management_channel: TextChannel, timezone: str, prefer_emojis: bool, ) -> None: @@ -92,7 +101,8 @@ class CogConfig(Cog): await guild.update( self.bot.cache, - channel_id=channel.id, + general_channel_id=general_channel.id, + management_channel_id=management_channel.id, category_id=category.id, timezone=str(timezone_parsed), prefer_emojis=prefer_emojis, @@ -145,7 +155,8 @@ class CogConfig(Cog): await ctx.respond( self.bot._("config_show", "messages", locale=ctx.locale).format( - channel_id=guild.channel_id, + general_channel_id=guild.general_channel_id, + management_channel_id=guild.management_channel_id, category_id=guild.category_id, timezone=guild.timezone, prefer_emojis=guild.prefer_emojis, diff --git a/locale/en-US.json b/locale/en-US.json index 5a49b88..f9f5f68 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -11,7 +11,7 @@ "admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.", "config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", "config_set": "Configuration has been updated. You can review it anytime using `/config show`.", - "config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`\nPrefer emojis: `{prefer_emojis}`", + "config_show": "**Guild config**\n\nCategory: <#{category_id}>\nGeneral channel: <#{general_channel_id}>\nManagement channel: <#{management_channel_id}>\nTimezone: `{timezone}`\nPrefer emojis: `{prefer_emojis}`", "event_cancelled": "Event **{event_name}** was cancelled.", "event_created": "Event **{event_name}** has been created and will take place .", "event_dates_parsing_failed": "Could not parse start and end dates. Please, make sure these are provided in `DD.MM.YYYY HH:MM` format.", @@ -20,6 +20,7 @@ "event_end_date_parsing_failed": "Could not parse the end date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format.", "event_end_past": "End date must not be in the past", "event_ended": "Event **{event_name}** has ended! Stages and respective answers are listed below.\n\n{stages}", + "event_ended_short": "Event **{event_name}** has ended! Stages and respective answers are listed below.", "event_is_cancelled": "This event was cancelled.", "event_is_starting": "Event **{event_name}** is starting!\n\nUse slash command `/guess` to suggest your answers to each event stage.", "event_name_duplicate": "There can only be one active event with the same name", @@ -28,8 +29,9 @@ "event_ongoing_not_editable": "Ongoing events cannot be modified.", "event_start_date_parsing_failed": "Could not parse the start date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format.", "event_start_past": "Start date must not be in the past", + "event_started": "Event **{event_name}** has started! Use command `/register` to participate in the event.", "event_updated": "Event **{event_name}** has been updated and will take place .", - "guess_completed_event": "Congratulations! You have completed the event!", + "guess_completed_event": "Congratulations! You have completed the event!\nPlease, do not share the answers with others until the event ends so that everyone can have fun. Thank you!", "guess_incorrect": "Provided answer is wrong.", "guess_incorrect_channel": "Usage outside own event channel is not allowed.", "guess_incorrect_event": "Your event could not be found. Please, contact the administrator.", @@ -45,6 +47,8 @@ "stage_created": "Event stage has been created.", "stage_deleted": "Event stage has been deleted.", "stage_entry": "**Stage {sequence}**\nAnswer: ||{answer}||", + "stage_entry_footer": "Answer: ||{answer}||", + "stage_entry_header": "**Stage {sequence}**\nQuestion: {question}", "stage_not_found": "Event stage was not found.", "stage_sequence_out_of_range": "Stage sequence out of range.", "stage_updated": "Event stage has been updated.", @@ -70,7 +74,10 @@ "category": { "description": "Category where channels for each user will be created" }, - "channel": { + "general_channel": { + "description": "Text channel for general notifications and bot usage" + }, + "management_channel": { "description": "Text channel for admin notifications" }, "timezone": { From 86c75d06fa79ee7919d18b79650bd3f28bccf76d Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 02:54:30 +0200 Subject: [PATCH 47/55] Worked on #13 and #4. There are some caching issues left, though. Introduced abstract class Cacheable. Replaced async_pymongo with pymongo --- classes/abstract/__init__.py | 1 + classes/abstract/cacheable.py | 81 +++++++++++++++++++++++++++++ classes/pycord_bot.py | 5 +- classes/pycord_event.py | 65 +++++++++++++++++------ classes/pycord_event_stage.py | 37 ++++++++++--- classes/pycord_guild.py | 29 ++++++++--- classes/pycord_user.py | 61 +++++++++++++++++++--- cogs/cog_config.py | 13 +++-- cogs/cog_event.py | 33 ++++++++---- cogs/cog_guess.py | 6 ++- cogs/cog_register.py | 14 +++-- cogs/cog_stage.py | 26 +++++---- cogs/cog_unregister.py | 13 +++-- cogs/cog_user.py | 48 ++++++++++------- cogs/cog_utility.py | 40 ++++++++------ modules/database.py | 18 ++++--- modules/utils/__init__.py | 2 +- modules/utils/autocomplete_utils.py | 36 ++++++++----- modules/utils/datetime_utils.py | 5 ++ modules/utils/event_utils.py | 8 +-- modules/utils/validation_utils.py | 6 +-- requirements.txt | 2 +- 22 files changed, 412 insertions(+), 137 deletions(-) create mode 100644 classes/abstract/__init__.py create mode 100644 classes/abstract/cacheable.py diff --git a/classes/abstract/__init__.py b/classes/abstract/__init__.py new file mode 100644 index 0000000..f33fd40 --- /dev/null +++ b/classes/abstract/__init__.py @@ -0,0 +1 @@ +from .cacheable import Cacheable diff --git a/classes/abstract/cacheable.py b/classes/abstract/cacheable.py new file mode 100644 index 0000000..936b611 --- /dev/null +++ b/classes/abstract/cacheable.py @@ -0,0 +1,81 @@ +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Dict, Optional + +from libbot.cache.classes import Cache +from pymongo.asynchronous.collection import AsyncCollection + + +class Cacheable(ABC): + """Abstract class for cacheable""" + + __short_name__: str + __collection__: ClassVar[AsyncCollection] + + @classmethod + @abstractmethod + async def from_id(cls, *args: Any, cache: Optional[Cache] = None, **kwargs: Any) -> Any: + pass + + @abstractmethod + async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: + pass + + @abstractmethod + async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: + pass + + @abstractmethod + def _get_cache_key(self) -> str: + pass + + @abstractmethod + def _update_cache(self, cache: Optional[Cache] = None) -> None: + pass + + @abstractmethod + def _delete_cache(self, cache: Optional[Cache] = None) -> None: + pass + + @staticmethod + @abstractmethod + def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: + pass + + @staticmethod + @abstractmethod + def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]: + pass + + @abstractmethod + def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: + pass + + @staticmethod + @abstractmethod + def get_defaults(**kwargs: Any) -> Dict[str, Any]: + pass + + @staticmethod + @abstractmethod + def get_default_value(key: str) -> Any: + pass + + @abstractmethod + async def update( + self, + cache: Optional[Cache] = None, + **kwargs: Any, + ) -> None: + pass + + @abstractmethod + async def reset( + self, + *args: str, + cache: Optional[Cache] = None, + ) -> None: + pass + + @abstractmethod + async def purge(self, cache: Optional[Cache] = None) -> None: + pass diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 86da158..f6e64a2 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -14,13 +14,13 @@ from typing_extensions import override from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser from classes.errors import ( + DiscordGuildMemberNotFoundError, EventNotFoundError, EventStageMissingSequenceError, EventStageNotFoundError, GuildNotFoundError, - DiscordGuildMemberNotFoundError, ) -from modules.database import col_events, col_users +from modules.database import col_events, col_users, _update_database_indexes from modules.utils import get_logger logger: Logger = get_logger(__name__) @@ -58,6 +58,7 @@ class PycordBot(LibPycordBot): @override async def start(self, *args: Any, **kwargs: Any) -> None: await self._schedule_tasks() + await _update_database_indexes() self.started = datetime.now(tz=ZoneInfo("UTC")) diff --git a/classes/pycord_event.py b/classes/pycord_event.py index b272a4c..a0cd3b9 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -1,7 +1,7 @@ """Module with class PycordEvent.""" from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, tzinfo from logging import Logger from typing import Any, Dict, List, Optional from zoneinfo import ZoneInfo @@ -11,6 +11,7 @@ from libbot.cache.classes import Cache from pymongo import DESCENDING from pymongo.results import InsertOneResult +from classes.abstract import Cacheable from classes.errors import EventNotFoundError from modules.database import col_events from modules.utils import get_logger, restore_from_cache @@ -19,7 +20,7 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordEvent: +class PycordEvent(Cacheable): """Object representation of an event in the database. Attributes: @@ -82,7 +83,7 @@ class PycordEvent: cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, event_id, cache=cache) if cached_entry is not None: - return cls(**cached_entry) + return cls(**cls._entry_from_cache(cached_entry)) db_entry = await cls.__collection__.find_one( {"_id": event_id if isinstance(event_id, ObjectId) else ObjectId(event_id)} @@ -92,7 +93,7 @@ class PycordEvent: raise EventNotFoundError(event_id=event_id) if cache is not None: - cache.set_json(f"{cls.__short_name__}_{event_id}", db_entry) + cache.set_json(f"{cls.__short_name__}_{event_id}", cls._entry_to_cache(dict(db_entry))) return cls(**db_entry) @@ -123,7 +124,7 @@ class PycordEvent: raise EventNotFoundError(event_name=event_name, guild_id=guild_id) if cache is not None: - cache.set_json(f"{cls.__short_name__}_{db_entry['_id']}", db_entry) + cache.set_json(f"{cls.__short_name__}_{db_entry['_id']}", cls._entry_to_cache(db_entry)) return cls(**db_entry) @@ -172,7 +173,7 @@ class PycordEvent: db_entry["_id"] = insert_result.inserted_id if cache is not None: - cache.set_json(f"{cls.__short_name__}_{guild_id}", db_entry) + cache.set_json(f"{cls.__short_name__}_{guild_id}", cls._entry_to_cache(db_entry)) return cls(**db_entry) @@ -215,10 +216,10 @@ class PycordEvent: if cache is None: return - user_dict: Dict[str, Any] = self.to_dict() + object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - if user_dict is not None: - cache.set_json(self._get_cache_key(), user_dict) + if object_dict is not None: + cache.set_json(self._get_cache_key(), object_dict) else: self._delete_cache(cache) @@ -253,6 +254,32 @@ class PycordEvent: if stage_index != old_stage_index: await (await bot.find_event_stage(event_stage_id)).update(cache, sequence=stage_index) + @staticmethod + def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: + cache_entry: Dict[str, Any] = db_entry.copy() + + cache_entry["_id"] = str(cache_entry["_id"]) + cache_entry["created"] = cache_entry["created"].isoformat() + cache_entry["ended"] = None if cache_entry["ended"] is None else cache_entry["ended"].isoformat() + cache_entry["starts"] = cache_entry["starts"].isoformat() + cache_entry["ends"] = cache_entry["ends"].isoformat() + cache_entry["stage_ids"] = [str(stage_id) for stage_id in cache_entry["stage_ids"]] + + return cache_entry + + @staticmethod + def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]: + db_entry: Dict[str, Any] = cache_entry.copy() + + db_entry["_id"] = ObjectId(db_entry["_id"]) + db_entry["created"] = datetime.fromisoformat(db_entry["created"]) + db_entry["ended"] = None if db_entry["ended"] is None else datetime.fromisoformat(db_entry["ended"]) + db_entry["starts"] = datetime.fromisoformat(db_entry["starts"]) + db_entry["ends"] = datetime.fromisoformat(db_entry["ends"]) + db_entry["stage_ids"] = [ObjectId(stage_id) for stage_id in db_entry["stage_ids"]] + + return db_entry + def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: """Convert the object to a JSON representation. @@ -266,14 +293,20 @@ class PycordEvent: "_id": self._id if not json_compatible else str(self._id), "name": self.name, "guild_id": self.guild_id, - "created": self.created, - "ended": self.ended, + "created": self.created if not json_compatible else self.created.isoformat(), + "ended": ( + self.ended + if not json_compatible + else (None if self.ended is None else self.ended.isoformat()) + ), "is_cancelled": self.is_cancelled, "creator_id": self.creator_id, - "starts": self.starts, - "ends": self.ends, + "starts": self.starts if not json_compatible else self.starts.isoformat(), + "ends": self.ends if not json_compatible else self.ends.isoformat(), "thumbnail": self.thumbnail, - "stage_ids": self.stage_ids, + "stage_ids": ( + self.stage_ids if not json_compatible else [str(stage_id) for stage_id in self.stage_ids] + ), } @staticmethod @@ -456,7 +489,7 @@ class PycordEvent: return self.ends.replace(tzinfo=ZoneInfo("UTC")) - def get_start_date_localized(self, tz: str | timezone | ZoneInfo) -> datetime: + def get_start_date_localized(self, tz: tzinfo) -> datetime: """Get the event start date in the provided timezone. Returns: @@ -470,7 +503,7 @@ class PycordEvent: return self.starts.replace(tzinfo=tz) - def get_end_date_localized(self, tz: str | timezone | ZoneInfo) -> datetime: + def get_end_date_localized(self, tz: tzinfo) -> datetime: """Get the event end date in the provided timezone. Returns: diff --git a/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index 95ee2a9..33f8154 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -10,6 +10,7 @@ from discord import File from libbot.cache.classes import Cache from pymongo.results import InsertOneResult +from classes.abstract import Cacheable from classes.errors import EventStageNotFoundError from modules.database import col_stages from modules.utils import get_logger, restore_from_cache @@ -18,7 +19,7 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordEventStage: +class PycordEventStage(Cacheable): __slots__ = ( "_id", "event_id", @@ -61,7 +62,7 @@ class PycordEventStage: cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, stage_id, cache=cache) if cached_entry is not None: - return cls(**cached_entry) + return cls(**cls._entry_from_cache(cached_entry)) db_entry = await cls.__collection__.find_one( {"_id": stage_id if isinstance(stage_id, ObjectId) else ObjectId(stage_id)} @@ -71,7 +72,7 @@ class PycordEventStage: raise EventStageNotFoundError(stage_id) if cache is not None: - cache.set_json(f"{cls.__short_name__}_{stage_id}", db_entry) + cache.set_json(f"{cls.__short_name__}_{stage_id}", cls._entry_to_cache(dict(db_entry))) return cls(**db_entry) @@ -104,7 +105,7 @@ class PycordEventStage: db_entry["_id"] = insert_result.inserted_id if cache is not None: - cache.set_json(f"{cls.__short_name__}_{guild_id}", db_entry) + cache.set_json(f"{cls.__short_name__}_{guild_id}", cls._entry_to_cache(db_entry)) return cls(**db_entry) @@ -159,10 +160,10 @@ class PycordEventStage: if cache is None: return - user_dict: Dict[str, Any] = self.to_dict() + object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - if user_dict is not None: - cache.set_json(self._get_cache_key(), user_dict) + if object_dict is not None: + cache.set_json(self._get_cache_key(), object_dict) else: self._delete_cache(cache) @@ -172,6 +173,26 @@ class PycordEventStage: cache.delete(self._get_cache_key()) + @staticmethod + def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: + cache_entry: Dict[str, Any] = db_entry.copy() + + cache_entry["_id"] = str(cache_entry["_id"]) + cache_entry["event_id"] = str(cache_entry["event_id"]) + cache_entry["created"] = cache_entry["created"].isoformat() + + return cache_entry + + @staticmethod + def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]: + db_entry: Dict[str, Any] = cache_entry.copy() + + db_entry["_id"] = ObjectId(db_entry["_id"]) + db_entry["event_id"] = ObjectId(db_entry["event_id"]) + db_entry["created"] = datetime.fromisoformat(db_entry["created"]) + + return db_entry + def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: """Convert the object to a JSON representation. @@ -186,7 +207,7 @@ class PycordEventStage: "event_id": self.event_id if not json_compatible else str(self.event_id), "guild_id": self.guild_id, "sequence": self.sequence, - "created": self.created, + "created": self.created if not json_compatible else self.created.isoformat(), "creator_id": self.creator_id, "question": self.question, "answer": self.answer, diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index bfcfb3a..c7c274b 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -6,6 +6,7 @@ from bson import ObjectId from libbot.cache.classes import Cache from pymongo.results import InsertOneResult +from classes.abstract import Cacheable from classes.errors import GuildNotFoundError from modules.database import col_guilds from modules.utils import get_logger, restore_from_cache @@ -14,7 +15,7 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordGuild: +class PycordGuild(Cacheable): """Dataclass of DB entry of a guild""" __slots__ = ( @@ -57,7 +58,7 @@ class PycordGuild: cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, guild_id, cache=cache) if cached_entry is not None: - return cls(**cached_entry) + return cls(**cls._entry_from_cache(cached_entry)) db_entry = await cls.__collection__.find_one({"id": guild_id}) @@ -72,7 +73,7 @@ class PycordGuild: db_entry["_id"] = insert_result.inserted_id if cache is not None: - cache.set_json(f"{cls.__short_name__}_{guild_id}", db_entry) + cache.set_json(f"{cls.__short_name__}_{guild_id}", cls._entry_to_cache(db_entry)) return cls(**db_entry) @@ -115,10 +116,10 @@ class PycordGuild: if cache is None: return - user_dict: Dict[str, Any] = self.to_dict() + object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - if user_dict is not None: - cache.set_json(self._get_cache_key(), user_dict) + if object_dict is not None: + cache.set_json(self._get_cache_key(), object_dict) else: self._delete_cache(cache) @@ -128,6 +129,22 @@ class PycordGuild: cache.delete(self._get_cache_key()) + @staticmethod + def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: + cache_entry: Dict[str, Any] = db_entry.copy() + + cache_entry["_id"] = str(cache_entry["_id"]) + + return cache_entry + + @staticmethod + def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]: + db_entry: Dict[str, Any] = cache_entry.copy() + + db_entry["_id"] = ObjectId(db_entry["_id"]) + + return db_entry + def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: """Convert the object to a JSON representation. diff --git a/classes/pycord_user.py b/classes/pycord_user.py index 3473a5b..f6f8260 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -17,6 +17,7 @@ from discord.abc import GuildChannel from libbot.cache.classes import Cache from pymongo.results import InsertOneResult +from classes.abstract import Cacheable from classes.errors import ( DiscordCategoryNotFoundError, DiscordChannelNotFoundError, @@ -33,9 +34,17 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordUser: +class PycordUser(Cacheable): """Dataclass of DB entry of a user""" + # TODO Implement this + async def update(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: + pass + + # TODO Implement this + async def reset(self, *args: str, cache: Optional[Cache] = None) -> None: + pass + __slots__ = ( "_id", "id", @@ -83,7 +92,7 @@ class PycordUser: ) if cached_entry is not None: - return cls(**cached_entry) + return cls(**cls._entry_from_cache(cached_entry)) db_entry = await cls.__collection__.find_one({"id": user_id, "guild_id": guild_id}) @@ -98,7 +107,7 @@ class PycordUser: db_entry["_id"] = insert_result.inserted_id if cache is not None: - cache.set_json(f"{cls.__short_name__}_{user_id}_{guild_id}", db_entry) + cache.set_json(f"{cls.__short_name__}_{user_id}_{guild_id}", cls._entry_to_cache(db_entry)) return cls(**db_entry) @@ -186,10 +195,10 @@ class PycordUser: if cache is None: return - user_dict: Dict[str, Any] = self.to_dict() + object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - if user_dict is not None: - cache.set_json(self._get_cache_key(), user_dict) + if object_dict is not None: + cache.set_json(self._get_cache_key(), object_dict) else: self._delete_cache(cache) @@ -199,6 +208,46 @@ class PycordUser: cache.delete(self._get_cache_key()) + @staticmethod + def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: + cache_entry: Dict[str, Any] = db_entry.copy() + + cache_entry["_id"] = str(cache_entry["_id"]) + cache_entry["current_event_id"] = ( + None if cache_entry["current_event_id"] is None else str(cache_entry["current_event_id"]) + ) + cache_entry["current_stage_id"] = ( + None if cache_entry["current_stage_id"] is None else str(cache_entry["current_stage_id"]) + ) + cache_entry["registered_event_ids"] = [ + str(event_id) for event_id in cache_entry["registered_event_ids"] + ] + cache_entry["completed_event_ids"] = [ + str(event_id) for event_id in cache_entry["completed_event_ids"] + ] + + return cache_entry + + @staticmethod + def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]: + db_entry: Dict[str, Any] = cache_entry.copy() + + db_entry["_id"] = ObjectId(db_entry["_id"]) + db_entry["current_event_id"] = ( + None if db_entry["current_event_id"] is None else ObjectId(db_entry["current_event_id"]) + ) + db_entry["current_stage_id"] = ( + None if db_entry["current_stage_id"] is None else ObjectId(db_entry["current_stage_id"]) + ) + db_entry["registered_event_ids"] = [ + ObjectId(event_id) for event_id in db_entry["registered_event_ids"] + ] + db_entry["completed_event_ids"] = [ + ObjectId(event_id) for event_id in db_entry["completed_event_ids"] + ] + + return db_entry + # TODO Add documentation @staticmethod def get_defaults(user_id: Optional[int] = None, guild_id: Optional[int] = None) -> Dict[str, Any]: diff --git a/cogs/cog_config.py b/cogs/cog_config.py index 69f69b6..91e49dc 100644 --- a/cogs/cog_config.py +++ b/cogs/cog_config.py @@ -88,14 +88,15 @@ class CogConfig(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return try: timezone_parsed: ZoneInfo = ZoneInfo(timezone) except ZoneInfoNotFoundError: await ctx.respond( - self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone) + self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone), + ephemeral=True, ) return @@ -130,7 +131,7 @@ class CogConfig(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return await guild.purge(self.bot.cache) @@ -146,11 +147,13 @@ class CogConfig(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True + ) return await ctx.respond( diff --git a/cogs/cog_event.py b/cogs/cog_event.py index e8641ed..e6561dd 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -84,11 +84,13 @@ class CogEvent(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True + ) return guild_timezone: ZoneInfo = ZoneInfo(guild.timezone) @@ -97,7 +99,9 @@ class CogEvent(Cog): start_date: datetime = datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) end_date: datetime = datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) except ValueError: - await ctx.respond(self.bot._("event_dates_parsing_failed", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("event_dates_parsing_failed", "messages", locale=ctx.locale), ephemeral=True + ) return if not await validate_event_validity(ctx, name, start_date, end_date, to_utc=True): @@ -180,7 +184,7 @@ class CogEvent(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return try: @@ -190,7 +194,9 @@ class CogEvent(Cog): return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True + ) return guild_timezone: ZoneInfo = ZoneInfo(guild.timezone) @@ -202,7 +208,9 @@ class CogEvent(Cog): else datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) ) except ValueError: - await ctx.respond(self.bot._("event_start_date_parsing_failed", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("event_start_date_parsing_failed", "messages", locale=ctx.locale), ephemeral=True + ) return try: @@ -212,7 +220,9 @@ class CogEvent(Cog): else datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) ) except ValueError: - await ctx.respond(self.bot._("event_end_date_parsing_failed", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("event_end_date_parsing_failed", "messages", locale=ctx.locale), ephemeral=True + ) return if not await validate_event_validity( @@ -280,7 +290,7 @@ class CogEvent(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return try: @@ -290,7 +300,9 @@ class CogEvent(Cog): return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True + ) return start_date: datetime = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) @@ -305,7 +317,8 @@ class CogEvent(Cog): await ctx.respond( self.bot._("event_not_editable", "messages", locale=ctx.locale).format( event_name=pycord_event.name - ) + ), + ephemeral=True, ) return diff --git a/cogs/cog_guess.py b/cogs/cog_guess.py index 61b6785..661050a 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -34,11 +34,13 @@ class CogGuess(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured", "messages", locale=ctx.locale), ephemeral=True + ) return user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) diff --git a/cogs/cog_register.py b/cogs/cog_register.py index 0f4b2f4..27810eb 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -4,7 +4,7 @@ from pathlib import Path from zoneinfo import ZoneInfo from bson.errors import InvalidId -from discord import ApplicationContext, Cog, TextChannel, option, slash_command, File +from discord import ApplicationContext, Cog, File, TextChannel, option, slash_command from discord.utils import basic_autocomplete from libbot.i18n import _, in_every_locale @@ -39,7 +39,7 @@ class CogRegister(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return try: @@ -49,17 +49,21 @@ class CogRegister(Cog): return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured", "messages", locale=ctx.locale), ephemeral=True + ) return user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) if user.is_jailed: - await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale), ephemeral=True) return if pycord_event._id in user.registered_event_ids: - await ctx.respond(self.bot._("register_already_registered", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("register_already_registered", "messages", locale=ctx.locale), ephemeral=True + ) return await user.event_register(pycord_event._id, cache=self.bot.cache) diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index d219bda..3a28379 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -84,11 +84,13 @@ class CogStage(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True + ) return try: @@ -196,11 +198,13 @@ class CogStage(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True + ) return try: @@ -215,11 +219,13 @@ class CogStage(Cog): try: event_stage: PycordEventStage = await self.bot.find_event_stage(stage) except (InvalidId, EventStageNotFoundError): - await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale), ephemeral=True) return if order is not None and order > len(pycord_event.stage_ids): - await ctx.respond(self.bot._("stage_sequence_out_of_range", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("stage_sequence_out_of_range", "messages", locale=ctx.locale), ephemeral=True + ) return processed_media: List[Dict[str, Any]] = ( @@ -278,11 +284,13 @@ class CogStage(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True + ) return try: @@ -297,7 +305,7 @@ class CogStage(Cog): try: event_stage: PycordEventStage = await self.bot.find_event_stage(stage) except (InvalidId, EventStageNotFoundError): - await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale), ephemeral=True) return await pycord_event.remove_stage(self.bot, event_stage._id, cache=self.bot.cache) diff --git a/cogs/cog_unregister.py b/cogs/cog_unregister.py index 4e18604..4b55468 100644 --- a/cogs/cog_unregister.py +++ b/cogs/cog_unregister.py @@ -43,7 +43,7 @@ class CogUnregister(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return try: @@ -53,17 +53,22 @@ class CogUnregister(Cog): return if not guild.is_configured(): - await ctx.respond(self.bot._("guild_unconfigured", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("guild_unconfigured", "messages", locale=ctx.locale), ephemeral=True + ) return user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) if user.is_jailed: - await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale), ephemeral=True) return + # TODO Fix a bug where registered_event_ids is invalid because of caching if pycord_event._id not in user.registered_event_ids: - await ctx.respond(self.bot._("unregister_not_registered", "messages", locale=ctx.locale)) + await ctx.respond( + self.bot._("unregister_not_registered", "messages", locale=ctx.locale), ephemeral=True + ) return await user.event_unregister(pycord_event._id, cache=self.bot.cache) diff --git a/cogs/cog_user.py b/cogs/cog_user.py index b50ace2..e337030 100644 --- a/cogs/cog_user.py +++ b/cogs/cog_user.py @@ -1,27 +1,27 @@ from datetime import datetime from logging import Logger from pathlib import Path -from typing import List, Dict, Any +from typing import Any, Dict, List from zoneinfo import ZoneInfo from bson import ObjectId from bson.errors import InvalidId from discord import ( ApplicationContext, + File, SlashCommandGroup, + TextChannel, User, option, - File, - TextChannel, ) from discord.ext.commands import Cog from libbot.i18n import _, in_every_locale -from classes import PycordUser, PycordEvent, PycordGuild +from classes import PycordEvent, PycordGuild, PycordUser from classes.errors import GuildNotFoundError from classes.pycord_bot import PycordBot from modules.database import col_users -from modules.utils import is_operation_confirmed, get_logger +from modules.utils import get_logger, is_operation_confirmed, get_utc_now logger: Logger = get_logger(__name__) @@ -54,35 +54,45 @@ class CogUser(Cog): try: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) except (InvalidId, GuildNotFoundError): - await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) + await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True) return pycord_user: PycordUser = await self.bot.find_user(user.id, ctx.guild.id) events: List[PycordEvent] = [] + utc_now: datetime = get_utc_now() + pipeline: List[Dict[str, Any]] = [ {"$match": {"id": pycord_user.id}}, { "$lookup": { "from": "events", - "localField": "registered_event_ids", - "foreignField": "_id", + "let": {"event_ids": "$registered_event_ids"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + {"$in": ["$_id", "$$event_ids"]}, + {"$eq": ["$ended", None]}, + {"$gt": ["$ends", utc_now]}, + {"$lt": ["$starts", utc_now]}, + {"$eq": ["$is_cancelled", False]}, + ] + } + } + } + ], "as": "registered_events", } }, - { - "$match": { - "registered_events.ended": None, - "registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, - "registered_events.starts": {"$lt": datetime.now(tz=ZoneInfo("UTC"))}, - "registered_events.is_cancelled": False, - } - }, + {"$match": {"registered_events.0": {"$exists": True}}}, ] - async for result in col_users.aggregate(pipeline): - for registered_event in result["registered_events"]: - events.append(PycordEvent(**registered_event)) + async with await col_users.aggregate(pipeline) as cursor: + async for result in cursor: + for registered_event in result["registered_events"]: + events.append(PycordEvent(**registered_event)) for event in events: if pycord_user.current_event_id is not None and pycord_user.current_event_id != event._id: diff --git a/cogs/cog_utility.py b/cogs/cog_utility.py index b8d63a7..67e32f5 100644 --- a/cogs/cog_utility.py +++ b/cogs/cog_utility.py @@ -6,13 +6,13 @@ from zoneinfo import ZoneInfo from bson import ObjectId from bson.errors import InvalidId -from discord import Activity, ActivityType, Cog, Member, TextChannel, File +from discord import Activity, ActivityType, Cog, File, Member, TextChannel from classes import PycordEvent, PycordGuild, PycordUser from classes.errors import GuildNotFoundError from classes.pycord_bot import PycordBot from modules.database import col_users -from modules.utils import get_logger +from modules.utils import get_logger, get_utc_now logger: Logger = get_logger(__name__) @@ -78,29 +78,39 @@ class CogUtility(Cog): user: PycordUser = await self.bot.find_user(member.id, member.guild.id) events: List[PycordEvent] = [] + utc_now: datetime = get_utc_now() + pipeline: List[Dict[str, Any]] = [ {"$match": {"id": user.id}}, { "$lookup": { "from": "events", - "localField": "registered_event_ids", - "foreignField": "_id", + "let": {"event_ids": "$registered_event_ids"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + {"$in": ["$_id", "$$event_ids"]}, + {"$eq": ["$ended", None]}, + {"$gt": ["$ends", utc_now]}, + {"$lt": ["$starts", utc_now]}, + {"$eq": ["$is_cancelled", False]}, + ] + } + } + } + ], "as": "registered_events", } }, - { - "$match": { - "registered_events.ended": None, - "registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, - "registered_events.starts": {"$lt": datetime.now(tz=ZoneInfo("UTC"))}, - "registered_events.is_cancelled": False, - } - }, + {"$match": {"registered_events.0": {"$exists": True}}}, ] - async for result in col_users.aggregate(pipeline): - for registered_event in result["registered_events"]: - events.append(PycordEvent(**registered_event)) + async with await col_users.aggregate(pipeline) as cursor: + async for result in cursor: + for registered_event in result["registered_events"]: + events.append(PycordEvent(**registered_event)) for event in events: if user.current_event_id is not None and user.current_event_id != event._id: diff --git a/modules/database.py b/modules/database.py index 8d1a360..cbcb38c 100644 --- a/modules/database.py +++ b/modules/database.py @@ -2,8 +2,10 @@ from typing import Any, Mapping -from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase from libbot.utils import config_get +from pymongo import AsyncMongoClient +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.asynchronous.database import AsyncDatabase db_config: Mapping[str, Any] = config_get("database") @@ -19,7 +21,7 @@ else: con_string = "mongodb://{0}:{1}/{2}".format(db_config["host"], db_config["port"], db_config["name"]) # Async declarations -db_client = AsyncClient(con_string) +db_client = AsyncMongoClient(con_string) db: AsyncDatabase = db_client.get_database(name=db_config["name"]) col_users: AsyncCollection = db.get_collection("users") @@ -27,10 +29,10 @@ col_guilds: AsyncCollection = db.get_collection("guilds") col_events: AsyncCollection = db.get_collection("events") col_stages: AsyncCollection = db.get_collection("stages") + # Update indexes -db.dispatch.get_collection("users").create_index("id", name="user_id", unique=True) -db.dispatch.get_collection("guilds").create_index("id", name="guild_id", unique=True) -db.dispatch.get_collection("events").create_index("guild_id", name="guild_id", unique=False) -db.dispatch.get_collection("stages").create_index( - ["event_id", "guild_id"], name="event_id-and-guild_id", unique=False -) +async def _update_database_indexes() -> None: + await col_users.create_index("id", name="user_id", unique=True) + await col_guilds.create_index("id", name="guild_id", unique=True) + await col_events.create_index("guild_id", name="guild_id", unique=False) + await col_stages.create_index(["event_id", "guild_id"], name="event_id-and-guild_id", unique=False) diff --git a/modules/utils/__init__.py b/modules/utils/__init__.py index f15d19e..9c4698d 100644 --- a/modules/utils/__init__.py +++ b/modules/utils/__init__.py @@ -7,7 +7,7 @@ from .autocomplete_utils import ( autocomplete_user_registered_events, ) from .cache_utils import restore_from_cache -from .datetime_utils import get_unix_timestamp +from .datetime_utils import get_unix_timestamp, get_utc_now from .event_utils import validate_event_validity from .git_utils import get_current_commit from .logging_utils import get_logger, get_logging_config diff --git a/modules/utils/autocomplete_utils.py b/modules/utils/autocomplete_utils.py index 30056ba..815cedb 100644 --- a/modules/utils/autocomplete_utils.py +++ b/modules/utils/autocomplete_utils.py @@ -49,31 +49,41 @@ async def autocomplete_user_available_events(ctx: AutocompleteContext) -> List[O async def autocomplete_user_registered_events(ctx: AutocompleteContext) -> List[OptionChoice]: """Return list of active events user is registered in""" + utc_now: datetime = datetime.now(tz=ZoneInfo("UTC")) + pipeline: List[Dict[str, Any]] = [ {"$match": {"id": ctx.interaction.user.id}}, { "$lookup": { "from": "events", - "localField": "registered_event_ids", - "foreignField": "_id", + "let": {"event_ids": "$registered_event_ids"}, + "pipeline": [ + { + "$match": { + "$expr": { + "$and": [ + {"$in": ["$_id", "$$event_ids"]}, + {"$eq": ["$ended", None]}, + {"$gt": ["$ends", utc_now]}, + {"$gt": ["$starts", utc_now]}, + {"$eq": ["$is_cancelled", False]}, + ] + } + } + } + ], "as": "registered_events", } }, - { - "$match": { - "registered_events.ended": None, - "registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, - "registered_events.starts": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, - "registered_events.is_cancelled": False, - } - }, + {"$match": {"registered_events.0": {"$exists": True}}}, ] event_names: List[OptionChoice] = [] - async for result in col_users.aggregate(pipeline): - for registered_event in result["registered_events"]: - event_names.append(OptionChoice(registered_event["name"], str(registered_event["_id"]))) + async with await col_users.aggregate(pipeline) as cursor: + async for result in cursor: + for registered_event in result["registered_events"]: + event_names.append(OptionChoice(registered_event["name"], str(registered_event["_id"]))) return event_names diff --git a/modules/utils/datetime_utils.py b/modules/utils/datetime_utils.py index a267962..19dcdf0 100644 --- a/modules/utils/datetime_utils.py +++ b/modules/utils/datetime_utils.py @@ -5,3 +5,8 @@ from zoneinfo import ZoneInfo # TODO Add documentation def get_unix_timestamp(date: datetime, to_utc: bool = False) -> int: return int((date if not to_utc else date.replace(tzinfo=ZoneInfo("UTC"))).timestamp()) + + +# TODO Add documentation +def get_utc_now() -> datetime: + return datetime.now(tz=ZoneInfo("UTC")) diff --git a/modules/utils/event_utils.py b/modules/utils/event_utils.py index 28500ab..85911c4 100644 --- a/modules/utils/event_utils.py +++ b/modules/utils/event_utils.py @@ -23,15 +23,15 @@ async def validate_event_validity( end_date_internal: datetime = end_date.astimezone(ZoneInfo("UTC")) if to_utc else end_date if start_date_internal < datetime.now(tz=ZoneInfo("UTC")): - await ctx.respond(_("event_start_past", "messages", locale=ctx.locale)) + await ctx.respond(_("event_start_past", "messages", locale=ctx.locale), ephemeral=True) return False if end_date_internal < datetime.now(tz=ZoneInfo("UTC")): - await ctx.respond(_("event_end_past", "messages", locale=ctx.locale)) + await ctx.respond(_("event_end_past", "messages", locale=ctx.locale), ephemeral=True) return False if start_date_internal >= end_date_internal: - await ctx.respond(_("event_end_before_start", "messages", locale=ctx.locale)) + await ctx.respond(_("event_end_before_start", "messages", locale=ctx.locale), ephemeral=True) return False # TODO Add validation for concurrent events. @@ -47,7 +47,7 @@ async def validate_event_validity( query["_id"] = {"$ne": event_id} if (await col_events.find_one(query)) is not None: - await ctx.respond(_("event_name_duplicate", "messages", locale=ctx.locale)) + await ctx.respond(_("event_name_duplicate", "messages", locale=ctx.locale), ephemeral=True) return False return True diff --git a/modules/utils/validation_utils.py b/modules/utils/validation_utils.py index 924bf06..50cfd10 100644 --- a/modules/utils/validation_utils.py +++ b/modules/utils/validation_utils.py @@ -7,7 +7,7 @@ from libbot.i18n import _ async def is_operation_confirmed(ctx: ApplicationContext, confirm: bool) -> bool: if confirm is None or not confirm: - await ctx.respond(ctx.bot._("operation_unconfirmed", "messages", locale=ctx.locale)) + await ctx.respond(ctx.bot._("operation_unconfirmed", "messages", locale=ctx.locale), ephemeral=True) return False return True @@ -18,7 +18,7 @@ async def is_event_status_valid( event: "PycordEvent", ) -> bool: if event.is_cancelled: - await ctx.respond(_("event_is_cancelled", "messages", locale=ctx.locale)) + await ctx.respond(_("event_is_cancelled", "messages", locale=ctx.locale), ephemeral=True) return False if ( @@ -26,7 +26,7 @@ async def is_event_status_valid( <= datetime.now(tz=ZoneInfo("UTC")) <= event.ends.replace(tzinfo=ZoneInfo("UTC")) ): - await ctx.respond(_("event_ongoing_not_editable", "messages", locale=ctx.locale)) + await ctx.respond(_("event_ongoing_not_editable", "messages", locale=ctx.locale), ephemeral=True) return False return True diff --git a/requirements.txt b/requirements.txt index b63f5a7..69e36aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ aiodns~=3.2.0 apscheduler~=3.11.0 -async_pymongo==0.1.11 brotlipy~=0.7.0 faust-cchardet~=2.1.19 libbot[speed,pycord,cache]==4.1.0 mongodb-migrations==1.3.1 msgspec~=0.19.0 +pymongo~=4.12.1,>=4.9 pytz~=2025.1 typing_extensions>=4.11.0 \ No newline at end of file From d1498f38e9dcdc9cab0372d7225b88058fa0c6d8 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 13:39:37 +0200 Subject: [PATCH 48/55] WIP: BaseCacheable --- classes/base/__init__.py | 0 classes/base/base_cacheable.py | 107 ++++++++++++++++++++++++++++++ classes/pycord_event.py | 90 +++++-------------------- classes/pycord_event_stage.py | 4 +- classes/pycord_guild.py | 118 ++++++++++++++------------------- classes/pycord_user.py | 6 +- 6 files changed, 177 insertions(+), 148 deletions(-) create mode 100644 classes/base/__init__.py create mode 100644 classes/base/base_cacheable.py diff --git a/classes/base/__init__.py b/classes/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/classes/base/base_cacheable.py b/classes/base/base_cacheable.py new file mode 100644 index 0000000..53031a7 --- /dev/null +++ b/classes/base/base_cacheable.py @@ -0,0 +1,107 @@ +from abc import ABC +from logging import Logger +from typing import Optional, Any, Dict + +from libbot.cache.classes import Cache + +from classes.abstract import Cacheable +from modules.utils import get_logger + +logger: Logger = get_logger(__name__) + + +class BaseCacheable(Cacheable, ABC): + """Base implementation of Cacheable used by all cachable classes.""" + + async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: + for key, value in kwargs.items(): + if not hasattr(self, key): + raise AttributeError() + + setattr(self, key, value) + + await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}) + + self._update_cache(cache) + + logger.info("Set attributes of %s to %s", self._id, kwargs) + + async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: + attributes: Dict[str, Any] = {} + + for key in args: + if not hasattr(self, key): + raise AttributeError() + + default_value: Any = self.get_default_value(key) + + setattr(self, key, default_value) + + attributes[key] = default_value + + await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}) + + self._update_cache(cache) + + logger.info("Reset attributes %s of %s to default values", args, self._id) + + def _update_cache(self, cache: Optional[Cache] = None) -> None: + if cache is None: + return + + object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) + + if object_dict is not None: + cache.set_json(self._get_cache_key(), object_dict) + else: + self._delete_cache(cache) + + def _delete_cache(self, cache: Optional[Cache] = None) -> None: + if cache is None: + return + + cache.delete(self._get_cache_key()) + + async def update( + self, + cache: Optional[Cache] = None, + **kwargs: Any, + ) -> None: + """Update attribute(s) on the object and save the updated entry into the database. + + Args: + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + **kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update. + + Raises: + AttributeError: Provided attribute does not exist in the class. + """ + await self._set(cache=cache, **kwargs) + + async def reset( + self, + *args: str, + cache: Optional[Cache] = None, + ) -> None: + """Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. + + Args: + *args (str): List of attributes to remove. + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + + Raises: + AttributeError: Provided attribute does not exist in the class. + """ + await self._remove(*args, cache=cache) + + async def purge(self, cache: Optional[Cache] = None) -> None: + """Completely remove object data from database. Currently only removes the record from a respective collection. + + Args: + cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. + """ + await self.__collection__.delete_one({"_id": self._id}) + + self._delete_cache(cache) + + logger.info("Purged %s from the database", self._id) diff --git a/classes/pycord_event.py b/classes/pycord_event.py index a0cd3b9..89492ab 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -12,6 +12,7 @@ from pymongo import DESCENDING from pymongo.results import InsertOneResult from classes.abstract import Cacheable +from classes.base.base_cacheable import BaseCacheable from classes.errors import EventNotFoundError from modules.database import col_events from modules.utils import get_logger, restore_from_cache @@ -20,7 +21,7 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordEvent(Cacheable): +class PycordEvent(BaseCacheable): """Object representation of an event in the database. Attributes: @@ -178,56 +179,19 @@ class PycordEvent(Cacheable): return cls(**db_entry) async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: - for key, value in kwargs.items(): - if not hasattr(self, key): - raise AttributeError(f"Attribute '{key}' does not exist in PycordEvent") - - setattr(self, key, value) - - await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True) - - self._update_cache(cache) - - logger.info("Set attributes of event %s to %s", self._id, kwargs) + await super()._set(cache, **kwargs) async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: - attributes: Dict[str, Any] = {} - - for key in args: - if not hasattr(self, key): - raise AttributeError(f"Attribute '{key}' does not exist in PycordEvent") - - default_value: Any = self.get_default_value(key) - - setattr(self, key, default_value) - - attributes[key] = default_value - - await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True) - - self._update_cache(cache) - - logger.info("Reset attributes %s of event %s to default values", args, self._id) + await super()._remove(*args, cache=cache) def _get_cache_key(self) -> str: return f"{self.__short_name__}_{self._id}" def _update_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - - if object_dict is not None: - cache.set_json(self._get_cache_key(), object_dict) - else: - self._delete_cache(cache) + super()._update_cache(cache) def _delete_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - cache.delete(self._get_cache_key()) + super()._delete_cache(cache) async def _update_event_stage_order( self, @@ -348,45 +312,21 @@ class PycordEvent(Cacheable): return PycordEvent.get_defaults()[key] async def update( - self, - cache: Optional[Cache] = None, - **kwargs: Any, + self, + cache: Optional[Cache] = None, + **kwargs: Any, ) -> None: - """Update attribute(s) on the object and save the updated entry into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. - **kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update. - - Raises: - AttributeError: Provided attribute does not exist in the class. - """ - await self._set(cache=cache, **kwargs) + await super().update(cache=cache, **kwargs) async def reset( - self, - *args: str, - cache: Optional[Cache] = None, + self, + *args: str, + cache: Optional[Cache] = None, ) -> None: - """Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. - - Args: - *args (str): List of attributes to remove. - cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. - - Raises: - AttributeError: Provided attribute does not exist in the class. - """ - await self._remove(*args, cache=cache) + await super().reset(*args, cache=cache) async def purge(self, cache: Optional[Cache] = None) -> None: - """Completely remove event data from database. Currently only removes the event record from events collection. - - Args: - cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. - """ - await self.__collection__.delete_one({"_id": self._id}) - self._delete_cache(cache) + await super().purge(cache) async def cancel(self, cache: Optional[Cache] = None) -> None: """Cancel the event. diff --git a/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index 33f8154..9a24fac 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -122,7 +122,7 @@ class PycordEventStage(Cacheable): setattr(self, key, value) - await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True) + await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}) self._update_cache(cache) @@ -147,7 +147,7 @@ class PycordEventStage(Cacheable): attributes[key] = default_value - await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True) + await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}) self._update_cache(cache) diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index c7c274b..684d9d2 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -6,7 +6,7 @@ from bson import ObjectId from libbot.cache.classes import Cache from pymongo.results import InsertOneResult -from classes.abstract import Cacheable +from classes.base.base_cacheable import BaseCacheable from classes.errors import GuildNotFoundError from modules.database import col_guilds from modules.utils import get_logger, restore_from_cache @@ -15,7 +15,7 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordGuild(Cacheable): +class PycordGuild(BaseCacheable): """Dataclass of DB entry of a guild""" __slots__ = ( @@ -78,56 +78,60 @@ class PycordGuild(Cacheable): return cls(**db_entry) async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: - for key, value in kwargs.items(): - if not hasattr(self, key): - raise AttributeError() - - setattr(self, key, value) - - await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True) - - self._update_cache(cache) - - logger.info("Set attributes of guild %s to %s", self.id, kwargs) + await super()._set(cache, **kwargs) + # for key, value in kwargs.items(): + # if not hasattr(self, key): + # raise AttributeError() + # + # setattr(self, key, value) + # + # await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}) + # + # self._update_cache(cache) + # + # logger.info("Set attributes of guild %s to %s", self.id, kwargs) async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: - attributes: Dict[str, Any] = {} - - for key in args: - if not hasattr(self, key): - raise AttributeError() - - default_value: Any = self.get_default_value(key) - - setattr(self, key, default_value) - - attributes[key] = default_value - - await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True) - - self._update_cache(cache) - - logger.info("Reset attributes %s of guild %s to default values", args, self.id) + await super()._remove(*args, cache=cache) + # attributes: Dict[str, Any] = {} + # + # for key in args: + # if not hasattr(self, key): + # raise AttributeError() + # + # default_value: Any = self.get_default_value(key) + # + # setattr(self, key, default_value) + # + # attributes[key] = default_value + # + # await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}) + # + # self._update_cache(cache) + # + # logger.info("Reset attributes %s of guild %s to default values", args, self.id) def _get_cache_key(self) -> str: return f"{self.__short_name__}_{self.id}" def _update_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - - if object_dict is not None: - cache.set_json(self._get_cache_key(), object_dict) - else: - self._delete_cache(cache) + super()._update_cache(cache) + # if cache is None: + # return + # + # object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) + # + # if object_dict is not None: + # cache.set_json(self._get_cache_key(), object_dict) + # else: + # self._delete_cache(cache) def _delete_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - cache.delete(self._get_cache_key()) + super()._delete_cache(cache) + # if cache is None: + # return + # + # cache.delete(self._get_cache_key()) @staticmethod def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: @@ -203,32 +207,14 @@ class PycordGuild(Cacheable): cache: Optional[Cache] = None, **kwargs: Any, ) -> None: - """Update attribute(s) on the object and save the updated entry into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. - **kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update. - - Raises: - AttributeError: Provided attribute does not exist in the class. - """ - await self._set(cache=cache, **kwargs) + await super().update(cache=cache, **kwargs) async def reset( self, *args: str, cache: Optional[Cache] = None, ) -> None: - """Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. - - Args: - *args (str): List of attributes to remove. - cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. - - Raises: - AttributeError: Provided attribute does not exist in the class. - """ - await self._remove(*args, cache=cache) + await super().reset(*args, cache=cache) async def purge(self, cache: Optional[Cache] = None) -> None: """Completely remove guild data from database. Currently only removes the guild record from guilds collection. @@ -236,11 +222,7 @@ class PycordGuild(Cacheable): Args: cache (:obj:`Cache`, optional): Cache engine to write the update into """ - await self.__collection__.delete_one({"_id": self._id}) - - self._delete_cache(cache) - - logger.info("Purged guild %s (%s) from the database", self.id, self._id) + await super().purge(cache) def is_configured(self) -> bool: """Return whether all attributes required for bot's use on the server are set. diff --git a/classes/pycord_user.py b/classes/pycord_user.py index f6f8260..cbfe1bb 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -157,7 +157,7 @@ class PycordUser(Cacheable): setattr(self, key, value) - await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True) + await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}) self._update_cache(cache) @@ -182,14 +182,14 @@ class PycordUser(Cacheable): attributes[key] = default_value - await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True) + await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}) self._update_cache(cache) logger.info("Reset attributes %s of user %s to default values", args, self.id) def _get_cache_key(self) -> str: - return f"{self.__short_name__}_{self.id}" + return f"{self.__short_name__}_{self.id}_{self.guild_id}" def _update_cache(self, cache: Optional[Cache] = None) -> None: if cache is None: From 96c1314234ee9c8a3f48dafad3b1145a7df7bcc5 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 20:16:44 +0200 Subject: [PATCH 49/55] Closes #13 --- classes/base/base_cacheable.py | 3 ++ classes/pycord_event_stage.py | 91 ++++------------------------------ classes/pycord_guild.py | 46 ----------------- classes/pycord_user.py | 91 ++++++++-------------------------- cogs/cog_unregister.py | 1 - 5 files changed, 33 insertions(+), 199 deletions(-) diff --git a/classes/base/base_cacheable.py b/classes/base/base_cacheable.py index 53031a7..ea60c3f 100644 --- a/classes/base/base_cacheable.py +++ b/classes/base/base_cacheable.py @@ -2,6 +2,7 @@ from abc import ABC from logging import Logger from typing import Optional, Any, Dict +from bson import ObjectId from libbot.cache.classes import Cache from classes.abstract import Cacheable @@ -13,6 +14,8 @@ logger: Logger = get_logger(__name__) class BaseCacheable(Cacheable, ABC): """Base implementation of Cacheable used by all cachable classes.""" + _id: ObjectId + async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: for key, value in kwargs.items(): if not hasattr(self, key): diff --git a/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index 9a24fac..5f03180 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -10,7 +10,7 @@ from discord import File from libbot.cache.classes import Cache from pymongo.results import InsertOneResult -from classes.abstract import Cacheable +from classes.base.base_cacheable import BaseCacheable from classes.errors import EventStageNotFoundError from modules.database import col_stages from modules.utils import get_logger, restore_from_cache @@ -19,7 +19,7 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordEventStage(Cacheable): +class PycordEventStage(BaseCacheable): __slots__ = ( "_id", "event_id", @@ -110,68 +110,19 @@ class PycordEventStage(Cacheable): return cls(**db_entry) async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: - """Set attribute data and save it into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - **kwargs (Any): Mapping of attribute names and respective values to be set - """ - for key, value in kwargs.items(): - if not hasattr(self, key): - raise AttributeError() - - setattr(self, key, value) - - await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}) - - self._update_cache(cache) - - logger.info("Set attributes of event stage %s to %s", self._id, kwargs) + await super()._set(cache, **kwargs) async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: - """Remove attribute data and save it into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - *args (str): List of attributes to remove - """ - attributes: Dict[str, Any] = {} - - for key in args: - if not hasattr(self, key): - raise AttributeError() - - default_value: Any = self.get_default_value(key) - - setattr(self, key, default_value) - - attributes[key] = default_value - - await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}) - - self._update_cache(cache) - - logger.info("Reset attributes %s of event stage %s to default values", args, self._id) + await super()._remove(*args, cache=cache) def _get_cache_key(self) -> str: return f"{self.__short_name__}_{self._id}" def _update_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - - if object_dict is not None: - cache.set_json(self._get_cache_key(), object_dict) - else: - self._delete_cache(cache) + super()._update_cache(cache) def _delete_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - cache.delete(self._get_cache_key()) + super()._delete_cache(cache) @staticmethod def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: @@ -255,41 +206,17 @@ class PycordEventStage(Cacheable): cache: Optional[Cache] = None, **kwargs: Any, ) -> None: - """Update attribute(s) on the object and save the updated entry into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. - **kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update. - - Raises: - AttributeError: Provided attribute does not exist in the class. - """ - await self._set(cache=cache, **kwargs) + await super().update(cache=cache, **kwargs) async def reset( self, *args: str, cache: Optional[Cache] = None, ) -> None: - """Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. - - Args: - *args (str): List of attributes to remove. - cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache. - - Raises: - AttributeError: Provided attribute does not exist in the class. - """ - await self._remove(*args, cache=cache) + await super().reset(*args, cache=cache) async def purge(self, cache: Optional[Cache] = None) -> None: - """Completely remove event stage data from database. Currently only removes the event stage record from events collection. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - """ - await self.__collection__.delete_one({"_id": self._id}) - self._delete_cache(cache) + await super().purge(cache) # TODO Add documentation def get_media_files(self) -> List[File] | None: diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index 684d9d2..6af35e7 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -79,59 +79,18 @@ class PycordGuild(BaseCacheable): async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: await super()._set(cache, **kwargs) - # for key, value in kwargs.items(): - # if not hasattr(self, key): - # raise AttributeError() - # - # setattr(self, key, value) - # - # await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}) - # - # self._update_cache(cache) - # - # logger.info("Set attributes of guild %s to %s", self.id, kwargs) async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: await super()._remove(*args, cache=cache) - # attributes: Dict[str, Any] = {} - # - # for key in args: - # if not hasattr(self, key): - # raise AttributeError() - # - # default_value: Any = self.get_default_value(key) - # - # setattr(self, key, default_value) - # - # attributes[key] = default_value - # - # await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}) - # - # self._update_cache(cache) - # - # logger.info("Reset attributes %s of guild %s to default values", args, self.id) def _get_cache_key(self) -> str: return f"{self.__short_name__}_{self.id}" def _update_cache(self, cache: Optional[Cache] = None) -> None: super()._update_cache(cache) - # if cache is None: - # return - # - # object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - # - # if object_dict is not None: - # cache.set_json(self._get_cache_key(), object_dict) - # else: - # self._delete_cache(cache) def _delete_cache(self, cache: Optional[Cache] = None) -> None: super()._delete_cache(cache) - # if cache is None: - # return - # - # cache.delete(self._get_cache_key()) @staticmethod def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: @@ -217,11 +176,6 @@ class PycordGuild(BaseCacheable): await super().reset(*args, cache=cache) async def purge(self, cache: Optional[Cache] = None) -> None: - """Completely remove guild data from database. Currently only removes the guild record from guilds collection. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - """ await super().purge(cache) def is_configured(self) -> bool: diff --git a/classes/pycord_user.py b/classes/pycord_user.py index cbfe1bb..56641dc 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -17,7 +17,7 @@ from discord.abc import GuildChannel from libbot.cache.classes import Cache from pymongo.results import InsertOneResult -from classes.abstract import Cacheable +from classes.base.base_cacheable import BaseCacheable from classes.errors import ( DiscordCategoryNotFoundError, DiscordChannelNotFoundError, @@ -34,17 +34,9 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordUser(Cacheable): +class PycordUser(BaseCacheable): """Dataclass of DB entry of a user""" - # TODO Implement this - async def update(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: - pass - - # TODO Implement this - async def reset(self, *args: str, cache: Optional[Cache] = None) -> None: - pass - __slots__ = ( "_id", "id", @@ -145,68 +137,19 @@ class PycordUser(Cacheable): } async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: - """Set attribute data and save it into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - **kwargs (Any): Mapping of attribute names and respective values to be set - """ - for key, value in kwargs.items(): - if not hasattr(self, key): - raise AttributeError() - - setattr(self, key, value) - - await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}) - - self._update_cache(cache) - - logger.info("Set attributes of user %s to %s", self.id, kwargs) + await super()._set(cache, **kwargs) async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: - """Remove attribute data and save it into the database. - - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - *args (str): List of attributes to remove - """ - attributes: Dict[str, Any] = {} - - for key in args: - if not hasattr(self, key): - raise AttributeError() - - default_value: Any = self.get_default_value(key) - - setattr(self, key, default_value) - - attributes[key] = default_value - - await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}) - - self._update_cache(cache) - - logger.info("Reset attributes %s of user %s to default values", args, self.id) + await super()._remove(*args, cache=cache) def _get_cache_key(self) -> str: return f"{self.__short_name__}_{self.id}_{self.guild_id}" def _update_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - object_dict: Dict[str, Any] = self.to_dict(json_compatible=True) - - if object_dict is not None: - cache.set_json(self._get_cache_key(), object_dict) - else: - self._delete_cache(cache) + super()._update_cache(cache) def _delete_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - cache.delete(self._get_cache_key()) + super()._delete_cache(cache) @staticmethod def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]: @@ -270,14 +213,22 @@ class PycordUser(Cacheable): return PycordUser.get_defaults()[key] - async def purge(self, cache: Optional[Cache] = None) -> None: - """Completely remove user data from database. Currently only removes the user record from users collection. + async def update( + self, + cache: Optional[Cache] = None, + **kwargs: Any, + ) -> None: + await super().update(cache=cache, **kwargs) - Args: - cache (:obj:`Cache`, optional): Cache engine to write the update into - """ - await self.__collection__.delete_one({"_id": self._id}) - self._delete_cache(cache) + async def reset( + self, + *args: str, + cache: Optional[Cache] = None, + ) -> None: + await super().reset(*args, cache=cache) + + async def purge(self, cache: Optional[Cache] = None) -> None: + await super().purge(cache) # TODO Add documentation async def event_register(self, event_id: str | ObjectId, cache: Optional[Cache] = None) -> None: diff --git a/cogs/cog_unregister.py b/cogs/cog_unregister.py index 4b55468..61fe792 100644 --- a/cogs/cog_unregister.py +++ b/cogs/cog_unregister.py @@ -64,7 +64,6 @@ class CogUnregister(Cog): await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale), ephemeral=True) return - # TODO Fix a bug where registered_event_ids is invalid because of caching if pycord_event._id not in user.registered_event_ids: await ctx.respond( self.bot._("unregister_not_registered", "messages", locale=ctx.locale), ephemeral=True From e7c719312fa0e79f312f82bd488897d1e9b27dcc Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 20:24:02 +0200 Subject: [PATCH 50/55] Completed the README documentation for #4 --- README.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 46f9085..024a1c4 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,64 @@ Open source Discord bot for quizzes and quest-like events. ## Configuration -TODO +```json +{ + "locale": "en-US", + // Bot's default locale. Based on file name from locale/ + "debug": false, + // Debug mode setting + "bot": { + "owners": [ + 0 + // Discord ID of bot's owner + ], + "debug_guilds": [ + 0 + // Discord ID of the debug guild + ], + "bot_token": "", + // Bot's token + "timezone": "UTC", + // Bot's timezone + "status": { + "enabled": true, + // Whether activity is enabled + "activity_type": "playing", + // Type of the activity. Can be: playing, watching, listening, streaming, competing and custom + "activity_text": "The Game Of Life" + // Text of the activity + } + }, + "database": { + "user": null, + // User name for database connection. null if without auth + "password": null, + // User password for database connection. null if without auth + "host": "127.0.0.1", + // Database host + "port": 27017, + // Database port + "name": "quiz_bot" + // Database name + }, + "cache": { + "type": null, + // Type of caching engine. Can be: memcached and redis + "memcached": { + "uri": "127.0.0.1:11211" + // Memcached URI + }, + "redis": { + "uri": "redis://127.0.0.1:6379/0" + // Redis URI + } + }, + "emojis": { + "guess_wrong": null + // Markdown of a Discord emoji to be used for wrong guesses + } +} +``` ## Upgrading From 3700e4055d3fb2df479124375f4dfe6e4578bd06 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 20:28:42 +0200 Subject: [PATCH 51/55] Fixed formatting in README --- README.md | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 024a1c4..0483db5 100644 --- a/README.md +++ b/README.md @@ -39,59 +39,66 @@ Open source Discord bot for quizzes and quest-like events. ```json { - "locale": "en-US", // Bot's default locale. Based on file name from locale/ - "debug": false, + "locale": "en-US", // Debug mode setting + "debug": false, + // Bot's config "bot": { + // Discord ID(s) of bot's owner(s) "owners": [ 0 - // Discord ID of bot's owner ], + // Discord ID(s) of the debug guild(s) "debug_guilds": [ 0 - // Discord ID of the debug guild ], - "bot_token": "", // Bot's token - "timezone": "UTC", + "bot_token": "", // Bot's timezone + "timezone": "UTC", + // Bot's status activity "status": { - "enabled": true, // Whether activity is enabled + "enabled": true, + // Type of the activity. Can be: "playing", "watching", "listening", "streaming", "competing" and "custom" "activity_type": "playing", - // Type of the activity. Can be: playing, watching, listening, streaming, competing and custom - "activity_text": "The Game Of Life" // Text of the activity + "activity_text": "The Game Of Life" } }, + // Database connection "database": { - "user": null, // User name for database connection. null if without auth - "password": null, + "user": null, // User password for database connection. null if without auth - "host": "127.0.0.1", + "password": null, // Database host - "port": 27017, + "host": "127.0.0.1", // Database port - "name": "quiz_bot" + "port": 27017, // Database name + "name": "quiz_bot" }, + // Cache connection "cache": { + // Type of caching engine. Can be: "memcached", "redis" or null "type": null, - // Type of caching engine. Can be: memcached and redis + // Memcached connection. Only used if cache type is "memcached" "memcached": { - "uri": "127.0.0.1:11211" // Memcached URI + "uri": "127.0.0.1:11211" }, + // Redis connection. Only used if cache type is "redis" "redis": { - "uri": "redis://127.0.0.1:6379/0" // Redis URI + "uri": "redis://127.0.0.1:6379/0" } }, + // Emojis used by guilds that prefer emoji messages "emojis": { - "guess_wrong": null // Markdown of a Discord emoji to be used for wrong guesses + "guess_wrong": null } } ``` From 8d60d8aef53cd1a1eb15ab9adc5434065d6d50da Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 20:29:49 +0200 Subject: [PATCH 52/55] Fixed formatting and typos --- README.md | 2 +- classes/base/base_cacheable.py | 2 +- classes/pycord_bot.py | 2 +- classes/pycord_event.py | 12 ++++++------ cogs/cog_user.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0483db5..74b543f 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Open source Discord bot for quizzes and quest-like events. "status": { // Whether activity is enabled "enabled": true, - // Type of the activity. Can be: "playing", "watching", "listening", "streaming", "competing" and "custom" + // Type of the activity. Can be: "playing", "watching", "listening", "streaming", "competing" or "custom" "activity_type": "playing", // Text of the activity "activity_text": "The Game Of Life" diff --git a/classes/base/base_cacheable.py b/classes/base/base_cacheable.py index ea60c3f..c07d9c0 100644 --- a/classes/base/base_cacheable.py +++ b/classes/base/base_cacheable.py @@ -1,6 +1,6 @@ from abc import ABC from logging import Logger -from typing import Optional, Any, Dict +from typing import Any, Dict, Optional from bson import ObjectId from libbot.cache.classes import Cache diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index f6e64a2..af2062d 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -20,7 +20,7 @@ from classes.errors import ( EventStageNotFoundError, GuildNotFoundError, ) -from modules.database import col_events, col_users, _update_database_indexes +from modules.database import _update_database_indexes, col_events, col_users from modules.utils import get_logger logger: Logger = get_logger(__name__) diff --git a/classes/pycord_event.py b/classes/pycord_event.py index 89492ab..ca3b576 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -312,16 +312,16 @@ class PycordEvent(BaseCacheable): return PycordEvent.get_defaults()[key] async def update( - self, - cache: Optional[Cache] = None, - **kwargs: Any, + self, + cache: Optional[Cache] = None, + **kwargs: Any, ) -> None: await super().update(cache=cache, **kwargs) async def reset( - self, - *args: str, - cache: Optional[Cache] = None, + self, + *args: str, + cache: Optional[Cache] = None, ) -> None: await super().reset(*args, cache=cache) diff --git a/cogs/cog_user.py b/cogs/cog_user.py index e337030..dc98f78 100644 --- a/cogs/cog_user.py +++ b/cogs/cog_user.py @@ -21,7 +21,7 @@ from classes import PycordEvent, PycordGuild, PycordUser from classes.errors import GuildNotFoundError from classes.pycord_bot import PycordBot from modules.database import col_users -from modules.utils import get_logger, is_operation_confirmed, get_utc_now +from modules.utils import get_logger, get_utc_now, is_operation_confirmed logger: Logger = get_logger(__name__) From 5f9ef163e10867726fdf16ac134d1098670783ef Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 20:33:50 +0200 Subject: [PATCH 53/55] Bump version to 1.0.0 --- classes/pycord_bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index af2062d..b3d34ec 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -27,7 +27,7 @@ logger: Logger = get_logger(__name__) class PycordBot(LibPycordBot): - __version__ = "0.1.0" + __version__ = "1.0.0" started: datetime cache: CacheMemcached | CacheRedis | None = None From c0451de27afa6f4203d6003f49e2198f7836dde3 Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 22:45:06 +0200 Subject: [PATCH 54/55] Fixed wrong string conversions during caching on PycordUser --- classes/pycord_event.py | 3 +-- classes/pycord_event_stage.py | 8 ++++++-- classes/pycord_guild.py | 2 +- classes/pycord_user.py | 12 +++++++++--- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/classes/pycord_event.py b/classes/pycord_event.py index ca3b576..beaa142 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -11,7 +11,6 @@ from libbot.cache.classes import Cache from pymongo import DESCENDING from pymongo.results import InsertOneResult -from classes.abstract import Cacheable from classes.base.base_cacheable import BaseCacheable from classes.errors import EventNotFoundError from modules.database import col_events @@ -86,7 +85,7 @@ class PycordEvent(BaseCacheable): if cached_entry is not None: return cls(**cls._entry_from_cache(cached_entry)) - db_entry = await cls.__collection__.find_one( + db_entry: Dict[str, Any] | None = await cls.__collection__.find_one( {"_id": event_id if isinstance(event_id, ObjectId) else ObjectId(event_id)} ) diff --git a/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index 5f03180..b9ae846 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -64,7 +64,7 @@ class PycordEventStage(BaseCacheable): if cached_entry is not None: return cls(**cls._entry_from_cache(cached_entry)) - db_entry = await cls.__collection__.find_one( + db_entry: Dict[str, Any] | None = await cls.__collection__.find_one( {"_id": stage_id if isinstance(stage_id, ObjectId) else ObjectId(stage_id)} ) @@ -155,7 +155,11 @@ class PycordEventStage(BaseCacheable): """ return { "_id": self._id if not json_compatible else str(self._id), - "event_id": self.event_id if not json_compatible else str(self.event_id), + "event_id": ( + self.event_id + if not json_compatible + else (None if self.event_id is None else str(self.event_id)) + ), "guild_id": self.guild_id, "sequence": self.sequence, "created": self.created if not json_compatible else self.created.isoformat(), diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index 6af35e7..ac30b14 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -60,7 +60,7 @@ class PycordGuild(BaseCacheable): if cached_entry is not None: return cls(**cls._entry_from_cache(cached_entry)) - db_entry = await cls.__collection__.find_one({"id": guild_id}) + db_entry: Dict[str, Any] | None = await cls.__collection__.find_one({"id": guild_id}) if db_entry is None: if not allow_creation: diff --git a/classes/pycord_user.py b/classes/pycord_user.py index 56641dc..516e0ce 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -86,7 +86,9 @@ class PycordUser(BaseCacheable): if cached_entry is not None: return cls(**cls._entry_from_cache(cached_entry)) - db_entry = await cls.__collection__.find_one({"id": user_id, "guild_id": guild_id}) + db_entry: Dict[str, Any] | None = await cls.__collection__.find_one( + {"id": user_id, "guild_id": guild_id} + ) if db_entry is None: if not allow_creation: @@ -119,10 +121,14 @@ class PycordUser(BaseCacheable): "event_channels": self.event_channels, "is_jailed": self.is_jailed, "current_event_id": ( - self.current_event_id if not json_compatible else str(self.current_event_id) + self.current_event_id + if not json_compatible + else (None if self.current_event_id is None else str(self.current_event_id)) ), "current_stage_id": ( - self.current_stage_id if not json_compatible else str(self.current_stage_id) + self.current_stage_id + if not json_compatible + else (None if self.current_stage_id is None else str(self.current_stage_id)) ), "registered_event_ids": ( self.registered_event_ids From 294a57338e92060866ff789d0b244454790398af Mon Sep 17 00:00:00 2001 From: profitroll Date: Tue, 6 May 2025 23:53:08 +0200 Subject: [PATCH 55/55] Bump version to 1.0.1 --- classes/pycord_bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index b3d34ec..9a6c389 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -27,7 +27,7 @@ logger: Logger = get_logger(__name__) class PycordBot(LibPycordBot): - __version__ = "1.0.0" + __version__ = "1.0.1" started: datetime cache: CacheMemcached | CacheRedis | None = None