diff --git a/README.md b/README.md index 647df6a..74b543f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,142 @@ # 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 directory of your choice +3. Go to the project's directory +4. Create a virtual environment: `python3 -m venv .venv` +5. Activate the 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` +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 directory: `cd QuizBot` +3. Continue from step 4 of [Installation from release](#installation-from-release) + +## Configuration + +```json +{ + // Bot's default locale. Based on file name from locale/ + "locale": "en-US", + // Debug mode setting + "debug": false, + // Bot's config + "bot": { + // Discord ID(s) of bot's owner(s) + "owners": [ + 0 + ], + // Discord ID(s) of the debug guild(s) + "debug_guilds": [ + 0 + ], + // Bot's token + "bot_token": "", + // Bot's timezone + "timezone": "UTC", + // Bot's status activity + "status": { + // Whether activity is enabled + "enabled": true, + // 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" + } + }, + // Database connection + "database": { + // User name for database connection. null if without auth + "user": null, + // User password for database connection. null if without auth + "password": null, + // Database host + "host": "127.0.0.1", + // Database port + "port": 27017, + // Database name + "name": "quiz_bot" + }, + // Cache connection + "cache": { + // Type of caching engine. Can be: "memcached", "redis" or null + "type": null, + // Memcached connection. Only used if cache type is "memcached" + "memcached": { + // Memcached URI + "uri": "127.0.0.1:11211" + }, + // Redis connection. Only used if cache type is "redis" + "redis": { + // Redis URI + "uri": "redis://127.0.0.1:6379/0" + } + }, + // Emojis used by guilds that prefer emoji messages + "emojis": { + // Markdown of a Discord emoji to be used for wrong guesses + "guess_wrong": null + } +} +``` + +## Upgrading + +### 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 + +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 daylight saving time (e.g. `CET` will be interpreted as `CEST` during daylight saving). \ No newline at end of file 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/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..c07d9c0 --- /dev/null +++ b/classes/base/base_cacheable.py @@ -0,0 +1,110 @@ +from abc import ABC +from logging import Logger +from typing import Any, Dict, Optional + +from bson import ObjectId +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.""" + + _id: ObjectId + + 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/errors/__init__.py b/classes/errors/__init__.py index baa580e..d8fbaa6 100644 --- a/classes/errors/__init__.py +++ b/classes/errors/__init__.py @@ -1,2 +1,14 @@ +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 UserNotFoundError +from .pycord_user import ( + UserAlreadyCompletedEventError, + UserAlreadyRegisteredForEventError, + UserNotFoundError, + UserNotRegisteredForEventError, +) diff --git a/classes/errors/discord.py b/classes/errors/discord.py new file mode 100644 index 0000000..9b2efc0 --- /dev/null +++ b/classes/errors/discord.py @@ -0,0 +1,32 @@ +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..84a2139 --- /dev/null +++ b/classes/errors/pycord_event.py @@ -0,0 +1,26 @@ +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, + 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") + + 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 for the guild {self.guild_id}" + ) diff --git a/classes/errors/pycord_event_stage.py b/classes/errors/pycord_event_stage.py new file mode 100644 index 0000000..1d4b4d0 --- /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: str | ObjectId = 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_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/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 df4219b..b3d34ec 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -1,23 +1,35 @@ 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, 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 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 +from classes.errors import ( + DiscordGuildMemberNotFoundError, + EventNotFoundError, + EventStageMissingSequenceError, + EventStageNotFoundError, + GuildNotFoundError, +) +from modules.database import _update_database_indexes, col_events, col_users from modules.utils import get_logger logger: Logger = get_logger(__name__) class PycordBot(LibPycordBot): + __version__ = "1.0.0" + + started: datetime cache: CacheMemcached | CacheRedis | None = None def __init__(self, *args, **kwargs) -> None: @@ -46,6 +58,10 @@ 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")) + await super().start(*args, **kwargs) @override @@ -53,7 +69,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() @@ -62,18 +80,36 @@ 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 for event in events: - 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) + guild: Guild = self.get_guild(event.guild_id) + + 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: + logger.error("Could not start the event %s: no event stages are defined.", event._id) + + await self.notify_admins( + guild, + pycord_guild, + self._("admin_event_no_stages_defined", "messages").format(event_name=event.name), + ) + + await event.cancel(self.cache) + continue - guild: Guild = self.get_guild(event.guild_id) - pycord_guild: PycordGuild = await self.find_guild(guild) first_stage: PycordEventStage = await self.find_event_stage(event.stage_ids[0]) # Get list of participants @@ -83,10 +119,49 @@ 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) + 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, + ) - # Send a notification about event start - user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)]) + await self.notify_admins( + guild, + pycord_guild, + self._("admin_channel_creation_failed_no_user", "messages").format( + user_id=user.id, event_name=event.name + ), + ) + + continue + + 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, + self._("admin_channel_creation_failed", "messages").format( + display_name=discord_user.display_name, + mention=discord_user.mention, + event_name=event.name, + ), + ) + + continue thumbnail: File | None = ( None @@ -94,62 +169,146 @@ class PycordBot(LibPycordBot): else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"]) ) - # 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.", + self._("event_is_starting", "messages").format(event_name=event.name), file=thumbnail, ) 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( guild, pycord_guild, - f"Event **{event.name}** has started! Users have gotten their channels and can already start submitting their answers.", + 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( - {"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 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) - # TODO Make a nice message + 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 + stages_string: str = "\n\n".join( - f"**Stage {stage.sequence+1}**\nQuestion: {stage.question}\nAnswer: ||{stage.answer}||" + self._("stage_entry", "messages").format(sequence=stage.sequence + 1, answer=stage.answer) for stage in stages ) # 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( + "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)]) - # TODO Make a nice message - await user_channel.send( - f"Event **{event.name}** has ended! Stages and respective answers are listed below.\n\n{stages_string}" - ) + 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) - # TODO Make a nice message + await event.end(cache=self.cache) + await self.notify_admins( guild, pycord_guild, - f"Event **{event.name}** has ended! Users can no longer submit their answers.", + self._("admin_event_ended", "messages").format(event_name=event.name), + ) + + 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 @@ -177,18 +336,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. @@ -241,8 +417,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) @@ -251,19 +426,25 @@ 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 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: 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: 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]] = [] @@ -273,3 +454,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_event.py b/classes/pycord_event.py index 8878945..ca3b576 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -1,14 +1,19 @@ +"""Module with class PycordEvent.""" + from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, tzinfo 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.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 @@ -16,7 +21,23 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordEvent: +class PycordEvent(BaseCacheable): + """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", @@ -47,53 +68,64 @@ 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) 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)} ) 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) + cache.set_json(f"{cls.__short_name__}_{event_id}", cls._entry_to_cache(dict(db_entry))) return cls(**db_entry) @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 RuntimeError(f"Event with name {event_name} not found") - - # TODO Add a unique exception - # raise EventNotFoundError(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) + cache.set_json(f"{cls.__short_name__}_{db_entry['_id']}", cls._entry_to_cache(db_entry)) return cls(**db_entry) @@ -108,6 +140,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, @@ -126,147 +174,24 @@ 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) 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}, 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: - """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}, 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 - - user_dict: Dict[str, Any] = self.to_dict() - - if user_dict is not None: - cache.set_json(self._get_cache_key(), user_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()) - - 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, - } - - @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": [], - } - - @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) + super()._delete_cache(cache) async def _update_event_stage_order( self, @@ -286,15 +211,154 @@ 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) - # TODO Add documentation - async def insert_stage( - self, bot: Bot, event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None + @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. + + 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 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 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 if not json_compatible else [str(stage_id) for stage_id in 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: Any, ) -> None: + await super().update(cache=cache, **kwargs) + + 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) + + 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) @@ -302,10 +366,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))) @@ -313,8 +384,16 @@ 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)) @@ -322,10 +401,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) - # - # # TODO Add documentation - # def get_localized_end_date(self, tz: str | timezone | ZoneInfo) -> datetime: - # return self.ends.replace(tzinfo=tz) + def get_start_date_utc(self) -> datetime: + """Get the event start date in UTC timezone. + + 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: tzinfo) -> 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: tzinfo) -> 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/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index bceb989..5f03180 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -10,6 +10,8 @@ from discord import File from libbot.cache.classes import Cache from pymongo.results import InsertOneResult +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 @@ -17,7 +19,7 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordEventStage: +class PycordEventStage(BaseCacheable): __slots__ = ( "_id", "event_id", @@ -44,38 +46,37 @@ 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) 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)} ) 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) + cache.set_json(f"{cls.__short_name__}_{stage_id}", cls._entry_to_cache(dict(db_entry))) return cls(**db_entry) + # TODO Add documentation @classmethod async def create( cls, @@ -104,89 +105,60 @@ 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) 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}, upsert=True) - - 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}, upsert=True) - - 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 - - user_dict: Dict[str, Any] = self.to_dict() - - if user_dict is not None: - cache.set_json(self._get_cache_key(), user_dict) - else: - self._delete_cache(cache) + super()._update_cache(cache) def _delete_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return + super()._delete_cache(cache) - 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 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), "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, @@ -195,6 +167,11 @@ class PycordEventStage: @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, @@ -208,35 +185,38 @@ class PycordEventStage: @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, - ): - await self._set(cache=cache, **kwargs) + **kwargs: Any, + ) -> None: + await super().update(cache=cache, **kwargs) - # TODO Add documentation async def reset( self, + *args: str, cache: Optional[Cache] = None, - *args, - ): - await self._remove(cache, *args) + ) -> None: + 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: @@ -245,3 +225,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/classes/pycord_guild.py b/classes/pycord_guild.py index 771a601..6af35e7 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.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 @@ -14,40 +15,50 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordGuild: +class PycordGuild(BaseCacheable): """Dataclass of DB entry of a guild""" - __slots__ = ("_id", "id", "channel_id", "category_id", "timezone") + __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 @classmethod 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) 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}) @@ -62,164 +73,122 @@ 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) 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}, upsert=True) - - self._update_cache(cache) - - logger.info("Set attributes of guild %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}, 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) 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 - - user_dict: Dict[str, Any] = self.to_dict() - - if user_dict is not None: - cache.set_json(self._get_cache_key(), user_dict) - else: - self._delete_cache(cache) + super()._update_cache(cache) def _delete_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return + super()._delete_cache(cache) - 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 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), "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, } @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, + "general_channel_id": None, + "management_channel_id": None, "category_id": None, "timezone": "UTC", + "prefer_emojis": False, } @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, - ): - await self._set(cache=cache, **kwargs) + **kwargs: Any, + ) -> None: + await super().update(cache=cache, **kwargs) - # TODO Add documentation async def reset( self, + *args: str, cache: Optional[Cache] = None, - *args, - ): - await self._remove(cache, *args) + ) -> None: + 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. + await super().purge(cache) - 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) - - # 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.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) ) - - # 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") diff --git a/classes/pycord_user.py b/classes/pycord_user.py index 011b985..56641dc 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -17,7 +17,16 @@ 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.base.base_cacheable import BaseCacheable +from classes.errors import ( + DiscordCategoryNotFoundError, + DiscordChannelNotFoundError, + DiscordGuildMemberNotFoundError, + UserAlreadyCompletedEventError, + UserAlreadyRegisteredForEventError, + UserNotFoundError, + UserNotRegisteredForEventError, +) from modules.database import col_users from modules.utils import get_logger, restore_from_cache @@ -25,7 +34,7 @@ logger: Logger = get_logger(__name__) @dataclass -class PycordUser: +class PycordUser(BaseCacheable): """Dataclass of DB entry of a user""" __slots__ = ( @@ -52,11 +61,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 @@ -80,7 +84,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}) @@ -95,7 +99,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) @@ -114,8 +118,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 @@ -129,69 +137,61 @@ class PycordUser: } 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}, upsert=True) - - 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}, upsert=True) - - 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}" + return f"{self.__short_name__}_{self.id}_{self.guild_id}" def _update_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return - - user_dict: Dict[str, Any] = self.to_dict() - - if user_dict is not None: - cache.set_json(self._get_cache_key(), user_dict) - else: - self._delete_cache(cache) + super()._update_cache(cache) def _delete_cache(self, cache: Optional[Cache] = None) -> None: - if cache is None: - return + super()._delete_cache(cache) - 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]: return { @@ -205,6 +205,7 @@ class PycordUser: "completed_event_ids": [], } + # TODO Add documentation @staticmethod def get_default_value(key: str) -> Any: if key not in PycordUser.get_defaults(): @@ -212,24 +213,29 @@ class PycordUser: 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: 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 +246,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,44 +257,33 @@ 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, guild: Guild, pycord_guild: "PycordGuild", pycord_event: "PycordEvent", + ignore_exists: bool = False, cache: Optional[Cache] = None, - ): - if str(pycord_event._id) in self.event_channels.keys(): - return + ) -> TextChannel | None: + 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) discord_category: GuildChannel | None = bot.get_channel(pycord_guild.category_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.category_id, guild.id) permission_overwrites: Dict[Role | Member, PermissionOverwrite] = { guild.default_role: PermissionOverwrite( @@ -316,6 +308,46 @@ class PycordUser: await self.set_event_channel(pycord_event._id, channel.id, cache=cache) + 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, guild: Guild, @@ -329,20 +361,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, @@ -371,10 +393,20 @@ 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 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) + # TODO Add documentation async def unjail(self, cache: Optional[Cache] = None) -> None: await self._set(cache, is_jailed=False) diff --git a/cogs/cog_config.py b/cogs/cog_config.py index c5b8cd7..91e49dc 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 @@ -36,37 +38,75 @@ 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( + "general_channel", + description=_("description", "commands", "config_set", "options", "general_channel"), + description_localizations=in_every_locale( + "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, ) - @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, ) + @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, + general_channel: TextChannel, + management_channel: TextChannel, timezone: str, + prefer_emojis: bool, ) -> 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), 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)) + await ctx.respond( + self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone), + ephemeral=True, + ) return 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, ) await ctx.respond(self.bot._("config_set", "messages", locale=ctx.locale)) @@ -79,14 +119,20 @@ 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: 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), ephemeral=True) + return await guild.purge(self.bot.cache) @@ -98,17 +144,25 @@ 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), 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( 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/cogs/cog_event.py b/cogs/cog_event.py index 744fca8..e6561dd 100644 --- a/cogs/cog_event.py +++ b/cogs/cog_event.py @@ -11,8 +11,10 @@ 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.errors import EventNotFoundError, GuildNotFoundError from classes.pycord_bot import PycordBot from modules.utils import ( autocomplete_active_events, @@ -28,18 +30,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, @@ -48,28 +81,31 @@ 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), 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) 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( - "Could not parse start and end dates. Please, make sure these are provided in `DD.MM.YYYY HH:MM` format." + self.bot._("event_dates_parsing_failed", "messages", locale=ctx.locale), ephemeral=True ) 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, to_utc=True): + return processed_media: List[Dict[str, Any]] = ( [] if thumbnail is None else await self.bot.process_attachments([thumbnail]) @@ -84,26 +120,58 @@ 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) + ) ) - # 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, @@ -113,40 +181,59 @@ 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), ephemeral=True) + 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), 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) try: start_date: datetime = ( - pycord_event.starts if start is None else datetime.strptime(start, "%d.%m.%Y %H:%M") + pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) + if start is None + else datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) ) - 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( + self.bot._("event_start_date_parsing_failed", "messages", locale=ctx.locale), ephemeral=True + ) return 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=guild_timezone) + end_date: datetime = ( + pycord_event.ends.replace(tzinfo=ZoneInfo("UTC")) + if end is None + 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.") + await ctx.respond( + self.bot._("event_end_date_parsing_failed", "messages", locale=ctx.locale), ephemeral=True + ) return - await validate_event_validity(ctx, name, start_date, end_date, guild_timezone) + 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]] = ( [] if thumbnail is None else await self.bot.process_attachments([thumbnail]) @@ -154,31 +241,43 @@ 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], ) # 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), + ) ) - # 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, @@ -188,17 +287,22 @@ 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), ephemeral=True) + 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), 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 start_date: datetime = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) @@ -210,56 +314,75 @@ 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 + ), + ephemeral=True, + ) 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 + ) + ) - # 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, ) 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): - # 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), ephemeral=True) 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) - # TODO Make a nice message stages_string: str = "\n\n".join( - f"**Stage {stage.sequence+1}**\nQuestion: {stage.question}\nAnswer: ||{stage.answer}||" + 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 - await ctx.respond( - 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 + + 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 7ce17b2..661050a 100644 --- a/cogs/cog_guess.py +++ b/cogs/cog_guess.py @@ -3,8 +3,14 @@ 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.errors import ( + EventNotFoundError, + EventStageNotFoundError, + GuildNotFoundError, +) from classes.pycord_bot import PycordBot @@ -14,48 +20,58 @@ 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) + 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), 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) 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.") + 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, report this issue to the event's management.") + 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(): - # 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 @@ -64,7 +80,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( @@ -74,30 +89,39 @@ 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, event_name=event.name + ), ) + return next_stage: PycordEventStage = await self.bot.find_event_stage(next_stage_id) 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) 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 c61a77a..27810eb 100644 --- a/cogs/cog_register.py +++ b/cogs/cog_register.py @@ -1,10 +1,19 @@ -from bson.errors import InvalidId -from discord import ApplicationContext, Cog, option, slash_command -from discord.utils import basic_autocomplete +from datetime import datetime +from logging import Logger +from pathlib import Path +from zoneinfo import ZoneInfo -from classes import PycordEvent, PycordGuild, PycordUser +from bson.errors import InvalidId +from discord import ApplicationContext, Cog, File, TextChannel, option, slash_command +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_unix_timestamp +from modules.utils import autocomplete_active_events, get_logger, get_unix_timestamp + +logger: Logger = get_logger(__name__) class CogRegister(Cog): @@ -13,49 +22,115 @@ class CogRegister(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot - # 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: - 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), ephemeral=True) + 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), 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) 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), ephemeral=True) 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), ephemeral=True + ) 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") ) + 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( + 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, + 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 + + thumbnail: File | None = ( + None + if pycord_event.thumbnail is None + else File(Path(f"data/{pycord_event.thumbnail['id']}"), pycord_event.thumbnail["filename"]) + ) + + await user_channel.send( + self.bot._("notice_event_already_started", "messages", locale=ctx.locale).format( + event_name=pycord_event.name + ), + file=thumbnail, + ) + + 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) + + await self.bot.send_stage_question(user_channel, pycord_event, first_stage) + def setup(bot: PycordBot) -> None: bot.add_cog(CogRegister(bot)) diff --git a/cogs/cog_stage.py b/cogs/cog_stage.py index 8096607..3a28379 100644 --- a/cogs/cog_stage.py +++ b/cogs/cog_stage.py @@ -4,8 +4,14 @@ 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.errors import ( + EventNotFoundError, + EventStageNotFoundError, + GuildNotFoundError, +) from classes.pycord_bot import PycordBot from modules.utils import ( autocomplete_active_events, @@ -21,23 +27,52 @@ 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? @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" + ), + max_length=500, + 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, @@ -46,23 +81,30 @@ 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), 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: 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, EventStageNotFoundError): + 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)): 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, @@ -75,33 +117,73 @@ 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)) - # 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" + ), + max_length=500, + 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, @@ -113,17 +195,22 @@ 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), 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: 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), ephemeral=True) return if not (await is_event_status_valid(ctx, pycord_event)): @@ -131,17 +218,19 @@ class CogStage(Cog): try: event_stage: PycordEventStage = await self.bot.find_event_stage(stage) - except (InvalidId, RuntimeError): - # TODO Make a nice message - await ctx.respond("Event stage was not found.") + except (InvalidId, EventStageNotFoundError): + 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): - # 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), ephemeral=True + ) 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( @@ -153,44 +242,61 @@ 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)) - # 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: 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), 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: 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), ephemeral=True) return if not (await is_event_status_valid(ctx, pycord_event)): @@ -198,16 +304,14 @@ class CogStage(Cog): try: event_stage: PycordEventStage = await self.bot.find_event_stage(stage) - except (InvalidId, RuntimeError): - # TODO Make a nice message - await ctx.respond("Event stage was not found.") + except (InvalidId, EventStageNotFoundError): + 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) await event_stage.purge(cache=self.bot.cache) - # TODO Make a nice message - await ctx.respond("Okay.") + await ctx.respond(self.bot._("stage_deleted", "messages", locale=ctx.locale)) def setup(bot: PycordBot) -> None: 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/cogs/cog_unregister.py b/cogs/cog_unregister.py index 65681e2..61fe792 100644 --- a/cogs/cog_unregister.py +++ b/cogs/cog_unregister.py @@ -1,8 +1,10 @@ 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.errors import EventNotFoundError, GuildNotFoundError from classes.pycord_bot import PycordBot from modules.utils import autocomplete_user_registered_events, is_operation_confirmed @@ -13,51 +15,66 @@ 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 - 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), ephemeral=True) + 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), 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) 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), ephemeral=True) 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), ephemeral=True + ) return await user.event_unregister(pycord_event._id, cache=self.bot.cache) # TODO Text channel must be locked and updated - await ctx.respond("Ok.") + await ctx.respond(self.bot._("unregister_unregistered", "messages", locale=ctx.locale)) def setup(bot: PycordBot) -> None: diff --git a/cogs/cog_user.py b/cogs/cog_user.py index cbeeb40..dc98f78 100644 --- a/cogs/cog_user.py +++ b/cogs/cog_user.py @@ -1,14 +1,29 @@ +from datetime import datetime +from logging import Logger +from pathlib import Path +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, ) from discord.ext.commands import Cog +from libbot.i18n import _, in_every_locale -from classes import PycordUser +from classes import PycordEvent, PycordGuild, PycordUser +from classes.errors import GuildNotFoundError from classes.pycord_bot import PycordBot -from modules.utils import is_operation_confirmed +from modules.database import col_users +from modules.utils import get_logger, get_utc_now, is_operation_confirmed + +logger: Logger = get_logger(__name__) class CogUser(Cog): @@ -17,56 +32,171 @@ 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", + name="update_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_create_channel(self, ctx: ApplicationContext, user: User) -> None: - await ctx.respond("Not implemented.") + 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), 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", + "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.0": {"$exists": True}}}, + ] + + 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: + 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 + ) + + 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 + + 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( + self.bot._("user_channels_updated", "messages", locale=ctx.locale).format( + display_name=user.display_name + ) + ) # 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="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="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 @@ -74,25 +204,42 @@ 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.") + await ctx.respond( + self.bot._("user_jail_successful", "messages", locale=ctx.locale).format( + display_name=user.display_name + ) + ) - # 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 @@ -100,14 +247,21 @@ 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.") + await ctx.respond( + self.bot._("user_unjail_successful", "messages", locale=ctx.locale).format( + display_name=user.display_name + ) + ) def setup(bot: PycordBot) -> None: diff --git a/cogs/cog_utility.py b/cogs/cog_utility.py new file mode 100644 index 0000000..67e32f5 --- /dev/null +++ b/cogs/cog_utility.py @@ -0,0 +1,150 @@ +from datetime import datetime +from logging import Logger +from pathlib import Path +from typing import Any, Dict, List +from zoneinfo import ZoneInfo + +from bson import ObjectId +from bson.errors import InvalidId +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, get_utc_now + +logger: Logger = get_logger(__name__) + + +class CogUtility(Cog): + def __init__(self, bot: PycordBot): + self.bot: PycordBot = bot + + @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) + + @Cog.listener("on_member_join") + async def on_member_join(self, member: Member) -> None: + 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] = [] + + utc_now: datetime = get_utc_now() + + pipeline: List[Dict[str, Any]] = [ + {"$match": {"id": user.id}}, + { + "$lookup": { + "from": "events", + "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.0": {"$exists": True}}}, + ] + + 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: + 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._("notice_event_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: + 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" } }, diff --git a/locale/en-US.json b/locale/en-US.json index 71d82f9..f9f5f68 100644 --- a/locale/en-US.json +++ b/locale/en-US.json @@ -1,12 +1,68 @@ { "messages": { - "operation_unconfirmed": "Operation not confirmed.", + "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}**", + "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\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.", + "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_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", + "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_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!\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.", + "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`.", + "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_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_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.", + "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.", - "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}" + "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." }, "commands": { "config": { @@ -18,11 +74,17 @@ "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": { "description": "Timezone in which events take place" + }, + "prefer_emojis": { + "description": "Prefer emojis over text messages where available" } } }, @@ -36,6 +98,196 @@ }, "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": "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": "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": "Delete the event stage", + "options": { + "event": { + "description": "Name of the event" + }, + "stage": { + "description": "Stage to delete" + }, + "confirm": { + "description": "Confirmation of the operation" + } + } + }, + "status": { + "description": "Get status of the bot" + }, + "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": "Create channel for the user", + "options": {} + }, + "user_update_channels": { + "description": "Update user's event channels", + "options": { + "user": { + "description": "Selected user" + } + } + }, + "user_delete_channel": { + "description": "Delete user's channel", + "options": {} + }, + "user_jail": { + "description": "Jail the user", + "options": { + "user": { + "description": "Selected user" + }, + "confirm": { + "description": "Confirmation of the operation" + } + } + }, + "user_unjail": { + "description": "Unjail the user", + "options": { + "user": { + "description": "Selected user" + }, + "confirm": { + "description": "Confirmation of the operation" + } + } } } } 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/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 c3f0ed2..9c4698d 100644 --- a/modules/utils/__init__.py +++ b/modules/utils/__init__.py @@ -7,7 +7,8 @@ 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 from .validation_utils import is_event_status_valid, is_operation_confirmed diff --git a/modules/utils/autocomplete_utils.py b/modules/utils/autocomplete_utils.py index 502244f..815cedb 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] = [] @@ -49,30 +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": {"$ne": True}, - } - }, + {"$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 @@ -92,6 +103,11 @@ 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'] if len(result['question']) < 50 else result['question'][:47] + '...'})", + str(result["_id"]), + ) + ) return event_stages diff --git a/modules/utils/datetime_utils.py b/modules/utils/datetime_utils.py index 61b384c..19dcdf0 100644 --- a/modules/utils/datetime_utils.py +++ b/modules/utils/datetime_utils.py @@ -3,5 +3,10 @@ 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()) + + +# 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 b40ae42..85911c4 100644 --- a/modules/utils/event_utils.py +++ b/modules/utils/event_utils.py @@ -1,11 +1,12 @@ from datetime import datetime -from typing import Any, Dict +from typing import Any, Dict, Optional from zoneinfo import ZoneInfo from bson import ObjectId from discord import ( ApplicationContext, ) +from libbot.i18n import _ from modules.database import col_events @@ -13,20 +14,25 @@ from modules.database import col_events async def validate_event_validity( ctx: ApplicationContext, name: str, - start_date: datetime | None, - finish_date: datetime | None, - guild_timezone: ZoneInfo, - event_id: ObjectId | None = None, -) -> None: - if start_date > finish_date: - # TODO Make a nice message - await ctx.respond("Start date must be before finish date") - return + 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 + end_date_internal: datetime = end_date.astimezone(ZoneInfo("UTC")) if to_utc else end_date - 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 + if start_date_internal < datetime.now(tz=ZoneInfo("UTC")): + 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), ephemeral=True) + return False + + if start_date_internal >= end_date_internal: + await ctx.respond(_("event_end_before_start", "messages", locale=ctx.locale), ephemeral=True) + return False # TODO Add validation for concurrent events. # Only one event can take place at the same time. @@ -41,6 +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") - return + await ctx.respond(_("event_name_duplicate", "messages", locale=ctx.locale), ephemeral=True) + return False + + return True 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 diff --git a/modules/utils/validation_utils.py b/modules/utils/validation_utils.py index f7f330f..50cfd10 100644 --- a/modules/utils/validation_utils.py +++ b/modules/utils/validation_utils.py @@ -2,11 +2,12 @@ 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: 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 @@ -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), ephemeral=True) 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), ephemeral=True) return False return True 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] diff --git a/requirements.txt b/requirements.txt index 91130da..69e36aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +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 -pytz~=2025.1 \ No newline at end of file +msgspec~=0.19.0 +pymongo~=4.12.1,>=4.9 +pytz~=2025.1 +typing_extensions>=4.11.0 \ No newline at end of file