From f7fd81f2996a17ca8d7a0d871b24cc05277f8c0a Mon Sep 17 00:00:00 2001 From: Profitroll Date: Fri, 18 Apr 2025 18:17:35 +0200 Subject: [PATCH] Implemented basic guild config, improved PycordUser and PycordGuild, fixed PycordBut, added stubs for events --- classes/pycord_bot.py | 33 ++++++++-- classes/pycord_guild.py | 141 ++++++++++++++++++++++++++++++++++++++-- classes/pycord_user.py | 22 ++++--- cogs/config.py | 56 ++++++++++++++++ cogs/event.py | 64 ++++++++++++++++++ 5 files changed, 297 insertions(+), 19 deletions(-) diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 185e625..731291c 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -1,17 +1,20 @@ -import logging from logging import Logger from typing import Any -from discord import User +from discord import User, Guild +from libbot.cache.classes import CacheMemcached, CacheRedis from libbot.cache.manager import create_cache_client from libbot.pycord.classes import PycordBot as LibPycordBot -from classes import PycordUser +from classes import PycordUser, PycordGuild +from modules.logging_utils import get_logger -logger: Logger = logging.getLogger(__name__) +logger: Logger = get_logger(__name__) class PycordBot(LibPycordBot): + cache: CacheMemcached | CacheRedis | None = None + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -24,6 +27,10 @@ class PycordBot(LibPycordBot): # i18n formats than provided by libbot self._ = self._modified_string_getter + def _set_cache_engine(self) -> None: + if "cache" in self.config and self.config["cache"]["type"] is not None: + self.cache = create_cache_client(self.config, self.config["cache"]["type"]) + def _modified_string_getter(self, key: str, *args: str, locale: str | None = None) -> Any: """This method exists because of the different i18n formats than provided by libbot. It splits "-" and takes the first part of the provided locale to make complex language codes @@ -53,6 +60,24 @@ class PycordBot(LibPycordBot): else await PycordUser.from_id(user.id, cache=self.cache) ) + async def find_guild(self, guild: int | Guild) -> PycordGuild: + """Find Guild by its ID or Guild object. + + Args: + guild (int | Guild): ID or User object to extract ID from + + Returns: + PycordGuild: Guild object + + Raises: + GuildNotFoundException: Guild was not found and creation was not allowed + """ + return ( + await PycordGuild.from_id(guild, cache=self.cache) + if isinstance(guild, int) + else await PycordGuild.from_id(guild.id, cache=self.cache) + ) + async def start(self, *args: Any, **kwargs: Any) -> None: await super().start(*args, **kwargs) diff --git a/classes/pycord_guild.py b/classes/pycord_guild.py index 9fb5eaf..8775bbf 100644 --- a/classes/pycord_guild.py +++ b/classes/pycord_guild.py @@ -1,21 +1,34 @@ from dataclasses import dataclass +from logging import Logger from typing import Any, Dict, Optional from bson import ObjectId from libbot.cache.classes import Cache +from pymongo.results import InsertOneResult + +from classes.errors import GuildNotFoundError +from modules.database import col_guilds +from modules.logging_utils import get_logger + +logger: Logger = get_logger(__name__) @dataclass class PycordGuild: + """Dataclass of DB entry of a guild""" + + __slots__ = ("_id", "id", "channel_id", "category_id") + __short_name__ = "guild" + __collection__ = col_guilds + _id: ObjectId id: int - - def __init__(self) -> None: - raise NotImplementedError() + channel_id: Optional[int] + category_id: Optional[int] @classmethod async def from_id( - cls, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None + cls, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None ) -> "PycordGuild": """Find guild in database and create new record if guild does not exist. @@ -30,7 +43,87 @@ class PycordGuild: Raises: GuildNotFoundError: User was not found and creation was not allowed """ - raise NotImplementedError() + if cache is not None: + cached_entry: Dict[str, Any] | None = cache.get_json(f"{cls.__short_name__}_{guild_id}") + + if cached_entry is not None: + return cls(**cached_entry) + + db_entry = await cls.__collection__.find_one({"id": guild_id}) + + if db_entry is None: + if not allow_creation: + raise GuildNotFoundError(guild_id) + + db_entry = PycordGuild.get_defaults(guild_id) + + insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry) + + db_entry["_id"] = insert_result.inserted_id + + if cache is not None: + cache.set_json(f"{cls.__short_name__}_{guild_id}", db_entry) + + return cls(**db_entry) + + async def _set(self, key: str, value: Any, cache: Optional[Cache] = None) -> None: + """Set attribute data and save it into the database. + + Args: + key (str): Attribute to change + value (Any): Value to set + cache (:obj:`Cache`, optional): Cache engine to write the update into + """ + if not hasattr(self, key): + raise AttributeError() + + setattr(self, key, value) + + await self.__collection__.update_one({"_id": self._id}, {"$set": {key: value}}, upsert=True) + + self._update_cache(cache) + + logger.info("Set attribute '%s' of user %s to '%s'", key, self.id, value) + + async def _remove(self, key: str, cache: Optional[Cache] = None) -> None: + """Remove attribute data and save it into the database. + + Args: + key (str): Attribute to remove + cache (:obj:`Cache`, optional): Cache engine to write the update into + """ + if not hasattr(self, key): + raise AttributeError() + + default_value: Any = PycordGuild.get_default_value(key) + + setattr(self, key, default_value) + + await self.__collection__.update_one({"_id": self._id}, {"$set": {key: default_value}}, upsert=True) + + self._update_cache(cache) + + logger.info("Removed attribute '%s' of guild %s", key, self.id) + + def _get_cache_key(self) -> str: + return f"{self.__short_name__}_{self.id}" + + def _update_cache(self, cache: Optional[Cache] = None) -> None: + if cache is None: + return + + user_dict: Dict[str, Any] = self.to_dict() + + if user_dict is not None: + cache.set_json(self._get_cache_key(), user_dict) + else: + self._delete_cache(cache) + + def _delete_cache(self, cache: Optional[Cache] = None) -> None: + if cache is None: + return + + cache.delete(self._get_cache_key()) def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]: """Convert PycordGuild object to a JSON representation. @@ -44,4 +137,42 @@ class PycordGuild: return { "_id": self._id if not json_compatible else str(self._id), "id": self.id, + "channel_id": self.channel_id, + "category_id": self.category_id, } + + @staticmethod + def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]: + return {"id": guild_id, "channel_id": None, "category_id": None} + + @staticmethod + def get_default_value(key: str) -> Any: + 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] + + async def purge(self, cache: Optional[Cache] = None) -> None: + """Completely remove guild data from database. Currently only removes the guild record from guilds collection. + + Args: + cache (:obj:`Cache`, optional): Cache engine to write the update into + """ + await self.__collection__.delete_one({"_id": self._id}) + self._delete_cache(cache) + + # TODO Add documentation + async def set_channel(self, channel_id: Optional[int] = None, cache: Optional[Cache] = None) -> None: + await self._set("channel_id", channel_id, cache) + + # TODO Add documentation + async def set_category(self, category_id: Optional[int] = None, cache: Optional[Cache] = None) -> None: + await self._set("category_id", category_id, cache) + + # TODO Add documentation + async def reset_channel(self, cache: Optional[Cache] = None) -> None: + await self._remove("channel_id", cache) + + # TODO Add documentation + async def reset_category(self, cache: Optional[Cache] = None) -> None: + await self._remove("category_id", cache) diff --git a/classes/pycord_user.py b/classes/pycord_user.py index c420797..7ae3b96 100644 --- a/classes/pycord_user.py +++ b/classes/pycord_user.py @@ -1,4 +1,3 @@ -import logging from dataclasses import dataclass from logging import Logger from typing import Any, Dict, Optional @@ -9,8 +8,9 @@ from pymongo.results import InsertOneResult from classes.errors.pycord_user import UserNotFoundError from modules.database import col_users +from modules.logging_utils import get_logger -logger: Logger = logging.getLogger(__name__) +logger: Logger = get_logger(__name__) @dataclass @@ -18,6 +18,8 @@ class PycordUser: """Dataclass of DB entry of a user""" __slots__ = ("_id", "id") + __short_name__ = "user" + __collection__ = col_users _id: ObjectId id: int @@ -40,12 +42,12 @@ class PycordUser: UserNotFoundError: User was not found and creation was not allowed """ if cache is not None: - cached_entry: Dict[str, Any] | None = cache.get_json(f"user_{user_id}") + cached_entry: Dict[str, Any] | None = cache.get_json(f"{cls.__short_name__}_{user_id}") if cached_entry is not None: return cls(**cached_entry) - db_entry = await col_users.find_one({"id": user_id}) + db_entry = await cls.__collection__.find_one({"id": user_id}) if db_entry is None: if not allow_creation: @@ -53,12 +55,12 @@ class PycordUser: db_entry = PycordUser.get_defaults(user_id) - insert_result: InsertOneResult = await col_users.insert_one(db_entry) + insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry) db_entry["_id"] = insert_result.inserted_id if cache is not None: - cache.set_json(f"user_{user_id}", db_entry) + cache.set_json(f"{cls.__short_name__}_{user_id}", db_entry) return cls(**db_entry) @@ -89,7 +91,7 @@ class PycordUser: setattr(self, key, value) - await col_users.update_one({"_id": self._id}, {"$set": {key: value}}, upsert=True) + await self.__collection__.update_one({"_id": self._id}, {"$set": {key: value}}, upsert=True) self._update_cache(cache) @@ -109,14 +111,14 @@ class PycordUser: setattr(self, key, default_value) - await col_users.update_one({"_id": self._id}, {"$set": {key: default_value}}, upsert=True) + await self.__collection__.update_one({"_id": self._id}, {"$set": {key: default_value}}, upsert=True) self._update_cache(cache) logger.info("Removed attribute '%s' of user %s", key, self.id) def _get_cache_key(self) -> str: - return f"user_{self.id}" + return f"{self.__short_name__}_{self.id}" def _update_cache(self, cache: Optional[Cache] = None) -> None: if cache is None: @@ -154,5 +156,5 @@ class PycordUser: Args: cache (:obj:`Cache`, optional): Cache engine to write the update into """ - await col_users.delete_one({"_id": self._id}) + await self.__collection__.delete_one({"_id": self._id}) self._delete_cache(cache) diff --git a/cogs/config.py b/cogs/config.py index 92a1416..18dcb7a 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -1,5 +1,7 @@ +from discord import SlashCommandGroup, option, CategoryChannel, ApplicationContext, TextChannel from discord.ext.commands import Cog +from classes import PycordGuild from classes.pycord_bot import PycordBot @@ -9,6 +11,60 @@ class Config(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot + # TODO Introduce i18n + command_group: SlashCommandGroup = SlashCommandGroup("config", "Guild management") + + # TODO Introduce i18n + @command_group.command( + name="set", + description="Configure the guild", + ) + @option("category", description="Category where channels for each user will be created", required=True) + @option("channel", description="Text channel for admin notifications", required=True) + async def command_config_set( + self, ctx: ApplicationContext, category: CategoryChannel, channel: TextChannel + ) -> None: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + + await guild.set_channel(channel.id, cache=self.bot.cache) + await guild.set_category(category.id, cache=self.bot.cache) + + # TODO Make a nice message + await ctx.respond("Okay.") + + # TODO Introduce i18n + @command_group.command( + name="reset", + description="Reset the guild's configuration", + ) + @option("confirm", description="Confirmation of the operation", required=False) + async def command_config_reset(self, ctx: ApplicationContext, confirm: bool = False) -> None: + if confirm is None or not confirm: + # TODO Make a nice message + await ctx.respond("Operation not confirmed.") + return + + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + + await guild.reset_channel(cache=self.bot.cache) + await guild.reset_category(cache=self.bot.cache) + + # TODO Make a nice message + await ctx.respond("Okay.") + + # TODO Introduce i18n + @command_group.command( + name="show", + description="Show the guild's configuration", + ) + async def command_config_reset(self, ctx: ApplicationContext) -> None: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + + # TODO Make a nice message + await ctx.respond( + f"**Guild config**\n\nChannel: <#{guild.channel_id}>\nCategory: <#{guild.category_id}>" + ) + def setup(bot: PycordBot) -> None: bot.add_cog(Config(bot)) diff --git a/cogs/event.py b/cogs/event.py index e5ca60d..4611051 100644 --- a/cogs/event.py +++ b/cogs/event.py @@ -1,5 +1,7 @@ +from discord import SlashCommandGroup, option, ApplicationContext, Attachment from discord.ext.commands import Cog +from classes import PycordGuild from classes.pycord_bot import PycordBot @@ -9,6 +11,68 @@ class Event(Cog): def __init__(self, bot: PycordBot): self.bot: PycordBot = bot + # TODO Introduce i18n + command_group: SlashCommandGroup = SlashCommandGroup("event", "Event management") + + # TODO Implement the command + @command_group.command( + name="create", + description="Create new event", + ) + @option("name", description="Name of the event", required=True) + @option("start", description="Date when the event starts (DD.MM.YYYY)", required=True) + @option("finish", description="Date when the event finishes (DD.MM.YYYY)", required=True) + @option("thumbnail", description="Thumbnail of the event", required=False) + async def command_event_create( + self, + ctx: ApplicationContext, + name: str, + start: str, + finish: str, + thumbnail: Attachment = None, + ) -> None: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + + await ctx.respond("Not implemented.") + + # TODO Implement the command + @command_group.command( + name="edit", + description="Edit event", + ) + @option("event", description="Name of the event", required=True) + @option("name", description="New name of the event", required=False) + @option("start", description="Date when the event starts (DD.MM.YYYY)", required=False) + @option("finish", description="Date when the event finishes (DD.MM.YYYY)", required=False) + @option("thumbnail", description="Thumbnail of the event", required=False) + async def command_event_edit( + self, + ctx: ApplicationContext, + event: str, + name: str, + start: str, + finish: str, + thumbnail: Attachment = None, + ) -> None: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + + await ctx.respond("Not implemented.") + + # TODO Implement the command + @command_group.command( + name="cancel", + description="Cancel event", + ) + @option("name", description="Name of the event", required=True) + async def command_event_cancel( + self, + ctx: ApplicationContext, + name: str, + ) -> None: + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + + await ctx.respond("Not implemented.") + def setup(bot: PycordBot) -> None: bot.add_cog(Event(bot))