22 Commits

Author SHA1 Message Date
17a445a230 Bot will now exit after the migration 2025-05-19 22:06:37 +02:00
126dfa8b30 Closes #19 2025-05-19 22:05:38 +02:00
294a57338e Bump version to 1.0.1 2025-05-06 23:53:08 +02:00
c0451de27a Fixed wrong string conversions during caching on PycordUser 2025-05-06 22:45:06 +02:00
5f9ef163e1 Bump version to 1.0.0 2025-05-06 20:33:50 +02:00
8d60d8aef5 Fixed formatting and typos 2025-05-06 20:29:49 +02:00
3700e4055d Fixed formatting in README 2025-05-06 20:28:42 +02:00
e7c719312f Completed the README documentation for #4 2025-05-06 20:24:02 +02:00
96c1314234 Closes #13 2025-05-06 20:16:44 +02:00
d1498f38e9 WIP: BaseCacheable 2025-05-06 13:39:37 +02:00
86c75d06fa Worked on #13 and #4. There are some caching issues left, though. Introduced abstract class Cacheable. Replaced async_pymongo with pymongo 2025-05-06 02:54:30 +02:00
9d562e2e9d Closes #14 2025-05-04 22:30:54 +02:00
6b5a276f00 Documented PycordGuild and partially documented PycordEventStage (#4) 2025-05-04 01:07:28 +02:00
134533e342 Added instructions for upgrading from release and source 2025-05-04 00:48:19 +02:00
498d822e09 Fixed wrong _ being used 2025-05-03 17:00:45 +02:00
44e144801d Added installation and usage instructions 2025-05-03 17:00:21 +02:00
798f5ac529 Fixed prefer_emojis missing from __slots__ in PycordGuild 2025-05-03 01:09:11 +02:00
f2e01e3b85 Fixed i18n for PycordBot 2025-05-03 01:07:24 +02:00
2d9bf1cfb9 Introduced prefer_emojis for PycordGuild 2025-05-03 00:55:13 +02:00
aa2f90e1c5 Introduced i18n to utility modules 2025-05-03 00:45:22 +02:00
34a506466d Introduced i18n to CogStage 2025-05-03 00:38:13 +02:00
327dcba544 Introduced i18n to CogEvent 2025-05-03 00:33:49 +02:00
28 changed files with 1040 additions and 527 deletions

140
README.md
View File

@@ -1,2 +1,142 @@
# QuizBot # 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).

View File

@@ -0,0 +1 @@
from .cacheable import Cacheable

View File

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

0
classes/base/__init__.py Normal file
View File

View File

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

View File

@@ -14,20 +14,20 @@ from typing_extensions import override
from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser
from classes.errors import ( from classes.errors import (
DiscordGuildMemberNotFoundError,
EventNotFoundError, EventNotFoundError,
EventStageMissingSequenceError, EventStageMissingSequenceError,
EventStageNotFoundError, EventStageNotFoundError,
GuildNotFoundError, GuildNotFoundError,
DiscordGuildMemberNotFoundError,
) )
from modules.database import col_events, col_users from modules.database import _update_database_indexes, col_events, col_users
from modules.utils import get_logger from modules.utils import get_logger
logger: Logger = get_logger(__name__) logger: Logger = get_logger(__name__)
class PycordBot(LibPycordBot): class PycordBot(LibPycordBot):
__version__ = "0.1.0" __version__ = "1.0.1"
started: datetime started: datetime
cache: CacheMemcached | CacheRedis | None = None cache: CacheMemcached | CacheRedis | None = None
@@ -58,6 +58,7 @@ class PycordBot(LibPycordBot):
@override @override
async def start(self, *args: Any, **kwargs: Any) -> None: async def start(self, *args: Any, **kwargs: Any) -> None:
await self._schedule_tasks() await self._schedule_tasks()
await _update_database_indexes()
self.started = datetime.now(tz=ZoneInfo("UTC")) self.started = datetime.now(tz=ZoneInfo("UTC"))
@@ -97,13 +98,12 @@ class PycordBot(LibPycordBot):
continue continue
if len(event.stage_ids) == 0: 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) logger.error("Could not start the event %s: no event stages are defined.", event._id)
await self.notify_admins( await self.notify_admins(
guild, guild,
pycord_guild, pycord_guild,
f"Could not start the event **{event.name}**: no event stages are defined.", self._("admin_event_no_stages_defined", "messages").format(event_name=event.name),
) )
await event.cancel(self.cache) await event.cancel(self.cache)
@@ -134,7 +134,9 @@ class PycordBot(LibPycordBot):
await self.notify_admins( await self.notify_admins(
guild, guild,
pycord_guild, pycord_guild,
f"Event channel could not be created for user with ID `{user.id}` (<@{user.id}>) and event **{event.name}**: user was not found on the server.", self._("admin_channel_creation_failed_no_user", "messages").format(
user_id=user.id, event_name=event.name
),
) )
continue continue
@@ -152,7 +154,11 @@ class PycordBot(LibPycordBot):
await self.notify_admins( await self.notify_admins(
guild, guild,
pycord_guild, pycord_guild,
f"Event channel could not be created for user **{discord_user.display_name}** ({discord_user.mention}) and event **{event.name}**.", self._("admin_channel_creation_failed", "messages").format(
display_name=discord_user.display_name,
mention=discord_user.mention,
event_name=event.name,
),
) )
continue continue
@@ -163,10 +169,8 @@ class PycordBot(LibPycordBot):
else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"]) else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"])
) )
# Send a notification about event start
# TODO Make a nice message
await user_channel.send( 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, file=thumbnail,
) )
@@ -180,11 +184,16 @@ class PycordBot(LibPycordBot):
chunk, files=None if index != question_chunks_length - 1 else first_stage_files chunk, files=None if index != question_chunks_length - 1 else first_stage_files
) )
# TODO Make a nice message
await self.notify_admins( await self.notify_admins(
guild, guild,
pycord_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: async def _process_events_end(self) -> None:
@@ -218,14 +227,25 @@ class PycordBot(LibPycordBot):
) )
continue continue
# TODO Make a nice message
stages_string: str = "\n\n".join( stages_string: str = "\n\n".join(
f"**Stage {stage.sequence+1}**\nAnswer: ||{stage.answer}||" for stage in stages self._("stage_entry", "messages").format(sequence=stage.sequence + 1, answer=stage.answer)
for stage in stages
) )
# Get list of participants # Get list of participants
users: List[PycordUser] = await self._get_event_participants(event._id) 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: for user in users:
if str(event._id) not in user.event_channels: if str(event._id) not in user.event_channels:
logger.warning( logger.warning(
@@ -238,32 +258,58 @@ class PycordBot(LibPycordBot):
# Send a notification about event start # Send a notification about event start
user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)]) user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)])
# TODO Make a nice message
event_ended_string: str = (
f"Event **{event.name}** has ended! Stages and respective answers are listed below.\n\n{stages_string}"
)
chunk_size: int = 2000
event_info_chunks: List[str] = [
event_ended_string[i : i + chunk_size]
for i in range(0, len(event_ended_string), chunk_size)
]
for chunk in event_info_chunks: for chunk in event_info_chunks:
await user_channel.send(chunk) await user_channel.send(chunk)
# Lock each participant out # Lock each participant out
await user.lock_event_channel(guild, event._id, channel=user_channel) 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( await self.notify_admins(
guild, guild,
pycord_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 event.end(cache=self.cache) await self._notify_general_channel_event_end(guild, pycord_guild, event, stages)
async def _notify_general_channel_event_end(
self, guild: Guild, pycord_guild: PycordGuild, event: PycordEvent, stages: List[PycordEventStage]
) -> None:
event_ended_string: str = self._("event_ended_short", "messages").format(event_name=event.name)
await self.notify_users(
guild,
pycord_guild,
event_ended_string,
)
chunk_size: int = 2000
for stage in stages:
header_full: str = self._("stage_entry_header", "messages").format(
sequence=stage.sequence + 1, question=stage.question
)
header_chunks: List[str] = [
header_full[i : i + chunk_size] for i in range(0, len(header_full), chunk_size)
]
header_chunks_length: int = len(header_chunks)
files: List[File] | None = stage.get_media_files()
for index, chunk in enumerate(header_chunks):
await self.notify_users(
guild,
pycord_guild,
chunk,
files=None if index != header_chunks_length - 1 else files,
)
await self.notify_users(
guild, pycord_guild, self._("stage_entry_footer", "messages").format(answer=stage.answer)
)
@staticmethod @staticmethod
async def _get_events(query: Dict[str, Any]) -> List[PycordEvent]: async def _get_events(query: Dict[str, Any]) -> List[PycordEvent]:
@@ -290,18 +336,35 @@ class PycordBot(LibPycordBot):
# TODO Add documentation # TODO Add documentation
@staticmethod @staticmethod
async def notify_admins(guild: Guild, pycord_guild: PycordGuild, message: str) -> None: 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: if management_channel is None:
logger.error( logger.error(
"Discord channel with ID %s in guild with ID %s could not be found!", "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, guild.id,
) )
return return
await management_channel.send(message) 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: async def find_user(self, user: int | User, guild: int | Guild) -> PycordUser:
"""Find User by its ID or User object. """Find User by its ID or User object.

View File

@@ -1,7 +1,7 @@
"""Module with class PycordEvent.""" """Module with class PycordEvent."""
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, tzinfo
from logging import Logger from logging import Logger
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -11,6 +11,7 @@ from libbot.cache.classes import Cache
from pymongo import DESCENDING from pymongo import DESCENDING
from pymongo.results import InsertOneResult from pymongo.results import InsertOneResult
from classes.base.base_cacheable import BaseCacheable
from classes.errors import EventNotFoundError from classes.errors import EventNotFoundError
from modules.database import col_events from modules.database import col_events
from modules.utils import get_logger, restore_from_cache from modules.utils import get_logger, restore_from_cache
@@ -19,7 +20,7 @@ logger: Logger = get_logger(__name__)
@dataclass @dataclass
class PycordEvent: class PycordEvent(BaseCacheable):
"""Object representation of an event in the database. """Object representation of an event in the database.
Attributes: Attributes:
@@ -82,9 +83,9 @@ class PycordEvent:
cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, event_id, cache=cache) cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, event_id, cache=cache)
if cached_entry is not None: 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( db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
{"_id": event_id if isinstance(event_id, ObjectId) else ObjectId(event_id)} {"_id": event_id if isinstance(event_id, ObjectId) else ObjectId(event_id)}
) )
@@ -92,7 +93,7 @@ class PycordEvent:
raise EventNotFoundError(event_id=event_id) raise EventNotFoundError(event_id=event_id)
if cache is not None: 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) return cls(**db_entry)
@@ -123,7 +124,7 @@ class PycordEvent:
raise EventNotFoundError(event_name=event_name, guild_id=guild_id) raise EventNotFoundError(event_name=event_name, guild_id=guild_id)
if cache is not None: 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) return cls(**db_entry)
@@ -172,61 +173,24 @@ class PycordEvent:
db_entry["_id"] = insert_result.inserted_id db_entry["_id"] = insert_result.inserted_id
if cache is not None: 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) return cls(**db_entry)
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
for key, value in kwargs.items(): await super()._set(cache, **kwargs)
if not hasattr(self, key):
raise AttributeError(f"Attribute '{key}' does not exist in PycordEvent")
setattr(self, key, value)
await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True)
self._update_cache(cache)
logger.info("Set attributes of event %s to %s", self._id, kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
attributes: Dict[str, Any] = {} await super()._remove(*args, cache=cache)
for key in args:
if not hasattr(self, key):
raise AttributeError(f"Attribute '{key}' does not exist in PycordEvent")
default_value: Any = self.get_default_value(key)
setattr(self, key, default_value)
attributes[key] = default_value
await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True)
self._update_cache(cache)
logger.info("Reset attributes %s of event %s to default values", args, self._id)
def _get_cache_key(self) -> str: def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self._id}" return f"{self.__short_name__}_{self._id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None: def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None: super()._update_cache(cache)
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)
def _delete_cache(self, cache: Optional[Cache] = None) -> None: def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None: super()._delete_cache(cache)
return
cache.delete(self._get_cache_key())
async def _update_event_stage_order( async def _update_event_stage_order(
self, self,
@@ -253,6 +217,32 @@ class PycordEvent:
if stage_index != old_stage_index: if stage_index != old_stage_index:
await (await bot.find_event_stage(event_stage_id)).update(cache, sequence=stage_index) await (await bot.find_event_stage(event_stage_id)).update(cache, sequence=stage_index)
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
cache_entry["created"] = cache_entry["created"].isoformat()
cache_entry["ended"] = None if cache_entry["ended"] is None else cache_entry["ended"].isoformat()
cache_entry["starts"] = cache_entry["starts"].isoformat()
cache_entry["ends"] = cache_entry["ends"].isoformat()
cache_entry["stage_ids"] = [str(stage_id) for stage_id in cache_entry["stage_ids"]]
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
db_entry["created"] = datetime.fromisoformat(db_entry["created"])
db_entry["ended"] = None if db_entry["ended"] is None else datetime.fromisoformat(db_entry["ended"])
db_entry["starts"] = datetime.fromisoformat(db_entry["starts"])
db_entry["ends"] = datetime.fromisoformat(db_entry["ends"])
db_entry["stage_ids"] = [ObjectId(stage_id) for stage_id in db_entry["stage_ids"]]
return db_entry
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert the object to a JSON representation. """Convert the object to a JSON representation.
@@ -266,14 +256,20 @@ class PycordEvent:
"_id": self._id if not json_compatible else str(self._id), "_id": self._id if not json_compatible else str(self._id),
"name": self.name, "name": self.name,
"guild_id": self.guild_id, "guild_id": self.guild_id,
"created": self.created, "created": self.created if not json_compatible else self.created.isoformat(),
"ended": self.ended, "ended": (
self.ended
if not json_compatible
else (None if self.ended is None else self.ended.isoformat())
),
"is_cancelled": self.is_cancelled, "is_cancelled": self.is_cancelled,
"creator_id": self.creator_id, "creator_id": self.creator_id,
"starts": self.starts, "starts": self.starts if not json_compatible else self.starts.isoformat(),
"ends": self.ends, "ends": self.ends if not json_compatible else self.ends.isoformat(),
"thumbnail": self.thumbnail, "thumbnail": self.thumbnail,
"stage_ids": self.stage_ids, "stage_ids": (
self.stage_ids if not json_compatible else [str(stage_id) for stage_id in self.stage_ids]
),
} }
@staticmethod @staticmethod
@@ -317,43 +313,19 @@ class PycordEvent:
async def update( async def update(
self, self,
cache: Optional[Cache] = None, cache: Optional[Cache] = None,
**kwargs, **kwargs: Any,
) -> None: ) -> None:
"""Update attribute(s) on the object and save the updated entry into the database. await super().update(cache=cache, **kwargs)
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( async def reset(
self, self,
*args, *args: str,
cache: Optional[Cache] = None, cache: Optional[Cache] = None,
) -> None: ) -> None:
"""Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database. await super().reset(*args, cache=cache)
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: async def purge(self, cache: Optional[Cache] = None) -> None:
"""Completely remove event data from database. Currently only removes the event record from events collection. await super().purge(cache)
Args:
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
"""
await self.__collection__.delete_one({"_id": self._id})
self._delete_cache(cache)
async def cancel(self, cache: Optional[Cache] = None) -> None: async def cancel(self, cache: Optional[Cache] = None) -> None:
"""Cancel the event. """Cancel the event.
@@ -456,7 +428,7 @@ class PycordEvent:
return self.ends.replace(tzinfo=ZoneInfo("UTC")) return self.ends.replace(tzinfo=ZoneInfo("UTC"))
def get_start_date_localized(self, tz: str | timezone | ZoneInfo) -> datetime: def get_start_date_localized(self, tz: tzinfo) -> datetime:
"""Get the event start date in the provided timezone. """Get the event start date in the provided timezone.
Returns: Returns:
@@ -470,7 +442,7 @@ class PycordEvent:
return self.starts.replace(tzinfo=tz) return self.starts.replace(tzinfo=tz)
def get_end_date_localized(self, tz: str | timezone | ZoneInfo) -> datetime: def get_end_date_localized(self, tz: tzinfo) -> datetime:
"""Get the event end date in the provided timezone. """Get the event end date in the provided timezone.
Returns: Returns:

View File

@@ -10,6 +10,7 @@ from discord import File
from libbot.cache.classes import Cache from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult from pymongo.results import InsertOneResult
from classes.base.base_cacheable import BaseCacheable
from classes.errors import EventStageNotFoundError from classes.errors import EventStageNotFoundError
from modules.database import col_stages from modules.database import col_stages
from modules.utils import get_logger, restore_from_cache from modules.utils import get_logger, restore_from_cache
@@ -18,7 +19,7 @@ logger: Logger = get_logger(__name__)
@dataclass @dataclass
class PycordEventStage: class PycordEventStage(BaseCacheable):
__slots__ = ( __slots__ = (
"_id", "_id",
"event_id", "event_id",
@@ -45,24 +46,25 @@ class PycordEventStage:
@classmethod @classmethod
async def from_id(cls, stage_id: str | ObjectId, cache: Optional[Cache] = None) -> "PycordEventStage": 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: Args:
stage_id (str | ObjectId): Stage's ID stage_id (str | ObjectId): ID of the event stage to look up.
cache (:obj:`Cache`, optional): Cache engine to get the cache from cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache.
Returns: Returns:
PycordEventStage: Event stage object PycordEventStage: Object of the found event stage.
Raises: 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) cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, stage_id, cache=cache)
if cached_entry is not None: 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( db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
{"_id": stage_id if isinstance(stage_id, ObjectId) else ObjectId(stage_id)} {"_id": stage_id if isinstance(stage_id, ObjectId) else ObjectId(stage_id)}
) )
@@ -70,7 +72,7 @@ class PycordEventStage:
raise EventStageNotFoundError(stage_id) raise EventStageNotFoundError(stage_id)
if cache is not None: 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) return cls(**db_entry)
@@ -103,98 +105,77 @@ class PycordEventStage:
db_entry["_id"] = insert_result.inserted_id db_entry["_id"] = insert_result.inserted_id
if cache is not None: 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) return cls(**db_entry)
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
"""Set attribute data and save it into the database. await super()._set(cache, **kwargs)
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)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
"""Remove attribute data and save it into the database. await super()._remove(*args, cache=cache)
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)
def _get_cache_key(self) -> str: def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self._id}" return f"{self.__short_name__}_{self._id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None: def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None: super()._update_cache(cache)
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)
def _delete_cache(self, cache: Optional[Cache] = None) -> None: def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None: super()._delete_cache(cache)
return
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]: 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: 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: Returns:
Dict[str, Any]: JSON representation of PycordEventStage Dict[str, Any]: JSON representation of the object.
""" """
return { return {
"_id": self._id if not json_compatible else str(self._id), "_id": self._id if not json_compatible else str(self._id),
"event_id": self.event_id if not json_compatible else str(self.event_id), "event_id": (
self.event_id
if not json_compatible
else (None if self.event_id is None else str(self.event_id))
),
"guild_id": self.guild_id, "guild_id": self.guild_id,
"sequence": self.sequence, "sequence": self.sequence,
"created": self.created, "created": self.created if not json_compatible else self.created.isoformat(),
"creator_id": self.creator_id, "creator_id": self.creator_id,
"question": self.question, "question": self.question,
"answer": self.answer, "answer": self.answer,
"media": self.media, "media": self.media,
} }
# TODO Add documentation
@staticmethod @staticmethod
def get_defaults() -> Dict[str, Any]: 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 { return {
"event_id": None, "event_id": None,
"guild_id": None, "guild_id": None,
@@ -206,38 +187,40 @@ class PycordEventStage:
"media": [], "media": [],
} }
# TODO Add documentation
@staticmethod @staticmethod
def get_default_value(key: str) -> Any: 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(): if key not in PycordEventStage.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordEventStage") raise KeyError(f"There's no default value for key '{key}' in PycordEventStage")
return PycordEventStage.get_defaults()[key] return PycordEventStage.get_defaults()[key]
# TODO Add documentation
async def update( async def update(
self, self,
cache: Optional[Cache] = None, cache: Optional[Cache] = None,
**kwargs, **kwargs: Any,
): ) -> None:
await self._set(cache=cache, **kwargs) await super().update(cache=cache, **kwargs)
# TODO Add documentation
async def reset( async def reset(
self, self,
*args: str,
cache: Optional[Cache] = None, cache: Optional[Cache] = None,
*args, ) -> None:
): await super().reset(*args, cache=cache)
await self._remove(cache, *args)
async def purge(self, cache: Optional[Cache] = None) -> None: 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. 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)
# TODO Add documentation # TODO Add documentation
def get_media_files(self) -> List[File] | None: def get_media_files(self) -> List[File] | None:

View File

@@ -6,6 +6,7 @@ from bson import ObjectId
from libbot.cache.classes import Cache from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult from pymongo.results import InsertOneResult
from classes.base.base_cacheable import BaseCacheable
from classes.errors import GuildNotFoundError from classes.errors import GuildNotFoundError
from modules.database import col_guilds from modules.database import col_guilds
from modules.utils import get_logger, restore_from_cache from modules.utils import get_logger, restore_from_cache
@@ -14,42 +15,52 @@ logger: Logger = get_logger(__name__)
@dataclass @dataclass
class PycordGuild: class PycordGuild(BaseCacheable):
"""Dataclass of DB entry of a guild""" """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" __short_name__ = "guild"
__collection__ = col_guilds __collection__ = col_guilds
_id: ObjectId _id: ObjectId
id: int id: int
channel_id: int | None general_channel_id: int | None
management_channel_id: int | None
category_id: int | None category_id: int | None
timezone: str timezone: str
prefer_emojis: bool
@classmethod @classmethod
async def from_id( async def from_id(
cls, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None cls, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None
) -> "PycordGuild": ) -> "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: Args:
guild_id (int): User's Discord ID guild_id (int): ID of the guild to look up.
allow_creation (:obj:`bool`, optional): Create new guild record if none found in the database allow_creation (:obj:`bool`, optional): Create a new record if none found in the database.
cache (:obj:`Cache`, optional): Cache engine to get the cache from cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache.
Returns: Returns:
PycordGuild: User object PycordGuild: Object of the found or newly created guild.
Raises: 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) cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, guild_id, cache=cache)
if cached_entry is not None: 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}) db_entry: Dict[str, Any] | None = await cls.__collection__.find_one({"id": guild_id})
if db_entry is None: if db_entry is None:
if not allow_creation: if not allow_creation:
@@ -62,165 +73,122 @@ class PycordGuild:
db_entry["_id"] = insert_result.inserted_id db_entry["_id"] = insert_result.inserted_id
if cache is not None: 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) return cls(**db_entry)
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
"""Set attribute data and save it into the database. await super()._set(cache, **kwargs)
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)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
"""Remove attribute data and save it into the database. await super()._remove(*args, cache=cache)
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)
def _get_cache_key(self) -> str: def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.id}" return f"{self.__short_name__}_{self.id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None: def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None: super()._update_cache(cache)
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)
def _delete_cache(self, cache: Optional[Cache] = None) -> None: def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None: super()._delete_cache(cache)
return
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]: 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: 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: Returns:
Dict[str, Any]: JSON representation of PycordGuild Dict[str, Any]: JSON representation of the object.
""" """
return { return {
"_id": self._id if not json_compatible else str(self._id), "_id": self._id if not json_compatible else str(self._id),
"id": 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, "category_id": self.category_id,
"timezone": self.timezone, "timezone": self.timezone,
"prefer_emojis": self.prefer_emojis,
} }
# TODO Add documentation
@staticmethod @staticmethod
def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]: 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 { return {
"id": guild_id, "id": guild_id,
"channel_id": None, "general_channel_id": None,
"management_channel_id": None,
"category_id": None, "category_id": None,
"timezone": "UTC", "timezone": "UTC",
"prefer_emojis": False,
} }
@staticmethod @staticmethod
def get_default_value(key: str) -> Any: 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(): if key not in PycordGuild.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordGuild") raise KeyError(f"There's no default value for key '{key}' in PycordGuild")
return PycordGuild.get_defaults()[key] return PycordGuild.get_defaults()[key]
# TODO Add documentation
async def update( async def update(
self, self,
cache: Optional[Cache] = None, cache: Optional[Cache] = None,
**kwargs, **kwargs: Any,
): ) -> None:
await self._set(cache=cache, **kwargs) await super().update(cache=cache, **kwargs)
# TODO Add documentation
async def reset( async def reset(
self, self,
*args: str,
cache: Optional[Cache] = None, cache: Optional[Cache] = None,
*args, ) -> None:
): await super().reset(*args, cache=cache)
await self._remove(cache, *args)
async def purge(self, cache: Optional[Cache] = None) -> None: 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: 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 ( return (
(self.id is not None) (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.category_id is not None)
and (self.timezone 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")

View File

@@ -17,6 +17,7 @@ from discord.abc import GuildChannel
from libbot.cache.classes import Cache from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult from pymongo.results import InsertOneResult
from classes.base.base_cacheable import BaseCacheable
from classes.errors import ( from classes.errors import (
DiscordCategoryNotFoundError, DiscordCategoryNotFoundError,
DiscordChannelNotFoundError, DiscordChannelNotFoundError,
@@ -33,7 +34,7 @@ logger: Logger = get_logger(__name__)
@dataclass @dataclass
class PycordUser: class PycordUser(BaseCacheable):
"""Dataclass of DB entry of a user""" """Dataclass of DB entry of a user"""
__slots__ = ( __slots__ = (
@@ -60,11 +61,6 @@ class PycordUser:
registered_event_ids: List[ObjectId] registered_event_ids: List[ObjectId]
completed_event_ids: List[ObjectId] completed_event_ids: List[ObjectId]
# TODO Review the redesign
# event_channel_ids: {
# "%event_id%": %channel_id%
# }
@classmethod @classmethod
async def from_id( async def from_id(
cls, user_id: int, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None cls, user_id: int, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None
@@ -88,9 +84,11 @@ class PycordUser:
) )
if cached_entry is not None: 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}) db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
{"id": user_id, "guild_id": guild_id}
)
if db_entry is None: if db_entry is None:
if not allow_creation: if not allow_creation:
@@ -103,7 +101,7 @@ class PycordUser:
db_entry["_id"] = insert_result.inserted_id db_entry["_id"] = insert_result.inserted_id
if cache is not None: 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) return cls(**db_entry)
@@ -123,10 +121,14 @@ class PycordUser:
"event_channels": self.event_channels, "event_channels": self.event_channels,
"is_jailed": self.is_jailed, "is_jailed": self.is_jailed,
"current_event_id": ( "current_event_id": (
self.current_event_id if not json_compatible else str(self.current_event_id) self.current_event_id
if not json_compatible
else (None if self.current_event_id is None else str(self.current_event_id))
), ),
"current_stage_id": ( "current_stage_id": (
self.current_stage_id if not json_compatible else str(self.current_stage_id) self.current_stage_id
if not json_compatible
else (None if self.current_stage_id is None else str(self.current_stage_id))
), ),
"registered_event_ids": ( "registered_event_ids": (
self.registered_event_ids self.registered_event_ids
@@ -141,68 +143,59 @@ class PycordUser:
} }
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None: async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
"""Set attribute data and save it into the database. await super()._set(cache, **kwargs)
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)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None: async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
"""Remove attribute data and save it into the database. await super()._remove(*args, cache=cache)
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)
def _get_cache_key(self) -> str: 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: def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None: super()._update_cache(cache)
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)
def _delete_cache(self, cache: Optional[Cache] = None) -> None: def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None: super()._delete_cache(cache)
return
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 # TODO Add documentation
@staticmethod @staticmethod
@@ -226,14 +219,22 @@ class PycordUser:
return PycordUser.get_defaults()[key] return PycordUser.get_defaults()[key]
async def purge(self, cache: Optional[Cache] = None) -> None: async def update(
"""Completely remove user data from database. Currently only removes the user record from users collection. self,
cache: Optional[Cache] = None,
**kwargs: Any,
) -> None:
await super().update(cache=cache, **kwargs)
Args: async def reset(
cache (:obj:`Cache`, optional): Cache engine to write the update into self,
""" *args: str,
await self.__collection__.delete_one({"_id": self._id}) cache: Optional[Cache] = None,
self._delete_cache(cache) ) -> None:
await super().reset(*args, cache=cache)
async def purge(self, cache: Optional[Cache] = None) -> None:
await super().purge(cache)
# TODO Add documentation # TODO Add documentation
async def event_register(self, event_id: str | ObjectId, cache: Optional[Cache] = None) -> None: async def event_register(self, event_id: str | ObjectId, cache: Optional[Cache] = None) -> None:
@@ -288,7 +289,7 @@ class PycordUser:
raise DiscordGuildMemberNotFoundError(self.id, guild.id) raise DiscordGuildMemberNotFoundError(self.id, guild.id)
if discord_category is None: if discord_category is None:
raise DiscordCategoryNotFoundError(pycord_guild.channel_id, guild.id) raise DiscordCategoryNotFoundError(pycord_guild.category_id, guild.id)
permission_overwrites: Dict[Role | Member, PermissionOverwrite] = { permission_overwrites: Dict[Role | Member, PermissionOverwrite] = {
guild.default_role: PermissionOverwrite( guild.default_role: PermissionOverwrite(

View File

@@ -43,7 +43,22 @@ class CogConfig(Cog):
), ),
required=True, required=True,
) )
@option("channel", description="Text channel for admin notifications", 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( @option(
"timezone", "timezone",
description=_("description", "commands", "config_set", "options", "timezone"), description=_("description", "commands", "config_set", "options", "timezone"),
@@ -53,32 +68,45 @@ class CogConfig(Cog):
autocomplete=basic_autocomplete(autocomplete_timezones), autocomplete=basic_autocomplete(autocomplete_timezones),
required=True, 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( async def command_config_set(
self, self,
ctx: ApplicationContext, ctx: ApplicationContext,
category: CategoryChannel, category: CategoryChannel,
channel: TextChannel, general_channel: TextChannel,
management_channel: TextChannel,
timezone: str, timezone: str,
prefer_emojis: bool,
) -> None: ) -> None:
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
try: try:
timezone_parsed: ZoneInfo = ZoneInfo(timezone) timezone_parsed: ZoneInfo = ZoneInfo(timezone)
except ZoneInfoNotFoundError: except ZoneInfoNotFoundError:
await ctx.respond( await ctx.respond(
self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone) self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone),
ephemeral=True,
) )
return return
await guild.update( await guild.update(
self.bot.cache, self.bot.cache,
channel_id=channel.id, general_channel_id=general_channel.id,
management_channel_id=management_channel.id,
category_id=category.id, category_id=category.id,
timezone=str(timezone_parsed), timezone=str(timezone_parsed),
prefer_emojis=prefer_emojis,
) )
await ctx.respond(self.bot._("config_set", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("config_set", "messages", locale=ctx.locale))
@@ -103,7 +131,7 @@ class CogConfig(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
await guild.purge(self.bot.cache) await guild.purge(self.bot.cache)
@@ -119,18 +147,22 @@ class CogConfig(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
if not guild.is_configured(): 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 return
await ctx.respond( await ctx.respond(
self.bot._("config_show", "messages", locale=ctx.locale).format( 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, category_id=guild.category_id,
timezone=guild.timezone, timezone=guild.timezone,
prefer_emojis=guild.prefer_emojis,
) )
) )

View File

@@ -24,7 +24,6 @@ from modules.utils import (
) )
# noinspection Mypy
class CogEvent(Cog): class CogEvent(Cog):
"""Cog with event management commands.""" """Cog with event management commands."""
@@ -85,11 +84,13 @@ class CogEvent(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
if not guild.is_configured(): 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 return
guild_timezone: ZoneInfo = ZoneInfo(guild.timezone) guild_timezone: ZoneInfo = ZoneInfo(guild.timezone)
@@ -98,9 +99,8 @@ class CogEvent(Cog):
start_date: datetime = datetime.strptime(start, "%d.%m.%Y %H:%M").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) end_date: datetime = datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone)
except ValueError: except ValueError:
# TODO Introduce i18n
await ctx.respond( 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 return
@@ -120,9 +120,10 @@ class CogEvent(Cog):
thumbnail=processed_media[0] if thumbnail else None, thumbnail=processed_media[0] if thumbnail else None,
) )
# TODO Introduce i18n
await ctx.respond( await ctx.respond(
f"Event **{event.name}** has been created and will take place <t:{get_unix_timestamp(event.starts, to_utc=True)}:R>." self.bot._("event_created", "messages", locale=ctx.locale).format(
event_name=event.name, start_time=get_unix_timestamp(event.starts, to_utc=True)
)
) )
@command_group.command( @command_group.command(
@@ -183,7 +184,7 @@ class CogEvent(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
try: try:
@@ -193,7 +194,9 @@ class CogEvent(Cog):
return return
if not guild.is_configured(): 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 return
guild_timezone: ZoneInfo = ZoneInfo(guild.timezone) guild_timezone: ZoneInfo = ZoneInfo(guild.timezone)
@@ -205,9 +208,8 @@ class CogEvent(Cog):
else datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) else datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone)
) )
except ValueError: except ValueError:
# TODO Make a nice message
await ctx.respond( await ctx.respond(
"Could not parse the start date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format." self.bot._("event_start_date_parsing_failed", "messages", locale=ctx.locale), ephemeral=True
) )
return return
@@ -218,9 +220,8 @@ class CogEvent(Cog):
else datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone) else datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone)
) )
except ValueError: except ValueError:
# TODO Make a nice message
await ctx.respond( await ctx.respond(
"Could not parse the end date. Please, make sure it is provided in `DD.MM.YYYY HH:MM` format." self.bot._("event_end_date_parsing_failed", "messages", locale=ctx.locale), ephemeral=True
) )
return return
@@ -248,9 +249,11 @@ class CogEvent(Cog):
# TODO Notify participants about time changes # TODO Notify participants about time changes
# TODO Make a nice message
await ctx.respond( await ctx.respond(
f"Event **{pycord_event.name}** has been updated and will take place <t:{get_unix_timestamp(pycord_event.starts, to_utc=True)}:R>." self.bot._("event_updated", "messages", locale=ctx.locale).format(
event_name=pycord_event.name,
start_time=get_unix_timestamp(pycord_event.starts, to_utc=True),
)
) )
@command_group.command( @command_group.command(
@@ -287,7 +290,7 @@ class CogEvent(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
try: try:
@@ -297,7 +300,9 @@ class CogEvent(Cog):
return return
if not guild.is_configured(): 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 return
start_date: datetime = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) start_date: datetime = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC"))
@@ -309,16 +314,23 @@ class CogEvent(Cog):
or end_date <= datetime.now(tz=ZoneInfo("UTC")) or end_date <= datetime.now(tz=ZoneInfo("UTC"))
or start_date <= datetime.now(tz=ZoneInfo("UTC")) or start_date <= datetime.now(tz=ZoneInfo("UTC"))
): ):
# TODO Make a nice message await ctx.respond(
await ctx.respond("Finished or ongoing events cannot be cancelled.") self.bot._("event_not_editable", "messages", locale=ctx.locale).format(
event_name=pycord_event.name
),
ephemeral=True,
)
return return
await pycord_event.cancel() await pycord_event.cancel()
# TODO Notify participants about cancellation # TODO Notify participants about cancellation
# TODO Make a nice message await ctx.respond(
await ctx.respond(f"Event **{pycord_event.name}** was cancelled.") self.bot._("event_cancelled", "messages", locale=ctx.locale).format(
event_name=pycord_event.name
)
)
@command_group.command( @command_group.command(
name="show", name="show",
@@ -346,16 +358,20 @@ class CogEvent(Cog):
stages: List[PycordEventStage] = await self.bot.get_event_stages(pycord_event) stages: List[PycordEventStage] = await self.bot.get_event_stages(pycord_event)
# TODO Make a nice message
stages_string: str = "\n\n".join( stages_string: str = "\n\n".join(
f"**Stage {stage.sequence+1}**\nAnswer: ||{stage.answer}||" for stage in stages self.bot._("stage_entry", "messages", locale=ctx.locale).format(
sequence=stage.sequence + 1, answer=stage.answer
)
for stage in stages
) )
# TODO Show users registered for the event # TODO Show users registered for the event
# TODO Introduce i18n event_info_string: str = self.bot._("event_details", "messages", locale=ctx.locale).format(
event_info_string: str = ( event_name=pycord_event.name,
f"**Event details**\n\nName: {pycord_event.name}\nStarts: <t:{get_unix_timestamp(starts_date)}>\nEnds: <t:{get_unix_timestamp(ends_date)}>\n\nStages:\n{stages_string}" start_time=get_unix_timestamp(starts_date),
end_time=get_unix_timestamp(ends_date),
stages=stages_string,
) )
chunk_size: int = 2000 chunk_size: int = 2000

View File

@@ -34,11 +34,13 @@ class CogGuess(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
if not guild.is_configured(): 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 return
user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild)
@@ -65,9 +67,11 @@ class CogGuess(Cog):
return return
if answer.lower() != stage.answer.lower(): if answer.lower() != stage.answer.lower():
# TODO Make a nice message await ctx.respond(
# await ctx.respond("Provided answer is wrong.") 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 return
next_stage_index = stage.sequence + 1 next_stage_index = stage.sequence + 1

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from bson.errors import InvalidId from bson.errors import InvalidId
from discord import ApplicationContext, Cog, TextChannel, option, slash_command, File from discord import ApplicationContext, Cog, File, TextChannel, option, slash_command
from discord.utils import basic_autocomplete from discord.utils import basic_autocomplete
from libbot.i18n import _, in_every_locale from libbot.i18n import _, in_every_locale
@@ -39,7 +39,7 @@ class CogRegister(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
try: try:
@@ -49,17 +49,21 @@ class CogRegister(Cog):
return return
if not guild.is_configured(): 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 return
user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild)
if user.is_jailed: if user.is_jailed:
await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale), ephemeral=True)
return return
if pycord_event._id in user.registered_event_ids: if pycord_event._id in user.registered_event_ids:
await ctx.respond(self.bot._("register_already_registered", "messages", locale=ctx.locale)) await ctx.respond(
self.bot._("register_already_registered", "messages", locale=ctx.locale), ephemeral=True
)
return return
await user.event_register(pycord_event._id, cache=self.bot.cache) await user.event_register(pycord_event._id, cache=self.bot.cache)

View File

@@ -84,11 +84,13 @@ class CogStage(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
if not guild.is_configured(): 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 return
try: try:
@@ -115,8 +117,7 @@ class CogStage(Cog):
media=[] if media is None else processed_media, media=[] if media is None else processed_media,
) )
# TODO Make a nice message await ctx.respond(self.bot._("stage_created", "messages", locale=ctx.locale))
await ctx.respond("Event stage has been created.")
@command_group.command( @command_group.command(
name="edit", name="edit",
@@ -197,11 +198,13 @@ class CogStage(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
if not guild.is_configured(): 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 return
try: try:
@@ -216,13 +219,13 @@ class CogStage(Cog):
try: try:
event_stage: PycordEventStage = await self.bot.find_event_stage(stage) event_stage: PycordEventStage = await self.bot.find_event_stage(stage)
except (InvalidId, EventStageNotFoundError): except (InvalidId, EventStageNotFoundError):
# TODO Make a nice message await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale), ephemeral=True)
await ctx.respond("Event stage was not found.")
return return
if order is not None and order > len(pycord_event.stage_ids): if order is not None and order > len(pycord_event.stage_ids):
# TODO Make a nice message await ctx.respond(
await ctx.respond("Stage sequence out of range.") self.bot._("stage_sequence_out_of_range", "messages", locale=ctx.locale), ephemeral=True
)
return return
processed_media: List[Dict[str, Any]] = ( processed_media: List[Dict[str, Any]] = (
@@ -239,7 +242,7 @@ class CogStage(Cog):
if order is not None and order - 1 != event_stage.sequence: 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 pycord_event.reorder_stage(self.bot, event_stage._id, order - 1, cache=self.bot.cache)
await ctx.respond("Event stage has been updated.") await ctx.respond(self.bot._("stage_updated", "messages", locale=ctx.locale))
@command_group.command( @command_group.command(
name="delete", name="delete",
@@ -281,11 +284,13 @@ class CogStage(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
if not guild.is_configured(): 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 return
try: try:
@@ -300,15 +305,13 @@ class CogStage(Cog):
try: try:
event_stage: PycordEventStage = await self.bot.find_event_stage(stage) event_stage: PycordEventStage = await self.bot.find_event_stage(stage)
except (InvalidId, EventStageNotFoundError): except (InvalidId, EventStageNotFoundError):
# TODO Make a nice message await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale), ephemeral=True)
await ctx.respond("Event stage was not found.")
return return
await pycord_event.remove_stage(self.bot, event_stage._id, cache=self.bot.cache) await pycord_event.remove_stage(self.bot, event_stage._id, cache=self.bot.cache)
await event_stage.purge(cache=self.bot.cache) await event_stage.purge(cache=self.bot.cache)
# TODO Make a nice message await ctx.respond(self.bot._("stage_deleted", "messages", locale=ctx.locale))
await ctx.respond("Event stage has been deleted.")
def setup(bot: PycordBot) -> None: def setup(bot: PycordBot) -> None:

View File

@@ -43,7 +43,7 @@ class CogUnregister(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
try: try:
@@ -53,17 +53,21 @@ class CogUnregister(Cog):
return return
if not guild.is_configured(): 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 return
user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild) user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild)
if user.is_jailed: if user.is_jailed:
await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale), ephemeral=True)
return return
if pycord_event._id not in user.registered_event_ids: if pycord_event._id not in user.registered_event_ids:
await ctx.respond(self.bot._("unregister_not_registered", "messages", locale=ctx.locale)) await ctx.respond(
self.bot._("unregister_not_registered", "messages", locale=ctx.locale), ephemeral=True
)
return return
await user.event_unregister(pycord_event._id, cache=self.bot.cache) await user.event_unregister(pycord_event._id, cache=self.bot.cache)

View File

@@ -1,27 +1,27 @@
from datetime import datetime from datetime import datetime
from logging import Logger from logging import Logger
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any from typing import Any, Dict, List
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from bson import ObjectId from bson import ObjectId
from bson.errors import InvalidId from bson.errors import InvalidId
from discord import ( from discord import (
ApplicationContext, ApplicationContext,
File,
SlashCommandGroup, SlashCommandGroup,
TextChannel,
User, User,
option, option,
File,
TextChannel,
) )
from discord.ext.commands import Cog from discord.ext.commands import Cog
from libbot.i18n import _, in_every_locale from libbot.i18n import _, in_every_locale
from classes import PycordUser, PycordEvent, PycordGuild from classes import PycordEvent, PycordGuild, PycordUser
from classes.errors import GuildNotFoundError from classes.errors import GuildNotFoundError
from classes.pycord_bot import PycordBot from classes.pycord_bot import PycordBot
from modules.database import col_users from modules.database import col_users
from modules.utils import is_operation_confirmed, get_logger from modules.utils import get_logger, get_utc_now, is_operation_confirmed
logger: Logger = get_logger(__name__) logger: Logger = get_logger(__name__)
@@ -54,35 +54,45 @@ class CogUser(Cog):
try: try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError): except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale)) await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return return
pycord_user: PycordUser = await self.bot.find_user(user.id, ctx.guild.id) pycord_user: PycordUser = await self.bot.find_user(user.id, ctx.guild.id)
events: List[PycordEvent] = [] events: List[PycordEvent] = []
utc_now: datetime = get_utc_now()
pipeline: List[Dict[str, Any]] = [ pipeline: List[Dict[str, Any]] = [
{"$match": {"id": pycord_user.id}}, {"$match": {"id": pycord_user.id}},
{ {
"$lookup": { "$lookup": {
"from": "events", "from": "events",
"localField": "registered_event_ids", "let": {"event_ids": "$registered_event_ids"},
"foreignField": "_id", "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", "as": "registered_events",
} }
}, },
{ {"$match": {"registered_events.0": {"$exists": True}}},
"$match": {
"registered_events.ended": None,
"registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))},
"registered_events.starts": {"$lt": datetime.now(tz=ZoneInfo("UTC"))},
"registered_events.is_cancelled": False,
}
},
] ]
async for result in col_users.aggregate(pipeline): async with await col_users.aggregate(pipeline) as cursor:
for registered_event in result["registered_events"]: async for result in cursor:
events.append(PycordEvent(**registered_event)) for registered_event in result["registered_events"]:
events.append(PycordEvent(**registered_event))
for event in events: for event in events:
if pycord_user.current_event_id is not None and pycord_user.current_event_id != event._id: if pycord_user.current_event_id is not None and pycord_user.current_event_id != event._id:

View File

@@ -6,13 +6,13 @@ from zoneinfo import ZoneInfo
from bson import ObjectId from bson import ObjectId
from bson.errors import InvalidId from bson.errors import InvalidId
from discord import Activity, ActivityType, Cog, Member, TextChannel, File from discord import Activity, ActivityType, Cog, File, Member, TextChannel
from classes import PycordEvent, PycordGuild, PycordUser from classes import PycordEvent, PycordGuild, PycordUser
from classes.errors import GuildNotFoundError from classes.errors import GuildNotFoundError
from classes.pycord_bot import PycordBot from classes.pycord_bot import PycordBot
from modules.database import col_users from modules.database import col_users
from modules.utils import get_logger from modules.utils import get_logger, get_utc_now
logger: Logger = get_logger(__name__) logger: Logger = get_logger(__name__)
@@ -78,29 +78,39 @@ class CogUtility(Cog):
user: PycordUser = await self.bot.find_user(member.id, member.guild.id) user: PycordUser = await self.bot.find_user(member.id, member.guild.id)
events: List[PycordEvent] = [] events: List[PycordEvent] = []
utc_now: datetime = get_utc_now()
pipeline: List[Dict[str, Any]] = [ pipeline: List[Dict[str, Any]] = [
{"$match": {"id": user.id}}, {"$match": {"id": user.id}},
{ {
"$lookup": { "$lookup": {
"from": "events", "from": "events",
"localField": "registered_event_ids", "let": {"event_ids": "$registered_event_ids"},
"foreignField": "_id", "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", "as": "registered_events",
} }
}, },
{ {"$match": {"registered_events.0": {"$exists": True}}},
"$match": {
"registered_events.ended": None,
"registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))},
"registered_events.starts": {"$lt": datetime.now(tz=ZoneInfo("UTC"))},
"registered_events.is_cancelled": False,
}
},
] ]
async for result in col_users.aggregate(pipeline): async with await col_users.aggregate(pipeline) as cursor:
for registered_event in result["registered_events"]: async for result in cursor:
events.append(PycordEvent(**registered_event)) for registered_event in result["registered_events"]:
events.append(PycordEvent(**registered_event))
for event in events: for event in events:
if user.current_event_id is not None and user.current_event_id != event._id: if user.current_event_id is not None and user.current_event_id != event._id:

View File

@@ -1,14 +1,38 @@
{ {
"messages": { "messages": {
"admin_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.",
"admin_channel_creation_failed_no_user": "Event channel could not be created for user with ID `{user_id}` (<@{user_id}>) and event **{event_name}**: user was not found on the server.",
"admin_event_ended": "Event **{event_name}** has ended! Users can no longer submit their answers.",
"admin_event_no_stages_defined": "Could not start the event **{event_name}**: no event stages are defined.",
"admin_event_started": "Event **{event_name}** has started! Users have gotten their channels and can already start submitting their answers.",
"admin_user_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.", "admin_user_channel_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_event": "User **{display_name}** ({mention}) has completed the event **{event_name}**",
"admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.", "admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.",
"admin_user_channel_fixed": "Fixed event channel of user **{display_name}** ({mention}) for the event **{event_name}**.",
"config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.", "config_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_set": "Configuration has been updated. You can review it anytime using `/config show`.",
"config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: `{timezone}`", "config_show": "**Guild config**\n\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 <t:{start_time}:R>.",
"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: <t:{start_time}>\nEnds: <t:{end_time}>\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_not_found": "Event was not found.",
"guess_completed_event": "Congratulations! You have completed the event!", "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 <t:{start_time}:R>.",
"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_channel": "Usage outside own event channel is not allowed.",
"guess_incorrect_event": "Your event could not be found. Please, contact the administrator.", "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.", "guess_unregistered": "You have no ongoing events. You can register for events using the `/register` command.",
@@ -20,17 +44,25 @@
"register_already_registered": "You are already registered for this event.", "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_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 <t:{event_starts}:R>. 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 <t:{event_starts}:R>. 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 <t:{start_time}>", "status": "**QuizBot** v{version}\n\nUptime: since <t:{start_time}>",
"status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: up since <t:{start_time}>", "status_git": "**QuizBot** v{version} (`{commit}`)\n\nUptime: up since <t:{start_time}>",
"timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.", "timezone_invalid": "Timezone **{timezone}** was not found. Please, select one of the timezones provided by the autocompletion.",
"unexpected_error": "An unexpected error has occurred. Please, contact the administrator.", "unexpected_error": "An unexpected error has occurred. Please, contact the administrator.",
"unregister_not_registered": "You are not registered for this event.", "unregister_not_registered": "You are not registered for this event.",
"unregister_unregistered": "You are no longer registered for this event.", "unregister_unregistered": "You are no longer registered for this event.",
"user_channels_updated": "Event channels of the user **{display_name}** were updated.",
"user_jail_already_jailed": "User **{display_name}** is already jailed.", "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_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_not_jailed": "User **{display_name}** is not jailed.",
"user_unjail_successful": "User **{display_name}** has been unjailed and can interact with events again.", "user_unjail_successful": "User **{display_name}** has been unjailed and can interact with events again."
"user_channels_updated": "Event channels of the user **{display_name}** were updated."
}, },
"commands": { "commands": {
"config": { "config": {
@@ -42,11 +74,17 @@
"category": { "category": {
"description": "Category where channels for each user will be created" "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" "description": "Text channel for admin notifications"
}, },
"timezone": { "timezone": {
"description": "Timezone in which events take place" "description": "Timezone in which events take place"
},
"prefer_emojis": {
"description": "Prefer emojis over text messages where available"
} }
} }
}, },

View File

@@ -45,6 +45,7 @@ def main() -> None:
# Perform migration if command line argument was provided # Perform migration if command line argument was provided
if args.migrate: if args.migrate:
migrate_database() migrate_database()
exit()
# if args.downgrade: # if args.downgrade:
# if not args.confirm: # if not args.confirm:

View File

@@ -0,0 +1,59 @@
from typing import Dict, Any
from mongodb_migrations.base import BaseMigration
class Migration(BaseMigration):
def upgrade(self):
index_information_users: Dict[str, Any] = self.db.users.index_information()
index_information_events: Dict[str, Any] = self.db.events.index_information()
index_information_stages: Dict[str, Any] = self.db.stages.index_information()
# Update users collection
if "user_id" in index_information_users:
self.db.users.drop_index("user_id")
if "user_id-guild_id" not in index_information_users:
self.db.users.create_index(["id", "guild_id"], name="user_id-guild_id", unique=True)
# Update events collection
if "guild_id" in index_information_events:
self.db.events.drop_index("guild_id")
if "event_name-guild_id" not in index_information_events:
self.db.events.create_index(["name", "guild_id"], name="event_name-guild_id", unique=False)
# Update stages collection
if "event_id-and-guild_id" in index_information_stages:
self.db.stages.drop_index("event_id-and-guild_id")
if "event_id-guild_id" not in index_information_stages:
self.db.stages.create_index(["event_id", "guild_id"], name="event_id-guild_id", unique=False)
def downgrade(self):
index_information_users: Dict[str, Any] = self.db.users.index_information()
index_information_events: Dict[str, Any] = self.db.events.index_information()
index_information_stages: Dict[str, Any] = self.db.stages.index_information()
# Update users collection
if "user_id-guild_id" in index_information_users:
self.db.users.drop_index("user_id-guild_id")
if "user_id" not in index_information_users:
self.db.users.create_index("id", name="user_id", unique=True)
# Update events collection
if "event_name-guild_id" in index_information_events:
self.db.events.drop_index("event_name-guild_id")
if "guild_id" not in index_information_events:
self.db.events.create_index("guild_id", name="guild_id", unique=False)
# Update stages collection
if "event_id-guild_id" in index_information_stages:
self.db.stages.drop_index("event_id-guild_id")
if "event_id-and-guild_id" not in index_information_stages:
self.db.stages.create_index(
["event_id", "guild_id"], name="event_id-and-guild_id", unique=False
)

View File

@@ -2,8 +2,10 @@
from typing import Any, Mapping from typing import Any, Mapping
from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase
from libbot.utils import config_get 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") 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"]) con_string = "mongodb://{0}:{1}/{2}".format(db_config["host"], db_config["port"], db_config["name"])
# Async declarations # Async declarations
db_client = AsyncClient(con_string) db_client = AsyncMongoClient(con_string)
db: AsyncDatabase = db_client.get_database(name=db_config["name"]) db: AsyncDatabase = db_client.get_database(name=db_config["name"])
col_users: AsyncCollection = db.get_collection("users") 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_events: AsyncCollection = db.get_collection("events")
col_stages: AsyncCollection = db.get_collection("stages") col_stages: AsyncCollection = db.get_collection("stages")
# Update indexes # Update indexes
db.dispatch.get_collection("users").create_index("id", name="user_id", unique=True) async def _update_database_indexes() -> None:
db.dispatch.get_collection("guilds").create_index("id", name="guild_id", unique=True) await col_users.create_index(["id", "guild_id"], name="user_id-guild_id", unique=True)
db.dispatch.get_collection("events").create_index("guild_id", name="guild_id", unique=False) await col_guilds.create_index("id", name="guild_id", unique=True)
db.dispatch.get_collection("stages").create_index( await col_events.create_index(["name", "guild_id"], name="event_name-guild_id", unique=False)
["event_id", "guild_id"], name="event_id-and-guild_id", unique=False await col_stages.create_index(["event_id", "guild_id"], name="event_id-guild_id", unique=False)
)

View File

@@ -7,7 +7,7 @@ from .autocomplete_utils import (
autocomplete_user_registered_events, autocomplete_user_registered_events,
) )
from .cache_utils import restore_from_cache 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 .event_utils import validate_event_validity
from .git_utils import get_current_commit from .git_utils import get_current_commit
from .logging_utils import get_logger, get_logging_config from .logging_utils import get_logger, get_logging_config

View File

@@ -49,31 +49,41 @@ async def autocomplete_user_available_events(ctx: AutocompleteContext) -> List[O
async def autocomplete_user_registered_events(ctx: AutocompleteContext) -> List[OptionChoice]: async def autocomplete_user_registered_events(ctx: AutocompleteContext) -> List[OptionChoice]:
"""Return list of active events user is registered in""" """Return list of active events user is registered in"""
utc_now: datetime = datetime.now(tz=ZoneInfo("UTC"))
pipeline: List[Dict[str, Any]] = [ pipeline: List[Dict[str, Any]] = [
{"$match": {"id": ctx.interaction.user.id}}, {"$match": {"id": ctx.interaction.user.id}},
{ {
"$lookup": { "$lookup": {
"from": "events", "from": "events",
"localField": "registered_event_ids", "let": {"event_ids": "$registered_event_ids"},
"foreignField": "_id", "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", "as": "registered_events",
} }
}, },
{ {"$match": {"registered_events.0": {"$exists": True}}},
"$match": {
"registered_events.ended": None,
"registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))},
"registered_events.starts": {"$gt": datetime.now(tz=ZoneInfo("UTC"))},
"registered_events.is_cancelled": False,
}
},
] ]
event_names: List[OptionChoice] = [] event_names: List[OptionChoice] = []
async for result in col_users.aggregate(pipeline): async with await col_users.aggregate(pipeline) as cursor:
for registered_event in result["registered_events"]: async for result in cursor:
event_names.append(OptionChoice(registered_event["name"], str(registered_event["_id"]))) for registered_event in result["registered_events"]:
event_names.append(OptionChoice(registered_event["name"], str(registered_event["_id"])))
return event_names return event_names

View File

@@ -5,3 +5,8 @@ from zoneinfo import ZoneInfo
# TODO Add documentation # TODO Add documentation
def get_unix_timestamp(date: datetime, to_utc: bool = False) -> int: 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()) 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"))

View File

@@ -6,6 +6,7 @@ from bson import ObjectId
from discord import ( from discord import (
ApplicationContext, ApplicationContext,
) )
from libbot.i18n import _
from modules.database import col_events from modules.database import col_events
@@ -22,18 +23,15 @@ async def validate_event_validity(
end_date_internal: datetime = end_date.astimezone(ZoneInfo("UTC")) if to_utc else end_date end_date_internal: datetime = end_date.astimezone(ZoneInfo("UTC")) if to_utc else end_date
if start_date_internal < datetime.now(tz=ZoneInfo("UTC")): if start_date_internal < datetime.now(tz=ZoneInfo("UTC")):
# TODO Make a nice message await ctx.respond(_("event_start_past", "messages", locale=ctx.locale), ephemeral=True)
await ctx.respond("Start date must not be in the past")
return False return False
if end_date_internal < datetime.now(tz=ZoneInfo("UTC")): if end_date_internal < datetime.now(tz=ZoneInfo("UTC")):
# TODO Make a nice message await ctx.respond(_("event_end_past", "messages", locale=ctx.locale), ephemeral=True)
await ctx.respond("End date must not be in the past")
return False return False
if start_date_internal >= end_date_internal: if start_date_internal >= end_date_internal:
# TODO Make a nice message await ctx.respond(_("event_end_before_start", "messages", locale=ctx.locale), ephemeral=True)
await ctx.respond("Start date must be before end date")
return False return False
# TODO Add validation for concurrent events. # TODO Add validation for concurrent events.
@@ -49,8 +47,7 @@ async def validate_event_validity(
query["_id"] = {"$ne": event_id} query["_id"] = {"$ne": event_id}
if (await col_events.find_one(query)) is not None: if (await col_events.find_one(query)) is not None:
# TODO Make a nice message await ctx.respond(_("event_name_duplicate", "messages", locale=ctx.locale), ephemeral=True)
await ctx.respond("There can only be one active event with the same name")
return False return False
return True return True

View File

@@ -2,11 +2,12 @@ from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from discord import ApplicationContext from discord import ApplicationContext
from libbot.i18n import _
async def is_operation_confirmed(ctx: ApplicationContext, confirm: bool) -> bool: async def is_operation_confirmed(ctx: ApplicationContext, confirm: bool) -> bool:
if confirm is None or not confirm: 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 False
return True return True
@@ -17,8 +18,7 @@ async def is_event_status_valid(
event: "PycordEvent", event: "PycordEvent",
) -> bool: ) -> bool:
if event.is_cancelled: if event.is_cancelled:
# TODO Make a nice message await ctx.respond(_("event_is_cancelled", "messages", locale=ctx.locale), ephemeral=True)
await ctx.respond("This event was cancelled.")
return False return False
if ( if (
@@ -26,8 +26,7 @@ async def is_event_status_valid(
<= datetime.now(tz=ZoneInfo("UTC")) <= datetime.now(tz=ZoneInfo("UTC"))
<= event.ends.replace(tzinfo=ZoneInfo("UTC")) <= event.ends.replace(tzinfo=ZoneInfo("UTC"))
): ):
# TODO Make a nice message await ctx.respond(_("event_ongoing_not_editable", "messages", locale=ctx.locale), ephemeral=True)
await ctx.respond("Ongoing events cannot be modified.")
return False return False
return True return True

View File

@@ -1,10 +1,10 @@
aiodns~=3.2.0 aiodns~=3.2.0
apscheduler~=3.11.0 apscheduler~=3.11.0
async_pymongo==0.1.11
brotlipy~=0.7.0 brotlipy~=0.7.0
faust-cchardet~=2.1.19 faust-cchardet~=2.1.19
libbot[speed,pycord,cache]==4.1.0 libbot[speed,pycord,cache]==4.1.0
mongodb-migrations==1.3.1 mongodb-migrations==1.3.1
msgspec~=0.19.0 msgspec~=0.19.0
pymongo~=4.12.1,>=4.9
pytz~=2025.1 pytz~=2025.1
typing_extensions>=4.11.0 typing_extensions>=4.11.0