Compare commits
57 Commits
ca1e47b55a
...
staging
Author | SHA1 | Date | |
---|---|---|---|
cd5e1a6a18
|
|||
294a57338e
|
|||
dfddea78e0
|
|||
c0451de27a
|
|||
5f9ef163e1
|
|||
8d60d8aef5
|
|||
3700e4055d
|
|||
e7c719312f
|
|||
96c1314234
|
|||
d1498f38e9
|
|||
86c75d06fa
|
|||
9d562e2e9d
|
|||
6b5a276f00
|
|||
134533e342
|
|||
498d822e09
|
|||
44e144801d
|
|||
798f5ac529
|
|||
f2e01e3b85
|
|||
2d9bf1cfb9
|
|||
aa2f90e1c5
|
|||
34a506466d
|
|||
327dcba544
|
|||
fa200ef92d
|
|||
3dcae36dec
|
|||
5507295b1b
|
|||
80eae3f1b1
|
|||
390145ca0e
|
|||
137ecffcf7
|
|||
efb9ae55ef
|
|||
9d39b803f3
|
|||
28d6340847
|
|||
112387115f
|
|||
2e9ed41a2c
|
|||
2ccdd6406a
|
|||
c4ebd1b891
|
|||
c96cb167b5
|
|||
22139aa486
|
|||
679d026286
|
|||
b9dbc9443b
|
|||
b212236b10
|
|||
99653c6fe1
|
|||
d41d41663f
|
|||
11f0cc384a
|
|||
923173ebe8
|
|||
9a5edbaa4d
|
|||
12a88d5a23
|
|||
638658af75
|
|||
6b143d8a2d
|
|||
e45a56835a
|
|||
64cd7b3bff
|
|||
2dac6a4714
|
|||
3b8da61b47
|
|||
9981143f87
|
|||
94c4cdbf65
|
|||
3e9edf91d5
|
|||
e6036d033e
|
|||
b86d03a84f
|
140
README.md
140
README.md
@@ -1,2 +1,142 @@
|
||||
# QuizBot
|
||||
|
||||
Open source Discord bot for quizzes and quest-like events.
|
||||
|
||||
## Installation
|
||||
|
||||
### Dependencies
|
||||
|
||||
- [Python 3.11+](https://www.python.org)
|
||||
- [MongoDB](https://www.mongodb.com)
|
||||
- [Redis](https://redis.io)/[Valkey](https://valkey.io) or [Memcached](https://memcached.org) (used for caching,
|
||||
optional)
|
||||
- [Git](https://git-scm.com) (only if installing from source)
|
||||
|
||||
### Installation from release
|
||||
|
||||
1. Download the release archive from [Releases](https://git.end-play.xyz/profitroll/QuizBot/releases)
|
||||
2. Unpack the archive to a directory of your choice
|
||||
3. Go to the project's directory
|
||||
4. Create a virtual environment: `python3 -m venv .venv`
|
||||
5. Activate the virtual environment:
|
||||
- Linux: `source .venv/bin/activate`
|
||||
- Windows (cmd): `.venv/bin/activate.bat`
|
||||
- Windows (PowerShell): `.venv/bin/activate.ps1`
|
||||
6. Install requirements: `pip install -r requirements.txt`
|
||||
7. Copy example config to a real file: `cp config_example.json config.json`
|
||||
8. Configure the bot (see [Configuration](#configuration))
|
||||
9. Start the bot: `python main.py`
|
||||
10. The bot can be stopped by a keyboard interrupt (`Ctrl+C`) and a virtual environment can be deactivated using
|
||||
`deactivate`
|
||||
|
||||
### Installation from source
|
||||
|
||||
1. Clone the repository: `git clone https://git.end-play.xyz/profitroll/QuizBot.git`
|
||||
2. Go to the project's directory: `cd QuizBot`
|
||||
3. Continue from step 4 of [Installation from release](#installation-from-release)
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
// Bot's default locale. Based on file name from locale/
|
||||
"locale": "en-US",
|
||||
// Debug mode setting
|
||||
"debug": false,
|
||||
// Bot's config
|
||||
"bot": {
|
||||
// Discord ID(s) of bot's owner(s)
|
||||
"owners": [
|
||||
0
|
||||
],
|
||||
// Discord ID(s) of the debug guild(s)
|
||||
"debug_guilds": [
|
||||
0
|
||||
],
|
||||
// Bot's token
|
||||
"bot_token": "",
|
||||
// Bot's timezone
|
||||
"timezone": "UTC",
|
||||
// Bot's status activity
|
||||
"status": {
|
||||
// Whether activity is enabled
|
||||
"enabled": true,
|
||||
// Type of the activity. Can be: "playing", "watching", "listening", "streaming", "competing" or "custom"
|
||||
"activity_type": "playing",
|
||||
// Text of the activity
|
||||
"activity_text": "The Game Of Life"
|
||||
}
|
||||
},
|
||||
// Database connection
|
||||
"database": {
|
||||
// User name for database connection. null if without auth
|
||||
"user": null,
|
||||
// User password for database connection. null if without auth
|
||||
"password": null,
|
||||
// Database host
|
||||
"host": "127.0.0.1",
|
||||
// Database port
|
||||
"port": 27017,
|
||||
// Database name
|
||||
"name": "quiz_bot"
|
||||
},
|
||||
// Cache connection
|
||||
"cache": {
|
||||
// Type of caching engine. Can be: "memcached", "redis" or null
|
||||
"type": null,
|
||||
// Memcached connection. Only used if cache type is "memcached"
|
||||
"memcached": {
|
||||
// Memcached URI
|
||||
"uri": "127.0.0.1:11211"
|
||||
},
|
||||
// Redis connection. Only used if cache type is "redis"
|
||||
"redis": {
|
||||
// Redis URI
|
||||
"uri": "redis://127.0.0.1:6379/0"
|
||||
}
|
||||
},
|
||||
// Emojis used by guilds that prefer emoji messages
|
||||
"emojis": {
|
||||
// Markdown of a Discord emoji to be used for wrong guesses
|
||||
"guess_wrong": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
### Upgrading from release
|
||||
|
||||
Installing over the older version is not supported. Fresh installation is necessary to prevent data corruption.
|
||||
|
||||
1. Make a backup of the project's directory. Some of the old files will be reused
|
||||
2. Follow the [Installation from release](#installation-from-release) from the beginning and stop before 7th step
|
||||
3. Copy file `config.json` and directory `data` from the backup you made into the new installation's directory
|
||||
4. While still in the virtual environment, migrate the database: `python main.py --migrate`
|
||||
|
||||
After these steps are performed, the bot is ready to be started and used.
|
||||
|
||||
### Upgrading from source
|
||||
|
||||
1. Make a backup of the project's directory
|
||||
2. Go to the project's directory
|
||||
3. Update the project: `git pull`
|
||||
4. Activate the virtual environment:
|
||||
- Linux: `source .venv/bin/activate`
|
||||
- Windows (cmd): `.venv/bin/activate.bat`
|
||||
- Windows (PowerShell): `.venv/bin/activate.ps1`
|
||||
5. Migrate the database: `python main.py --migrate`
|
||||
|
||||
After these steps are performed, the bot is ready to be started and used.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Invite the bot to your server with permissions `137707834448` and `applications.commands` scope.
|
||||
You can also use the following URL template to invite your bot after replacing `CLIENT_ID` with you bot's client
|
||||
ID:
|
||||
`https://discord.com/oauth2/authorize?client_id=CLIENT_ID&permissions=137707834448&integration_type=0&scope=applications.commands+bot`
|
||||
2. Go to "Server Settings > Integrations > QuizBot" and disable access to admin commands for you default role.
|
||||
Only admins should have access to following commands: `/config`, `/event`, `/stage` and `/user`.
|
||||
Allowing access to `/status` is not recommended, however won't do any harm if done so.
|
||||
3. Configure bot for usage on your server using `/config set` providing all the necessary arguments.
|
||||
Timezones are compatible with daylight saving time (e.g. `CET` will be interpreted as `CEST` during daylight saving).
|
1
classes/abstract/__init__.py
Normal file
1
classes/abstract/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .cacheable import Cacheable
|
81
classes/abstract/cacheable.py
Normal file
81
classes/abstract/cacheable.py
Normal 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
0
classes/base/__init__.py
Normal file
110
classes/base/base_cacheable.py
Normal file
110
classes/base/base_cacheable.py
Normal 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)
|
@@ -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
32
classes/errors/discord.py
Normal 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}"
|
||||
)
|
26
classes/errors/pycord_event.py
Normal file
26
classes/errors/pycord_event.py
Normal 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}"
|
||||
)
|
17
classes/errors/pycord_event_stage.py
Normal file
17
classes/errors/pycord_event_stage.py
Normal 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")
|
@@ -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")
|
||||
|
@@ -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}")
|
||||
|
@@ -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.1"
|
||||
|
||||
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)
|
||||
|
@@ -1,14 +1,18 @@
|
||||
"""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.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 +20,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 +67,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(
|
||||
db_entry: Dict[str, Any] | None = 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 +139,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 +173,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 +210,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 +365,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 +383,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 +400,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)
|
||||
|
@@ -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(
|
||||
db_entry: Dict[str, Any] | None = 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,64 @@ 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),
|
||||
"event_id": (
|
||||
self.event_id
|
||||
if not json_compatible
|
||||
else (None if self.event_id is None else str(self.event_id))
|
||||
),
|
||||
"guild_id": self.guild_id,
|
||||
"sequence": self.sequence,
|
||||
"created": self.created,
|
||||
"created": self.created if not json_compatible else self.created.isoformat(),
|
||||
"creator_id": self.creator_id,
|
||||
"question": self.question,
|
||||
"answer": self.answer,
|
||||
@@ -195,6 +171,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 +189,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 +229,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)]
|
||||
|
@@ -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,42 +15,52 @@ 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})
|
||||
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one({"id": guild_id})
|
||||
|
||||
if db_entry is None:
|
||||
if not allow_creation:
|
||||
@@ -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")
|
||||
|
@@ -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,9 +84,11 @@ 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})
|
||||
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
|
||||
{"id": user_id, "guild_id": guild_id}
|
||||
)
|
||||
|
||||
if db_entry is None:
|
||||
if not allow_creation:
|
||||
@@ -95,7 +101,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 +120,16 @@ 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 (None if self.current_event_id is None else str(self.current_event_id))
|
||||
),
|
||||
"current_stage_id": (
|
||||
self.current_stage_id
|
||||
if not json_compatible
|
||||
else (None if self.current_stage_id is None else str(self.current_stage_id))
|
||||
),
|
||||
"registered_event_ids": (
|
||||
self.registered_event_ids
|
||||
if not json_compatible
|
||||
@@ -129,69 +143,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 +211,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 +219,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 +252,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 +263,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 +314,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 +367,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 +399,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)
|
||||
|
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
@@ -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))
|
||||
|
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -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))
|
||||
|
@@ -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
42
cogs/cog_status.py
Normal 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))
|
@@ -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:
|
||||
|
248
cogs/cog_user.py
248
cogs/cog_user.py
@@ -1,14 +1,29 @@
|
||||
from datetime import datetime
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from bson import ObjectId
|
||||
from bson.errors import InvalidId
|
||||
from discord import (
|
||||
ApplicationContext,
|
||||
File,
|
||||
SlashCommandGroup,
|
||||
TextChannel,
|
||||
User,
|
||||
option,
|
||||
)
|
||||
from discord.ext.commands import Cog
|
||||
from libbot.i18n import _, in_every_locale
|
||||
|
||||
from classes import PycordUser
|
||||
from classes import PycordEvent, PycordGuild, PycordUser
|
||||
from classes.errors import GuildNotFoundError
|
||||
from classes.pycord_bot import PycordBot
|
||||
from modules.utils import is_operation_confirmed
|
||||
from modules.database import col_users
|
||||
from modules.utils import get_logger, get_utc_now, is_operation_confirmed
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CogUser(Cog):
|
||||
@@ -17,56 +32,171 @@ class CogUser(Cog):
|
||||
def __init__(self, bot: PycordBot):
|
||||
self.bot: PycordBot = bot
|
||||
|
||||
# TODO Introduce i18n
|
||||
command_group: SlashCommandGroup = SlashCommandGroup("user", "User management")
|
||||
command_group: SlashCommandGroup = SlashCommandGroup(
|
||||
"user",
|
||||
description=_("description", "commands", "user"),
|
||||
description_localizations=in_every_locale("description", "commands", "user"),
|
||||
)
|
||||
|
||||
# TODO Implement the command
|
||||
@command_group.command(
|
||||
name="create_channel",
|
||||
description="Create channel for the user",
|
||||
name="update_channels",
|
||||
description=_("description", "commands", "user_update_channels"),
|
||||
description_localizations=in_every_locale("description", "commands", "user_update_channels"),
|
||||
)
|
||||
@option(
|
||||
"user",
|
||||
description="Selected user",
|
||||
description=_("description", "commands", "user_update_channels", "options", "user"),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "user_update_channels", "options", "user"
|
||||
),
|
||||
)
|
||||
async def command_user_create_channel(self, ctx: ApplicationContext, user: User) -> None:
|
||||
await ctx.respond("Not implemented.")
|
||||
async def command_user_update_channels(self, ctx: ApplicationContext, user: User) -> None:
|
||||
try:
|
||||
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
|
||||
except (InvalidId, GuildNotFoundError):
|
||||
await ctx.respond(self.bot._("unexpected_error", "messages", locale=ctx.locale), ephemeral=True)
|
||||
return
|
||||
|
||||
pycord_user: PycordUser = await self.bot.find_user(user.id, ctx.guild.id)
|
||||
events: List[PycordEvent] = []
|
||||
|
||||
utc_now: datetime = get_utc_now()
|
||||
|
||||
pipeline: List[Dict[str, Any]] = [
|
||||
{"$match": {"id": pycord_user.id}},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "events",
|
||||
"let": {"event_ids": "$registered_event_ids"},
|
||||
"pipeline": [
|
||||
{
|
||||
"$match": {
|
||||
"$expr": {
|
||||
"$and": [
|
||||
{"$in": ["$_id", "$$event_ids"]},
|
||||
{"$eq": ["$ended", None]},
|
||||
{"$gt": ["$ends", utc_now]},
|
||||
{"$lt": ["$starts", utc_now]},
|
||||
{"$eq": ["$is_cancelled", False]},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"as": "registered_events",
|
||||
}
|
||||
},
|
||||
{"$match": {"registered_events.0": {"$exists": True}}},
|
||||
]
|
||||
|
||||
async with await col_users.aggregate(pipeline) as cursor:
|
||||
async for result in cursor:
|
||||
for registered_event in result["registered_events"]:
|
||||
events.append(PycordEvent(**registered_event))
|
||||
|
||||
for event in events:
|
||||
if pycord_user.current_event_id is not None and pycord_user.current_event_id != event._id:
|
||||
continue
|
||||
|
||||
if pycord_user.current_event_id is None:
|
||||
await pycord_user.set_event(event._id, cache=self.bot.cache)
|
||||
|
||||
channel: TextChannel | None = await pycord_user.fix_event_channel(
|
||||
self.bot, ctx.guild, guild, event, cache=self.bot.cache
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.notify_admins(
|
||||
ctx.guild,
|
||||
guild,
|
||||
self.bot._("admin_user_channel_fixed", "messages", locale=ctx.locale).format(
|
||||
display_name=user.display_name, mention=user.mention, event_name=event.name
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Could not notify admins that user %s got their event channel for %s fixed due to: %s",
|
||||
user.id,
|
||||
event._id,
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
if channel is None:
|
||||
continue
|
||||
|
||||
thumbnail: File | None = (
|
||||
None
|
||||
if event.thumbnail is None
|
||||
else File(Path(f"data/{event.thumbnail['id']}"), event.thumbnail["filename"])
|
||||
)
|
||||
|
||||
await channel.send(
|
||||
self.bot._("notice_event_already_started", "messages").format(event_name=event.name),
|
||||
file=thumbnail,
|
||||
)
|
||||
|
||||
stage_id: ObjectId = (
|
||||
event.stage_ids[0] if pycord_user.current_stage_id is None else pycord_user.current_stage_id
|
||||
)
|
||||
|
||||
await pycord_user.set_event_stage(stage_id, cache=self.bot.cache)
|
||||
|
||||
await self.bot.send_stage_question(channel, event, await self.bot.find_event_stage(stage_id))
|
||||
|
||||
await ctx.respond(
|
||||
self.bot._("user_channels_updated", "messages", locale=ctx.locale).format(
|
||||
display_name=user.display_name
|
||||
)
|
||||
)
|
||||
|
||||
# TODO Implement the command
|
||||
@command_group.command(
|
||||
name="update_channel",
|
||||
description="Update user's channel",
|
||||
)
|
||||
@option(
|
||||
"user",
|
||||
description="Selected user",
|
||||
)
|
||||
async def command_user_update_channel(self, ctx: ApplicationContext, user: User) -> None:
|
||||
await ctx.respond("Not implemented.")
|
||||
# @command_group.command(
|
||||
# name="create_channel",
|
||||
# description="Create channel for the user",
|
||||
# )
|
||||
# @option(
|
||||
# "user",
|
||||
# description="Selected user",
|
||||
# )
|
||||
# async def command_user_create_channel(self, ctx: ApplicationContext, user: User) -> None:
|
||||
# await ctx.respond("Not implemented.")
|
||||
|
||||
# TODO Implement the command
|
||||
@command_group.command(
|
||||
name="delete_channel",
|
||||
description="Delete user's channel",
|
||||
)
|
||||
@option(
|
||||
"user",
|
||||
description="Selected user",
|
||||
)
|
||||
@option("confirm", description="Confirmation of the operation", required=False)
|
||||
async def command_user_delete_channel(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None:
|
||||
await ctx.respond("Not implemented.")
|
||||
# @command_group.command(
|
||||
# name="delete_channel",
|
||||
# description="Delete user's channel",
|
||||
# )
|
||||
# @option(
|
||||
# "user",
|
||||
# description="Selected user",
|
||||
# )
|
||||
# @option("confirm", description="Confirmation of the operation", required=False)
|
||||
# async def command_user_delete_channel(
|
||||
# self, ctx: ApplicationContext, user: User, confirm: bool = False
|
||||
# ) -> None:
|
||||
# await ctx.respond("Not implemented.")
|
||||
|
||||
# TODO Introduce i18n
|
||||
@command_group.command(
|
||||
name="jail",
|
||||
description="Jail the user",
|
||||
description=_("description", "commands", "user_jail"),
|
||||
description_localizations=in_every_locale("description", "commands", "user_jail"),
|
||||
)
|
||||
@option(
|
||||
"user",
|
||||
description="Selected user",
|
||||
description=_("description", "commands", "user_jail", "options", "user"),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "user_jail", "options", "user"
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"confirm",
|
||||
description=_("description", "commands", "user_jail", "options", "confirm"),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "user_jail", "options", "confirm"
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
@option("confirm", description="Confirmation of the operation", required=False)
|
||||
async def command_user_jail(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None:
|
||||
if not (await is_operation_confirmed(ctx, confirm)):
|
||||
return
|
||||
@@ -74,25 +204,42 @@ class CogUser(Cog):
|
||||
pycord_user: PycordUser = await self.bot.find_user(user, ctx.guild)
|
||||
|
||||
if pycord_user.is_jailed:
|
||||
# TODO Introduce i18n
|
||||
await ctx.respond(f"User **{user.display_name}** is already jailed.")
|
||||
await ctx.respond(
|
||||
self.bot._("user_jail_already_jailed", "messages", locale=ctx.locale).format(
|
||||
display_name=user.display_name
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
await pycord_user.jail(self.bot.cache)
|
||||
|
||||
# TODO Introduce i18n
|
||||
await ctx.respond(f"User **{user.display_name}** has been jailed and cannot interact with events anymore.")
|
||||
await ctx.respond(
|
||||
self.bot._("user_jail_successful", "messages", locale=ctx.locale).format(
|
||||
display_name=user.display_name
|
||||
)
|
||||
)
|
||||
|
||||
# TODO Introduce i18n
|
||||
@command_group.command(
|
||||
name="unjail",
|
||||
description="Unjail the user",
|
||||
description=_("description", "commands", "user_unjail"),
|
||||
description_localizations=in_every_locale("description", "commands", "user_unjail"),
|
||||
)
|
||||
@option(
|
||||
"user",
|
||||
description="Selected user",
|
||||
description=_("description", "commands", "user_unjail", "options", "user"),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "user_unjail", "options", "user"
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"confirm",
|
||||
description=_("description", "commands", "user_unjail", "options", "confirm"),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "user_unjail", "options", "confirm"
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
@option("confirm", description="Confirmation of the operation", required=False)
|
||||
async def command_user_unjail(self, ctx: ApplicationContext, user: User, confirm: bool = False) -> None:
|
||||
if not (await is_operation_confirmed(ctx, confirm)):
|
||||
return
|
||||
@@ -100,14 +247,21 @@ class CogUser(Cog):
|
||||
pycord_user: PycordUser = await self.bot.find_user(user, ctx.guild)
|
||||
|
||||
if not pycord_user.is_jailed:
|
||||
# TODO Introduce i18n
|
||||
await ctx.respond(f"User **{user.display_name}** is not jailed.")
|
||||
await ctx.respond(
|
||||
self.bot._("user_unjail_not_jailed", "messages", locale=ctx.locale).format(
|
||||
display_name=user.display_name
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
await pycord_user.unjail(self.bot.cache)
|
||||
|
||||
# TODO Introduce i18n
|
||||
await ctx.respond(f"User **{user.display_name}** has been unjailed and can interact with events again.")
|
||||
await ctx.respond(
|
||||
self.bot._("user_unjail_successful", "messages", locale=ctx.locale).format(
|
||||
display_name=user.display_name
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def setup(bot: PycordBot) -> None:
|
||||
|
150
cogs/cog_utility.py
Normal file
150
cogs/cog_utility.py
Normal 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))
|
@@ -12,7 +12,7 @@
|
||||
"timezone": "UTC",
|
||||
"status": {
|
||||
"enabled": true,
|
||||
"activity_type": 0,
|
||||
"activity_type": "playing",
|
||||
"activity_text": "The Game Of Life"
|
||||
}
|
||||
},
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
4
main.py
4
main.py
@@ -1,3 +1,5 @@
|
||||
"""Main module with entry point that must be executed for the bot to start"""
|
||||
|
||||
import contextlib
|
||||
import logging.config
|
||||
from argparse import ArgumentParser
|
||||
@@ -39,7 +41,7 @@ with contextlib.suppress(ImportError):
|
||||
uvloop.install()
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
# Perform migration if command line argument was provided
|
||||
if args.migrate:
|
||||
migrate_database()
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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"))
|
||||
|
@@ -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
|
||||
|
27
modules/utils/git_utils.py
Normal file
27
modules/utils/git_utils.py
Normal 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
|
@@ -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
|
||||
|
@@ -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]
|
||||
|
@@ -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
|
Reference in New Issue
Block a user