diff --git a/classes/pycord_bot.py b/classes/pycord_bot.py index 5a18c49..26137bf 100644 --- a/classes/pycord_bot.py +++ b/classes/pycord_bot.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Any +from typing import Any, override from bson import ObjectId from discord import Guild, User @@ -43,6 +43,14 @@ class PycordBot(LibPycordBot): if "cache" in self.config and self.config["cache"]["type"] is not None: self.cache = create_cache_client(self.config, self.config["cache"]["type"]) + @override + async def start(self, *args: Any, **kwargs: Any) -> None: + await super().start(*args, **kwargs) + + @override + async def close(self, **kwargs) -> None: + await super().close(**kwargs) + async def find_user(self, user: int | User) -> PycordUser: """Find User by its ID or User object. @@ -100,16 +108,11 @@ class PycordBot(LibPycordBot): event_stage: PycordEventStage = await PycordEventStage.create(**kwargs, cache=self.cache) - await event.insert_stage(event_stage._id, kwargs["sequence"], cache=self.cache) + await event.insert_stage(self, event_stage._id, kwargs["sequence"], cache=self.cache) return event_stage - async def start(self, *args: Any, **kwargs: Any) -> None: - await super().start(*args, **kwargs) - - async def close(self, **kwargs) -> None: - await super().close(**kwargs) - + # TODO Document this method async def find_event(self, event_id: str | ObjectId | None = None, event_name: str | None = None): if event_id is None and event_name is None: raise AttributeError("Either event's ID or name must be provided!") @@ -118,3 +121,7 @@ class PycordBot(LibPycordBot): return await PycordEvent.from_id(event_id, cache=self.cache) else: return await PycordEvent.from_name(event_name, 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) diff --git a/classes/pycord_event.py b/classes/pycord_event.py index 88d16f8..f9962c6 100644 --- a/classes/pycord_event.py +++ b/classes/pycord_event.py @@ -5,10 +5,11 @@ 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.results import InsertOneResult -from modules.database import col_events, col_stages +from modules.database import col_events from modules.logging_utils import get_logger logger: Logger = get_logger(__name__) @@ -266,14 +267,62 @@ class PycordEvent: 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, cancelled=True) - async def insert_stage( - self, event_stage_id: ObjectId, sequence: int, cache: Optional[Cache] = None + async def _update_event_stage_order( + self, + bot: Any, + old_stage_ids: List[ObjectId], + cache: Optional[Cache] = None, ) -> None: - self.stage_ids.insert(sequence, event_stage_id) - await self._set(cache, stage_ids=self.stage_ids) + logger.info("Updating event stages order for %s...", self._id) - # TODO Check if this works - await col_stages.update_many({"_id": {"$eq": self.stage_ids[sequence:]}}, {"$inc": {"sequence": 1}}) + logger.debug("Old stage IDs: %s", old_stage_ids) + logger.debug("New stage IDs: %s", self.stage_ids) + + for event_stage_id in self.stage_ids: + if event_stage_id not in old_stage_ids: + continue + + 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 + ) + + 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 + ) -> None: + old_stage_ids: List[ObjectId] = self.stage_ids.copy() + + self.stage_ids.insert(index, event_stage_id) + + 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 + ) -> None: + 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))) + + 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: + old_stage_ids: List[ObjectId] = self.stage_ids.copy() + + self.stage_ids.pop(self.stage_ids.index(event_stage_id)) + + await self._set(cache, stage_ids=self.stage_ids) + await self._update_event_stage_order(bot, old_stage_ids, cache=cache) diff --git a/classes/pycord_event_stage.py b/classes/pycord_event_stage.py index aa7b4d8..5de4cbd 100644 --- a/classes/pycord_event_stage.py +++ b/classes/pycord_event_stage.py @@ -38,7 +38,7 @@ class PycordEventStage: creator_id: int question: str answer: str - media: List[str] + media: List[int] @classmethod async def from_id(cls, stage_id: str | ObjectId, cache: Optional[Cache] = None) -> "PycordEventStage": diff --git a/cogs/config.py b/cogs/config.py index 6ca313a..267bc88 100644 --- a/cogs/config.py +++ b/cogs/config.py @@ -12,7 +12,7 @@ from discord.utils import basic_autocomplete from classes import PycordGuild from classes.pycord_bot import PycordBot -from modules.utils import autofill_timezones, autofill_languages +from modules.utils import autocomplete_timezones, autocomplete_languages class Config(Cog): @@ -34,13 +34,13 @@ class Config(Cog): @option( "timezone", description="Timezone in which events take place", - autocomplete=basic_autocomplete(autofill_timezones), + autocomplete=basic_autocomplete(autocomplete_timezones), required=True, ) @option( "language", description="Language for bot's messages", - autocomplete=basic_autocomplete(autofill_languages), + autocomplete=basic_autocomplete(autocomplete_languages), required=True, ) async def command_config_set( @@ -97,6 +97,11 @@ class Config(Cog): async def command_config_show(self, ctx: ApplicationContext) -> None: guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + if not guild.is_configured(): + # TODO Make a nice message + await ctx.respond("Guild is not configured.") + return + # TODO Make a nice message await ctx.respond( f"**Guild config**\n\nChannel: <#{guild.channel_id}>\nCategory: <#{guild.category_id}>\nTimezone: {guild.timezone}\nLanguage: {guild.language}" diff --git a/cogs/event.py b/cogs/event.py index 5d74601..19da64e 100644 --- a/cogs/event.py +++ b/cogs/event.py @@ -15,7 +15,7 @@ from discord.utils import basic_autocomplete from classes import PycordEvent, PycordGuild from classes.pycord_bot import PycordBot from modules.database import col_events -from modules.utils import autofill_active_events +from modules.utils import autocomplete_active_events # TODO Move to staticmethod or to a separate module @@ -120,7 +120,7 @@ class Event(Cog): @option( "event", description="Name of the event", - autocomplete=basic_autocomplete(autofill_active_events), + autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) @option("name", description="New name of the event", required=False) @@ -192,14 +192,21 @@ class Event(Cog): @option( "event", description="Name of the event", - autocomplete=basic_autocomplete(autofill_active_events), + autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) + @option("confirm", description="Confirmation of the operation", required=False) async def command_event_cancel( self, ctx: ApplicationContext, event: str, + 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) pycord_event: PycordEvent = await self.bot.find_event(event_id=event) @@ -239,7 +246,7 @@ class Event(Cog): @option( "event", description="Name of the event", - autocomplete=basic_autocomplete(autofill_active_events), + autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) async def command_event_show(self, ctx: ApplicationContext, event: str) -> None: diff --git a/cogs/stage.py b/cogs/stage.py index fb587ab..59e57f1 100644 --- a/cogs/stage.py +++ b/cogs/stage.py @@ -4,7 +4,7 @@ from discord.utils import basic_autocomplete from classes import PycordGuild, PycordEventStage, PycordEvent from classes.pycord_bot import PycordBot -from modules.utils import autofill_active_events +from modules.utils import autocomplete_active_events, autocomplete_event_stages class Stage(Cog): @@ -24,7 +24,7 @@ class Stage(Cog): @option( "event", description="Name of the event", - autocomplete=basic_autocomplete(autofill_active_events), + autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) @option("question", description="Question to be answered", required=True) @@ -54,7 +54,7 @@ class Stage(Cog): creator_id=ctx.author.id, question=question, answer=answer, - media=None if media is None else media.id, + media=[] if media is None else [media.id], ) # TODO Make a nice message @@ -69,14 +69,21 @@ class Stage(Cog): @option( "event", description="Name of the event", - autocomplete=basic_autocomplete(autofill_active_events), + autocomplete=basic_autocomplete(autocomplete_active_events), required=True, ) - @option("stage", description="Stage to edit", required=True) - @option("order", description="Number in the event stages' order", required=False) + # TODO Add autofill + @option( + "stage", + description="Stage to edit", + 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) async def command_stage_edit( self, ctx: ApplicationContext, @@ -86,8 +93,33 @@ class Stage(Cog): question: str, answer: str, media: Attachment = None, + remove_media: bool = False, ) -> None: - await ctx.respond("Not implemented.") + guild: PycordGuild = await self.bot.find_guild(ctx.guild.id) + pycord_event: PycordEvent = await self.bot.find_event(event) + event_stage: PycordEventStage = await self.bot.find_event_stage(stage) + + if not guild.is_configured(): + # TODO Make a nice message + await ctx.respond("Guild is not configured.") + 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.") + return + + if not (question is None and answer is None and media is None and remove_media is False): + await event_stage.update( + question=event_stage.question if question is None else question, + answer=event_stage.answer if answer is None else answer, + media=[] if remove_media else (event_stage.media if media is None else [media.id]), + ) + + 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.") # TODO Implement the command # /stage delete @@ -98,15 +130,38 @@ class Stage(Cog): @option( "event", description="Name of the event", - autocomplete=basic_autocomplete(autofill_active_events), + autocomplete=basic_autocomplete(autocomplete_active_events), + required=True, + ) + @option( + "stage", + description="Stage to delete", + autocomplete=basic_autocomplete(autocomplete_event_stages), required=True, ) - @option("stage", description="Stage to edit", required=True) @option("confirm", description="Confirmation of the operation", required=False) async def command_stage_delete( self, ctx: ApplicationContext, event: str, stage: str, confirm: bool = False ) -> None: - await ctx.respond("Not implemented.") + 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) + pycord_event: PycordEvent = await self.bot.find_event(event) + event_stage: PycordEventStage = await self.bot.find_event_stage(stage) + + if not guild.is_configured(): + # TODO Make a nice message + await ctx.respond("Guild is not configured.") + 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.") def setup(bot: PycordBot) -> None: diff --git a/modules/utils.py b/modules/utils.py index 087d9d2..fce03fb 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -2,9 +2,11 @@ from datetime import datetime from typing import List, Dict, Any from zoneinfo import ZoneInfo, available_timezones +from bson import ObjectId from discord import AutocompleteContext, OptionChoice +from pymongo import ASCENDING -from modules.database import col_events +from modules.database import col_events, col_stages def hex_to_int(hex_color: str) -> int: @@ -16,19 +18,19 @@ def int_to_hex(integer_color: int) -> str: # TODO Maybe move to a separate module -async def autofill_timezones(ctx: AutocompleteContext) -> List[str]: +async def autocomplete_timezones(ctx: AutocompleteContext) -> List[str]: return sorted(list(available_timezones())) # TODO Maybe move to a separate module -async def autofill_languages(ctx: AutocompleteContext) -> List[str]: +async def autocomplete_languages(ctx: AutocompleteContext) -> List[str]: # TODO Discord normally uses a different set of locales. # For example, "en" being "en-US", etc. This will require changes to locale handling later. return ctx.bot.locales.keys() # TODO Maybe move to a separate module -async def autofill_active_events(ctx: AutocompleteContext) -> List[OptionChoice]: +async def autocomplete_active_events(ctx: AutocompleteContext) -> List[OptionChoice]: query: Dict[str, Any] = { "ended": None, "ends": {"$gt": datetime.now(tz=ZoneInfo("UTC"))}, @@ -41,3 +43,24 @@ async def autofill_active_events(ctx: AutocompleteContext) -> List[OptionChoice] event_names.append(OptionChoice(result["name"], str(result["_id"]))) return event_names + + +# TODO Maybe move to a separate module +async def autocomplete_event_stages(ctx: AutocompleteContext) -> List[OptionChoice]: + event_id: str | None = ctx.options["event"] + + if event_id is None: + return [] + + query: Dict[str, Any] = { + "event_id": ObjectId(event_id), + } + + 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"])) + ) + + return event_stages