54 Commits

Author SHA1 Message Date
7dbd1fcc95 Merge pull request 'v1.0.0' (#15) from dev into main
Reviewed-on: #15
2025-05-06 21:34:31 +03: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
fa200ef92d Introduced i18n for "/user update_channels" 2025-05-02 14:18:58 +02:00
3dcae36dec Implemented "/user update_channels" (#10) 2025-05-02 14:07:00 +02:00
5507295b1b Fixed handling of event dates (for #2) 2025-05-02 12:20:24 +02:00
80eae3f1b1 Closes #11 2025-05-02 12:01:23 +02:00
390145ca0e Added i18n to the jailing mechanism 2025-04-29 16:47:15 +02:00
137ecffcf7 Optimized imports 2025-04-29 13:54:17 +02:00
efb9ae55ef Completion message sent to admins will now include the event name 2025-04-29 13:53:38 +02:00
9d39b803f3 Improved error handling; Introduced i18n for /register, /unregister and /guess 2025-04-29 13:50:38 +02:00
28d6340847 Set min version of typing_extensions to 4.11.0 2025-04-29 12:18:33 +02:00
112387115f Slightly improved hardcoded messages 2025-04-29 01:04:50 +02:00
2e9ed41a2c Added Pycord speedups to requirements 2025-04-29 00:58:00 +02:00
2ccdd6406a Improved handling of larger event stages 2025-04-28 14:20:06 +02:00
c4ebd1b891 Added /status command 2025-04-28 13:06:55 +02:00
c96cb167b5 Fixed stage not being updated on guessing 2025-04-28 02:00:19 +02:00
22139aa486 Fixed some event-related issues and guessing 2025-04-28 01:51:43 +02:00
679d026286 Fixed category id not being provided 2025-04-28 01:33:24 +02:00
b9dbc9443b Fixed event validation 2025-04-28 01:27:46 +02:00
b212236b10 Fixed categories and some timestamps 2025-04-28 01:21:20 +02:00
99653c6fe1 Improved (still partial) i18n for /stage and /user 2025-04-27 22:30:21 +02:00
d41d41663f Fixed broken imports 2025-04-27 22:08:42 +02:00
11f0cc384a Changed line length to 108 in black 2025-04-27 22:06:35 +02:00
923173ebe8 Added a stub for #11 and slightly improved typing 2025-04-27 22:05:34 +02:00
9a5edbaa4d Introduced i18n for /event, /guess, /register and /unregister. Prepared other commands for i18n too 2025-04-27 22:04:14 +02:00
12a88d5a23 Fully documented and updated PycordEvent (#4) 2025-04-27 17:41:14 +02:00
638658af75 Added more custom exceptions and prepared everything for documentation 2025-04-27 12:39:30 +02:00
6b143d8a2d Implemented activities 2025-04-26 21:31:35 +02:00
e45a56835a Fixed messages about not created channels 2025-04-26 20:01:52 +02:00
64cd7b3bff Fixed a typo in argument name 2025-04-26 19:37:53 +02:00
2dac6a4714 Fixed the goddamn timezone for event start check 2025-04-26 19:32:49 +02:00
3b8da61b47 Fixed event not being set as ended correctly 2025-04-26 19:27:15 +02:00
9981143f87 Fixed sending message to user without an event channel 2025-04-26 19:21:24 +02:00
94c4cdbf65 Registering after the event already began will now create a channel 2025-04-26 19:16:34 +02:00
3e9edf91d5 Added a message to admins when not stages are defined for the event to be started 2025-04-26 19:05:15 +02:00
e6036d033e Fixed missing astimezone() call 2025-04-26 18:58:14 +02:00
b86d03a84f Using override from typing_extensions instead of typing 2025-04-26 18:36:29 +02:00
37 changed files with 2643 additions and 817 deletions

140
README.md
View File

@@ -1,2 +1,142 @@
# QuizBot
Open source Discord bot for quizzes and quest-like events.
## Installation
### Dependencies
- [Python 3.11+](https://www.python.org)
- [MongoDB](https://www.mongodb.com)
- [Redis](https://redis.io)/[Valkey](https://valkey.io) or [Memcached](https://memcached.org) (used for caching,
optional)
- [Git](https://git-scm.com) (only if installing from source)
### Installation from release
1. Download the release archive from [Releases](https://git.end-play.xyz/profitroll/QuizBot/releases)
2. Unpack the archive to a directory of your choice
3. Go to the project's directory
4. Create a virtual environment: `python3 -m venv .venv`
5. Activate the virtual environment:
- Linux: `source .venv/bin/activate`
- Windows (cmd): `.venv/bin/activate.bat`
- Windows (PowerShell): `.venv/bin/activate.ps1`
6. Install requirements: `pip install -r requirements.txt`
7. Copy example config to a real file: `cp config_example.json config.json`
8. Configure the bot (see [Configuration](#configuration))
9. Start the bot: `python main.py`
10. The bot can be stopped by a keyboard interrupt (`Ctrl+C`) and a virtual environment can be deactivated using
`deactivate`
### Installation from source
1. Clone the repository: `git clone https://git.end-play.xyz/profitroll/QuizBot.git`
2. Go to the project's directory: `cd QuizBot`
3. Continue from step 4 of [Installation from release](#installation-from-release)
## Configuration
```json
{
// Bot's default locale. Based on file name from locale/
"locale": "en-US",
// Debug mode setting
"debug": false,
// Bot's config
"bot": {
// Discord ID(s) of bot's owner(s)
"owners": [
0
],
// Discord ID(s) of the debug guild(s)
"debug_guilds": [
0
],
// Bot's token
"bot_token": "",
// Bot's timezone
"timezone": "UTC",
// Bot's status activity
"status": {
// Whether activity is enabled
"enabled": true,
// Type of the activity. Can be: "playing", "watching", "listening", "streaming", "competing" or "custom"
"activity_type": "playing",
// Text of the activity
"activity_text": "The Game Of Life"
}
},
// Database connection
"database": {
// User name for database connection. null if without auth
"user": null,
// User password for database connection. null if without auth
"password": null,
// Database host
"host": "127.0.0.1",
// Database port
"port": 27017,
// Database name
"name": "quiz_bot"
},
// Cache connection
"cache": {
// Type of caching engine. Can be: "memcached", "redis" or null
"type": null,
// Memcached connection. Only used if cache type is "memcached"
"memcached": {
// Memcached URI
"uri": "127.0.0.1:11211"
},
// Redis connection. Only used if cache type is "redis"
"redis": {
// Redis URI
"uri": "redis://127.0.0.1:6379/0"
}
},
// Emojis used by guilds that prefer emoji messages
"emojis": {
// Markdown of a Discord emoji to be used for wrong guesses
"guess_wrong": null
}
}
```
## Upgrading
### Upgrading from release
Installing over the older version is not supported. Fresh installation is necessary to prevent data corruption.
1. Make a backup of the project's directory. Some of the old files will be reused
2. Follow the [Installation from release](#installation-from-release) from the beginning and stop before 7th step
3. Copy file `config.json` and directory `data` from the backup you made into the new installation's directory
4. While still in the virtual environment, migrate the database: `python main.py --migrate`
After these steps are performed, the bot is ready to be started and used.
### Upgrading from source
1. Make a backup of the project's directory
2. Go to the project's directory
3. Update the project: `git pull`
4. Activate the virtual environment:
- Linux: `source .venv/bin/activate`
- Windows (cmd): `.venv/bin/activate.bat`
- Windows (PowerShell): `.venv/bin/activate.ps1`
5. Migrate the database: `python main.py --migrate`
After these steps are performed, the bot is ready to be started and used.
## Usage
1. Invite the bot to your server with permissions `137707834448` and `applications.commands` scope.
You can also use the following URL template to invite your bot after replacing `CLIENT_ID` with you bot's client
ID:
`https://discord.com/oauth2/authorize?client_id=CLIENT_ID&permissions=137707834448&integration_type=0&scope=applications.commands+bot`
2. Go to "Server Settings > Integrations > QuizBot" and disable access to admin commands for you default role.
Only admins should have access to following commands: `/config`, `/event`, `/stage` and `/user`.
Allowing access to `/status` is not recommended, however won't do any harm if done so.
3. Configure bot for usage on your server using `/config set` providing all the necessary arguments.
Timezones are compatible with daylight saving time (e.g. `CET` will be interpreted as `CEST` during daylight saving).

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

@@ -1,2 +1,14 @@
from .discord import (
DiscordCategoryNotFoundError,
DiscordChannelNotFoundError,
DiscordGuildMemberNotFoundError,
)
from .pycord_event import EventNotFoundError
from .pycord_event_stage import EventStageMissingSequenceError, EventStageNotFoundError
from .pycord_guild import GuildNotFoundError
from .pycord_user import UserNotFoundError
from .pycord_user import (
UserAlreadyCompletedEventError,
UserAlreadyRegisteredForEventError,
UserNotFoundError,
UserNotRegisteredForEventError,
)

32
classes/errors/discord.py Normal file
View File

@@ -0,0 +1,32 @@
class DiscordGuildMemberNotFoundError(Exception):
"""Member was not found in a discord guild"""
def __init__(self, user_id: int, guild_id: int) -> None:
self.user_id: int = user_id
self.guild_id: int = guild_id
super().__init__(f"Member with id {self.user_id} was not found in guild with id {self.guild_id}")
class DiscordCategoryNotFoundError(Exception):
"""Category was not found in a discord guild"""
def __init__(self, category_id: int, guild_id: int) -> None:
self.category_id: int = category_id
self.guild_id: int = guild_id
super().__init__(
f"Category with id {self.category_id} was not found in guild with id {self.guild_id}"
)
class DiscordChannelNotFoundError(Exception):
"""Channel was not found in a discord guild"""
def __init__(self, channel_id: int, guild_id: int) -> None:
self.channel_id: int = channel_id
self.guild_id: int = guild_id
super().__init__(
f"Channel with id {self.channel_id} was not found in guild with id {self.guild_id}"
)

View File

@@ -0,0 +1,26 @@
from typing import Optional
from bson import ObjectId
class EventNotFoundError(Exception):
"""PycordEvent could not find event with such an ID in the database"""
def __init__(
self,
event_id: Optional[str | ObjectId] = None,
event_name: Optional[str] = None,
guild_id: Optional[int] = None,
) -> None:
self.event_id: str | ObjectId | None = event_id
self.event_name: str | None = event_name
self.guild_id: int | None = guild_id
if self.event_id is None and self.event_name is None:
raise AttributeError("Either event id or name must be provided")
super().__init__(
f"Event with id {self.event_id} was not found"
if event_id is not None
else f"Event with name {self.event_name} was not found for the guild {self.guild_id}"
)

View File

@@ -0,0 +1,17 @@
from bson import ObjectId
class EventStageNotFoundError(Exception):
"""PycordEventStage could not find event with such an ID in the database"""
def __init__(self, stage_id: str | ObjectId) -> None:
self.stage_id: str | ObjectId = stage_id
super().__init__(f"Stage with id {self.stage_id} was not found")
class EventStageMissingSequenceError(Exception):
"""No sequence is provided for the event stage"""
def __init__(self) -> None:
super().__init__("Stage does not have a defined sequence")

View File

@@ -2,6 +2,6 @@ class GuildNotFoundError(Exception):
"""PycordGuild could not find guild with such an ID in the database"""
def __init__(self, guild_id: int) -> None:
self.guild_id = guild_id
self.guild_id: int = guild_id
super().__init__(f"Guild with id {self.guild_id} was not found")

View File

@@ -1,3 +1,6 @@
from bson import ObjectId
class UserNotFoundError(Exception):
"""PycordUser could not find user with such an ID in the database"""
@@ -6,3 +9,33 @@ class UserNotFoundError(Exception):
self.guild_id: int = guild_id
super().__init__(f"User with id {self.user_id} was not found in guild {self.guild_id}")
class UserAlreadyRegisteredForEventError(Exception):
"""PycordUser is already registered for the provided event"""
def __init__(self, user_id: int, event_id: str | ObjectId) -> None:
self.user_id: int = user_id
self.event_id: str | ObjectId = event_id
super().__init__(f"User with id {self.user_id} is already registered for the event {self.event_id}")
class UserNotRegisteredForEventError(Exception):
"""PycordUser is not registered for the provided event"""
def __init__(self, user_id: int, event_id: str | ObjectId) -> None:
self.user_id: int = user_id
self.event_id: str | ObjectId = event_id
super().__init__(f"User with id {self.user_id} is not registered for the event {self.event_id}")
class UserAlreadyCompletedEventError(Exception):
"""PycordUser already completed the provided event"""
def __init__(self, user_id: int, event_id: str | ObjectId) -> None:
self.user_id: int = user_id
self.event_id: str | ObjectId = event_id
super().__init__(f"User with id {self.user_id} already completed the event {self.event_id}")

View File

@@ -1,23 +1,35 @@
from datetime import datetime
from logging import Logger
from pathlib import Path
from typing import Any, Dict, List, override
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from bson import ObjectId
from bson.errors import InvalidId
from discord import Attachment, File, Guild, TextChannel, User
from libbot.cache.classes import CacheMemcached, CacheRedis
from libbot.cache.manager import create_cache_client
from libbot.pycord.classes import PycordBot as LibPycordBot
from typing_extensions import override
from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser
from modules.database import col_events, col_users
from classes.errors import (
DiscordGuildMemberNotFoundError,
EventNotFoundError,
EventStageMissingSequenceError,
EventStageNotFoundError,
GuildNotFoundError,
)
from modules.database import _update_database_indexes, col_events, col_users
from modules.utils import get_logger
logger: Logger = get_logger(__name__)
class PycordBot(LibPycordBot):
__version__ = "1.0.0"
started: datetime
cache: CacheMemcached | CacheRedis | None = None
def __init__(self, *args, **kwargs) -> None:
@@ -46,6 +58,10 @@ class PycordBot(LibPycordBot):
@override
async def start(self, *args: Any, **kwargs: Any) -> None:
await self._schedule_tasks()
await _update_database_indexes()
self.started = datetime.now(tz=ZoneInfo("UTC"))
await super().start(*args, **kwargs)
@override
@@ -53,7 +69,9 @@ class PycordBot(LibPycordBot):
await super().close(**kwargs)
async def _schedule_tasks(self) -> None:
self.scheduler.add_job(self._execute_event_controller, trigger="cron", minute="*/1", id="event_controller")
self.scheduler.add_job(
self._execute_event_controller, trigger="cron", minute="*/1", id="event_controller"
)
async def _execute_event_controller(self) -> None:
await self._process_events_start()
@@ -62,18 +80,36 @@ class PycordBot(LibPycordBot):
async def _process_events_start(self) -> None:
# Get events to start
events: List[PycordEvent] = await self._get_events(
{"starts": datetime.now(tz=ZoneInfo("UTC")).replace(second=0, microsecond=0)}
{
"starts": datetime.now(tz=ZoneInfo("UTC")).replace(second=0, microsecond=0),
"is_cancelled": False,
"ended": None,
}
)
# Process each event
for event in events:
if len(event.stage_ids) == 0:
# TODO Make a nice message for management
logger.error("Could not start the event %s: no event stages are defined.", event._id)
guild: Guild = self.get_guild(event.guild_id)
try:
pycord_guild: PycordGuild = await self.find_guild(guild)
except (InvalidId, GuildNotFoundError) as exc:
logger.error("Could not find guild %s (%s) due to: %s.", guild, guild.id, exc_info=exc)
continue
if len(event.stage_ids) == 0:
logger.error("Could not start the event %s: no event stages are defined.", event._id)
await self.notify_admins(
guild,
pycord_guild,
self._("admin_event_no_stages_defined", "messages").format(event_name=event.name),
)
await event.cancel(self.cache)
continue
guild: Guild = self.get_guild(event.guild_id)
pycord_guild: PycordGuild = await self.find_guild(guild)
first_stage: PycordEventStage = await self.find_event_stage(event.stage_ids[0])
# Get list of participants
@@ -83,10 +119,49 @@ class PycordBot(LibPycordBot):
await user._set(self.cache, current_event_id=event._id, current_stage_id=first_stage._id)
# Create a channel for each participant
await user.setup_event_channel(self, guild, pycord_guild, event, cache=self.cache)
try:
user_channel: TextChannel | None = await user.setup_event_channel(
self, guild, pycord_guild, event, cache=self.cache
)
except DiscordGuildMemberNotFoundError:
logger.error(
"Could not create and configure event channel for user %s in %s (event %s): user not found in the guild",
user.id,
guild.id,
event._id,
)
# Send a notification about event start
user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)])
await self.notify_admins(
guild,
pycord_guild,
self._("admin_channel_creation_failed_no_user", "messages").format(
user_id=user.id, event_name=event.name
),
)
continue
if user_channel is None:
logger.error(
"Event channel was not created for user %s from guild %s and event %s after registration.",
user.id,
guild.id,
event._id,
)
discord_user: User = self.get_user(user.id)
await self.notify_admins(
guild,
pycord_guild,
self._("admin_channel_creation_failed", "messages").format(
display_name=discord_user.display_name,
mention=discord_user.mention,
event_name=event.name,
),
)
continue
thumbnail: File | None = (
None
@@ -94,62 +169,146 @@ class PycordBot(LibPycordBot):
else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"])
)
# TODO Make a nice message
# TODO Also send a thumbnail, event info and short explanation on how to play
await user_channel.send(
f"Event **{event.name}** is starting!\n\nUse slash command `/guess` to suggest your answers to each event stage.",
self._("event_is_starting", "messages").format(event_name=event.name),
file=thumbnail,
)
first_stage_files: List[File] | None = first_stage.get_media_files()
await user_channel.send(f"First stage...\n\n{first_stage.question}", files=first_stage_files)
question_chunks: List[str] = first_stage.get_question_chunked(2000)
question_chunks_length: int = len(question_chunks)
for index, chunk in enumerate(question_chunks):
await user_channel.send(
chunk, files=None if index != question_chunks_length - 1 else first_stage_files
)
# TODO Make a nice message
await self.notify_admins(
guild,
pycord_guild,
f"Event **{event.name}** has started! Users have gotten their channels and can already start submitting their answers.",
self._("admin_event_started", "messages").format(event_name=event.name),
)
await self.notify_users(
guild,
pycord_guild,
self._("event_started", "messages").format(event_name=event.name),
)
async def _process_events_end(self) -> None:
# Get events to end
events: List[PycordEvent] = await self._get_events(
{"ends": datetime.now(tz=ZoneInfo("UTC")).replace(second=0, microsecond=0)}
{
"ends": datetime.now(tz=ZoneInfo("UTC")).replace(second=0, microsecond=0),
"is_cancelled": False,
"ended": None,
}
)
# Process each event
for event in events:
guild: Guild = self.get_guild(event.guild_id)
pycord_guild: PycordGuild = await self.find_guild(guild)
stages: List[PycordEventStage] = await self.get_event_stages(event)
# TODO Make a nice message
try:
pycord_guild: PycordGuild = await self.find_guild(guild)
except (InvalidId, GuildNotFoundError) as exc:
logger.error("Could not find guild %s (%s) due to: %s.", guild, guild.id, exc_info=exc)
continue
try:
stages: List[PycordEventStage] = await self.get_event_stages(event)
except (InvalidId, EventNotFoundError, EventStageNotFoundError) as exc:
logger.error(
"Could not event stages of the event %s (%s) due to: %s.",
event,
event._id,
exc_info=exc,
)
continue
stages_string: str = "\n\n".join(
f"**Stage {stage.sequence+1}**\nQuestion: {stage.question}\nAnswer: ||{stage.answer}||"
self._("stage_entry", "messages").format(sequence=stage.sequence + 1, answer=stage.answer)
for stage in stages
)
# Get list of participants
users: List[PycordUser] = await self._get_event_participants(event._id)
event_ended_string: str = self._("event_ended", "messages").format(
event_name=event.name, stages=stages_string
)
chunk_size: int = 2000
event_info_chunks: List[str] = [
event_ended_string[i : i + chunk_size]
for i in range(0, len(event_ended_string), chunk_size)
]
for user in users:
if str(event._id) not in user.event_channels:
logger.warning(
"User %s participated in the event %s but did not have a channel. End message will not be sent and permissions will not be updated.",
user.id,
event._id,
)
continue
# Send a notification about event start
user_channel: TextChannel = guild.get_channel(user.event_channels[str(event._id)])
# TODO Make a nice message
await user_channel.send(
f"Event **{event.name}** has ended! Stages and respective answers are listed below.\n\n{stages_string}"
)
for chunk in event_info_chunks:
await user_channel.send(chunk)
# Lock each participant out
await user.lock_event_channel(guild, event._id, channel=user_channel)
# TODO Make a nice message
await event.end(cache=self.cache)
await self.notify_admins(
guild,
pycord_guild,
f"Event **{event.name}** has ended! Users can no longer submit their answers.",
self._("admin_event_ended", "messages").format(event_name=event.name),
)
await self._notify_general_channel_event_end(guild, pycord_guild, event, stages)
async def _notify_general_channel_event_end(
self, guild: Guild, pycord_guild: PycordGuild, event: PycordEvent, stages: List[PycordEventStage]
) -> None:
event_ended_string: str = self._("event_ended_short", "messages").format(event_name=event.name)
await self.notify_users(
guild,
pycord_guild,
event_ended_string,
)
chunk_size: int = 2000
for stage in stages:
header_full: str = self._("stage_entry_header", "messages").format(
sequence=stage.sequence + 1, question=stage.question
)
header_chunks: List[str] = [
header_full[i : i + chunk_size] for i in range(0, len(header_full), chunk_size)
]
header_chunks_length: int = len(header_chunks)
files: List[File] | None = stage.get_media_files()
for index, chunk in enumerate(header_chunks):
await self.notify_users(
guild,
pycord_guild,
chunk,
files=None if index != header_chunks_length - 1 else files,
)
await self.notify_users(
guild, pycord_guild, self._("stage_entry_footer", "messages").format(answer=stage.answer)
)
@staticmethod
@@ -177,18 +336,35 @@ class PycordBot(LibPycordBot):
# TODO Add documentation
@staticmethod
async def notify_admins(guild: Guild, pycord_guild: PycordGuild, message: str) -> None:
management_channel: TextChannel | None = guild.get_channel(pycord_guild.channel_id)
management_channel: TextChannel | None = guild.get_channel(pycord_guild.management_channel_id)
if management_channel is None:
logger.error(
"Discord channel with ID %s in guild with ID %s could not be found!",
pycord_guild.channel_id,
pycord_guild.management_channel_id,
guild.id,
)
return
await management_channel.send(message)
# TODO Add documentation
@staticmethod
async def notify_users(
guild: Guild, pycord_guild: PycordGuild, message: str, files: Optional[List[File]] = None
) -> None:
general_channel: TextChannel | None = guild.get_channel(pycord_guild.general_channel_id)
if general_channel is None:
logger.error(
"Discord channel with ID %s in guild with ID %s could not be found!",
pycord_guild.general_channel_id,
guild.id,
)
return
await general_channel.send(message, files=files)
async def find_user(self, user: int | User, guild: int | Guild) -> PycordUser:
"""Find User by its ID or User object.
@@ -241,8 +417,7 @@ class PycordBot(LibPycordBot):
# event: PycordEvent = await self.find_event(event_id=kwargs["event_id"])
if "sequence" not in kwargs:
# TODO Create a nicer exception
raise RuntimeError("Stage must have a defined sequence")
raise EventStageMissingSequenceError()
event_stage: PycordEventStage = await PycordEventStage.create(**kwargs, cache=self.cache)
@@ -251,19 +426,25 @@ class PycordBot(LibPycordBot):
return event_stage
# TODO Document this method
async def find_event(self, event_id: str | ObjectId | None = None, event_name: str | None = None) -> PycordEvent:
if event_id is None and event_name is None:
raise AttributeError("Either event's ID or name must be provided!")
async def find_event(
self,
event_id: Optional[str | ObjectId] = None,
event_name: Optional[str] = None,
guild_id: Optional[int] = None,
) -> PycordEvent:
if event_id is None and (event_name is None or guild_id is None):
raise AttributeError("Either event ID or name with guild ID must be provided")
if event_id is not None:
return await PycordEvent.from_id(event_id, cache=self.cache)
else:
return await PycordEvent.from_name(event_name, cache=self.cache)
return await PycordEvent.from_name(event_name, guild_id, cache=self.cache)
# TODO Document this method
async def find_event_stage(self, stage_id: str | ObjectId) -> PycordEventStage:
return await PycordEventStage.from_id(stage_id, cache=self.cache)
# TODO Add documentation
@staticmethod
async def process_attachments(attachments: List[Attachment]) -> List[Dict[str, Any]]:
processed_attachments: List[Dict[str, Any]] = []
@@ -273,3 +454,25 @@ class PycordBot(LibPycordBot):
processed_attachments.append({"id": attachment.id, "filename": attachment.filename})
return processed_attachments
# TODO Add documentation
async def send_stage_question(
self,
channel: TextChannel,
event: PycordEvent,
stage: Optional[PycordEventStage] = None,
use_first_stage: bool = False,
) -> None:
stage: PycordEventStage = (
stage
if not use_first_stage or stage is not None
else await self.find_event_stage(event.stage_ids[0])
)
stage_files: List[File] | None = stage.get_media_files()
question_chunks: List[str] = stage.get_question_chunked(2000)
question_chunks_length: int = len(question_chunks)
for index, chunk in enumerate(question_chunks):
await channel.send(chunk, files=None if index != question_chunks_length - 1 else stage_files)

View File

@@ -1,14 +1,19 @@
"""Module with class PycordEvent."""
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, tzinfo
from logging import Logger
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from bson import ObjectId
from discord import Bot
from libbot.cache.classes import Cache
from pymongo import DESCENDING
from pymongo.results import InsertOneResult
from classes.abstract import Cacheable
from classes.base.base_cacheable import BaseCacheable
from classes.errors import EventNotFoundError
from modules.database import col_events
from modules.utils import get_logger, restore_from_cache
@@ -16,7 +21,23 @@ logger: Logger = get_logger(__name__)
@dataclass
class PycordEvent:
class PycordEvent(BaseCacheable):
"""Object representation of an event in the database.
Attributes:
_id (ObjectId): ID of the event generated by the database.
name (str): Name of the event.
guild_id (int): Discord ID of the guild where the event takes place.
created (datetime): Date of event's creation in UTC.
ended (datetime | None): Date of the event's actual end in UTC.
is_cancelled (bool): Whether the event is cancelled.
creator_id (int): Discord ID of the creator.
starts (datetime): Date of the event's planned start in UTC.
ends (datetime): Date of the event's planned end in UTC.
thumbnail (Dict[str, Any] | None): Thumbnail to use for the event in format `{"id": thumbnail_id (int), "filename": thumbnail_filename (str)}`.
stage_ids (List[ObjectId]): Database ID's of the event's stages ordered in the completion order.
"""
__slots__ = (
"_id",
"name",
@@ -47,53 +68,64 @@ class PycordEvent:
@classmethod
async def from_id(cls, event_id: str | ObjectId, cache: Optional[Cache] = None) -> "PycordEvent":
"""Find event in the database.
"""Find the event by its ID and construct PycordEvent from database entry.
Args:
event_id (str | ObjectId): Event's ID
cache (:obj:`Cache`, optional): Cache engine to get the cache from
event_id (str | ObjectId): ID of the event to look up.
cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache.
Returns:
PycordEvent: Event object
PycordEvent: Object of the found event.
Raises:
EventNotFoundError: Event was not found
InvalidId: Invalid event ID was provided
EventNotFoundError: Event with such ID does not exist.
InvalidId: Provided event ID is of invalid format.
"""
cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, event_id, cache=cache)
if cached_entry is not None:
return cls(**cached_entry)
return cls(**cls._entry_from_cache(cached_entry))
db_entry = await cls.__collection__.find_one(
{"_id": event_id if isinstance(event_id, ObjectId) else ObjectId(event_id)}
)
if db_entry is None:
raise RuntimeError(f"Event {event_id} not found")
# TODO Add a unique exception
# raise EventNotFoundError(event_id)
raise EventNotFoundError(event_id=event_id)
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{event_id}", db_entry)
cache.set_json(f"{cls.__short_name__}_{event_id}", cls._entry_to_cache(dict(db_entry)))
return cls(**db_entry)
@classmethod
async def from_name(cls, event_name: str, cache: Optional[Cache] = None) -> "PycordEvent":
# TODO Add sorting by creation date or something.
# Duplicate events should be avoided, latest active event should be returned.
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one({"name": event_name})
async def from_name(
cls, event_name: str, guild_id: int, cache: Optional[Cache] = None
) -> "PycordEvent":
"""Find the event by its name and construct PycordEvent from database entry.
If multiple events with the same name exist, the one with the greatest start date will be returned.
Args:
event_name (str): Name of the event to look up.
guild_id (int): Discord ID of the guild where the event takes place.
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
Returns:
PycordEvent: Object of the found event.
Raises:
EventNotFoundError: Event with such name does not exist.
"""
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
{"name": event_name, "guild_id": guild_id}, sort=[("starts", DESCENDING)]
)
if db_entry is None:
raise RuntimeError(f"Event with name {event_name} not found")
# TODO Add a unique exception
# raise EventNotFoundError(event_name)
raise EventNotFoundError(event_name=event_name, guild_id=guild_id)
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{db_entry['_id']}", db_entry)
cache.set_json(f"{cls.__short_name__}_{db_entry['_id']}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
@@ -108,6 +140,22 @@ class PycordEvent:
thumbnail: Dict[str, Any] | None,
cache: Optional[Cache] = None,
) -> "PycordEvent":
"""Create an event, write it to the database and return the constructed PycordEvent object.
Creation date will be set to current time in UTC automatically.
Args:
name (str): Name of the event.
guild_id (int): Guild ID where the event takes place.
creator_id (int): Discord ID of the event creator.
starts (datetime): Date when the event starts. Must be UTC.
ends (datetime): Date when the event ends. Must be UTC.
thumbnail (:obj:`Dict[str, Any]`, optional): Thumbnail to use for the event in format `{"id": thumbnail_id (int), "filename": thumbnail_filename (str)}`.
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
Returns:
PycordEvent: Object of the created event.
"""
db_entry: Dict[str, Any] = {
"name": name,
"guild_id": guild_id,
@@ -126,147 +174,24 @@ class PycordEvent:
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{guild_id}", db_entry)
cache.set_json(f"{cls.__short_name__}_{guild_id}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
"""Set attribute data and save it into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
**kwargs (Any): Mapping of attribute names and respective values to be set
"""
for key, value in kwargs.items():
if not hasattr(self, key):
raise AttributeError()
setattr(self, key, value)
await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True)
self._update_cache(cache)
logger.info("Set attributes of event %s to %s", self._id, kwargs)
await super()._set(cache, **kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
"""Remove attribute data and save it into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
*args (str): List of attributes to remove
"""
attributes: Dict[str, Any] = {}
for key in args:
if not hasattr(self, key):
raise AttributeError()
default_value: Any = self.get_default_value(key)
setattr(self, key, default_value)
attributes[key] = default_value
await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True)
self._update_cache(cache)
logger.info("Reset attributes %s of event %s to default values", args, self._id)
await super()._remove(*args, cache=cache)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self._id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
user_dict: Dict[str, Any] = self.to_dict()
if user_dict is not None:
cache.set_json(self._get_cache_key(), user_dict)
else:
self._delete_cache(cache)
super()._update_cache(cache)
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
cache.delete(self._get_cache_key())
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert PycordEvent object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
Returns:
Dict[str, Any]: JSON representation of PycordEvent
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"name": self.name,
"guild_id": self.guild_id,
"created": self.created,
"ended": self.ended,
"is_cancelled": self.is_cancelled,
"creator_id": self.creator_id,
"starts": self.starts,
"ends": self.ends,
"thumbnail": self.thumbnail,
"stage_ids": self.stage_ids,
}
@staticmethod
def get_defaults() -> Dict[str, Any]:
return {
"name": None,
"guild_id": None,
"created": None,
"ended": None,
"is_cancelled": False,
"creator_id": None,
"starts": None,
"ends": None,
"thumbnail": None,
"stage_ids": [],
}
@staticmethod
def get_default_value(key: str) -> Any:
if key not in PycordEvent.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordEvent")
return PycordEvent.get_defaults()[key]
# TODO Add documentation
async def update(
self,
cache: Optional[Cache] = None,
**kwargs,
):
await self._set(cache=cache, **kwargs)
# TODO Add documentation
async def reset(
self,
cache: Optional[Cache] = None,
*args,
):
await self._remove(cache, *args)
async def purge(self, cache: Optional[Cache] = None) -> None:
"""Completely remove event data from database. Currently only removes the event record from events collection.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
"""
await self.__collection__.delete_one({"_id": self._id})
self._delete_cache(cache)
# TODO Add documentation
async def cancel(self, cache: Optional[Cache] = None):
await self._set(cache, is_cancelled=True)
super()._delete_cache(cache)
async def _update_event_stage_order(
self,
@@ -286,15 +211,154 @@ class PycordEvent:
stage_index: int = self.stage_ids.index(event_stage_id)
old_stage_index: int = old_stage_ids.index(event_stage_id)
logger.debug("Indexes for %s: was %s and is now %s", event_stage_id, old_stage_index, stage_index)
logger.debug(
"Indexes for %s: was %s and is now %s", event_stage_id, old_stage_index, stage_index
)
if stage_index != old_stage_index:
await (await bot.find_event_stage(event_stage_id)).update(cache, sequence=stage_index)
# TODO Add documentation
async def insert_stage(
self, bot: Bot, event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
cache_entry["created"] = cache_entry["created"].isoformat()
cache_entry["ended"] = None if cache_entry["ended"] is None else cache_entry["ended"].isoformat()
cache_entry["starts"] = cache_entry["starts"].isoformat()
cache_entry["ends"] = cache_entry["ends"].isoformat()
cache_entry["stage_ids"] = [str(stage_id) for stage_id in cache_entry["stage_ids"]]
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
db_entry["created"] = datetime.fromisoformat(db_entry["created"])
db_entry["ended"] = None if db_entry["ended"] is None else datetime.fromisoformat(db_entry["ended"])
db_entry["starts"] = datetime.fromisoformat(db_entry["starts"])
db_entry["ends"] = datetime.fromisoformat(db_entry["ends"])
db_entry["stage_ids"] = [ObjectId(stage_id) for stage_id in db_entry["stage_ids"]]
return db_entry
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert the object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted.
Returns:
Dict[str, Any]: JSON representation of the object.
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"name": self.name,
"guild_id": self.guild_id,
"created": self.created if not json_compatible else self.created.isoformat(),
"ended": (
self.ended
if not json_compatible
else (None if self.ended is None else self.ended.isoformat())
),
"is_cancelled": self.is_cancelled,
"creator_id": self.creator_id,
"starts": self.starts if not json_compatible else self.starts.isoformat(),
"ends": self.ends if not json_compatible else self.ends.isoformat(),
"thumbnail": self.thumbnail,
"stage_ids": (
self.stage_ids if not json_compatible else [str(stage_id) for stage_id in self.stage_ids]
),
}
@staticmethod
def get_defaults() -> Dict[str, Any]:
"""Get default values for the object attributes.
Returns:
Dict[str, Any]: Mapping of attributes and their respective values in format `{"attribute_name:" attribute_value}`.
"""
return {
"name": None,
"guild_id": None,
"created": None,
"ended": None,
"is_cancelled": False,
"creator_id": None,
"starts": None,
"ends": None,
"thumbnail": None,
"stage_ids": [],
}
@staticmethod
def get_default_value(key: str) -> Any:
"""Get default value of the attribute for the object.
Args:
key (str): Name of the attribute.
Returns:
Any: Default value of the attribute.
Raises:
KeyError: There's no default value for the provided attribute.
"""
if key not in PycordEvent.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordEvent")
return PycordEvent.get_defaults()[key]
async def update(
self,
cache: Optional[Cache] = None,
**kwargs: Any,
) -> None:
await super().update(cache=cache, **kwargs)
async def reset(
self,
*args: str,
cache: Optional[Cache] = None,
) -> None:
await super().reset(*args, cache=cache)
async def purge(self, cache: Optional[Cache] = None) -> None:
await super().purge(cache)
async def cancel(self, cache: Optional[Cache] = None) -> None:
"""Cancel the event.
Attribute `is_cancelled` will be set to `True`.
Args:
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
"""
await self._set(cache, is_cancelled=True)
async def end(self, cache: Optional[Cache] = None) -> None:
"""End the event.
Attribute `ended` will be set to the current date in UTC.
Args:
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
"""
await self._set(cache, ended=datetime.now(tz=ZoneInfo("UTC")))
async def insert_stage(
self, bot: "PycordBot", event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None
) -> None:
"""Insert a stage at the provided index.
Args:
bot (PycordBot): Bot object.
event_stage_id (ObjectId): Stage ID to be inserted.
index (int): Index to be inserted at.
cache: Cache engine that will be used to update the cache.
"""
old_stage_ids: List[ObjectId] = self.stage_ids.copy()
self.stage_ids.insert(index, event_stage_id)
@@ -302,10 +366,17 @@ class PycordEvent:
await self._set(cache, stage_ids=self.stage_ids)
await self._update_event_stage_order(bot, old_stage_ids, cache=cache)
# TODO Add documentation
async def reorder_stage(
self, bot: Any, event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None
self, bot: "PycordBot", event_stage_id: ObjectId, index: int, cache: Optional[Cache] = None
) -> None:
"""Reorder a stage to the provided index.
Args:
bot (PycordBot): Bot object.
event_stage_id (ObjectId): Stage ID to be reordered.
index (int): Index to be reordered to.
cache: Cache engine that will be used to update the cache.
"""
old_stage_ids: List[ObjectId] = self.stage_ids.copy()
self.stage_ids.insert(index, self.stage_ids.pop(self.stage_ids.index(event_stage_id)))
@@ -313,8 +384,16 @@ class PycordEvent:
await self._set(cache, stage_ids=self.stage_ids)
await self._update_event_stage_order(bot, old_stage_ids, cache=cache)
# TODO Add documentation
async def remove_stage(self, bot: Bot, event_stage_id: ObjectId, cache: Optional[Cache] = None) -> None:
async def remove_stage(
self, bot: "PycordBot", event_stage_id: ObjectId, cache: Optional[Cache] = None
) -> None:
"""Remove a stage from the event.
Args:
bot (PycordBot): Bot object.
event_stage_id (ObjectId): Stage ID to be reordered.
cache: Cache engine that will be used to update the cache.
"""
old_stage_ids: List[ObjectId] = self.stage_ids.copy()
self.stage_ids.pop(self.stage_ids.index(event_stage_id))
@@ -322,10 +401,58 @@ class PycordEvent:
await self._set(cache, stage_ids=self.stage_ids)
await self._update_event_stage_order(bot, old_stage_ids, cache=cache)
# # TODO Add documentation
# def get_localized_start_date(self, tz: str | timezone | ZoneInfo) -> datetime:
# return self.starts.replace(tzinfo=tz)
#
# # TODO Add documentation
# def get_localized_end_date(self, tz: str | timezone | ZoneInfo) -> datetime:
# return self.ends.replace(tzinfo=tz)
def get_start_date_utc(self) -> datetime:
"""Get the event start date in UTC timezone.
Returns:
datetime: Start date in UTC.
Raises:
ValueError: Event does not have a start date.
"""
if self.starts is None:
raise ValueError("Event does not have a start date")
return self.starts.replace(tzinfo=ZoneInfo("UTC"))
def get_end_date_utc(self) -> datetime:
"""Get the event end date in UTC timezone.
Returns:
datetime: End date in UTC.
Raises:
ValueError: Event does not have an end date.
"""
if self.ends is None:
raise ValueError("Event does not have an end date")
return self.ends.replace(tzinfo=ZoneInfo("UTC"))
def get_start_date_localized(self, tz: tzinfo) -> datetime:
"""Get the event start date in the provided timezone.
Returns:
datetime: Start date in the provided timezone.
Raises:
ValueError: Event does not have a start date.
"""
if self.starts is None:
raise ValueError("Event does not have a start date")
return self.starts.replace(tzinfo=tz)
def get_end_date_localized(self, tz: tzinfo) -> datetime:
"""Get the event end date in the provided timezone.
Returns:
datetime: End date in the provided timezone.
Raises:
ValueError: Event does not have an end date.
"""
if self.ends is None:
raise ValueError("Event does not have an end date")
return self.ends.replace(tzinfo=tz)

View File

@@ -10,6 +10,8 @@ from discord import File
from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult
from classes.base.base_cacheable import BaseCacheable
from classes.errors import EventStageNotFoundError
from modules.database import col_stages
from modules.utils import get_logger, restore_from_cache
@@ -17,7 +19,7 @@ logger: Logger = get_logger(__name__)
@dataclass
class PycordEventStage:
class PycordEventStage(BaseCacheable):
__slots__ = (
"_id",
"event_id",
@@ -44,38 +46,37 @@ class PycordEventStage:
@classmethod
async def from_id(cls, stage_id: str | ObjectId, cache: Optional[Cache] = None) -> "PycordEventStage":
"""Find event stage in the database.
"""Find the event stage by its ID and construct PycordEventStage from database entry.
Args:
stage_id (str | ObjectId): Stage's ID
cache (:obj:`Cache`, optional): Cache engine to get the cache from
stage_id (str | ObjectId): ID of the event stage to look up.
cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache.
Returns:
PycordEventStage: Event stage object
PycordEventStage: Object of the found event stage.
Raises:
EventStageNotFoundError: Event stage was not found
EventStageNotFoundError: Event stage with such ID does not exist.
InvalidId: Provided event stage ID is of invalid format.
"""
cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, stage_id, cache=cache)
if cached_entry is not None:
return cls(**cached_entry)
return cls(**cls._entry_from_cache(cached_entry))
db_entry = await cls.__collection__.find_one(
{"_id": stage_id if isinstance(stage_id, ObjectId) else ObjectId(stage_id)}
)
if db_entry is None:
raise RuntimeError(f"Event stage {stage_id} not found")
# TODO Add a unique exception
# raise EventStageNotFoundError(event_id)
raise EventStageNotFoundError(stage_id)
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{stage_id}", db_entry)
cache.set_json(f"{cls.__short_name__}_{stage_id}", cls._entry_to_cache(dict(db_entry)))
return cls(**db_entry)
# TODO Add documentation
@classmethod
async def create(
cls,
@@ -104,89 +105,60 @@ class PycordEventStage:
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{guild_id}", db_entry)
cache.set_json(f"{cls.__short_name__}_{guild_id}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
"""Set attribute data and save it into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
**kwargs (Any): Mapping of attribute names and respective values to be set
"""
for key, value in kwargs.items():
if not hasattr(self, key):
raise AttributeError()
setattr(self, key, value)
await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True)
self._update_cache(cache)
logger.info("Set attributes of event stage %s to %s", self._id, kwargs)
await super()._set(cache, **kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
"""Remove attribute data and save it into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
*args (str): List of attributes to remove
"""
attributes: Dict[str, Any] = {}
for key in args:
if not hasattr(self, key):
raise AttributeError()
default_value: Any = self.get_default_value(key)
setattr(self, key, default_value)
attributes[key] = default_value
await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True)
self._update_cache(cache)
logger.info("Reset attributes %s of event stage %s to default values", args, self._id)
await super()._remove(*args, cache=cache)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self._id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
user_dict: Dict[str, Any] = self.to_dict()
if user_dict is not None:
cache.set_json(self._get_cache_key(), user_dict)
else:
self._delete_cache(cache)
super()._update_cache(cache)
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
super()._delete_cache(cache)
cache.delete(self._get_cache_key())
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
cache_entry["event_id"] = str(cache_entry["event_id"])
cache_entry["created"] = cache_entry["created"].isoformat()
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
db_entry["event_id"] = ObjectId(db_entry["event_id"])
db_entry["created"] = datetime.fromisoformat(db_entry["created"])
return db_entry
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert PycordEventStage object to a JSON representation.
"""Convert the object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted.
Returns:
Dict[str, Any]: JSON representation of PycordEventStage
Dict[str, Any]: JSON representation of the object.
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"event_id": self.event_id if not json_compatible else str(self.event_id),
"guild_id": self.guild_id,
"sequence": self.sequence,
"created": self.created,
"created": self.created if not json_compatible else self.created.isoformat(),
"creator_id": self.creator_id,
"question": self.question,
"answer": self.answer,
@@ -195,6 +167,11 @@ class PycordEventStage:
@staticmethod
def get_defaults() -> Dict[str, Any]:
"""Get default values for the object attributes.
Returns:
Dict[str, Any]: Mapping of attributes and their respective values in format `{"attribute_name:" attribute_value}`.
"""
return {
"event_id": None,
"guild_id": None,
@@ -208,35 +185,38 @@ class PycordEventStage:
@staticmethod
def get_default_value(key: str) -> Any:
"""Get default value of the attribute for the object.
Args:
key (str): Name of the attribute.
Returns:
Any: Default value of the attribute.
Raises:
KeyError: There's no default value for the provided attribute.
"""
if key not in PycordEventStage.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordEventStage")
return PycordEventStage.get_defaults()[key]
# TODO Add documentation
async def update(
self,
cache: Optional[Cache] = None,
**kwargs,
):
await self._set(cache=cache, **kwargs)
**kwargs: Any,
) -> None:
await super().update(cache=cache, **kwargs)
# TODO Add documentation
async def reset(
self,
*args: str,
cache: Optional[Cache] = None,
*args,
):
await self._remove(cache, *args)
) -> None:
await super().reset(*args, cache=cache)
async def purge(self, cache: Optional[Cache] = None) -> None:
"""Completely remove event stage data from database. Currently only removes the event stage record from events collection.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
"""
await self.__collection__.delete_one({"_id": self._id})
self._delete_cache(cache)
await super().purge(cache)
# TODO Add documentation
def get_media_files(self) -> List[File] | None:
@@ -245,3 +225,7 @@ class PycordEventStage:
if len(self.media) == 0
else [File(Path(f"data/{media['id']}"), media["filename"]) for media in self.media]
)
# TODO Add documentation
def get_question_chunked(self, chunk_size: int) -> List[str]:
return [self.question[i : i + chunk_size] for i in range(0, len(self.question), chunk_size)]

View File

@@ -6,6 +6,7 @@ from bson import ObjectId
from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult
from classes.base.base_cacheable import BaseCacheable
from classes.errors import GuildNotFoundError
from modules.database import col_guilds
from modules.utils import get_logger, restore_from_cache
@@ -14,40 +15,50 @@ logger: Logger = get_logger(__name__)
@dataclass
class PycordGuild:
class PycordGuild(BaseCacheable):
"""Dataclass of DB entry of a guild"""
__slots__ = ("_id", "id", "channel_id", "category_id", "timezone")
__slots__ = (
"_id",
"id",
"general_channel_id",
"management_channel_id",
"category_id",
"timezone",
"prefer_emojis",
)
__short_name__ = "guild"
__collection__ = col_guilds
_id: ObjectId
id: int
channel_id: int | None
general_channel_id: int | None
management_channel_id: int | None
category_id: int | None
timezone: str
prefer_emojis: bool
@classmethod
async def from_id(
cls, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None
) -> "PycordGuild":
"""Find guild in database and create new record if guild does not exist.
"""Find the guild by its ID and construct PycordEventStage from database entry.
Args:
guild_id (int): User's Discord ID
allow_creation (:obj:`bool`, optional): Create new guild record if none found in the database
cache (:obj:`Cache`, optional): Cache engine to get the cache from
guild_id (int): ID of the guild to look up.
allow_creation (:obj:`bool`, optional): Create a new record if none found in the database.
cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache.
Returns:
PycordGuild: User object
PycordGuild: Object of the found or newly created guild.
Raises:
GuildNotFoundError: User was not found and creation was not allowed
GuildNotFoundError: Guild with such ID does not exist and creation was not allowed.
"""
cached_entry: Dict[str, Any] | None = restore_from_cache(cls.__short_name__, guild_id, cache=cache)
if cached_entry is not None:
return cls(**cached_entry)
return cls(**cls._entry_from_cache(cached_entry))
db_entry = await cls.__collection__.find_one({"id": guild_id})
@@ -62,164 +73,122 @@ class PycordGuild:
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{guild_id}", db_entry)
cache.set_json(f"{cls.__short_name__}_{guild_id}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
"""Set attribute data and save it into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
**kwargs (Any): Mapping of attribute names and respective values to be set
"""
for key, value in kwargs.items():
if not hasattr(self, key):
raise AttributeError()
setattr(self, key, value)
await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True)
self._update_cache(cache)
logger.info("Set attributes of guild %s to %s", self.id, kwargs)
await super()._set(cache, **kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
"""Remove attribute data and save it into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
*args (str): List of attributes to remove
"""
attributes: Dict[str, Any] = {}
for key in args:
if not hasattr(self, key):
raise AttributeError()
default_value: Any = self.get_default_value(key)
setattr(self, key, default_value)
attributes[key] = default_value
await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True)
self._update_cache(cache)
logger.info("Reset attributes %s of guild %s to default values", args, self.id)
await super()._remove(*args, cache=cache)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
user_dict: Dict[str, Any] = self.to_dict()
if user_dict is not None:
cache.set_json(self._get_cache_key(), user_dict)
else:
self._delete_cache(cache)
super()._update_cache(cache)
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
super()._delete_cache(cache)
cache.delete(self._get_cache_key())
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
return db_entry
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert PycordGuild object to a JSON representation.
"""Convert the object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted.
Returns:
Dict[str, Any]: JSON representation of PycordGuild
Dict[str, Any]: JSON representation of the object.
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"id": self.id,
"channel_id": self.channel_id,
"general_channel_id": self.general_channel_id,
"management_channel_id": self.management_channel_id,
"category_id": self.category_id,
"timezone": self.timezone,
"prefer_emojis": self.prefer_emojis,
}
@staticmethod
def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]:
"""Get default values for the object attributes.
Returns:
Dict[str, Any]: Mapping of attributes and their respective values in format `{"attribute_name:" attribute_value}`.
"""
return {
"id": guild_id,
"channel_id": None,
"general_channel_id": None,
"management_channel_id": None,
"category_id": None,
"timezone": "UTC",
"prefer_emojis": False,
}
@staticmethod
def get_default_value(key: str) -> Any:
"""Get default value of the attribute for the object.
Args:
key (str): Name of the attribute.
Returns:
Any: Default value of the attribute.
Raises:
KeyError: There's no default value for the provided attribute.
"""
if key not in PycordGuild.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordGuild")
return PycordGuild.get_defaults()[key]
# TODO Add documentation
async def update(
self,
cache: Optional[Cache] = None,
**kwargs,
):
await self._set(cache=cache, **kwargs)
**kwargs: Any,
) -> None:
await super().update(cache=cache, **kwargs)
# TODO Add documentation
async def reset(
self,
*args: str,
cache: Optional[Cache] = None,
*args,
):
await self._remove(cache, *args)
) -> None:
await super().reset(*args, cache=cache)
async def purge(self, cache: Optional[Cache] = None) -> None:
"""Completely remove guild data from database. Currently only removes the guild record from guilds collection.
await super().purge(cache)
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
"""
await self.__collection__.delete_one({"_id": self._id})
self._delete_cache(cache)
logger.info("Purged guild %s (%s) from the database", self.id, self._id)
# TODO Add documentation
def is_configured(self) -> bool:
"""Return whether all attributes required for bot's use on the server are set.
Returns:
bool: `True` if yes and `False` if not.
"""
return (
(self.id is not None)
and (self.channel_id is not None)
and (self.general_channel_id is not None)
and (self.management_channel_id is not None)
and (self.category_id is not None)
and (self.timezone is not None)
and (self.prefer_emojis is not None)
)
# TODO Add documentation
async def set_channel(self, channel_id: Optional[int] = None, cache: Optional[Cache] = None) -> None:
await self._set(cache, channel_id=channel_id)
# TODO Add documentation
async def reset_channel(self, cache: Optional[Cache] = None) -> None:
await self._remove(cache, "channel_id")
# TODO Add documentation
async def set_category(self, category_id: Optional[int] = None, cache: Optional[Cache] = None) -> None:
await self._set(cache, category_id=category_id)
# TODO Add documentation
async def reset_category(self, cache: Optional[Cache] = None) -> None:
await self._remove(cache, "category_id")
# TODO Add documentation
async def set_timezone(self, timezone: str, cache: Optional[Cache] = None) -> None:
await self._set(cache, timezone=timezone)
# TODO Add documentation
async def reset_timezone(self, cache: Optional[Cache] = None) -> None:
await self._remove(cache, "timezone")

View File

@@ -17,7 +17,16 @@ from discord.abc import GuildChannel
from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult
from classes.errors.pycord_user import UserNotFoundError
from classes.base.base_cacheable import BaseCacheable
from classes.errors import (
DiscordCategoryNotFoundError,
DiscordChannelNotFoundError,
DiscordGuildMemberNotFoundError,
UserAlreadyCompletedEventError,
UserAlreadyRegisteredForEventError,
UserNotFoundError,
UserNotRegisteredForEventError,
)
from modules.database import col_users
from modules.utils import get_logger, restore_from_cache
@@ -25,7 +34,7 @@ logger: Logger = get_logger(__name__)
@dataclass
class PycordUser:
class PycordUser(BaseCacheable):
"""Dataclass of DB entry of a user"""
__slots__ = (
@@ -52,11 +61,6 @@ class PycordUser:
registered_event_ids: List[ObjectId]
completed_event_ids: List[ObjectId]
# TODO Review the redesign
# event_channel_ids: {
# "%event_id%": %channel_id%
# }
@classmethod
async def from_id(
cls, user_id: int, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None
@@ -80,7 +84,7 @@ class PycordUser:
)
if cached_entry is not None:
return cls(**cached_entry)
return cls(**cls._entry_from_cache(cached_entry))
db_entry = await cls.__collection__.find_one({"id": user_id, "guild_id": guild_id})
@@ -95,7 +99,7 @@ class PycordUser:
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{user_id}_{guild_id}", db_entry)
cache.set_json(f"{cls.__short_name__}_{user_id}_{guild_id}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
@@ -114,8 +118,12 @@ class PycordUser:
"guild_id": self.guild_id,
"event_channels": self.event_channels,
"is_jailed": self.is_jailed,
"current_event_id": (self.current_event_id if not json_compatible else str(self.current_event_id)),
"current_stage_id": (self.current_stage_id if not json_compatible else str(self.current_stage_id)),
"current_event_id": (
self.current_event_id if not json_compatible else str(self.current_event_id)
),
"current_stage_id": (
self.current_stage_id if not json_compatible else str(self.current_stage_id)
),
"registered_event_ids": (
self.registered_event_ids
if not json_compatible
@@ -129,69 +137,61 @@ class PycordUser:
}
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
"""Set attribute data and save it into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
**kwargs (Any): Mapping of attribute names and respective values to be set
"""
for key, value in kwargs.items():
if not hasattr(self, key):
raise AttributeError()
setattr(self, key, value)
await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs}, upsert=True)
self._update_cache(cache)
logger.info("Set attributes of user %s to %s", self.id, kwargs)
await super()._set(cache, **kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
"""Remove attribute data and save it into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
*args (str): List of attributes to remove
"""
attributes: Dict[str, Any] = {}
for key in args:
if not hasattr(self, key):
raise AttributeError()
default_value: Any = self.get_default_value(key)
setattr(self, key, default_value)
attributes[key] = default_value
await self.__collection__.update_one({"_id": self._id}, {"$set": attributes}, upsert=True)
self._update_cache(cache)
logger.info("Reset attributes %s of user %s to default values", args, self.id)
await super()._remove(*args, cache=cache)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.id}"
return f"{self.__short_name__}_{self.id}_{self.guild_id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
user_dict: Dict[str, Any] = self.to_dict()
if user_dict is not None:
cache.set_json(self._get_cache_key(), user_dict)
else:
self._delete_cache(cache)
super()._update_cache(cache)
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
super()._delete_cache(cache)
cache.delete(self._get_cache_key())
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
cache_entry["current_event_id"] = (
None if cache_entry["current_event_id"] is None else str(cache_entry["current_event_id"])
)
cache_entry["current_stage_id"] = (
None if cache_entry["current_stage_id"] is None else str(cache_entry["current_stage_id"])
)
cache_entry["registered_event_ids"] = [
str(event_id) for event_id in cache_entry["registered_event_ids"]
]
cache_entry["completed_event_ids"] = [
str(event_id) for event_id in cache_entry["completed_event_ids"]
]
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
db_entry["current_event_id"] = (
None if db_entry["current_event_id"] is None else ObjectId(db_entry["current_event_id"])
)
db_entry["current_stage_id"] = (
None if db_entry["current_stage_id"] is None else ObjectId(db_entry["current_stage_id"])
)
db_entry["registered_event_ids"] = [
ObjectId(event_id) for event_id in db_entry["registered_event_ids"]
]
db_entry["completed_event_ids"] = [
ObjectId(event_id) for event_id in db_entry["completed_event_ids"]
]
return db_entry
# TODO Add documentation
@staticmethod
def get_defaults(user_id: Optional[int] = None, guild_id: Optional[int] = None) -> Dict[str, Any]:
return {
@@ -205,6 +205,7 @@ class PycordUser:
"completed_event_ids": [],
}
# TODO Add documentation
@staticmethod
def get_default_value(key: str) -> Any:
if key not in PycordUser.get_defaults():
@@ -212,24 +213,29 @@ class PycordUser:
return PycordUser.get_defaults()[key]
async def purge(self, cache: Optional[Cache] = None) -> None:
"""Completely remove user data from database. Currently only removes the user record from users collection.
async def update(
self,
cache: Optional[Cache] = None,
**kwargs: Any,
) -> None:
await super().update(cache=cache, **kwargs)
Args:
cache (:obj:`Cache`, optional): Cache engine to write the update into
"""
await self.__collection__.delete_one({"_id": self._id})
self._delete_cache(cache)
async def reset(
self,
*args: str,
cache: Optional[Cache] = None,
) -> None:
await super().reset(*args, cache=cache)
async def purge(self, cache: Optional[Cache] = None) -> None:
await super().purge(cache)
# TODO Add documentation
async def event_register(self, event_id: str | ObjectId, cache: Optional[Cache] = None) -> None:
event_id: ObjectId = ObjectId(event_id) if isinstance(event_id, str) else event_id
if event_id in self.registered_event_ids:
raise RuntimeError(f"User is already registered for event {event_id}")
# TODO Add a unique exception
# raise UserAlreadyRegisteredForEventError(event_name)
raise UserAlreadyRegisteredForEventError(self.id, event_id)
self.registered_event_ids.append(event_id)
@@ -240,10 +246,7 @@ class PycordUser:
event_id: ObjectId = ObjectId(event_id) if isinstance(event_id, str) else event_id
if event_id not in self.registered_event_ids:
raise RuntimeError(f"User is not registered for event {event_id}")
# TODO Add a unique exception
# raise UserNotRegisteredForEventError(event_name)
raise UserNotRegisteredForEventError(self.id, event_id)
self.registered_event_ids.remove(event_id)
@@ -254,44 +257,33 @@ class PycordUser:
event_id: ObjectId = ObjectId(event_id) if isinstance(event_id, str) else event_id
if event_id in self.completed_event_ids:
raise RuntimeError(f"User has already completed event {event_id}")
# TODO Add a unique exception
# raise UserAlreadyCompletedEventError(event_name)
raise UserAlreadyCompletedEventError(self.id, event_id)
self.completed_event_ids.append(event_id)
await self._set(cache, completed_event_ids=self.completed_event_ids)
# TODO Add documentation
async def setup_event_channel(
self,
bot: Bot,
guild: Guild,
pycord_guild: "PycordGuild",
pycord_event: "PycordEvent",
ignore_exists: bool = False,
cache: Optional[Cache] = None,
):
if str(pycord_event._id) in self.event_channels.keys():
return
) -> TextChannel | None:
if not ignore_exists and str(pycord_event._id) in self.event_channels.keys():
return None
discord_member: Member | None = guild.get_member(self.id)
discord_category: GuildChannel | None = bot.get_channel(pycord_guild.category_id)
if discord_member is None:
raise RuntimeError(
f"Discord guild member with ID {self.id} in guild with ID {guild.id} could not be found!"
)
# TODO Add a unique exception
# raise DiscordGuildMemberNotFoundError(self.id, guild.id)
raise DiscordGuildMemberNotFoundError(self.id, guild.id)
if discord_category is None:
raise RuntimeError(
f"Discord category with ID {pycord_guild.category_id} in guild with ID {guild.id} could not be found!"
)
# TODO Add a unique exception
# raise DiscordCategoryNotFoundError(pycord_guild.category_id, guild.id)
raise DiscordCategoryNotFoundError(pycord_guild.category_id, guild.id)
permission_overwrites: Dict[Role | Member, PermissionOverwrite] = {
guild.default_role: PermissionOverwrite(
@@ -316,6 +308,46 @@ class PycordUser:
await self.set_event_channel(pycord_event._id, channel.id, cache=cache)
return channel
# TODO Add documentation
async def fix_event_channel(
self,
bot: Bot,
guild: Guild,
pycord_guild: "PycordGuild",
pycord_event: "PycordEvent",
cache: Optional[Cache] = None,
) -> TextChannel | None:
# Configure channel if not set
if str(pycord_event._id) not in self.event_channels.keys():
return await self.setup_event_channel(bot, guild, pycord_guild, pycord_event, cache=cache)
discord_member: Member | None = guild.get_member(self.id)
if discord_member is None:
raise DiscordGuildMemberNotFoundError(self.id, guild.id)
channel: TextChannel = guild.get_channel(self.event_channels[str(pycord_event._id)])
if channel is None:
return await self.setup_event_channel(
bot, guild, pycord_guild, pycord_event, ignore_exists=True, cache=cache
)
await channel.set_permissions(
discord_member,
overwrite=PermissionOverwrite(
view_channel=True,
send_messages=True,
use_application_commands=True,
),
reason=f"Updated event channel of {self.id} for event {pycord_event._id}",
)
return channel
# TODO Add documentation
async def lock_event_channel(
self,
guild: Guild,
@@ -329,20 +361,10 @@ class PycordUser:
)
if discord_member is None:
raise RuntimeError(
f"Discord guild member with ID {self.id} in guild with ID {guild.id} could not be found!"
)
# TODO Add a unique exception
# raise DiscordGuildMemberNotFoundError(self.id, guild.id)
raise DiscordGuildMemberNotFoundError(self.id, guild.id)
if discord_member is None:
raise RuntimeError(
f"Discord channel with ID {self.event_channels[str(event_id)]} in guild with ID {guild.id} could not be found!"
)
# TODO Add a unique exception
# raise DiscordChannelNotFoundError(self.event_channels[str(event_id)], guild.id)
raise DiscordChannelNotFoundError(self.event_channels[str(event_id)], guild.id)
permission_overwrite: PermissionOverwrite = PermissionOverwrite(
view_channel=not completely,
@@ -371,10 +393,20 @@ class PycordUser:
# TODO Add documentation
async def set_event_stage(self, stage_id: str | ObjectId | None, cache: Optional[Cache] = None) -> None:
await self._set(cache, current_stage_id=stage_id if isinstance(stage_id, str) else ObjectId(stage_id))
await self._set(
cache, current_stage_id=stage_id if isinstance(stage_id, str) else ObjectId(stage_id)
)
# TODO Add documentation
async def set_event(self, event_id: str | ObjectId | None, cache: Optional[Cache] = None) -> None:
await self._set(
cache, current_event_id=event_id if isinstance(event_id, str) else ObjectId(event_id)
)
# TODO Add documentation
async def jail(self, cache: Optional[Cache] = None) -> None:
await self._set(cache, is_jailed=True)
# TODO Add documentation
async def unjail(self, cache: Optional[Cache] = None) -> None:
await self._set(cache, is_jailed=False)

View File

@@ -1,5 +1,6 @@
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from bson.errors import InvalidId
from discord import (
ApplicationContext,
CategoryChannel,
@@ -12,6 +13,7 @@ from discord.utils import basic_autocomplete
from libbot.i18n import _, in_every_locale
from classes import PycordGuild
from classes.errors import GuildNotFoundError
from classes.pycord_bot import PycordBot
from modules.utils import autocomplete_timezones, is_operation_confirmed
@@ -36,37 +38,75 @@ class CogConfig(Cog):
@option(
"category",
description=_("description", "commands", "config_set", "options", "category"),
description_localizations=in_every_locale("description", "commands", "config_set", "options", "category"),
description_localizations=in_every_locale(
"description", "commands", "config_set", "options", "category"
),
required=True,
)
@option(
"general_channel",
description=_("description", "commands", "config_set", "options", "general_channel"),
description_localizations=in_every_locale(
"description", "commands", "config_set", "options", "general_channel"
),
required=True,
)
@option(
"management_channel",
description=_("description", "commands", "config_set", "options", "management_channel"),
description_localizations=in_every_locale(
"description", "commands", "config_set", "options", "management_channel"
),
required=True,
)
@option("channel", description="Text channel for admin notifications", required=True)
@option(
"timezone",
description=_("description", "commands", "config_set", "options", "timezone"),
description_localizations=in_every_locale("description", "commands", "config_set", "options", "timezone"),
description_localizations=in_every_locale(
"description", "commands", "config_set", "options", "timezone"
),
autocomplete=basic_autocomplete(autocomplete_timezones),
required=True,
)
@option(
"prefer_emojis",
description=_("description", "commands", "config_set", "options", "prefer_emojis"),
description_localizations=in_every_locale(
"description", "commands", "config_set", "options", "prefer_emojis"
),
required=True,
)
async def command_config_set(
self,
ctx: ApplicationContext,
category: CategoryChannel,
channel: TextChannel,
general_channel: TextChannel,
management_channel: TextChannel,
timezone: str,
prefer_emojis: bool,
) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
try:
timezone_parsed: ZoneInfo = ZoneInfo(timezone)
except ZoneInfoNotFoundError:
await ctx.respond(self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone))
await ctx.respond(
self.bot._("timezone_invalid", "messages", locale=ctx.locale).format(timezone=timezone),
ephemeral=True,
)
return
await guild.update(
self.bot.cache,
channel_id=channel.id,
general_channel_id=general_channel.id,
management_channel_id=management_channel.id,
category_id=category.id,
timezone=str(timezone_parsed),
prefer_emojis=prefer_emojis,
)
await ctx.respond(self.bot._("config_set", "messages", locale=ctx.locale))
@@ -79,14 +119,20 @@ class CogConfig(Cog):
@option(
"confirm",
description=_("description", "commands", "config_reset", "options", "confirm"),
description_localizations=in_every_locale("description", "commands", "config_reset", "options", "confirm"),
description_localizations=in_every_locale(
"description", "commands", "config_reset", "options", "confirm"
),
required=False,
)
async def command_config_reset(self, ctx: ApplicationContext, confirm: bool = False) -> None:
if not (await is_operation_confirmed(ctx, confirm)):
return
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
await guild.purge(self.bot.cache)
@@ -98,17 +144,25 @@ class CogConfig(Cog):
description_localizations=in_every_locale("description", "commands", "config_show"),
)
async def command_config_show(self, ctx: ApplicationContext) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True
)
return
await ctx.respond(
self.bot._("config_show", "messages", locale=ctx.locale).format(
channel_id=guild.channel_id,
general_channel_id=guild.general_channel_id,
management_channel_id=guild.management_channel_id,
category_id=guild.category_id,
timezone=guild.timezone,
prefer_emojis=guild.prefer_emojis,
)
)

View File

@@ -11,8 +11,10 @@ from discord import (
)
from discord.ext.commands import Cog
from discord.utils import basic_autocomplete
from libbot.i18n import _, in_every_locale
from classes import PycordEvent, PycordEventStage, PycordGuild
from classes.errors import EventNotFoundError, GuildNotFoundError
from classes.pycord_bot import PycordBot
from modules.utils import (
autocomplete_active_events,
@@ -28,18 +30,49 @@ class CogEvent(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
# TODO Introduce i18n
command_group: SlashCommandGroup = SlashCommandGroup("event", "Event management")
command_group: SlashCommandGroup = SlashCommandGroup(
"event",
description=_("description", "commands", "event"),
description_localizations=in_every_locale("description", "commands", "event"),
)
# TODO Introduce i18n
@command_group.command(
name="create",
description="Create new event",
description=_("description", "commands", "event_create"),
description_localizations=in_every_locale("description", "commands", "event_create"),
)
@option(
"name",
description=_("description", "commands", "event_create", "options", "name"),
description_localizations=in_every_locale(
"description", "commands", "event_create", "options", "name"
),
required=True,
)
@option(
"start",
description=_("description", "commands", "event_create", "options", "start"),
description_localizations=in_every_locale(
"description", "commands", "event_create", "options", "start"
),
required=True,
)
@option(
"end",
description=_("description", "commands", "event_create", "options", "end"),
description_localizations=in_every_locale(
"description", "commands", "event_create", "options", "end"
),
required=True,
)
@option(
"thumbnail",
description=_("description", "commands", "event_create", "options", "thumbnail"),
description_localizations=in_every_locale(
"description", "commands", "event_create", "options", "thumbnail"
),
required=False,
)
@option("name", description="Name of the event", required=True)
@option("start", description="Date when the event starts (DD.MM.YYYY HH:MM)", required=True)
@option("end", description="Date when the event ends (DD.MM.YYYY HH:MM)", required=True)
@option("thumbnail", description="Thumbnail of the event", required=False)
async def command_event_create(
self,
ctx: ApplicationContext,
@@ -48,28 +81,31 @@ class CogEvent(Cog):
end: str,
thumbnail: Attachment = None,
) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True
)
return
guild_timezone: ZoneInfo = ZoneInfo(guild.timezone)
try:
start_date: datetime = datetime.strptime(start, "%d.%m.%Y %H:%M")
end_date: datetime = datetime.strptime(end, "%d.%m.%Y %H:%M")
start_date = start_date.replace(tzinfo=guild_timezone)
end_date = end_date.replace(tzinfo=guild_timezone)
start_date: datetime = datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone)
end_date: datetime = datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone)
except ValueError:
# TODO Introduce i18n
await ctx.respond(
"Could not parse start and end dates. Please, make sure these are provided in `DD.MM.YYYY HH:MM` format."
self.bot._("event_dates_parsing_failed", "messages", locale=ctx.locale), ephemeral=True
)
return
await validate_event_validity(ctx, name, start_date, end_date, guild_timezone)
if not await validate_event_validity(ctx, name, start_date, end_date, to_utc=True):
return
processed_media: List[Dict[str, Any]] = (
[] if thumbnail is None else await self.bot.process_attachments([thumbnail])
@@ -84,26 +120,58 @@ class CogEvent(Cog):
thumbnail=processed_media[0] if thumbnail else None,
)
# TODO Introduce i18n
await ctx.respond(
f"Event **{event.name}** has been created and will take place <t:{get_unix_timestamp(event.starts)}:R>."
self.bot._("event_created", "messages", locale=ctx.locale).format(
event_name=event.name, start_time=get_unix_timestamp(event.starts, to_utc=True)
)
)
# TODO Introduce i18n
@command_group.command(
name="edit",
description="Edit event",
description=_("description", "commands", "event_edit"),
description_localizations=in_every_locale("description", "commands", "event_edit"),
)
@option(
"event",
description="Name of the event",
description=_("description", "commands", "event_edit", "options", "event"),
description_localizations=in_every_locale(
"description", "commands", "event_edit", "options", "event"
),
autocomplete=basic_autocomplete(autocomplete_active_events),
required=True,
)
@option("name", description="New name of the event", required=False)
@option("start", description="Date when the event starts (DD.MM.YYYY HH:MM)", required=False)
@option("end", description="Date when the event ends (DD.MM.YYYY HH:MM)", required=False)
@option("thumbnail", description="Thumbnail of the event", required=False)
@option(
"name",
description=_("description", "commands", "event_edit", "options", "name"),
description_localizations=in_every_locale(
"description", "commands", "event_edit", "options", "name"
),
required=False,
)
@option(
"start",
description=_("description", "commands", "event_edit", "options", "start"),
description_localizations=in_every_locale(
"description", "commands", "event_edit", "options", "start"
),
required=False,
)
@option(
"end",
description=_("description", "commands", "event_edit", "options", "end"),
description_localizations=in_every_locale(
"description", "commands", "event_edit", "options", "end"
),
required=False,
)
@option(
"thumbnail",
description=_("description", "commands", "event_edit", "options", "thumbnail"),
description_localizations=in_every_locale(
"description", "commands", "event_edit", "options", "thumbnail"
),
required=False,
)
async def command_event_edit(
self,
ctx: ApplicationContext,
@@ -113,40 +181,59 @@ class CogEvent(Cog):
end: str = None,
thumbnail: Attachment = None,
) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
try:
pycord_event: PycordEvent = await self.bot.find_event(event_id=event)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event was not found.")
except (InvalidId, EventNotFoundError):
await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True
)
return
guild_timezone: ZoneInfo = ZoneInfo(guild.timezone)
try:
start_date: datetime = (
pycord_event.starts if start is None else datetime.strptime(start, "%d.%m.%Y %H:%M")
pycord_event.starts.replace(tzinfo=ZoneInfo("UTC"))
if start is None
else datetime.strptime(start, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone)
)
start_date = start_date.replace(tzinfo=guild_timezone)
except ValueError:
# TODO Make a nice message
await ctx.respond("Could not parse the start date.")
await ctx.respond(
self.bot._("event_start_date_parsing_failed", "messages", locale=ctx.locale), ephemeral=True
)
return
try:
end_date: datetime = pycord_event.ends if end is None else datetime.strptime(end, "%d.%m.%Y %H:%M")
end_date = end_date.replace(tzinfo=guild_timezone)
end_date: datetime = (
pycord_event.ends.replace(tzinfo=ZoneInfo("UTC"))
if end is None
else datetime.strptime(end, "%d.%m.%Y %H:%M").replace(tzinfo=guild_timezone)
)
except ValueError:
# TODO Make a nice message
await ctx.respond("Could not parse the end date.")
await ctx.respond(
self.bot._("event_end_date_parsing_failed", "messages", locale=ctx.locale), ephemeral=True
)
return
await validate_event_validity(ctx, name, start_date, end_date, guild_timezone)
if not await validate_event_validity(
ctx,
pycord_event.name if name is None else name,
start_date,
end_date,
event_id=pycord_event._id,
to_utc=True,
):
return
processed_media: List[Dict[str, Any]] = (
[] if thumbnail is None else await self.bot.process_attachments([thumbnail])
@@ -154,31 +241,43 @@ class CogEvent(Cog):
await pycord_event.update(
self.bot.cache,
starts=start_date,
ends=end_date,
starts=start_date.astimezone(ZoneInfo("UTC")),
ends=end_date.astimezone(ZoneInfo("UTC")),
name=pycord_event.name if name is None else name,
thumbnail=pycord_event.thumbnail if thumbnail is None else processed_media[0],
)
# TODO Notify participants about time changes
# TODO Make a nice message
await ctx.respond(
f"Event **{pycord_event.name}** has been updated and will take place <t:{get_unix_timestamp(pycord_event.starts)}: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),
)
)
# TODO Introduce i18n
@command_group.command(
name="cancel",
description="Cancel event",
description=_("description", "commands", "event_cancel"),
description_localizations=in_every_locale("description", "commands", "event_cancel"),
)
@option(
"event",
description="Name of the event",
description=_("description", "commands", "event_cancel", "options", "event"),
description_localizations=in_every_locale(
"description", "commands", "event_cancel", "options", "event"
),
autocomplete=basic_autocomplete(autocomplete_active_events),
required=True,
)
@option("confirm", description="Confirmation of the operation", required=False)
@option(
"confirm",
description=_("description", "commands", "event_cancel", "options", "confirm"),
description_localizations=in_every_locale(
"description", "commands", "event_cancel", "options", "confirm"
),
required=False,
)
async def command_event_cancel(
self,
ctx: ApplicationContext,
@@ -188,17 +287,22 @@ class CogEvent(Cog):
if not (await is_operation_confirmed(ctx, confirm)):
return
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
try:
pycord_event: PycordEvent = await self.bot.find_event(event_id=event)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event was not found.")
except (InvalidId, EventNotFoundError):
await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True
)
return
start_date: datetime = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC"))
@@ -210,56 +314,75 @@ class CogEvent(Cog):
or end_date <= datetime.now(tz=ZoneInfo("UTC"))
or start_date <= datetime.now(tz=ZoneInfo("UTC"))
):
# TODO Make a nice message
await ctx.respond("Finished or ongoing events cannot be cancelled.")
await ctx.respond(
self.bot._("event_not_editable", "messages", locale=ctx.locale).format(
event_name=pycord_event.name
),
ephemeral=True,
)
return
await pycord_event.cancel()
# TODO Notify participants about cancellation
# TODO Make a nice message
await ctx.respond(f"Event **{pycord_event.name}** was cancelled.")
await ctx.respond(
self.bot._("event_cancelled", "messages", locale=ctx.locale).format(
event_name=pycord_event.name
)
)
# TODO Introduce i18n
@command_group.command(
name="show",
description="Show the details about certain event",
description=_("description", "commands", "event_show"),
description_localizations=in_every_locale("description", "commands", "event_show"),
)
@option(
"event",
description="Name of the event",
description=_("description", "commands", "event_show", "options", "event"),
description_localizations=in_every_locale(
"description", "commands", "event_show", "options", "event"
),
autocomplete=basic_autocomplete(autocomplete_active_events),
required=True,
)
async def command_event_show(self, ctx: ApplicationContext, event: str) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
pycord_event: PycordEvent = await self.bot.find_event(event_id=event)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event was not found.")
except (InvalidId, EventNotFoundError):
await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
starts_date: datetime = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC"))
ends_date: datetime = pycord_event.ends.replace(tzinfo=ZoneInfo("UTC"))
starts_date: datetime = pycord_event.get_start_date_utc()
ends_date: datetime = pycord_event.get_end_date_utc()
stages: List[PycordEventStage] = await self.bot.get_event_stages(pycord_event)
# TODO Make a nice message
stages_string: str = "\n\n".join(
f"**Stage {stage.sequence+1}**\nQuestion: {stage.question}\nAnswer: ||{stage.answer}||"
self.bot._("stage_entry", "messages", locale=ctx.locale).format(
sequence=stage.sequence + 1, answer=stage.answer
)
for stage in stages
)
# TODO Show users registered for the event
# TODO Introduce i18n
await ctx.respond(
f"**Event details**\n\nName: {pycord_event.name}\nStarts: <t:{get_unix_timestamp(starts_date)}>\nEnds: <t:{get_unix_timestamp(ends_date)}>\n\nStages:\n{stages_string}"
event_info_string: str = self.bot._("event_details", "messages", locale=ctx.locale).format(
event_name=pycord_event.name,
start_time=get_unix_timestamp(starts_date),
end_time=get_unix_timestamp(ends_date),
stages=stages_string,
)
chunk_size: int = 2000
event_info_chunks: List[str] = [
event_info_string[i : i + chunk_size] for i in range(0, len(event_info_string), chunk_size)
]
for chunk in event_info_chunks:
await ctx.respond(chunk)
def setup(bot: PycordBot) -> None:
bot.add_cog(CogEvent(bot))

View File

@@ -3,8 +3,14 @@ from typing import List
from bson import ObjectId
from bson.errors import InvalidId
from discord import ApplicationContext, Cog, File, option, slash_command
from libbot.i18n import _, in_every_locale
from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser
from classes.errors import (
EventNotFoundError,
EventStageNotFoundError,
GuildNotFoundError,
)
from classes.pycord_bot import PycordBot
@@ -14,48 +20,58 @@ class CogGuess(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
# TODO Implement the command
@slash_command(
name="guess",
description="Propose an answer to the current event stage",
description=_("description", "commands", "guess"),
description_localizations=in_every_locale("description", "commands", "guess"),
)
@option(
"answer",
description=_("description", "commands", "guess", "options", "answer"),
description_localizations=in_every_locale("description", "commands", "guess", "options", "answer"),
)
@option("answer", description="An answer to the current stage")
async def command_guess(self, ctx: ApplicationContext, answer: str) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured", "messages", locale=ctx.locale), ephemeral=True
)
return
user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild)
if user.is_jailed:
# TODO Make a nice message
await ctx.respond("You are jailed and cannot interact with events. Please, contact the administrator.")
await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale))
return
if user.current_event_id is None or user.current_stage_id is None:
# TODO Make a nice message
await ctx.respond("You have no ongoing events.")
await ctx.respond(self.bot._("guess_unregistered", "messages", locale=ctx.locale))
return
try:
event: PycordEvent = await self.bot.find_event(event_id=user.current_event_id)
stage: PycordEventStage = await self.bot.find_event_stage(user.current_stage_id)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Your event could not be found. Please, report this issue to the event's management.")
except (InvalidId, EventNotFoundError, EventStageNotFoundError):
await ctx.respond(self.bot._("guess_incorrect_event", "messages", locale=ctx.locale))
return
if ctx.channel_id != user.event_channels[str(event._id)]:
# TODO Make a nice message
await ctx.respond("Usage outside own event channel is not allowed.", ephemeral=True)
await ctx.respond(
self.bot._("guess_incorrect_channel", "messages", locale=ctx.locale), ephemeral=True
)
return
if answer.lower() != stage.answer.lower():
# TODO Make a nice message
# await ctx.respond("Provided answer is wrong.")
await ctx.respond(self.bot.config["emojis"]["guess_wrong"])
await ctx.respond(
self.bot.config["emojis"]["guess_wrong"]
if guild.prefer_emojis
else self.bot._("guess_incorrect", "messages", locale=ctx.locale)
)
return
next_stage_index = stage.sequence + 1
@@ -64,7 +80,6 @@ class CogGuess(Cog):
)
if next_stage_id is None:
# TODO Make a nice message
user.completed_event_ids.append(event._id)
await user._set(
@@ -74,30 +89,39 @@ class CogGuess(Cog):
completed_event_ids=user.completed_event_ids,
)
await ctx.respond("Congratulations! You have completed the event!")
await ctx.respond(self.bot._("guess_completed_event", "messages", locale=ctx.locale))
await self.bot.notify_admins(
ctx.guild,
guild,
f"User **{ctx.author.display_name}** ({ctx.author.mention}) has completed the event",
self.bot._("admin_user_completed_event", "messages", locale=ctx.locale).format(
display_name=ctx.author.display_name, mention=ctx.author.mention, event_name=event.name
),
)
return
next_stage: PycordEventStage = await self.bot.find_event_stage(next_stage_id)
files: List[File] | None = next_stage.get_media_files()
await ctx.respond(
f"Provided answer is correct! Next stage...\n\n{next_stage.question}",
files=files,
)
next_question_chunks: List[str] = next_stage.get_question_chunked(2000)
next_question_chunks_length: int = len(next_question_chunks)
for index, chunk in enumerate(next_question_chunks):
await ctx.respond(chunk, files=None if index != next_question_chunks_length - 1 else files)
await user.set_event_stage(next_stage._id, cache=self.bot.cache)
await self.bot.notify_admins(
ctx.guild,
guild,
f"User **{ctx.author.display_name}** ({ctx.author.mention}) has completed the stage {stage.sequence+1} of the event **{event.name}**.",
self.bot._("admin_user_completed_stage", "messages", locale=ctx.locale).format(
display_name=ctx.author.display_name,
mention=ctx.author.mention,
stage_sequence=stage.sequence + 1,
event_name=event.name,
),
)

View File

@@ -1,10 +1,19 @@
from bson.errors import InvalidId
from discord import ApplicationContext, Cog, option, slash_command
from discord.utils import basic_autocomplete
from datetime import datetime
from logging import Logger
from pathlib import Path
from zoneinfo import ZoneInfo
from classes import PycordEvent, PycordGuild, PycordUser
from bson.errors import InvalidId
from discord import ApplicationContext, Cog, File, TextChannel, option, slash_command
from discord.utils import basic_autocomplete
from libbot.i18n import _, in_every_locale
from classes import PycordEvent, PycordEventStage, PycordGuild, PycordUser
from classes.errors import EventNotFoundError, GuildNotFoundError
from classes.pycord_bot import PycordBot
from modules.utils import autocomplete_active_events, get_unix_timestamp
from modules.utils import autocomplete_active_events, get_logger, get_unix_timestamp
logger: Logger = get_logger(__name__)
class CogRegister(Cog):
@@ -13,49 +22,115 @@ class CogRegister(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
# TODO Introduce i18n
@slash_command(
name="register",
description="Enter the selected event",
description=_("description", "commands", "register"),
description_localizations=in_every_locale("description", "commands", "register"),
)
@option(
"event",
description="Name of the event",
description=_("description", "commands", "register", "options", "event"),
description_localizations=in_every_locale(
"description", "commands", "register", "options", "event"
),
autocomplete=basic_autocomplete(autocomplete_active_events),
)
async def command_register(self, ctx: ApplicationContext, event: str) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
try:
pycord_event: PycordEvent = await self.bot.find_event(event_id=event)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event was not found.")
except (InvalidId, EventNotFoundError):
await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured", "messages", locale=ctx.locale), ephemeral=True
)
return
user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild)
if user.is_jailed:
# TODO Make a nice message
await ctx.respond("You are jailed and cannot interact with events. Please, contact the administrator.")
await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale), ephemeral=True)
return
if pycord_event._id in user.registered_event_ids:
# TODO Make a nice message
await ctx.respond("You are already registered for this event.")
await ctx.respond(
self.bot._("register_already_registered", "messages", locale=ctx.locale), ephemeral=True
)
return
await user.event_register(pycord_event._id, cache=self.bot.cache)
# TODO Make a nice message
await ctx.respond(
f"You are now registered for the event **{pycord_event.name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts <t:{get_unix_timestamp(pycord_event.starts)}:R>. Good luck!"
event_ongoing: bool = pycord_event.starts.replace(tzinfo=ZoneInfo("UTC")) < datetime.now(
tz=ZoneInfo("UTC")
)
registered_message: str = (
self.bot._("register_success_ongoing", "messages", locale=ctx.locale).format(
event_name=pycord_event.name
)
if event_ongoing
else self.bot._("register_success_scheduled", "messages", locale=ctx.locale).format(
event_name=pycord_event.name,
event_starts=get_unix_timestamp(pycord_event.starts, to_utc=True),
)
)
await ctx.respond(registered_message)
if event_ongoing:
await user.set_event(pycord_event._id, cache=self.bot.cache)
user_channel: TextChannel = await user.setup_event_channel(
self.bot, ctx.guild, guild, pycord_event, cache=self.bot.cache
)
if user_channel is None:
logger.error(
"Event channel was not created for user %s from guild %s and event %s after registration.",
ctx.author.id,
guild.id,
pycord_event._id,
)
await self.bot.notify_admins(
ctx.guild,
guild,
self.bot._("admin_user_channel_creation_failed", "messages", locale=ctx.locale).format(
display_name=ctx.author.display_name,
mention=ctx.author.mention,
event_name=pycord_event.name,
),
)
return
thumbnail: File | None = (
None
if pycord_event.thumbnail is None
else File(Path(f"data/{pycord_event.thumbnail['id']}"), pycord_event.thumbnail["filename"])
)
await user_channel.send(
self.bot._("notice_event_already_started", "messages", locale=ctx.locale).format(
event_name=pycord_event.name
),
file=thumbnail,
)
first_stage: PycordEventStage = await self.bot.find_event_stage(pycord_event.stage_ids[0])
await user.set_event_stage(first_stage._id, cache=self.bot.cache)
await self.bot.send_stage_question(user_channel, pycord_event, first_stage)
def setup(bot: PycordBot) -> None:
bot.add_cog(CogRegister(bot))

View File

@@ -4,8 +4,14 @@ from bson.errors import InvalidId
from discord import ApplicationContext, Attachment, SlashCommandGroup, option
from discord.ext.commands import Cog
from discord.utils import basic_autocomplete
from libbot.i18n import _, in_every_locale
from classes import PycordEvent, PycordEventStage, PycordGuild
from classes.errors import (
EventNotFoundError,
EventStageNotFoundError,
GuildNotFoundError,
)
from classes.pycord_bot import PycordBot
from modules.utils import (
autocomplete_active_events,
@@ -21,23 +27,52 @@ class CogStage(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
command_group: SlashCommandGroup = SlashCommandGroup("stage", "Event stage management")
command_group: SlashCommandGroup = SlashCommandGroup(
"stage",
description=_("description", "commands", "stage"),
description_localizations=in_every_locale("description", "commands", "stage"),
)
# TODO Introduce i18n
# TODO Maybe add an option for order?
@command_group.command(
name="add",
description="Add new event stage",
description=_("description", "commands", "stage_add"),
description_localizations=in_every_locale("description", "commands", "stage_add"),
)
@option(
"event",
description="Name of the event",
description=_("description", "commands", "stage_add", "options", "event"),
description_localizations=in_every_locale(
"description", "commands", "stage_add", "options", "event"
),
autocomplete=basic_autocomplete(autocomplete_active_events),
required=True,
)
@option("question", description="Question to be answered", required=True)
@option("answer", description="Answer to the stage's question", required=True)
@option("media", description="Media file to be attached", required=False)
@option(
"question",
description=_("description", "commands", "stage_add", "options", "question"),
description_localizations=in_every_locale(
"description", "commands", "stage_add", "options", "question"
),
required=True,
)
@option(
"answer",
description=_("description", "commands", "stage_add", "options", "answer"),
description_localizations=in_every_locale(
"description", "commands", "stage_add", "options", "answer"
),
max_length=500,
required=True,
)
@option(
"media",
description=_("description", "commands", "stage_add", "options", "media"),
description_localizations=in_every_locale(
"description", "commands", "stage_add", "options", "media"
),
required=False,
)
async def command_stage_add(
self,
ctx: ApplicationContext,
@@ -46,23 +81,30 @@ class CogStage(Cog):
answer: str,
media: Attachment = None,
) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True
)
return
try:
pycord_event: PycordEvent = await self.bot.find_event(event_id=event)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event was not found.")
except (InvalidId, EventStageNotFoundError):
await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
if not (await is_event_status_valid(ctx, pycord_event)):
return
processed_media: List[Dict[str, Any]] = [] if media is None else await self.bot.process_attachments([media])
processed_media: List[Dict[str, Any]] = (
[] if media is None else await self.bot.process_attachments([media])
)
event_stage: PycordEventStage = await self.bot.create_event_stage(
event=pycord_event,
@@ -75,33 +117,73 @@ class CogStage(Cog):
media=[] if media is None else processed_media,
)
# TODO Make a nice message
await ctx.respond("Event stage has been created.")
await ctx.respond(self.bot._("stage_created", "messages", locale=ctx.locale))
# TODO Implement the command
# /stage edit <event> <stage> <order> <question> <answer> <media>
@command_group.command(
name="edit",
description="Edit the event stage",
description=_("description", "commands", "stage_edit"),
description_localizations=in_every_locale("description", "commands", "stage_edit"),
)
@option(
"event",
description="Name of the event",
description=_("description", "commands", "stage_edit", "options", "event"),
description_localizations=in_every_locale(
"description", "commands", "stage_edit", "options", "event"
),
autocomplete=basic_autocomplete(autocomplete_active_events),
required=True,
)
# TODO Add autofill
@option(
"stage",
description="Stage to edit",
description=_("description", "commands", "stage_edit", "options", "stage"),
description_localizations=in_every_locale(
"description", "commands", "stage_edit", "options", "stage"
),
autocomplete=basic_autocomplete(autocomplete_event_stages),
required=True,
)
@option("order", description="Number in the event stages' order", min_value=1, required=False)
@option("question", description="Question to be answered", required=False)
@option("answer", description="Answer to the stage's question", required=False)
@option("media", description="Media file to be attached", required=False)
@option("remove_media", description="Remove attached media", required=False)
@option(
"order",
description=_("description", "commands", "stage_edit", "options", "order"),
description_localizations=in_every_locale(
"description", "commands", "stage_edit", "options", "order"
),
min_value=1,
required=False,
)
@option(
"question",
description=_("description", "commands", "stage_edit", "options", "question"),
description_localizations=in_every_locale(
"description", "commands", "stage_edit", "options", "question"
),
required=False,
)
@option(
"answer",
description=_("description", "commands", "stage_edit", "options", "answer"),
description_localizations=in_every_locale(
"description", "commands", "stage_edit", "options", "answer"
),
max_length=500,
required=False,
)
@option(
"media",
description=_("description", "commands", "stage_edit", "options", "media"),
description_localizations=in_every_locale(
"description", "commands", "stage_edit", "options", "media"
),
required=False,
)
@option(
"remove_media",
description=_("description", "commands", "stage_edit", "options", "remove_media"),
description_localizations=in_every_locale(
"description", "commands", "stage_edit", "options", "remove_media"
),
required=False,
)
async def command_stage_edit(
self,
ctx: ApplicationContext,
@@ -113,17 +195,22 @@ class CogStage(Cog):
media: Attachment = None,
remove_media: bool = False,
) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True
)
return
try:
pycord_event: PycordEvent = await self.bot.find_event(event_id=event)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event was not found.")
except (InvalidId, EventNotFoundError):
await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
if not (await is_event_status_valid(ctx, pycord_event)):
@@ -131,17 +218,19 @@ class CogStage(Cog):
try:
event_stage: PycordEventStage = await self.bot.find_event_stage(stage)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event stage was not found.")
except (InvalidId, EventStageNotFoundError):
await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
if order is not None and order > len(pycord_event.stage_ids):
# TODO Make a nice message
await ctx.respond("Stage sequence out of range.")
await ctx.respond(
self.bot._("stage_sequence_out_of_range", "messages", locale=ctx.locale), ephemeral=True
)
return
processed_media: List[Dict[str, Any]] = [] if media is None else await self.bot.process_attachments([media])
processed_media: List[Dict[str, Any]] = (
[] if media is None else await self.bot.process_attachments([media])
)
if not (question is None and answer is None and media is None and remove_media is False):
await event_stage.update(
@@ -153,44 +242,61 @@ class CogStage(Cog):
if order is not None and order - 1 != event_stage.sequence:
await pycord_event.reorder_stage(self.bot, event_stage._id, order - 1, cache=self.bot.cache)
await ctx.respond("Event stage has been updated.")
await ctx.respond(self.bot._("stage_updated", "messages", locale=ctx.locale))
# TODO Implement the command
# /stage delete <event> <stage> <confirm>
@command_group.command(
name="delete",
description="Delete the event stage",
description=_("description", "commands", "stage_delete"),
description_localizations=in_every_locale("description", "commands", "stage_delete"),
)
@option(
"event",
description="Name of the event",
description=_("description", "commands", "stage_delete", "options", "event"),
description_localizations=in_every_locale(
"description", "commands", "stage_delete", "options", "event"
),
autocomplete=basic_autocomplete(autocomplete_active_events),
required=True,
)
@option(
"stage",
description="Stage to delete",
description=_("description", "commands", "stage_delete", "options", "stage"),
description_localizations=in_every_locale(
"description", "commands", "stage_delete", "options", "stage"
),
autocomplete=basic_autocomplete(autocomplete_event_stages),
required=True,
)
@option("confirm", description="Confirmation of the operation", required=False)
@option(
"confirm",
description=_("description", "commands", "stage_delete", "options", "confirm"),
description_localizations=in_every_locale(
"description", "commands", "stage_delete", "options", "confirm"
),
required=False,
)
async def command_stage_delete(
self, ctx: ApplicationContext, event: str, stage: str, confirm: bool = False
) -> None:
if not (await is_operation_confirmed(ctx, confirm)):
return
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured_admin", "messages", locale=ctx.locale), ephemeral=True
)
return
try:
pycord_event: PycordEvent = await self.bot.find_event(event_id=event)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event was not found.")
except (InvalidId, EventNotFoundError):
await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
if not (await is_event_status_valid(ctx, pycord_event)):
@@ -198,16 +304,14 @@ class CogStage(Cog):
try:
event_stage: PycordEventStage = await self.bot.find_event_stage(stage)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event stage was not found.")
except (InvalidId, EventStageNotFoundError):
await ctx.respond(self.bot._("stage_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
await pycord_event.remove_stage(self.bot, event_stage._id, cache=self.bot.cache)
await event_stage.purge(cache=self.bot.cache)
# TODO Make a nice message
await ctx.respond("Okay.")
await ctx.respond(self.bot._("stage_deleted", "messages", locale=ctx.locale))
def setup(bot: PycordBot) -> None:

42
cogs/cog_status.py Normal file
View File

@@ -0,0 +1,42 @@
from discord import ApplicationContext, Cog, slash_command
from libbot.i18n import _, in_every_locale
from classes.pycord_bot import PycordBot
from modules.utils import get_current_commit, get_unix_timestamp
class CogStatus(Cog):
"""Cog with the status command."""
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
@slash_command(
name="status",
description=_("description", "commands", "status"),
description_localizations=in_every_locale("description", "commands", "status"),
)
async def command_status(self, ctx: ApplicationContext) -> None:
current_commit: str | None = await get_current_commit()
if current_commit is None:
await ctx.respond(
self.bot._("status", "messages", locale=ctx.locale).format(
version=self.bot.__version__,
start_time=get_unix_timestamp(self.bot.started),
)
)
return
await ctx.respond(
self.bot._("status_git", "messages", locale=ctx.locale).format(
version=self.bot.__version__,
commit=current_commit if len(current_commit) < 10 else current_commit[:10],
start_time=get_unix_timestamp(self.bot.started),
)
)
def setup(bot: PycordBot) -> None:
bot.add_cog(CogStatus(bot))

View File

@@ -1,8 +1,10 @@
from bson.errors import InvalidId
from discord import ApplicationContext, Cog, option, slash_command
from discord.utils import basic_autocomplete
from libbot.i18n import _, in_every_locale
from classes import PycordEvent, PycordGuild, PycordUser
from classes.errors import EventNotFoundError, GuildNotFoundError
from classes.pycord_bot import PycordBot
from modules.utils import autocomplete_user_registered_events, is_operation_confirmed
@@ -13,51 +15,66 @@ class CogUnregister(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
# TODO Introduce i18n
@slash_command(
name="unregister",
description="Leave the selected event",
description=_("description", "commands", "unregister"),
description_localizations=in_every_locale("description", "commands", "unregister"),
)
@option(
"event",
description="Name of the event",
description=_("description", "commands", "unregister", "options", "event"),
description_localizations=in_every_locale(
"description", "commands", "unregister", "options", "event"
),
autocomplete=basic_autocomplete(autocomplete_user_registered_events),
)
@option("confirm", description="Confirmation of the operation", required=False)
@option(
"confirm",
description=_("description", "commands", "unregister", "options", "confirm"),
description_localizations=in_every_locale(
"description", "commands", "unregister", "options", "confirm"
),
required=False,
)
async def command_unregister(self, ctx: ApplicationContext, event: str, confirm: bool = False) -> None:
if not (await is_operation_confirmed(ctx, confirm)):
return
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
try:
pycord_event: PycordEvent = await self.bot.find_event(event_id=event)
except (InvalidId, RuntimeError):
# TODO Make a nice message
await ctx.respond("Event was not found.")
except (InvalidId, EventNotFoundError):
await ctx.respond(self.bot._("event_not_found", "messages", locale=ctx.locale), ephemeral=True)
return
if not guild.is_configured():
await ctx.respond(self.bot._("guild_unconfigured", "messages", locale=ctx.locale))
await ctx.respond(
self.bot._("guild_unconfigured", "messages", locale=ctx.locale), ephemeral=True
)
return
user: PycordUser = await self.bot.find_user(ctx.author, ctx.guild)
if user.is_jailed:
# TODO Make a nice message
await ctx.respond("You are jailed and cannot interact with events. Please, contact the administrator.")
await ctx.respond(self.bot._("jailed_error", "messages", locale=ctx.locale), ephemeral=True)
return
if pycord_event._id not in user.registered_event_ids:
# TODO Make a nice message
await ctx.respond("You are not registered for this event.")
await ctx.respond(
self.bot._("unregister_not_registered", "messages", locale=ctx.locale), ephemeral=True
)
return
await user.event_unregister(pycord_event._id, cache=self.bot.cache)
# TODO Text channel must be locked and updated
await ctx.respond("Ok.")
await ctx.respond(self.bot._("unregister_unregistered", "messages", locale=ctx.locale))
def setup(bot: PycordBot) -> None:

View File

@@ -1,14 +1,29 @@
from datetime import datetime
from logging import Logger
from pathlib import Path
from typing import Any, Dict, List
from zoneinfo import ZoneInfo
from bson import ObjectId
from bson.errors import InvalidId
from discord import (
ApplicationContext,
File,
SlashCommandGroup,
TextChannel,
User,
option,
)
from discord.ext.commands import Cog
from libbot.i18n import _, in_every_locale
from classes import PycordUser
from classes import PycordEvent, PycordGuild, PycordUser
from classes.errors import GuildNotFoundError
from classes.pycord_bot import PycordBot
from modules.utils import is_operation_confirmed
from modules.database import col_users
from modules.utils import get_logger, get_utc_now, is_operation_confirmed
logger: Logger = get_logger(__name__)
class CogUser(Cog):
@@ -17,56 +32,171 @@ class CogUser(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
# TODO Introduce i18n
command_group: SlashCommandGroup = SlashCommandGroup("user", "User management")
command_group: SlashCommandGroup = SlashCommandGroup(
"user",
description=_("description", "commands", "user"),
description_localizations=in_every_locale("description", "commands", "user"),
)
# TODO Implement the command
@command_group.command(
name="create_channel",
description="Create channel for the user",
name="update_channels",
description=_("description", "commands", "user_update_channels"),
description_localizations=in_every_locale("description", "commands", "user_update_channels"),
)
@option(
"user",
description="Selected user",
description=_("description", "commands", "user_update_channels", "options", "user"),
description_localizations=in_every_locale(
"description", "commands", "user_update_channels", "options", "user"
),
)
async def command_user_create_channel(self, ctx: ApplicationContext, user: User) -> None:
await ctx.respond("Not implemented.")
async def command_user_update_channels(self, ctx: ApplicationContext, user: User) -> None:
try:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
except (InvalidId, GuildNotFoundError):
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
return
pycord_user: PycordUser = await self.bot.find_user(user.id, ctx.guild.id)
events: List[PycordEvent] = []
utc_now: datetime = get_utc_now()
pipeline: List[Dict[str, Any]] = [
{"$match": {"id": pycord_user.id}},
{
"$lookup": {
"from": "events",
"let": {"event_ids": "$registered_event_ids"},
"pipeline": [
{
"$match": {
"$expr": {
"$and": [
{"$in": ["$_id", "$$event_ids"]},
{"$eq": ["$ended", None]},
{"$gt": ["$ends", utc_now]},
{"$lt": ["$starts", utc_now]},
{"$eq": ["$is_cancelled", False]},
]
}
}
}
],
"as": "registered_events",
}
},
{"$match": {"registered_events.0": {"$exists": True}}},
]
async with await col_users.aggregate(pipeline) as cursor:
async for result in cursor:
for registered_event in result["registered_events"]:
events.append(PycordEvent(**registered_event))
for event in events:
if pycord_user.current_event_id is not None and pycord_user.current_event_id != event._id:
continue
if pycord_user.current_event_id is None:
await pycord_user.set_event(event._id, cache=self.bot.cache)
channel: TextChannel | None = await pycord_user.fix_event_channel(
self.bot, ctx.guild, guild, event, cache=self.bot.cache
)
try:
await self.bot.notify_admins(
ctx.guild,
guild,
self.bot._("admin_user_channel_fixed", "messages", locale=ctx.locale).format(
display_name=user.display_name, mention=user.mention, event_name=event.name
),
)
except Exception as exc:
logger.error(
"Could not notify admins that user %s got their event channel for %s fixed due to: %s",
user.id,
event._id,
exc,
exc_info=exc,
)
if channel is None:
continue
thumbnail: File | None = (
None
if event.thumbnail is None
else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"])
)
await channel.send(
self.bot._("notice_event_already_started", "messages").format(event_name=event.name),
file=thumbnail,
)
stage_id: ObjectId = (
event.stage_ids[0] if pycord_user.current_stage_id is None else pycord_user.current_stage_id
)
await pycord_user.set_event_stage(stage_id, cache=self.bot.cache)
await self.bot.send_stage_question(channel, event, await self.bot.find_event_stage(stage_id))
await ctx.respond(
self.bot._("user_channels_updated", "messages", locale=ctx.locale).format(
display_name=user.display_name
)
)
# TODO Implement the command
@command_group.command(
name="update_channel",
description="Update user's channel",
)
@option(
"user",
description="Selected user",
)
async def command_user_update_channel(self, ctx: ApplicationContext, user: User) -> None:
await ctx.respond("Not implemented.")
# @command_group.command(
# name="create_channel",
# description="Create channel for the user",
# )
# @option(
# "user",
# description="Selected user",
# )
# async def command_user_create_channel(self, ctx: ApplicationContext, user: User) -> None:
# await ctx.respond("Not implemented.")
# TODO Implement the command
@command_group.command(
name="delete_channel",
description="Delete user's channel",
)
@option(
"user",
description="Selected user",
)
@option("confirm", description="Confirmation of the operation", required=False)
async def command_user_delete_channel(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None:
await ctx.respond("Not implemented.")
# @command_group.command(
# name="delete_channel",
# description="Delete user's channel",
# )
# @option(
# "user",
# description="Selected user",
# )
# @option("confirm", description="Confirmation of the operation", required=False)
# async def command_user_delete_channel(
# self, ctx: ApplicationContext, user: User, confirm: bool = False
# ) -> None:
# await ctx.respond("Not implemented.")
# TODO Introduce i18n
@command_group.command(
name="jail",
description="Jail the user",
description=_("description", "commands", "user_jail"),
description_localizations=in_every_locale("description", "commands", "user_jail"),
)
@option(
"user",
description="Selected user",
description=_("description", "commands", "user_jail", "options", "user"),
description_localizations=in_every_locale(
"description", "commands", "user_jail", "options", "user"
),
)
@option(
"confirm",
description=_("description", "commands", "user_jail", "options", "confirm"),
description_localizations=in_every_locale(
"description", "commands", "user_jail", "options", "confirm"
),
required=False,
)
@option("confirm", description="Confirmation of the operation", required=False)
async def command_user_jail(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None:
if not (await is_operation_confirmed(ctx, confirm)):
return
@@ -74,25 +204,42 @@ class CogUser(Cog):
pycord_user: PycordUser = await self.bot.find_user(user, ctx.guild)
if pycord_user.is_jailed:
# TODO Introduce i18n
await ctx.respond(f"User **{user.display_name}** is already jailed.")
await ctx.respond(
self.bot._("user_jail_already_jailed", "messages", locale=ctx.locale).format(
display_name=user.display_name
),
ephemeral=True,
)
return
await pycord_user.jail(self.bot.cache)
# TODO Introduce i18n
await ctx.respond(f"User **{user.display_name}** has been jailed and cannot interact with events anymore.")
await ctx.respond(
self.bot._("user_jail_successful", "messages", locale=ctx.locale).format(
display_name=user.display_name
)
)
# TODO Introduce i18n
@command_group.command(
name="unjail",
description="Unjail the user",
description=_("description", "commands", "user_unjail"),
description_localizations=in_every_locale("description", "commands", "user_unjail"),
)
@option(
"user",
description="Selected user",
description=_("description", "commands", "user_unjail", "options", "user"),
description_localizations=in_every_locale(
"description", "commands", "user_unjail", "options", "user"
),
)
@option(
"confirm",
description=_("description", "commands", "user_unjail", "options", "confirm"),
description_localizations=in_every_locale(
"description", "commands", "user_unjail", "options", "confirm"
),
required=False,
)
@option("confirm", description="Confirmation of the operation", required=False)
async def command_user_unjail(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None:
if not (await is_operation_confirmed(ctx, confirm)):
return
@@ -100,14 +247,21 @@ class CogUser(Cog):
pycord_user: PycordUser = await self.bot.find_user(user, ctx.guild)
if not pycord_user.is_jailed:
# TODO Introduce i18n
await ctx.respond(f"User **{user.display_name}** is not jailed.")
await ctx.respond(
self.bot._("user_unjail_not_jailed", "messages", locale=ctx.locale).format(
display_name=user.display_name
),
ephemeral=True,
)
return
await pycord_user.unjail(self.bot.cache)
# TODO Introduce i18n
await ctx.respond(f"User **{user.display_name}** has been unjailed and can interact with events again.")
await ctx.respond(
self.bot._("user_unjail_successful", "messages", locale=ctx.locale).format(
display_name=user.display_name
)
)
def setup(bot: PycordBot) -> None:

150
cogs/cog_utility.py Normal file
View File

@@ -0,0 +1,150 @@
from datetime import datetime
from logging import Logger
from pathlib import Path
from typing import Any, Dict, List
from zoneinfo import ZoneInfo
from bson import ObjectId
from bson.errors import InvalidId
from discord import Activity, ActivityType, Cog, File, Member, TextChannel
from classes import PycordEvent, PycordGuild, PycordUser
from classes.errors import GuildNotFoundError
from classes.pycord_bot import PycordBot
from modules.database import col_users
from modules.utils import get_logger, get_utc_now
logger: Logger = get_logger(__name__)
class CogUtility(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
@Cog.listener()
async def on_ready(self) -> None:
"""Listener for the event when bot connects to Discord and becomes "ready"."""
logger.info("Logged in as %s", self.bot.user)
activity_enabled: bool = self.bot.config["bot"]["status"]["enabled"]
activity_type: str = self.bot.config["bot"]["status"]["activity_type"]
activity_message: str = self.bot.config["bot"]["status"]["activity_text"]
if not activity_enabled:
return
if activity_type == "playing":
await self.bot.change_presence(
activity=Activity(type=ActivityType.playing, name=activity_message)
)
elif activity_type == "watching":
await self.bot.change_presence(
activity=Activity(type=ActivityType.watching, name=activity_message)
)
elif activity_type == "listening":
await self.bot.change_presence(
activity=Activity(type=ActivityType.listening, name=activity_message)
)
elif activity_type == "streaming":
await self.bot.change_presence(
activity=Activity(type=ActivityType.streaming, name=activity_message)
)
elif activity_type == "competing":
await self.bot.change_presence(
activity=Activity(type=ActivityType.competing, name=activity_message)
)
elif activity_type == "custom":
await self.bot.change_presence(
activity=Activity(type=ActivityType.custom, name=activity_message)
)
else:
return
logger.info("Set activity type to %s with message %s", activity_type, activity_message)
@Cog.listener("on_member_join")
async def on_member_join(self, member: Member) -> None:
try:
guild: PycordGuild = await self.bot.find_guild(member.guild.id)
except (InvalidId, GuildNotFoundError) as exc:
logger.error(
"Could not process member join event for %s in %s due to: %s",
member.id,
member.guild.id,
exc,
)
return
user: PycordUser = await self.bot.find_user(member.id, member.guild.id)
events: List[PycordEvent] = []
utc_now: datetime = get_utc_now()
pipeline: List[Dict[str, Any]] = [
{"$match": {"id": user.id}},
{
"$lookup": {
"from": "events",
"let": {"event_ids": "$registered_event_ids"},
"pipeline": [
{
"$match": {
"$expr": {
"$and": [
{"$in": ["$_id", "$$event_ids"]},
{"$eq": ["$ended", None]},
{"$gt": ["$ends", utc_now]},
{"$lt": ["$starts", utc_now]},
{"$eq": ["$is_cancelled", False]},
]
}
}
}
],
"as": "registered_events",
}
},
{"$match": {"registered_events.0": {"$exists": True}}},
]
async with await col_users.aggregate(pipeline) as cursor:
async for result in cursor:
for registered_event in result["registered_events"]:
events.append(PycordEvent(**registered_event))
for event in events:
if user.current_event_id is not None and user.current_event_id != event._id:
continue
if user.current_event_id is None:
await user.set_event(event._id, cache=self.bot.cache)
channel: TextChannel | None = await user.fix_event_channel(
self.bot, member.guild, guild, event, cache=self.bot.cache
)
if channel is None:
continue
thumbnail: File | None = (
None
if event.thumbnail is None
else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"])
)
await channel.send(
self.bot._("notice_event_already_started", "messages").format(event_name=event.name),
file=thumbnail,
)
stage_id: ObjectId = (
event.stage_ids[0] if user.current_stage_id is None else user.current_stage_id
)
await user.set_event_stage(stage_id, cache=self.bot.cache)
await self.bot.send_stage_question(channel, event, await self.bot.find_event_stage(stage_id))
def setup(bot: PycordBot) -> None:
bot.add_cog(CogUtility(bot))

View File

@@ -12,7 +12,7 @@
"timezone": "UTC",
"status": {
"enabled": true,
"activity_type": 0,
"activity_type": "playing",
"activity_text": "The Game Of Life"
}
},

View File

@@ -1,12 +1,68 @@
{
"messages": {
"operation_unconfirmed": "Operation not confirmed.",
"admin_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.",
"admin_channel_creation_failed_no_user": "Event channel could not be created for user with ID `{user_id}` (<@{user_id}>) and event **{event_name}**: user was not found on the server.",
"admin_event_ended": "Event **{event_name}** has ended! Users can no longer submit their answers.",
"admin_event_no_stages_defined": "Could not start the event **{event_name}**: no event stages are defined.",
"admin_event_started": "Event **{event_name}** has started! Users have gotten their channels and can already start submitting their answers.",
"admin_user_channel_creation_failed": "Event channel could not be created for user **{display_name}** ({mention}) and event **{event_name}**.",
"admin_user_channel_fixed": "Fixed event channel of user **{display_name}** ({mention}) for the event **{event_name}**.",
"admin_user_completed_event": "User **{display_name}** ({mention}) has completed the event **{event_name}**",
"admin_user_completed_stage": "User **{display_name}** ({mention}) has completed the stage {stage_sequence} of the event **{event_name}**.",
"config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.",
"config_set": "Configuration has been updated. You can review it anytime using `/config show`.",
"config_show": "**Guild config**\n\nCategory: <#{category_id}>\nGeneral channel: <#{general_channel_id}>\nManagement channel: <#{management_channel_id}>\nTimezone: `{timezone}`\nPrefer emojis: `{prefer_emojis}`",
"event_cancelled": "Event **{event_name}** was cancelled.",
"event_created": "Event **{event_name}** has been created and will take place <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_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_event": "Your event could not be found. Please, contact the administrator.",
"guess_unregistered": "You have no ongoing events. You can register for events using the `/register` command.",
"guild_unconfigured": "Guild is not configured. Please, report this to the administrator.",
"guild_unconfigured_admin": "Guild is not configured. Please, configure it using `/config set`.",
"jailed_error": "You are jailed and cannot interact with events. Please, contact the administrator.",
"notice_event_already_started": "Event **{event_name}** has already started!\n\nUse slash command `/guess` to suggest your answers to each event stage.",
"operation_unconfirmed": "Operation not confirmed.",
"register_already_registered": "You are already registered for this event.",
"register_success_ongoing": "You are now registered for the event **{event_name}**.\n\nNew channel has been created for you and further instructions will are provided in it. Good luck!",
"register_success_scheduled": "You are now registered for the event **{event_name}**.\n\nNew channel will be created for you and further instructions will be provided as soon as the event starts <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_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.",
"config_set": "Configuration has been updated. You can review it anytime using `/config show`.",
"config_reset": "Configuration has been reset. You can update it using `/config set`, otherwise no events can be held.",
"config_show": "**Guild config**\n\nChannel: <#{channel_id}>\nCategory: <#{category_id}>\nTimezone: {timezone}"
"unexpected_error": "An unexpected error has occurred. Please, contact the administrator.",
"unregister_not_registered": "You are not registered for this event.",
"unregister_unregistered": "You are no longer registered for this event.",
"user_channels_updated": "Event channels of the user **{display_name}** were updated.",
"user_jail_already_jailed": "User **{display_name}** is already jailed.",
"user_jail_successful": "User **{display_name}** has been jailed and cannot interact with events anymore.",
"user_unjail_not_jailed": "User **{display_name}** is not jailed.",
"user_unjail_successful": "User **{display_name}** has been unjailed and can interact with events again."
},
"commands": {
"config": {
@@ -18,11 +74,17 @@
"category": {
"description": "Category where channels for each user will be created"
},
"channel": {
"general_channel": {
"description": "Text channel for general notifications and bot usage"
},
"management_channel": {
"description": "Text channel for admin notifications"
},
"timezone": {
"description": "Timezone in which events take place"
},
"prefer_emojis": {
"description": "Prefer emojis over text messages where available"
}
}
},
@@ -36,6 +98,196 @@
},
"config_show": {
"description": "Show the guild's configuration"
},
"event": {
"description": "Event management"
},
"event_create": {
"description": "Create new event",
"options": {
"name": {
"description": "Name of the event"
},
"start": {
"description": "Date when the event starts (DD.MM.YYYY HH:MM)"
},
"end": {
"description": "Date when the event ends (DD.MM.YYYY HH:MM)"
},
"thumbnail": {
"description": "Thumbnail of the event"
}
}
},
"event_edit": {
"description": "Edit the event",
"options": {
"event": {
"description": "Name of the event"
},
"name": {
"description": "New name of the event"
},
"start": {
"description": "Date when the event starts (DD.MM.YYYY HH:MM)"
},
"end": {
"description": "Date when the event ends (DD.MM.YYYY HH:MM)"
},
"thumbnail": {
"description": "Thumbnail of the event"
}
}
},
"event_cancel": {
"description": "Cancel the event",
"options": {
"event": {
"description": "Name of the event"
},
"confirm": {
"description": "Confirmation of the operation"
}
}
},
"event_show": {
"description": "Show details about the event",
"options": {
"event": {
"description": "Name of the event"
}
}
},
"guess": {
"description": "Provide an answer to the current event stage",
"options": {
"answer": {
"description": "Answer to the current stage"
}
}
},
"register": {
"description": "Register for the selected event",
"options": {
"event": {
"description": "Name of the event"
}
}
},
"stage": {
"description": "Event stage management"
},
"stage_add": {
"description": "Add new event stage",
"options": {
"event": {
"description": "Name of the event"
},
"question": {
"description": "Question to be answered"
},
"answer": {
"description": "Answer to the stage's question"
},
"media": {
"description": "Media file to be attached"
}
}
},
"stage_edit": {
"description": "Edit the event stage",
"options": {
"event": {
"description": "Name of the event"
},
"stage": {
"description": "Stage to edit"
},
"order": {
"description": "Number in the event stages' order"
},
"question": {
"description": "Question to be answered"
},
"answer": {
"description": "Answer to the question"
},
"media": {
"description": "Media file to be attached"
},
"remove_media": {
"description": "Remove attached media"
}
}
},
"stage_delete": {
"description": "Delete the event stage",
"options": {
"event": {
"description": "Name of the event"
},
"stage": {
"description": "Stage to delete"
},
"confirm": {
"description": "Confirmation of the operation"
}
}
},
"status": {
"description": "Get status of the bot"
},
"unregister": {
"description": "Leave the selected event",
"options": {
"event": {
"description": "Name of the event"
},
"confirm": {
"description": "Confirmation of the operation"
}
}
},
"user": {
"description": "User management"
},
"user_create_channel": {
"description": "Create channel for the user",
"options": {}
},
"user_update_channels": {
"description": "Update user's event channels",
"options": {
"user": {
"description": "Selected user"
}
}
},
"user_delete_channel": {
"description": "Delete user's channel",
"options": {}
},
"user_jail": {
"description": "Jail the user",
"options": {
"user": {
"description": "Selected user"
},
"confirm": {
"description": "Confirmation of the operation"
}
}
},
"user_unjail": {
"description": "Unjail the user",
"options": {
"user": {
"description": "Selected user"
},
"confirm": {
"description": "Confirmation of the operation"
}
}
}
}
}

View File

@@ -1,3 +1,5 @@
"""Main module with entry point that must be executed for the bot to start"""
import contextlib
import logging.config
from argparse import ArgumentParser
@@ -39,7 +41,7 @@ with contextlib.suppress(ImportError):
uvloop.install()
def main():
def main() -> None:
# Perform migration if command line argument was provided
if args.migrate:
migrate_database()

View File

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

View File

@@ -7,7 +7,8 @@ from .autocomplete_utils import (
autocomplete_user_registered_events,
)
from .cache_utils import restore_from_cache
from .datetime_utils import get_unix_timestamp
from .datetime_utils import get_unix_timestamp, get_utc_now
from .event_utils import validate_event_validity
from .git_utils import get_current_commit
from .logging_utils import get_logger, get_logging_config
from .validation_utils import is_event_status_valid, is_operation_confirmed

View File

@@ -29,7 +29,7 @@ async def autocomplete_active_events(ctx: AutocompleteContext) -> List[OptionCho
query: Dict[str, Any] = {
"ended": None,
"ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))},
"is_cancelled": {"$ne": True},
"is_cancelled": False,
}
event_names: List[OptionChoice] = []
@@ -49,30 +49,41 @@ async def autocomplete_user_available_events(ctx: AutocompleteContext) -> List[O
async def autocomplete_user_registered_events(ctx: AutocompleteContext) -> List[OptionChoice]:
"""Return list of active events user is registered in"""
utc_now: datetime = datetime.now(tz=ZoneInfo("UTC"))
pipeline: List[Dict[str, Any]] = [
{"$match": {"id": ctx.interaction.user.id}},
{
"$lookup": {
"from": "events",
"localField": "registered_event_ids",
"foreignField": "_id",
"let": {"event_ids": "$registered_event_ids"},
"pipeline": [
{
"$match": {
"$expr": {
"$and": [
{"$in": ["$_id", "$$event_ids"]},
{"$eq": ["$ended", None]},
{"$gt": ["$ends", utc_now]},
{"$gt": ["$starts", utc_now]},
{"$eq": ["$is_cancelled", False]},
]
}
}
}
],
"as": "registered_events",
}
},
{
"$match": {
"registered_events.ended": None,
"registered_events.ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))},
"registered_events.starts": {"$gt": datetime.now(tz=ZoneInfo("UTC"))},
"registered_events.is_cancelled": {"$ne": True},
}
},
{"$match": {"registered_events.0": {"$exists": True}}},
]
event_names: List[OptionChoice] = []
async for result in col_users.aggregate(pipeline):
for registered_event in result["registered_events"]:
event_names.append(OptionChoice(registered_event["name"], str(registered_event["_id"])))
async with await col_users.aggregate(pipeline) as cursor:
async for result in cursor:
for registered_event in result["registered_events"]:
event_names.append(OptionChoice(registered_event["name"], str(registered_event["_id"])))
return event_names
@@ -92,6 +103,11 @@ async def autocomplete_event_stages(ctx: AutocompleteContext) -> List[OptionChoi
event_stages: List[OptionChoice] = []
async for result in col_stages.find(query).sort([("sequence", ASCENDING)]):
event_stages.append(OptionChoice(f"{result['sequence']+1} ({result['question']})", str(result["_id"])))
event_stages.append(
OptionChoice(
f"{result['sequence']+1} ({result['question'] if len(result['question']) < 50 else result['question'][:47] + '...'})",
str(result["_id"]),
)
)
return event_stages

View File

@@ -3,5 +3,10 @@ from zoneinfo import ZoneInfo
# TODO Add documentation
def get_unix_timestamp(date: datetime) -> int:
return int((date.replace(tzinfo=ZoneInfo("UTC"))).timestamp())
def get_unix_timestamp(date: datetime, to_utc: bool = False) -> int:
return int((date if not to_utc else date.replace(tzinfo=ZoneInfo("UTC"))).timestamp())
# TODO Add documentation
def get_utc_now() -> datetime:
return datetime.now(tz=ZoneInfo("UTC"))

View File

@@ -1,11 +1,12 @@
from datetime import datetime
from typing import Any, Dict
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
from bson import ObjectId
from discord import (
ApplicationContext,
)
from libbot.i18n import _
from modules.database import col_events
@@ -13,20 +14,25 @@ from modules.database import col_events
async def validate_event_validity(
ctx: ApplicationContext,
name: str,
start_date: datetime | None,
finish_date: datetime | None,
guild_timezone: ZoneInfo,
event_id: ObjectId | None = None,
) -> None:
if start_date > finish_date:
# TODO Make a nice message
await ctx.respond("Start date must be before finish date")
return
start_date: datetime,
end_date: datetime,
event_id: Optional[ObjectId] = None,
to_utc: bool = False,
) -> bool:
start_date_internal: datetime = start_date.astimezone(ZoneInfo("UTC")) if to_utc else start_date
end_date_internal: datetime = end_date.astimezone(ZoneInfo("UTC")) if to_utc else end_date
if start_date < datetime.now(tz=guild_timezone):
# TODO Make a nice message
await ctx.respond("Start date must not be in the past")
return
if start_date_internal < datetime.now(tz=ZoneInfo("UTC")):
await ctx.respond(_("event_start_past", "messages", locale=ctx.locale), ephemeral=True)
return False
if end_date_internal < datetime.now(tz=ZoneInfo("UTC")):
await ctx.respond(_("event_end_past", "messages", locale=ctx.locale), ephemeral=True)
return False
if start_date_internal >= end_date_internal:
await ctx.respond(_("event_end_before_start", "messages", locale=ctx.locale), ephemeral=True)
return False
# TODO Add validation for concurrent events.
# Only one event can take place at the same time.
@@ -41,6 +47,7 @@ async def validate_event_validity(
query["_id"] = {"$ne": event_id}
if (await col_events.find_one(query)) is not None:
# TODO Make a nice message
await ctx.respond("There can only be one active event with the same name")
return
await ctx.respond(_("event_name_duplicate", "messages", locale=ctx.locale), ephemeral=True)
return False
return True

View File

@@ -0,0 +1,27 @@
from pathlib import Path
import aiofiles
# TODO Add documentation
async def get_current_commit() -> str | None:
head_path: Path = Path(".git/HEAD")
if not head_path.exists():
return None
async with aiofiles.open(head_path, "r", encoding="utf-8") as head_file:
head_content: str = (await head_file.read()).strip()
if not head_content.startswith("ref:"):
return head_content
head_ref_path: Path = Path(".git/").joinpath(" ".join(head_content.split(" ")[1:]))
if not head_ref_path.exists():
return None
async with aiofiles.open(head_ref_path, "r", encoding="utf-8") as head_ref_file:
head_ref_content: str = (await head_ref_file.read()).strip()
return head_ref_content

View File

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

View File

@@ -5,7 +5,7 @@ readme = "README.md"
requires-python = ">=3.11"
[tool.black]
line-length = 118
line-length = 108
target-version = ["py311", "py312", "py313"]
[tool.isort]

View File

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