diff --git a/.env.example b/.env.example deleted file mode 100644 index fbf9482ee..000000000 --- a/.env.example +++ /dev/null @@ -1,108 +0,0 @@ -# !!REQUIRED!! -# The Discord token for the bot you created (available on your bot page in the developer portal: https://discord.com/developers/applications)) -# Must be a valid Discord bot token (see https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts) -DISCORD_BOT_TOKEN=[Replace with your Discord bot token] - -# !!REQUIRED!! -# The ID of the your Discord guild -# Must be a valid Discord guild ID (see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id) -DISCORD_GUILD_ID=[Replace with the ID of the your Discord guild] - -# The webhook URL of the Discord text channel where error logs should be sent -# Error logs will always be sent to the console, this setting allows them to also be sent to a Discord log channel -# Must be a valid Discord channel webhook URL (see https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) -DISCORD_LOG_CHANNEL_WEBHOOK_URL=[Replace with your Discord log channel webhook URL] - -# The full name of your community group, do NOT use an abbreviation. -# This is substituted into many error/welcome messages sent into your Discord guild, by the bot. -# If this is not set the group-full-name will be retrieved from the name of your group's Discord guild -GROUP_NAME=[Replace with the full name of your community group (not an abbreviation)] - -# The short colloquial name of your community group, it is recommended that you set this to be an abbreviation of your group's name. -# If this is not set the group-short-name will be determined from your group's full name -GROUP_SHORT_NAME=[Replace with the short colloquial name of your community group] - -# The URL of the page where guests can purchase a full membership to join your community group -# Must be a valid URL -PURCHASE_MEMBERSHIP_URL=[Replace with your group's purchase=membership URL] - - -# The minimum level that logs must meet in order to be logged to the console output stream -# One of: DEBUG, INFO, WARNING, ERROR, CRITICAL -CONSOLE_LOG_LEVEL=INFO - - -# !!REQUIRED!! -# The URL to retrieve the list of IDs of people that have purchased a membership to your community group -# Ensure that all members are visible without pagination. For example, if your members-list is found on the UoB Guild of Students website, ensure the URL includes the "sort by groups" option -# Must be a valid URL -MEMBERS_LIST_URL=[Replace with your group's members-list URL] - -# !!REQUIRED!! -# The members-list URL session cookie -# If your group's members-list is stored at a URL that requires authentication, this session cookie should authenticate the bot to view your group's members-list, as if it were logged in to the website as a Committee member -# This can be extracted from your web-browser, after logging in to view your members-list yourself. It will probably be listed as a cookie named `.ASPXAUTH` -MEMBERS_LIST_URL_SESSION_COOKIE=[Replace with your .ASPXAUTH cookie] - - -# The probability that the more rare ping command response will be sent instead of the normal one -# Must be a float between & including 1 & 0 -PING_COMMAND_EASTER_EGG_PROBABILITY=0.01 - - -# The path to the messages JSON file that contains the common messages sent by the bot -# Must be a path to a JSON file that exists, that contains a JSON string that can be decoded into a Python dict object -MESSAGES_FILE_PATH=messages.json - - -# Whether introduction reminders will be sent to Discord members that are not inducted, saying that they need to send an introduction to be allowed access -# One of: Once, Interval, False -SEND_INTRODUCTION_REMINDERS=Once - -# How long to wait after a user joins your guild before sending them the first/only message remind them to send an introduction -# Is ignored if SEND_INTRODUCTION_REMINDERS=False -# Must be a string of the seconds, minutes, hours, days or weeks before the first/only reminder is sent (format: "smhdw") -# The delay must be longer than or equal to 1 day (in any allowed format) -SEND_INTRODUCTION_REMINDERS_DELAY=40h - -# The interval of time between sending out reminders to Discord members that are not inducted, saying that they need to send an introduction to be allowed access -# Is ignored if SEND_INTRODUCTION_REMINDERS=Once or SEND_INTRODUCTION_REMINDERS=False -# Must be a string of the seconds, minutes or hours between reminders (format: "smh") -SEND_INTRODUCTION_REMINDERS_INTERVAL=6h - -# Whether reminders will be sent to Discord members that have been inducted, saying that they can get opt-in roles. (This message will be only sent once per Discord member) -# Must be a boolean (True or False) -SEND_GET_ROLES_REMINDERS=True - -# How long to wait after a user is inducted before sending them the message to get some opt-in roles -# Is ignored if SEND_GET_ROLES_REMINDERS=False -# Must be a string of the seconds, minutes, hours, days or weeks before a reminder is sent (format: "smhdw") -# The delay must be longer than or equal to 1 day (in any allowed format) -SEND_GET_ROLES_REMINDERS_DELAY=40h - -# !!This is an advanced configuration variable, so is unlikely to need to be changed from its default value!! -# The interval of time between sending out reminders to Discord members that have been inducted, saying that they can get opt-in roles. (This message will be only sent once, the interval is just how often the check for new guests occurs) -# Is ignored if SEND_GET_ROLES_REMINDERS=False -# Must be a string of the seconds, minutes or hours between reminders (format: "smh") -ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL=24h - - -# The number of days to look over messages sent, to generate statistics data -# Must be a float representing the number of days to look back through -STATISTICS_DAYS=30 - -# The names of the roles to gather statistics about, to display in bar chart graphs -# Must be a comma seperated list of strings of role names -STATISTICS_ROLES=Committee,Committee-Elect,Student Rep,Member,Guest,Server Booster,Foundation Year,First Year,Second Year,Final Year,Year In Industry,Year Abroad,PGT,PGR,Alumnus/Alumna,Postdoc,Quiz Victor - - -# !!REQUIRED!! -# The URL of the your group's Discord guild moderation document -# Must be a valid URL -MODERATION_DOCUMENT_URL=[Replace with your group's moderation document URL] - - -# The name of the channel, that warning messages will be sent to when a committee-member manually applies a moderation action (instead of using the `/strike` command) -# Must be the name of a Discord channel in your group's Discord guild, or the value "DM" (which indicates that the messages will be sent in the committee-member's DMs) -# This can be the name of ANY Discord channel (so the offending person *will* be able to see these messages if a public channel is chosen) -MANUAL_MODERATION_WARNING_MESSAGE_LOCATION=DM diff --git a/.gitignore b/.gitignore index f4d92b6a9..3541cde8e 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,75 @@ local/ *.sqlite3.bak *.db.bak local_stubs/ +TeX-Bot-deployment.yaml +TeX-Bot-deployment.yaml.original +TeX-Bot-deployment.yaml.backup +TeX-Bot-deployment.yaml.bkp +TeX-Bot-deployment.yaml.bckp +TeX-Bot-deployment.yaml.bak +TeX-Bot-deployment.yml +TeX-Bot-deployment.yml.original +TeX-Bot-deployment.yml.backup +TeX-Bot-deployment.yml.bkp +TeX-Bot-deployment.yml.bckp +TeX-Bot-deployment.yml.bak +tex-bot-deployment.yaml +tex-bot-deployment.yaml.original +tex-bot-deployment.yaml.backup +tex-bot-deployment.yaml.bkp +tex-bot-deployment.yaml.bckp +tex-bot-deployment.yaml.bak +tex-bot-deployment.yml +tex-bot-deployment.yml.original +tex-bot-deployment.yml.backup +tex-bot-deployment.yml.bkp +tex-bot-deployment.yml.bckp +tex-bot-deployment.yml.bak +TeX-Bot-settings.yaml +TeX-Bot-settings.yaml.original +TeX-Bot-settings.yaml.backup +TeX-Bot-settings.yaml.bkp +TeX-Bot-settings.yaml.bckp +TeX-Bot-settings.yaml.bak +TeX-Bot-settings.yml +TeX-Bot-settings.yml.original +TeX-Bot-settings.yml.backup +TeX-Bot-settings.yml.bkp +TeX-Bot-settings.yml.bckp +TeX-Bot-settings.yml.bak +tex-bot-settings.yaml +tex-bot-settings.yaml.original +tex-bot-settings.yaml.backup +tex-bot-settings.yaml.bkp +tex-bot-settings.yaml.bckp +tex-bot-settings.yaml.bak +tex-bot-settings.yml +tex-bot-settings.yml.original +tex-bot-settings.yml.backup +tex-bot-settings.yml.bkp +tex-bot-settings.yml.bckp +tex-bot-settings.yml.bak +TeX-Bot-config.yaml +TeX-Bot-config.yaml.original +TeX-Bot-config.yaml.backup +TeX-Bot-config.yaml.bkp +TeX-Bot-config.yaml.bckp +TeX-Bot-config.yaml.bak +TeX-Bot-config.yml +TeX-Bot-config.yml.original +TeX-Bot-config.yml.backup +TeX-Bot-config.yml.bkp +TeX-Bot-config.yml.bckp +TeX-Bot-config.yml.bak +tex-bot-config.yaml +tex-bot-config.yaml.original +tex-bot-config.yaml.backup +tex-bot-config.yaml.bkp +tex-bot-config.yaml.bckp +tex-bot-config.yaml.bak +tex-bot-config.yml +tex-bot-config.yml.original +tex-bot-config.yml.backup +tex-bot-config.yml.bkp +tex-bot-config.yml.bckp +tex-bot-config.yml.bak diff --git a/Dockerfile b/Dockerfile index 2d440b4db..2ccbe0bc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,12 +35,13 @@ COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} WORKDIR /app -COPY LICENSE .en[v] config.py main.py messages.json ./ +COPY LICENSE main.py ./ RUN chmod +x main.py COPY exceptions/ ./exceptions/ COPY utils/ ./utils/ COPY db/ ./db/ +COPY config/ ./config/ COPY cogs/ ./cogs/ ENTRYPOINT ["python", "-m", "main"] diff --git a/cogs/__init__.py b/cogs/__init__.py index bfd6accc7..07692dae8 100644 --- a/cogs/__init__.py +++ b/cogs/__init__.py @@ -9,6 +9,7 @@ __all__: Sequence[str] = ( "ArchiveCommandCog", + "ConfigChangeCommandsCog", "GetTokenAuthorisationCommandCog", "CommandErrorCog", "DeleteAllCommandsCog", @@ -50,6 +51,7 @@ CommitteeHandoverCommandCog, ) from .archive import ArchiveCommandCog +from .change_config import CheckConfigFileChangedTaskCog, ConfigChangeCommandsCog from .command_error import CommandErrorCog from .delete_all import DeleteAllCommandsCog from .edit_message import EditMessageCommandCog @@ -84,6 +86,8 @@ def setup(bot: TeXBot) -> None: cogs: Iterable[type[TeXBotBaseCog]] = ( ArchiveCommandCog, GetTokenAuthorisationCommandCog, + CheckConfigFileChangedTaskCog, + ConfigChangeCommandsCog, CommandErrorCog, DeleteAllCommandsCog, EditMessageCommandCog, diff --git a/cogs/archive.py b/cogs/archive.py index 7e348adea..655bfd7cb 100644 --- a/cogs/archive.py +++ b/cogs/archive.py @@ -5,11 +5,12 @@ __all__: Sequence[str] = ("ArchiveCommandCog",) +import functools import logging import re from collections.abc import Set from logging import Logger -from typing import Final +from typing import Final, Protocol import discord @@ -26,6 +27,10 @@ logger: Final[Logger] = logging.getLogger("TeX-Bot") +class _SetPermissionsFunc(Protocol): + async def __call__(self, channel: AllChannelTypes) -> None: ... + + class ArchiveCommandCog(TeXBotBaseCog): """Cog class that defines the "/archive" command and its call-back method.""" @@ -53,13 +58,86 @@ async def autocomplete_get_categories(ctx: TeXBotAutocompleteContext) -> Set[dis return { discord.OptionChoice(name=category.name, value=str(category.id)) - for category - in main_guild.categories + for category in main_guild.categories if category.permissions_for(interaction_user).is_superset( discord.Permissions(send_messages=True, view_channel=True), ) } + async def _set_permissions(self, channel: AllChannelTypes, ctx: TeXBotApplicationContext, interaction_member: discord.Member, *, committee_role: discord.Role, guest_role: discord.Role, member_role: discord.Role, archivist_role: discord.Role, everyone_role: discord.Role) -> None: # noqa: PLR0913,E501 + CHANNEL_NEEDS_COMMITTEE_ARCHIVING: Final[bool] = bool( + channel.permissions_for(committee_role).is_superset( + discord.Permissions(view_channel=True), + ) + and not channel.permissions_for(guest_role).is_superset( + discord.Permissions(view_channel=True), + ) # noqa: COM812 + ) + if CHANNEL_NEEDS_COMMITTEE_ARCHIVING: + await channel.set_permissions( + everyone_role, + reason=f"{interaction_member.display_name} used \"/archive\".", + view_channel=False, + ) + await channel.set_permissions( + guest_role, + overwrite=None, + reason=f"{interaction_member.display_name} used \"/archive\".", + ) + await channel.set_permissions( + member_role, + overwrite=None, + reason=f"{interaction_member.display_name} used \"/archive\".", + ) + await channel.set_permissions( + committee_role, + overwrite=None, + reason=f"{interaction_member.display_name} used \"/archive\".", + ) + return + + CHANNEL_NEEDS_NORMAL_ARCHIVING: Final[bool] = channel.permissions_for( + guest_role, + ).is_superset( + discord.Permissions(view_channel=True), + ) + if CHANNEL_NEEDS_NORMAL_ARCHIVING: + await channel.set_permissions( + everyone_role, + reason=f"{interaction_member.display_name} used \"/archive\".", + view_channel=False, + ) + await channel.set_permissions( + guest_role, + overwrite=None, + reason=f"{interaction_member.display_name} used \"/archive\".", + ) + await channel.set_permissions( + member_role, + overwrite=None, + reason=f"{interaction_member.display_name} used \"/archive\".", + ) + await channel.set_permissions( + committee_role, + reason=f"{interaction_member.display_name} used \"/archive\".", + view_channel=False, + ) + await channel.set_permissions( + archivist_role, + reason=f"{interaction_member.display_name} used \"/archive\".", + view_channel=True, + ) + return + + await self.command_send_error( + ctx, + message=f"Channel {channel.mention} had invalid permissions", + ) + logger.error( + "Channel %s had invalid permissions, so could not be archived.", + channel.name, + ) + @discord.slash_command( # type: ignore[no-untyped-call, misc] name="archive", description="Archives the selected category.", @@ -78,8 +156,8 @@ async def archive(self, ctx: TeXBotApplicationContext, str_category_id: str) -> """ Definition & callback response of the "archive" command. - The "archive" command hides a given category from view of casual members unless they - have the "Archivist" role. + The "archive" command hides a given category from the view of casual members + unless they have the "Archivist" role. """ # NOTE: Shortcut accessors are placed at the top of the function, so that the exceptions they raise are displayed before any further errors may be sent main_guild: discord.Guild = self.bot.main_guild @@ -120,81 +198,22 @@ async def archive(self, ctx: TeXBotApplicationContext, str_category_id: str) -> ) return + set_permissions_func: _SetPermissionsFunc = functools.partial( + self._set_permissions, + ctx=ctx, + interaction_member=interaction_member, + committee_role=committee_role, + guest_role=guest_role, + member_role=member_role, + archivist_role=archivist_role, + everyone_role=everyone_role, + ) + + # noinspection PyUnreachableCode channel: AllChannelTypes for channel in category.channels: try: - CHANNEL_NEEDS_COMMITTEE_ARCHIVING: bool = ( - channel.permissions_for(committee_role).is_superset( - discord.Permissions(view_channel=True), - ) and not channel.permissions_for(guest_role).is_superset( - discord.Permissions(view_channel=True), - ) - ) - CHANNEL_NEEDS_NORMAL_ARCHIVING: bool = ( - channel.permissions_for(guest_role).is_superset( - discord.Permissions(view_channel=True), - ) - ) - if CHANNEL_NEEDS_COMMITTEE_ARCHIVING: - await channel.set_permissions( - everyone_role, - reason=f"{interaction_member.display_name} used \"/archive\".", - view_channel=False, - ) - await channel.set_permissions( - guest_role, - overwrite=None, - reason=f"{interaction_member.display_name} used \"/archive\".", - ) - await channel.set_permissions( - member_role, - overwrite=None, - reason=f"{interaction_member.display_name} used \"/archive\".", - ) - await channel.set_permissions( - committee_role, - overwrite=None, - reason=f"{interaction_member.display_name} used \"/archive\".", - ) - - elif CHANNEL_NEEDS_NORMAL_ARCHIVING: - await channel.set_permissions( - everyone_role, - reason=f"{interaction_member.display_name} used \"/archive\".", - view_channel=False, - ) - await channel.set_permissions( - guest_role, - overwrite=None, - reason=f"{interaction_member.display_name} used \"/archive\".", - ) - await channel.set_permissions( - member_role, - overwrite=None, - reason=f"{interaction_member.display_name} used \"/archive\".", - ) - await channel.set_permissions( - committee_role, - reason=f"{interaction_member.display_name} used \"/archive\".", - view_channel=False, - ) - await channel.set_permissions( - archivist_role, - reason=f"{interaction_member.display_name} used \"/archive\".", - view_channel=True, - ) - - else: - await self.command_send_error( - ctx, - message=f"Channel {channel.mention} had invalid permissions", - ) - logger.error( - "Channel %s had invalid permissions, so could not be archived.", - channel.name, - ) - return - + await set_permissions_func(channel=channel) except discord.Forbidden: await self.command_send_error( ctx, diff --git a/cogs/change_config.py b/cogs/change_config.py new file mode 100644 index 000000000..8774e9847 --- /dev/null +++ b/cogs/change_config.py @@ -0,0 +1,890 @@ +"""Contains cog classes for any config-changing interactions.""" + +from collections.abc import Sequence + +__all__: Sequence[str] = ("CheckConfigFileChangedTaskCog", "ConfigChangeCommandsCog") + + +import contextlib +import itertools +import logging +import os +import random +import re +import stat +import urllib.parse +from collections.abc import MutableSequence, Set +from io import BytesIO +from logging import Logger +from typing import Final, NamedTuple, Self, override + +import discord +from aiopath import AsyncPath +from anyio import AsyncFile +from discord.ext import tasks +from discord.ui import View +from strictyaml import StrictYAMLError + +import config +from config import CONFIG_SETTINGS_HELPS, ConfigSettingHelp, LogLevels, settings +from config.constants import MESSAGES_LOCALE_CODES, SendIntroductionRemindersFlagType +from exceptions import ( + ChangingSettingWithRequiredSiblingError, + CommitteeRoleDoesNotExistError, + DiscordMemberNotInMainGuildError, +) +from exceptions.base import BaseDoesNotExistError +from utils import ( + CommandChecks, + EditorResponseComponent, + GenericResponderComponent, + SenderResponseComponent, + TeXBot, + TeXBotApplicationContext, + TeXBotAutocompleteContext, + TeXBotBaseCog, +) + +logger: Final[Logger] = logging.getLogger("TeX-Bot") + + +class FileStats(NamedTuple): + """Container to hold stats information about a single file.""" + + type: int + size: int + modified_time: float + + @classmethod + async def _public_from_file_path(cls, file_path: AsyncPath) -> Self: # type: ignore[misc] + return cls._public_from_full_stats(await file_path.stat()) + + @classmethod + def _public_from_full_stats(cls, full_stats: os.stat_result) -> Self: + file_type: int = stat.S_IFMT(full_stats.st_mode) + if file_type != stat.S_IFREG: + INVALID_FILE_TYPE_MESSAGE: Final[str] = "File type must be 'S_IFREG'." + raise ValueError(INVALID_FILE_TYPE_MESSAGE) + + return cls( + type=file_type, + size=full_stats.st_size, + modified_time=full_stats.st_mtime, + ) + + +class FileComparer(NamedTuple): + """Container to hold all the information to compare one file to another.""" + + stats: FileStats + raw_content: bytes + + @classmethod + async def _public_from_file_path(cls, file_path: AsyncPath) -> Self: # type: ignore[misc] + # noinspection PyProtectedMember + return cls( + stats=await FileStats._public_from_file_path(file_path), # noqa: SLF001 + raw_content=await file_path.read_bytes(), + ) + + +class ConfirmSetConfigSettingValueView(View): + """A discord.View containing two buttons to confirm setting a given config setting.""" + + @discord.ui.button( # type: ignore[misc] + label="Yes", + style=discord.ButtonStyle.red, + custom_id="set_config_confirm", + ) + async def confirm_set_config_button_callback(self, _: discord.Button, interaction: discord.Interaction) -> None: # noqa: E501 + """When the yes button is pressed, delete the message.""" + logger.debug("\"Yes\" button pressed. %s", interaction) + + @discord.ui.button( # type: ignore[misc] + label="No", + style=discord.ButtonStyle.green, + custom_id="set_config_cancel", + ) + async def cancel_set_config_button_callback(self, _: discord.Button, interaction: discord.Interaction) -> None: # noqa: E501 + """When the no button is pressed, delete the message.""" + logger.debug("\"No\" button pressed. %s", interaction) + + +class CheckConfigFileChangedTaskCog(TeXBotBaseCog): + """Cog class that defines the check_config_file_changed task.""" + + _STATS_CACHE: Final[dict[tuple[FileStats, FileStats], bool]] = {} + + @override + def __init__(self, bot: TeXBot) -> None: + """Start all task managers when this cog is initialised.""" + self._previous_file_comparer: FileComparer | None = None + + self.check_config_file_changed.start() + + super().__init__(bot) + + @override + def cog_unload(self) -> None: + """ + Unload hook that ends all running tasks whenever the tasks cog is unloaded. + + This may be run dynamically or when the bot closes. + """ + self.check_config_file_changed.cancel() + + @classmethod + async def _file_raw_contents_is_same(cls, current_file: AsyncFile[bytes], previous_raw_contents: bytes) -> bool: # noqa: E501 + BUFFER_SIZE: Final[int] = 8*1024 + + previous_file: BytesIO = BytesIO(previous_raw_contents) + + while True: + partial_current_contents: bytes = await current_file.read(BUFFER_SIZE) + partial_previous_contents: bytes = previous_file.read(BUFFER_SIZE) + + if partial_current_contents != partial_previous_contents: + return False + if not partial_current_contents: + return True + + @classmethod + async def _check_config_actually_is_same(cls, previous_file_comparer: FileComparer) -> bool: # noqa: E501 + SETTINGS_FILE_PATH: Final[AsyncPath] = ( + await config._settings.utils.get_settings_file_path() + ) + # noinspection PyProtectedMember + current_file_stats: FileStats = await FileStats._public_from_file_path( # noqa: SLF001 + SETTINGS_FILE_PATH, + ) + + if current_file_stats.size != previous_file_comparer.stats.size: + return False + + outcome: bool | None = cls._STATS_CACHE.get( + (current_file_stats, previous_file_comparer.stats), + None, + ) + if outcome is not None: + return outcome + + async with SETTINGS_FILE_PATH.open("rb") as current_file: + outcome = await cls._file_raw_contents_is_same( + current_file, + previous_file_comparer.raw_content, + ) + + if len(cls._STATS_CACHE) > 100: + cls._STATS_CACHE.clear() + + cls._STATS_CACHE[(current_file_stats, previous_file_comparer.stats)] = outcome + return outcome + + @tasks.loop(seconds=settings["CHECK_CONFIG_FILE_CHANGED_INTERVAL_SECONDS"]) + async def check_config_file_changed(self) -> None: + """Recurring task to check whether the config settings file has changed.""" + if self._previous_file_comparer is None: + # noinspection PyProtectedMember + self._previous_file_comparer = await FileComparer._public_from_file_path( # noqa: SLF001 + await config._settings.utils.get_settings_file_path(), + ) + return + + if await self._check_config_actually_is_same(self._previous_file_comparer): + return + + # noinspection PyProtectedMember + self._previous_file_comparer = await FileComparer._public_from_file_path( # noqa: SLF001 + await config._settings.utils.get_settings_file_path(), + ) + + # 1. retrieve new yaml + # 2. recurse yaml and get changed values + # 3. if it needs restart, do total restart + # 4. if not needs restart, do reload func + # 5. if is messages-code changed, reload messages + + raise NotImplementedError # TODO: reload/update changes + + # { + # config_setting_name + # for config_setting_name, config_setting_help + # in CONFIG_SETTINGS_HELPS.items() + # if config_setting_help.requires_restart_after_changed + # } + + @check_config_file_changed.before_loop + async def before_tasks(self) -> None: + """Pre-execution hook, preventing any tasks from executing before the bot is ready.""" + await self.bot.wait_until_ready() + + +class ConfigChangeCommandsCog(TeXBotBaseCog): + """Cog class that defines the "/config" command group and command call-back methods.""" + + change_config: discord.SlashCommandGroup = discord.SlashCommandGroup( + name="config", + description="Display, edit and get help about TeX-Bot's configuration.", + ) + + @classmethod + def get_formatted_change_delay_message(cls) -> str: + return f"Changes could take up to { + ( + str(int(settings["CHECK_CONFIG_FILE_CHANGED_INTERVAL_SECONDS"] * 2.1)) + if (settings["CHECK_CONFIG_FILE_CHANGED_INTERVAL_SECONDS"] * 2.1) % 1 == 0 + else f"{settings["CHECK_CONFIG_FILE_CHANGED_INTERVAL_SECONDS"] * 2.1:.2f}" + ) + } seconds to take effect." + + @staticmethod + async def autocomplete_get_settings_names(ctx: TeXBotAutocompleteContext) -> Set[discord.OptionChoice] | Set[str]: # noqa: E501 + """Autocomplete callable that generates the set of available settings names.""" + if not ctx.interaction.user: + return set() + + try: + if not await ctx.bot.check_user_has_committee_role(ctx.interaction.user): + return set() + except (BaseDoesNotExistError, DiscordMemberNotInMainGuildError): + return set() + + return set(config.CONFIG_SETTINGS_HELPS) + + @staticmethod + async def autocomplete_get_unsetable_settings_names(ctx: TeXBotAutocompleteContext) -> Set[discord.OptionChoice] | Set[str]: # noqa: E501 + """Autocomplete callable that generates the set of unsetable settings names.""" + if not ctx.interaction.user: + return set() + + try: + if not await ctx.bot.check_user_has_committee_role(ctx.interaction.user): + return set() + except (BaseDoesNotExistError, DiscordMemberNotInMainGuildError): + return set() + + return { + setting_name + for setting_name, setting_help in config.CONFIG_SETTINGS_HELPS.items() + if setting_help.default is not None + } + + @staticmethod + async def autocomplete_get_example_setting_values(ctx: TeXBotAutocompleteContext) -> Set[discord.OptionChoice] | Set[str]: # noqa: C901,PLR0911,PLR0912,E501 + """Autocomplete callable that generates example values for a configuration setting.""" + HAS_CONTEXT: Final[bool] = bool( + ctx.interaction.user and "setting" in ctx.options and ctx.options["setting"] # noqa: COM812 + ) + if not HAS_CONTEXT: + return set() + + try: + if not await ctx.bot.check_user_has_committee_role(ctx.interaction.user): # type: ignore[arg-type] + return set() + except (BaseDoesNotExistError, DiscordMemberNotInMainGuildError): + return set() + + setting_name: str = ctx.options["setting"] + + if "token" in setting_name or "cookie" in setting_name or "secret" in setting_name: + return set() + + if ":log-level" in setting_name: + return {log_level.value for log_level in LogLevels} + + if "discord" in setting_name and ":webhook-url" in setting_name: + return {"https://discord.com/api/webhooks/"} + + if "members-list:id-format" in setting_name: + return ( + {r"\A[a-z0-9]{" + str(2 ** id_length) + r"}\Z" for id_length in range(2, 9)} + | {r"\A[A-F0-9]{" + str(2 ** id_length) + r"}\Z" for id_length in range(2, 9)} + | {r"\A[0-9]{" + str(2 ** id_length) + r"}\Z" for id_length in range(2, 9)} + ) + + if "probability" in setting_name: + return { + "0", + "0.01", + "0.025", + "0.05", + "0.1", + "0.125", + "0.15", + "0.20", + "0.25", + "0.4", + "0.45", + "0.5", + "0.6", + "0.65", + "0.7", + "0.75", + "0.8", + "0.85", + "0.9", + "0.95", + "0.975", + "0.99", + "0.999", + "1", + } + + if ":lookback-days" in setting_name: + return { + "5", + "7", + "10", + "20", + "25", + "27", + "28", + "30", + "31", + "50", + "75", + "100", + "150", + "200", + "250", + "500", + "750", + "1000", + "1250", + "1500", + "1826", + } + + if ":displayed-roles" in setting_name: + return { + "Committee,Member,Guest", + ( + "Foundation Year,First Year,Second Year,Final Year,Year In Industry," + "Year Abroad,PGT,PGR,Alumnus/Alumna,Postdoc" + ), + } + + if "locale-code" in setting_name: + return MESSAGES_LOCALE_CODES + + if "send-introduction-reminders:enable" in setting_name: + return { + str(flag_value).lower() + for flag_value in getattr(SendIntroductionRemindersFlagType, "__args__") # noqa: B009 + } + + if "send-get-roles-reminders:enable" in setting_name: + return {"true", "false"} + + SETTING_NAME_IS_TIMEDELTA: Final[bool] = bool( + any( + part in setting_name + for part in ( + ":timeout-duration:", + ":delay:", + ":interval:", + ":timeout-duration-", + ":delay-", + ":interval-", + "-timeout-duration:", + "-delay:", + "-interval:", + ) + ) + or setting_name.endswith( + ( + ":timeout-duration", + ":delay", + ":interval", + "-timeout-duration", + "-delay", + "-interval", + ), + ) # noqa: COM812 + ) + if SETTING_NAME_IS_TIMEDELTA: + timedelta_scales: MutableSequence[str] = ["s", "m"] + + if setting_name != "check-if-config-changed-interval": + timedelta_scales.extend(["h"]) + + if any(part in setting_name for part in ("timeout-duration", "delay")): + timedelta_scales.extend(["d", "w"]) + + return { + "".join( + ( + ( + f"{ + ( + f"{ + str( + random.choice( + ( + random.randint(1, 110), + round( + random.random() * 110, + random.randint(1, 3), + ), + ), + ), + ).removesuffix(".0").removesuffix(".00").removesuffix( + ".000", + ) + }{ + selected_timedelta_scale + }" + ) + if selected_timedelta_scale + else "" + }" + ) + for selected_timedelta_scale + in selected_timedelta_scales + ), + ) + for _ in range(4) + for selected_timedelta_scales in itertools.product( + *(("", timedelta_scale) for timedelta_scale in timedelta_scales), + ) + if any(selected_timedelta_scales) + } + + if setting_name.endswith(":url") or re.search(r":links:[^:]+\Z", setting_name): + if "purchase-membership" in setting_name or "membership-perks" in setting_name: + return { + "https://", + "https://www.guildofstudents.com/studentgroups/societies/", + "https://www.guildofstudents.com/organisation/", + } + + if "document" in setting_name: + # noinspection SpellCheckingInspection + return ( + { + "https://", + "https://drive.google.com/file/d/", + "https://docs.google.com/document/d/", + "https://onedrive.live.com/edit.aspx?resid=", + "https://1drv.ms/p/", + } + | { + f"https://{domain}.com/{path}" + for domain, path in itertools.product( + ("github", "raw.githubusercontent"), + (f"{urllib.parse.quote(ctx.bot.group_short_name)}/", ""), + ) + } + | { + f"https://{subdomain}dropbox{domain_suffix}.com/{path}" + for subdomain, domain_suffix, path in itertools.product( + ("dl.", ""), + ("usercontent", ""), + ("shared/", "", "s/", "scl/fi/"), + ) + } + ) + + return {"https://"} + + try: + main_guild: discord.Guild = ctx.bot.main_guild + except (BaseDoesNotExistError, DiscordMemberNotInMainGuildError): + return set() + + if "community" in setting_name: + SIMPLIFIED_FULL_NAME: Final[str] = ( + main_guild.name.strip().removeprefix("the").removeprefix("The").strip() + ) + + if ":full-name" in setting_name: + return { + main_guild.name.strip(), + main_guild.name.strip().title(), + SIMPLIFIED_FULL_NAME.split()[0].rsplit("'")[0], + SIMPLIFIED_FULL_NAME.split()[0].rsplit("'")[0].capitalize(), + } + + if ":short-name" in setting_name: + return { + "".join(word[0].upper() for word in SIMPLIFIED_FULL_NAME.split()), + "".join(word[0].lower() for word in SIMPLIFIED_FULL_NAME.split()), + } + + try: + interaction_member: discord.Member = await ctx.bot.get_main_guild_member( + ctx.interaction.user, # type: ignore[arg-type] + ) + except (BaseDoesNotExistError, DiscordMemberNotInMainGuildError): + return set() + + if ":performed-manually-warning-location" in setting_name: + return {"DM"} | { + channel.name + for channel in main_guild.text_channels + if channel.permissions_for(interaction_member).is_superset( + discord.Permissions(send_messages=True), + ) + } + + return set() + + @change_config.command( + name="get", + description="Display the current value of a configuration setting.", + ) + @discord.option( # type: ignore[no-untyped-call, misc] + name="setting", + description="The name of the configuration setting value to retrieve.", + input_type=str, + autocomplete=discord.utils.basic_autocomplete(autocomplete_get_settings_names), # type: ignore[arg-type] + required=True, + parameter_name="config_setting_name", + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def get_config_value(self, ctx: TeXBotApplicationContext, config_setting_name: str) -> None: # noqa: E501 + """Definition & callback response of the "get_config_value" command.""" + if config_setting_name not in config.CONFIG_SETTINGS_HELPS: + await self.command_send_error( + ctx, + message=f"Invalid setting: {config_setting_name!r}", + ) + return + + config_setting_value: str | None = config.view_single_config_setting_value( + config_setting_name, + ) + + if isinstance(config_setting_value, str): + config_setting_value = config_setting_value.strip() + + CONFIG_SETTING_IS_SECRET: Final[bool] = bool( + "token" in config_setting_name + or "cookie" in config_setting_name + or "secret" in config_setting_name # noqa: COM812 + ) + + await ctx.respond( + ( + f"`{config_setting_name.replace("`", "\\`")}` " + f"{ + "**cannot be viewed**." if CONFIG_SETTING_IS_SECRET else ( + f"**=** `{config_setting_value.replace("`", "\\`")}`" + if config_setting_value + else f"**is not set**.{ + f"\nThe default value is `{ + CONFIG_SETTINGS_HELPS[config_setting_name].default.replace( # type: ignore[union-attr] + "`", + "\\`", + ) + }`" + if CONFIG_SETTINGS_HELPS[config_setting_name].default is not None + else "" + }" + ) + }" + ), + ephemeral=True, + ) + + @change_config.command( + name="help", + description="Show the description of what a given configuration setting does.", + ) + @discord.option( # type: ignore[no-untyped-call, misc] + name="setting", + description="The name of the configuration setting to show the description of.", + input_type=str, + autocomplete=discord.utils.basic_autocomplete(autocomplete_get_settings_names), # type: ignore[arg-type] + required=True, + parameter_name="config_setting_name", + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def help_config_setting(self, ctx: TeXBotApplicationContext, config_setting_name: str) -> None: # noqa: E501 + """Definition & callback response of the "help_config_setting" command.""" + if config_setting_name not in config.CONFIG_SETTINGS_HELPS: + await self.command_send_error( + ctx, + message=f"Invalid setting: {config_setting_name!r}", + ) + return + + config_setting_help: ConfigSettingHelp = config.CONFIG_SETTINGS_HELPS[ + config_setting_name + ] + + await ctx.respond( + ( + f"## `{ + config_setting_name.replace("`", "\\`") + }`\n" + f"{ + config_setting_help.description.replace( + "**`@TeX-Bot`**", + self.bot.user.mention if self.bot.user else "**`@TeX-Bot`**", + ).replace( + "TeX-Bot", + self.bot.user.mention if self.bot.user else "**`@TeX-Bot`**", + ).replace( + "the bot", + self.bot.user.mention if self.bot.user else "**`@TeX-Bot`**", + ) + }\n\n" + f"{ + f"{config_setting_help.value_type_message}\n\n" + if config_setting_help.value_type_message + else "" + }" + f"This setting is **{ + "required" if config_setting_help.required else "optional" + }**.\n\n" + f"{ + f"The default value for this setting is: `{ + config_setting_help.default.replace("`", "\\`") + }`" + if config_setting_help.default + else "" + }" + ), + ephemeral=True, + ) + + @change_config.command( + name="set", + description="Assign a new value to the specified configuration setting.", + ) + @discord.option( # type: ignore[no-untyped-call, misc] + name="setting", + description="The name of the configuration setting to assign a new value to.", + input_type=str, + autocomplete=discord.utils.basic_autocomplete(autocomplete_get_settings_names), # type: ignore[arg-type] + required=True, + parameter_name="config_setting_name", + ) + @discord.option( # type: ignore[no-untyped-call, misc] + name="value", + description="The new value to assign to the specified configuration setting.", + input_type=str, + autocomplete=discord.utils.basic_autocomplete(autocomplete_get_example_setting_values), # type: ignore[arg-type] + required=True, + parameter_name="new_config_value", + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def set_config_value(self, ctx: TeXBotApplicationContext, config_setting_name: str, new_config_value: str) -> None: # noqa: E501 + """Definition & callback response of the "set_config_value" command.""" + if config_setting_name not in config.CONFIG_SETTINGS_HELPS: + await self.command_send_error( + ctx, + message=f"Invalid setting: {config_setting_name!r}", + ) + return + + SELECTED_SETTING_HAS_DEFAULT: Final[bool] = ( + config.CONFIG_SETTINGS_HELPS[config_setting_name].default is not None + ) + + if not SELECTED_SETTING_HAS_DEFAULT: + response: discord.Message | discord.Interaction = await ctx.respond( + content=( + f"Setting {config_setting_name.replace("`", "\\`")} " + "has no default value." + "If you overwrite it with a new value the old one will be lost " + "and cannot be restored.\n" + "Are you sure you want to overwrite the old value?\n\n" + "Please confirm using the buttons below." + ), + view=ConfirmSetConfigSettingValueView(), + ephemeral=True, + ) + + committee_role: discord.Role | None = None + with contextlib.suppress(CommitteeRoleDoesNotExistError): + committee_role = await self.bot.committee_role + + confirmation_message: discord.Message = ( + response + if isinstance(response, discord.Message) + else await response.original_response() + ) + button_interaction: discord.Interaction = await self.bot.wait_for( + "interaction", + check=lambda interaction: bool( + interaction.type == discord.InteractionType.component + and interaction.message.id == confirmation_message.id + and ( + (committee_role in interaction.user.roles) if committee_role else True + ) + and "custom_id" in interaction.data + and interaction.data["custom_id"] in { + "shutdown_confirm", + "shutdown_cancel", + } # noqa: COM812 + ), + ) + + match button_interaction.data["custom_id"]: # type: ignore[index, typeddict-item] + case "set_config_cancel": + await confirmation_message.edit( + content=( + "Aborting editing config setting: " + f"{config_setting_name.replace("`", "\\`")}" + ), + view=None, + ) + return + + case "set_config_confirm": + pass + + case _: + raise ValueError + + previous_config_setting_value: str | None = config.view_single_config_setting_value( + config_setting_name, + ) + + responder: GenericResponderComponent = ( + EditorResponseComponent(ctx.interaction) + if not SELECTED_SETTING_HAS_DEFAULT + else SenderResponseComponent(ctx.interaction, ephemeral=True) + ) + + yaml_error: StrictYAMLError + changing_setting_error: ChangingSettingWithRequiredSiblingError + try: + await config.assign_single_config_setting_value( + config_setting_name, + new_config_value, + ) + + except StrictYAMLError as yaml_error: + if str(yaml_error) != yaml_error.context: + INCONCLUSIVE_YAML_ERROR_MESSAGE: Final[str] = ( + "Could not determine the error message from invalid YAML validation." + ) + raise NotImplementedError(INCONCLUSIVE_YAML_ERROR_MESSAGE) from None + + await self.command_send_error( + ctx, + message=( + f"Changing setting value failed: " + f"{str(yaml_error.context)[0].upper()}" + f"{str(yaml_error.context)[1:].strip(" .")}." + ), + responder_component=responder, + ) + return + + except ChangingSettingWithRequiredSiblingError as changing_setting_error: + await self.command_send_error( + ctx, + message=( + f"{changing_setting_error} " + f"It will be easier to make your changes " + f"directly within the \"tex-bot-deployment.yaml\" file." + ), + responder_component=responder, + ) + return + + changed_config_setting_value: str | None = config.view_single_config_setting_value( + config_setting_name, + ) + + if changed_config_setting_value == previous_config_setting_value: + await responder.respond( + "No changes made. Provided value was the same as the previous value.", + view=None, + ) + return + + if isinstance(changed_config_setting_value, str): + changed_config_setting_value = changed_config_setting_value.strip() + + CONFIG_SETTING_IS_SECRET: Final[bool] = bool( + "token" in config_setting_name + or "cookie" in config_setting_name + or "secret" in config_setting_name # noqa: COM812 + ) + await responder.respond( + content=( + f"Successfully updated setting: `{ + config_setting_name.replace("`", "\\`") + }`" + f"{ + "" if CONFIG_SETTING_IS_SECRET else ( + ( + f"**=** `{ + changed_config_setting_value.replace("`", "\\`") + }`" + ) + if changed_config_setting_value + else "**to be not set**." + ) + }\n\n{self.get_formatted_change_delay_message()}" + ), + view=None, + ) + + @change_config.command( + name="unset", + description=( + "Unset the specified configuration setting, " + "so that it returns to its default value." + ), + ) + @discord.option( # type: ignore[no-untyped-call, misc] + name="setting", + description="The name of the configuration setting to unset.", + input_type=str, + autocomplete=discord.utils.basic_autocomplete( + autocomplete_get_unsetable_settings_names, # type: ignore[arg-type] + ), + required=True, + parameter_name="config_setting_name", + ) + @CommandChecks.check_interaction_user_has_committee_role + @CommandChecks.check_interaction_user_in_main_guild + async def unset_config_value(self, ctx: TeXBotApplicationContext, config_setting_name: str) -> None: # noqa: E501 + """Definition & callback response of the "unset_config_value" command.""" + if config_setting_name not in config.CONFIG_SETTINGS_HELPS: + await self.command_send_error( + ctx, + message=f"Invalid setting: {config_setting_name!r}", + ) + return + + if config.CONFIG_SETTINGS_HELPS[config_setting_name].default is None: + await self.command_send_error( + ctx, + message=( + f"Setting {config_setting_name!r} cannot be unset, " + "because it has no default value" + ), + ) + return + + try: + await config.remove_single_config_setting_value(config_setting_name) # TODO: Fix sibling not removed correctly (E.g. reminders enables/disabled) + except KeyError: + await ctx.respond( + content=( + ":information_source: " + f"Setting `{config_setting_name}` already has the default value" + " :information_source:" + ), + ephemeral=True, + ) + return + + await ctx.respond( + content=( + f"Successfully unset setting `{ + config_setting_name.replace("`", "\\`") + }`\n\n{self.get_formatted_change_delay_message()}" + ), + ephemeral=True, + ) diff --git a/cogs/command_error.py b/cogs/command_error.py index f7906d0ca..96bc84606 100644 --- a/cogs/command_error.py +++ b/cogs/command_error.py @@ -16,7 +16,9 @@ from exceptions import ( CommitteeRoleDoesNotExistError, + ErrorCodeCouldNotBeIdentifiedError, GuildDoesNotExistError, + UnknownDjangoError, ) from exceptions.base import BaseErrorWithErrorCode from utils import CommandChecks, TeXBotApplicationContext, TeXBotBaseCog @@ -27,29 +29,60 @@ class CommandErrorCog(TeXBotBaseCog): """Cog class that defines additional code to execute upon a command error.""" + @classmethod + def _get_logging_message_from_error(cls, error: discord.ApplicationCommandInvokeError) -> str | None: # noqa: E501 + if isinstance(error.original, GuildDoesNotExistError): + return None + + if not str(error.original).strip(". -\"'"): + return f"{error.original.__class__.__name__} was raised." + + if str(error.original).startswith("\"") or str(error.original).startswith("'"): + return f"{error.original.__class__.__name__}: {error.original}" + + if isinstance(error.original, UnknownDjangoError): + return str(error.original) + + if isinstance(error.original, RuntimeError | NotImplementedError): + return f"{error.original.__class__.__name__}: {error.original}" + + return str(error.original) + + @classmethod + def _get_error_code_from_error(cls, error: discord.ApplicationCommandInvokeError) -> str: + if isinstance(error.original, Forbidden): + return "E1044" + + if isinstance(error.original, BaseErrorWithErrorCode): + return error.original.ERROR_CODE + + raise ErrorCodeCouldNotBeIdentifiedError(other_error=error.original) + @TeXBotBaseCog.listener() async def on_application_command_error(self, ctx: TeXBotApplicationContext, error: discord.ApplicationCommandError) -> None: # noqa: E501 """Log any major command errors in the logging channel & stderr.""" + IS_FATAL: Final[bool] = bool( + isinstance(error, discord.ApplicationCommandInvokeError) + and bool( + isinstance(error.original, RuntimeError | NotImplementedError) + or type(error.original) is Exception # noqa: COM812 + ) # noqa: COM812 + ) + error_code: str | None = None - message: str | None = "Please contact a committee member." + message: str | None = "Please contact a committee member." if not IS_FATAL else "" logging_message: str | BaseException | None = None if isinstance(error, discord.ApplicationCommandInvokeError): - message = None - logging_message = ( - None if isinstance(error.original, GuildDoesNotExistError) else error.original - ) - - if isinstance(error.original, Forbidden): - error_code = "E1044" - - elif isinstance(error.original, BaseErrorWithErrorCode): - error_code = error.original.ERROR_CODE + logging_message = self._get_logging_message_from_error(error) + with contextlib.suppress(ErrorCodeCouldNotBeIdentifiedError): + error_code = self._get_error_code_from_error(error) elif isinstance(error, CheckAnyFailure): if CommandChecks.is_interaction_user_in_main_guild_failure(error.checks[0]): message = ( - f"You must be a member of the {self.bot.group_short_name} Discord server " + "You must be a member of " + f"the {self.bot.group_short_name} Discord server " "to use this command." ) @@ -65,26 +98,41 @@ async def on_application_command_error(self, ctx: TeXBotApplicationContext, erro error_code=error_code, message=message, logging_message=logging_message, + is_fatal=IS_FATAL, ) - if isinstance(error, discord.ApplicationCommandInvokeError) and isinstance(error.original, GuildDoesNotExistError): # noqa: E501 - command_name: str = ( - ctx.command.callback.__name__ - if ( - hasattr(ctx.command, "callback") - and not ctx.command.callback.__name__.startswith("_") - ) - else ctx.command.qualified_name - ) - logger.critical( - " ".join( - message_part - for message_part in ( - error.original.ERROR_CODE, - f"({command_name})" if command_name in self.ERROR_ACTIVITIES else "", - str(error.original).rstrip(".:"), + if isinstance(error, discord.ApplicationCommandInvokeError): + if isinstance(error.original, GuildDoesNotExistError): + command_name: str = ( + ctx.command.callback.__name__ + if ( + hasattr(ctx.command, "callback") + and not ctx.command.callback.__name__.startswith("_") ) - if message_part - ), + else ctx.command.qualified_name + ) + logger.critical( + " ".join( + message_part + for message_part in ( + error.original.ERROR_CODE, + ( + f"({command_name})" + if command_name in self.ERROR_ACTIVITIES + else "" + ), + str(error.original).rstrip(".:"), + ) + if message_part + ), + ) + + TEX_BOT_NEEDS_CLOSING: Final[bool] = ( + isinstance( + error.original, + RuntimeError | NotImplementedError | GuildDoesNotExistError, + ) + or type(error.original) is Exception ) - await self.bot.close() + if TEX_BOT_NEEDS_CLOSING: + await self.bot.close() diff --git a/cogs/get_token_authorisation.py b/cogs/get_token_authorisation.py index 9d5e59c87..31e168234 100644 --- a/cogs/get_token_authorisation.py +++ b/cogs/get_token_authorisation.py @@ -121,7 +121,8 @@ async def get_token_authorisation(self, ctx: TeXBotApplicationContext) -> None: organisation for organisation in organisations )}", ephemeral=bool( - (not guest_role) or ctx.channel.permissions_for(guest_role).is_superset( + (not guest_role) + or ctx.channel.permissions_for(guest_role).is_superset( discord.Permissions(view_channel=True), ) # noqa: COM812 ), diff --git a/cogs/induct.py b/cogs/induct.py index 52cf2b8a2..8a285eedc 100644 --- a/cogs/induct.py +++ b/cogs/induct.py @@ -20,7 +20,7 @@ import discord -from config import settings +from config import messages, settings from db.core.models import IntroductionReminderOptOutMember from exceptions import ( ApplicantRoleDoesNotExistError, @@ -116,11 +116,19 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) await after.send( f"You can also get yourself an annual membership " f"to {self.bot.group_full_name} for only £5! " - f"Just head to {settings["PURCHASE_MEMBERSHIP_URL"]}. " + f"{ + f"Just head to {settings["PURCHASE_MEMBERSHIP_LINK"]}. " + if settings["PURCHASE_MEMBERSHIP_LINK"] + else "" + }" "You'll get awesome perks like a free T-shirt:shirt:, " "access to member only events:calendar_spiral: and a cool green name on " f"the {self.bot.group_short_name} Discord server:green_square:! " - f"Checkout all the perks at {settings["MEMBERSHIP_PERKS_URL"]}", + f"{ + f"Checkout all the perks at {settings["MEMBERSHIP_PERKS_LINK"]}" + if settings["MEMBERSHIP_PERKS_LINK"] + else "" + }", ) except discord.Forbidden: logger.info( @@ -139,7 +147,7 @@ class BaseInductCog(TeXBotBaseCog): async def get_random_welcome_message(self, induction_member: discord.User | discord.Member | None = None) -> str: # noqa: E501 """Get & format a random welcome message.""" - random_welcome_message: str = random.choice(tuple(settings["WELCOME_MESSAGES"])) + random_welcome_message: str = random.choice(tuple(messages["WELCOME_MESSAGES"])) if "" in random_welcome_message: if not induction_member: @@ -161,13 +169,13 @@ async def get_random_welcome_message(self, induction_member: discord.User | disc committee_role_mention, ) - if "" in random_welcome_message: - if not settings["PURCHASE_MEMBERSHIP_URL"]: + if "" in random_welcome_message: + if not settings["PURCHASE_MEMBERSHIP_LINK"]: return await self.get_random_welcome_message(induction_member) random_welcome_message = random_welcome_message.replace( - "", - settings["PURCHASE_MEMBERSHIP_URL"], + "", + settings["PURCHASE_MEMBERSHIP_LINK"], ) if "" in random_welcome_message: @@ -178,7 +186,6 @@ async def get_random_welcome_message(self, induction_member: discord.User | disc return random_welcome_message.strip() - async def _perform_induction(self, ctx: TeXBotApplicationContext, induction_member: discord.Member, *, silent: bool) -> None: # noqa: E501 """Perform the actual process of inducting a member by giving them the Guest role.""" # NOTE: Shortcut accessors are placed at the top of the function, so that the exceptions they raise are displayed before any further errors may be sent @@ -225,7 +232,7 @@ async def _perform_induction(self, ctx: TeXBotApplicationContext, induction_memb message_already_sent: bool = False message: discord.Message - async for message in general_channel.history(limit=7): + async for message in general_channel.history(limit=10): message_already_sent = bool( message.author == self.bot.user and "grab your roles" in message.content # noqa: COM812 @@ -300,21 +307,17 @@ async def autocomplete_get_members(ctx: TeXBotAutocompleteContext) -> Set[discor except (GuildDoesNotExistError, GuestRoleDoesNotExistError): return set() - members: set[discord.Member] = { - member - for member - in main_guild.members - if not member.bot and guest_role not in member.roles - } - - if not ctx.value or ctx.value.startswith("@"): - return { - discord.OptionChoice(name=f"@{member.name}", value=str(member.id)) - for member in members - } - return { - discord.OptionChoice(name=member.name, value=str(member.id)) for member in members + discord.OptionChoice( + name=( + f"@{member.name}" + if not ctx.value or ctx.value.startswith("@") + else member.name + ), + value=str(member.id), + ) + for member in main_guild.members + if not member.bot and guest_role not in member.roles } @discord.slash_command( # type: ignore[no-untyped-call, misc] @@ -349,7 +352,7 @@ async def induct(self, ctx: TeXBotApplicationContext, str_induct_member_id: str, """ member_id_not_integer_error: ValueError try: - induct_member: discord.Member = await self.bot.get_member_from_str_id( + induct_member: discord.Member = await self.bot.get_main_guild_member( str_induct_member_id, ) except ValueError as member_id_not_integer_error: @@ -376,7 +379,6 @@ async def non_silent_user_induct(self, ctx: TeXBotApplicationContext, member: di """ await self._perform_induction(ctx, member, silent=False) - @discord.user_command(name="Silently Induct User") # type: ignore[no-untyped-call, misc] @CommandChecks.check_interaction_user_has_committee_role @CommandChecks.check_interaction_user_in_main_guild @@ -391,7 +393,6 @@ async def silent_user_induct(self, ctx: TeXBotApplicationContext, member: discor """ await self._perform_induction(ctx, member, silent=True) - @discord.message_command(name="Induct Message Author") # type: ignore[no-untyped-call, misc] @CommandChecks.check_interaction_user_has_committee_role @CommandChecks.check_interaction_user_in_main_guild @@ -405,7 +406,7 @@ async def non_silent_message_induct(self, ctx: TeXBotApplicationContext, message by giving them the "Guest" role. """ try: - member: discord.Member = await self.bot.get_member_from_str_id( + member: discord.Member = await self.bot.get_main_guild_member( str(message.author.id), ) except ValueError: @@ -421,7 +422,6 @@ async def non_silent_message_induct(self, ctx: TeXBotApplicationContext, message await self._perform_induction(ctx, member, silent=False) - @discord.message_command(name="Silently Induct Message Author") # type: ignore[no-untyped-call, misc] @CommandChecks.check_interaction_user_has_committee_role @CommandChecks.check_interaction_user_in_main_guild @@ -435,7 +435,7 @@ async def silent_message_induct(self, ctx: TeXBotApplicationContext, message: di by giving them the "Guest" role, only without broadcasting a welcome message. """ try: - member: discord.Member = await self.bot.get_member_from_str_id( + member: discord.Member = await self.bot.get_main_guild_member( str(message.author.id), ) except ValueError: diff --git a/cogs/kill.py b/cogs/kill.py index 3269998b9..da91ce7de 100644 --- a/cogs/kill.py +++ b/cogs/kill.py @@ -89,19 +89,25 @@ async def kill(self, ctx: TeXBotApplicationContext) -> None: ), ) - if button_interaction.data["custom_id"] == "shutdown_confirm": # type: ignore[index, typeddict-item] - await confirmation_message.edit( - content="My battery is low and it's getting dark...", - view=None, - ) - await self.bot.perform_kill_and_close(initiated_by_user=ctx.interaction.user) - - if button_interaction.data["custom_id"] == "shutdown_cancel": # type: ignore[index, typeddict-item] - await confirmation_message.edit( - content="Shutdown has been cancelled.", - view=None, - ) - logger.info("Manual shutdown cancelled by %s.", ctx.interaction.user) - return - - raise ValueError + match button_interaction.data["custom_id"]: # type: ignore[index, typeddict-item] + case "shutdown_confirm": + await confirmation_message.edit( + content="My battery is low and it's getting dark...", + view=None, + ) + await self.bot.perform_kill_and_close( + initiated_by_user=ctx.interaction.user, + ) + + case "shutdown_cancel": + await confirmation_message.edit( + content="Shutdown has been cancelled.", + view=None, + ) + logger.info( + "Manual shutdown cancelled by %s.", + ctx.interaction.user, + ) + + case _: + raise ValueError diff --git a/cogs/make_applicant.py b/cogs/make_applicant.py index c2783c357..0ebb1dfcb 100644 --- a/cogs/make_applicant.py +++ b/cogs/make_applicant.py @@ -107,22 +107,19 @@ async def autocomplete_get_members(ctx: TeXBotApplicationContext) -> set[discord except (GuildDoesNotExistError, ApplicantRoleDoesNotExistError): return set() - members: set[discord.Member] = { - member + return { + discord.OptionChoice( + name=( + f"@{member.name}" + if not ctx.value or ctx.value.startswith("@") + else member.name + ), + value=str(member.id), + ) for member in main_guild.members if not member.bot and applicant_role not in member.roles } - if not ctx.value or ctx.value.startswith("@"): - return { - discord.OptionChoice(name=f"@{member.name}", value=str(member.id)) - for member in members - } - - return { - discord.OptionChoice(name=member.name, value=str(member.id)) for member in members - } - @discord.slash_command( # type: ignore[no-untyped-call, misc] name="make-applicant", description="Gives the user @Applicant role and removes the @Guest role if present.", @@ -146,7 +143,7 @@ async def make_applicant(self, ctx: TeXBotApplicationContext, str_applicant_memb """ member_id_not_integer_error: ValueError try: - applicant_member: discord.Member = await self.bot.get_member_from_str_id( + applicant_member: discord.Member = await self.bot.get_main_guild_member( str_applicant_member_id, ) except ValueError as member_id_not_integer_error: @@ -184,7 +181,7 @@ async def message_make_applicant(self, ctx: TeXBotApplicationContext, message: d "Applicant" role and removes the "Guest" role if they have it. """ try: - member: discord.Member = await self.bot.get_member_from_str_id( + member: discord.Member = await self.bot.get_main_guild_member( str(message.author.id), ) except ValueError: diff --git a/cogs/make_member.py b/cogs/make_member.py index fc9ec509e..a10d4d5ec 100644 --- a/cogs/make_member.py +++ b/cogs/make_member.py @@ -131,7 +131,7 @@ async def make_member(self, ctx: TeXBotApplicationContext, group_member_id: str) ) return - if not re.fullmatch(r"\A\d{7}\Z", group_member_id): + if not re.fullmatch(settings["MEMBERS_LIST_ID_FORMAT"], group_member_id): await self.command_send_error( ctx, message=( diff --git a/cogs/ping.py b/cogs/ping.py index f3f3d13aa..dc3fbd787 100644 --- a/cogs/ping.py +++ b/cogs/ping.py @@ -26,7 +26,7 @@ async def ping(self, ctx: TeXBotApplicationContext) -> None: "`64 bytes from TeX-Bot: icmp_seq=1 ttl=63 time=0.01 ms`", ], weights=( - 100 - settings["PING_COMMAND_EASTER_EGG_PROBABILITY"], + 1 - settings["PING_COMMAND_EASTER_EGG_PROBABILITY"], settings["PING_COMMAND_EASTER_EGG_PROBABILITY"], ), )[0], diff --git a/cogs/remind_me.py b/cogs/remind_me.py index 23e18c11a..b64724d3e 100644 --- a/cogs/remind_me.py +++ b/cogs/remind_me.py @@ -21,6 +21,7 @@ from django.utils import timezone from db.core.models import DiscordMember, DiscordReminder +from exceptions import UnknownDjangoError from utils import TeXBot, TeXBotApplicationContext, TeXBotAutocompleteContext, TeXBotBaseCog if TYPE_CHECKING: @@ -232,12 +233,12 @@ async def remind_me(self, ctx: TeXBotApplicationContext, delay: str, message: st ) # noqa: COM812 ) if not ERROR_IS_ALREADY_EXISTS: - await self.command_send_error(ctx, message="An unrecoverable error occurred.") - logger.critical( - "Error when creating DiscordReminder object: %s", - create_discord_reminder_error, - ) - await self.bot.close() + raise UnknownDjangoError( + message=( + f"Error when creating DiscordReminder object: " + f"{create_discord_reminder_error}" + ), + ) from create_discord_reminder_error await self.command_send_error( ctx, diff --git a/cogs/send_get_roles_reminders.py b/cogs/send_get_roles_reminders.py index c9157ad47..d3e6280dc 100644 --- a/cogs/send_get_roles_reminders.py +++ b/cogs/send_get_roles_reminders.py @@ -37,7 +37,7 @@ class SendGetRolesRemindersTaskCog(TeXBotBaseCog): @override def __init__(self, bot: TeXBot) -> None: """Start all task managers when this cog is initialised.""" - if settings["SEND_GET_ROLES_REMINDERS"]: + if settings["SEND_GET_ROLES_REMINDERS_ENABLED"]: self.send_get_roles_reminders.start() super().__init__(bot) @@ -51,7 +51,7 @@ def cog_unload(self) -> None: """ self.send_get_roles_reminders.cancel() - @tasks.loop(**settings["ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL"]) + @tasks.loop(seconds=settings["SEND_GET_ROLES_REMINDERS_INTERVAL_SECONDS"]) @functools.partial( ErrorCaptureDecorators.capture_error_and_close, error_type=GuestRoleDoesNotExistError, diff --git a/cogs/send_introduction_reminders.py b/cogs/send_introduction_reminders.py index f866a36c0..8ebaadd85 100644 --- a/cogs/send_introduction_reminders.py +++ b/cogs/send_introduction_reminders.py @@ -5,6 +5,7 @@ __all__: Sequence[str] = ("SendIntroductionRemindersTaskCog",) +import datetime import functools import logging from logging import Logger @@ -39,8 +40,8 @@ class SendIntroductionRemindersTaskCog(TeXBotBaseCog): @override def __init__(self, bot: TeXBot) -> None: """Start all task managers when this cog is initialised.""" - if settings["SEND_INTRODUCTION_REMINDERS"]: - if settings["SEND_INTRODUCTION_REMINDERS"] == "interval": + if settings["SEND_INTRODUCTION_REMINDERS_ENABLED"]: + if settings["SEND_INTRODUCTION_REMINDERS_ENABLED"] == "interval": SentOneOffIntroductionReminderMember.objects.all().delete() self.send_introduction_reminders.start() @@ -63,7 +64,34 @@ async def on_ready(self) -> None: self.OptOutIntroductionRemindersView(self.bot), ) - @tasks.loop(**settings["SEND_INTRODUCTION_REMINDERS_INTERVAL"]) + @classmethod + async def _check_if_member_needs_reminder(cls, member_id: int, member_joined_at: datetime.datetime) -> bool: # noqa: E501 + MEMBER_NEEDS_ONE_OFF_REMINDER: Final[bool] = ( + settings["SEND_INTRODUCTION_REMINDERS_ENABLED"] == "once" + and not await ( + await SentOneOffIntroductionReminderMember.objects.afilter( + discord_id=member_id, + ) + ).aexists() + ) + MEMBER_NEEDS_RECURRING_REMINDER: Final[bool] = ( + settings["SEND_INTRODUCTION_REMINDERS_ENABLED"] == "interval" + ) + MEMBER_RECENTLY_JOINED: bool = ( + discord.utils.utcnow() - member_joined_at + ) <= settings["SEND_INTRODUCTION_REMINDERS_DELAY"] + MEMBER_OPTED_OUT_FROM_REMINDERS: Final[bool] = await ( + await IntroductionReminderOptOutMember.objects.afilter( + discord_id=member_id, + ) + ).aexists() + return ( + (MEMBER_NEEDS_ONE_OFF_REMINDER or MEMBER_NEEDS_RECURRING_REMINDER) + and not MEMBER_RECENTLY_JOINED + and not MEMBER_OPTED_OUT_FROM_REMINDERS + ) + + @tasks.loop(seconds=settings["SEND_INTRODUCTION_REMINDERS_INTERVAL_SECONDS"]) @functools.partial( ErrorCaptureDecorators.capture_error_and_close, error_type=GuestRoleDoesNotExistError, @@ -100,32 +128,7 @@ async def send_introduction_reminders(self) -> None: ) continue - member_needs_one_off_reminder: bool = ( - settings["SEND_INTRODUCTION_REMINDERS"] == "once" - and not await ( - await SentOneOffIntroductionReminderMember.objects.afilter( - discord_id=member.id, - ) - ).aexists() - ) - member_needs_recurring_reminder: bool = ( - settings["SEND_INTRODUCTION_REMINDERS"] == "interval" - ) - member_recently_joined: bool = ( - discord.utils.utcnow() - member.joined_at - ) <= settings["SEND_INTRODUCTION_REMINDERS_DELAY"] - member_opted_out_from_reminders: bool = await ( - await IntroductionReminderOptOutMember.objects.afilter( - discord_id=member.id, - ) - ).aexists() - member_needs_reminder: bool = ( - (member_needs_one_off_reminder or member_needs_recurring_reminder) - and not member_recently_joined - and not member_opted_out_from_reminders - ) - - if not member_needs_reminder: + if not await self._check_if_member_needs_reminder(member.id, member.joined_at): continue async for message in member.history(): @@ -160,7 +163,7 @@ async def send_introduction_reminders(self) -> None: ), view=( self.OptOutIntroductionRemindersView(self.bot) - if settings["SEND_INTRODUCTION_REMINDERS"] == "interval" + if settings["SEND_INTRODUCTION_REMINDERS_ENABLED"] == "interval" else None # type: ignore[arg-type] ), ) @@ -275,17 +278,14 @@ async def opt_out_introduction_reminders_button_callback(self, button: discord.B discord_id=interaction_member.id, ) except ValidationError as create_introduction_reminder_opt_out_member_error: - error_is_already_exists: bool = ( + ERROR_IS_ALREADY_EXISTS: Final[bool] = bool( "hashed_member_id" in create_introduction_reminder_opt_out_member_error.message_dict # noqa: E501 and any( "already exists" in error - for error - in create_introduction_reminder_opt_out_member_error.message_dict[ - "hashed_member_id" - ] - ) + for error in create_introduction_reminder_opt_out_member_error.message_dict["hashed_member_id"] # noqa: E501 + ) # noqa: COM812 ) - if not error_is_already_exists: + if not ERROR_IS_ALREADY_EXISTS: raise button.style = discord.ButtonStyle.green diff --git a/cogs/startup.py b/cogs/startup.py index 0e179caac..f05d2febe 100644 --- a/cogs/startup.py +++ b/cogs/startup.py @@ -14,6 +14,7 @@ import utils from config import settings +from config.constants import DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME from exceptions import ( ArchivistRoleDoesNotExistError, CommitteeRoleDoesNotExistError, @@ -31,37 +32,63 @@ class StartupCog(TeXBotBaseCog): """Cog class that defines additional code to execute upon startup.""" - @TeXBotBaseCog.listener() - async def on_ready(self) -> None: - """ - Populate the shortcut accessors of TeX-Bot after initialisation. + def _setup_discord_log_channel(self) -> None: + NO_DISCORD_LOG_CHANNEL_SET_MESSAGE: Final[str] = ( + "Discord log-channel webhook-URL was not set, " + "so error logs will not be sent to the Discord log-channel." + ) - Shortcut accessors should only be populated once TeX-Bot is ready to make API requests. - """ - if settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"]: - discord_logging_handler: logging.Handler = DiscordHandler( - self.bot.user.name if self.bot.user else "TeXBot", - settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"], - avatar_url=( - self.bot.user.avatar.url - if self.bot.user and self.bot.user.avatar - else None - ), - ) - discord_logging_handler.setLevel(logging.WARNING) - # noinspection SpellCheckingInspection - discord_logging_handler.setFormatter( - logging.Formatter("{levelname} | {message}", style="{"), + discord_logging_handlers: set[DiscordHandler] = { + handler for handler in logger.handlers if isinstance(handler, DiscordHandler) + } + + if len(discord_logging_handlers) > 1: + CANNOT_DETERMINE_LOGGING_HANDLER_MESSAGE: Final[str] = ( + "Cannot determine which logging Discord-webhook-handler to update." ) + raise ValueError(CANNOT_DETERMINE_LOGGING_HANDLER_MESSAGE) + + if len(discord_logging_handlers) == 1: + existing_discord_logging_handler: DiscordHandler = discord_logging_handlers.pop() - logger.addHandler(discord_logging_handler) + logger.removeHandler(existing_discord_logging_handler) + + if settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"]: + new_discord_logging_handler: DiscordHandler = DiscordHandler( + ( + existing_discord_logging_handler.name + if existing_discord_logging_handler.name != DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME # noqa: E501 + else ( + self.bot.user.name + if self.bot.user + else DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME + ) + ), + settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"], + avatar_url=( + self.bot.user.avatar.url + if self.bot.user and self.bot.user.avatar + else None + ), + ) + new_discord_logging_handler.setLevel(existing_discord_logging_handler.level) + new_discord_logging_handler.setFormatter( + existing_discord_logging_handler.formatter, + ) + new_discord_logging_handler.avatar_url = new_discord_logging_handler.avatar_url + + logger.addHandler(new_discord_logging_handler) + + else: + logger.warning(NO_DISCORD_LOG_CHANNEL_SET_MESSAGE) + + elif len(discord_logging_handlers) == 0 or not settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"]: # noqa: E501 + logger.warning(NO_DISCORD_LOG_CHANNEL_SET_MESSAGE) else: - logger.warning( - "DISCORD_LOG_CHANNEL_WEBHOOK_URL was not set, " - "so error logs will not be sent to the Discord log channel.", - ) + raise ValueError + async def _initialise_main_guild(self) -> None: try: main_guild: discord.Guild | None = self.bot.main_guild except GuildDoesNotExistError: @@ -78,20 +105,54 @@ async def on_ready(self) -> None: settings["_DISCORD_MAIN_GUILD_ID"], ), ) - logger.critical(GuildDoesNotExistError( - guild_id=settings["_DISCORD_MAIN_GUILD_ID"]), + logger.critical( + GuildDoesNotExistError(guild_id=settings["_DISCORD_MAIN_GUILD_ID"]), ) await self.bot.close() - if self.bot.application_id: - logger.debug( - "Invite URL: %s", - utils.generate_invite_url( - self.bot.application_id, - settings["_DISCORD_MAIN_GUILD_ID"], + async def _check_strike_performed_manually_warning_location_exists(self) -> None: + if settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"] == "DM": + return + + STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_EXISTS: Final[bool] = bool( + discord.utils.get( + self.bot.main_guild.text_channels, + name=settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"], + ) # noqa: COM812 + ) + if STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_EXISTS: + return + + logger.critical( + ( + "The channel %s does not exist, so cannot be used as the location " + "for sending manual-moderation warning messages" + ), + repr(settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"]), + ) + + strike_performed_manually_warning_location_similar_to_dm: bool = ( + settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"].lower() in ( + "dm", + "dms", + ) + ) + if strike_performed_manually_warning_location_similar_to_dm: + logger.info( + ( + "If you meant to set the location " + "for sending manual-moderation warning messages to be " + "the DMs of the committee member that applied " + "the manual moderation action, use the value of %s" ), + repr("DM"), ) + await self.bot.close() + + async def _check_all_shortcut_accessors(self) -> None: + main_guild: discord.Guild = self.bot.main_guild + if not discord.utils.get(main_guild.roles, name="Committee"): logger.warning(CommitteeRoleDoesNotExistError()) @@ -110,34 +171,28 @@ async def on_ready(self) -> None: if not discord.utils.get(main_guild.text_channels, name="general"): logger.warning(GeneralChannelDoesNotExistError()) - if settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"] != "DM": - manual_moderation_warning_message_location_exists: bool = bool( - discord.utils.get( - main_guild.text_channels, - name=settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"], + await self._check_strike_performed_manually_warning_location_exists() + + @TeXBotBaseCog.listener() + async def on_ready(self) -> None: + """ + Populate the shortcut accessors of TeX-Bot after initialisation. + + Shortcut accessors should only be populated once TeX-Bot is ready to make API requests. + """ + self._setup_discord_log_channel() + + await self._initialise_main_guild() + + if self.bot.application_id: + logger.debug( + "Invite URL: %s", + utils.generate_invite_url( + self.bot.application_id, + settings["_DISCORD_MAIN_GUILD_ID"], ), ) - if not manual_moderation_warning_message_location_exists: - logger.critical( - ( - "The channel %s does not exist, so cannot be used as the location " - "for sending manual-moderation warning messages" - ), - repr(settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"]), - ) - manual_moderation_warning_message_location_similar_to_dm: bool = ( - settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"].lower() in ("dm", "dms") # noqa: E501 - ) - if manual_moderation_warning_message_location_similar_to_dm: - logger.info( - ( - "If you meant to set the location " - "for sending manual-moderation warning messages to be " - "the DMs of the committee member that applied " - "the manual moderation action, use the value of %s" - ), - repr("DM"), - ) - await self.bot.close() + + await self._check_all_shortcut_accessors() logger.info("Ready! Logged in as %s", self.bot.user) diff --git a/cogs/strike.py b/cogs/strike.py index 2010937b9..2495b6945 100644 --- a/cogs/strike.py +++ b/cogs/strike.py @@ -21,7 +21,7 @@ import re from collections.abc import Mapping, Set from logging import Logger -from typing import Final +from typing import Final, Literal, TypeAlias import discord @@ -55,7 +55,20 @@ logger: Final[Logger] = logging.getLogger("TeX-Bot") -FORMATTED_MODERATION_ACTIONS: Final[Mapping[discord.AuditLogAction, str]] = { +# noinspection PyTypeHints +PossibleMuteModerationActions: TypeAlias = Literal[ + discord.AuditLogAction.member_update + | discord.AuditLogAction.auto_moderation_user_communication_disabled +] + +# noinspection PyTypeHints +KnownModerationActions: TypeAlias = Literal[ + PossibleMuteModerationActions + | discord.AuditLogAction.kick + | discord.AuditLogAction.ban +] + +FORMATTED_MODERATION_ACTIONS: Final[Mapping[KnownModerationActions, str]] = { discord.AuditLogAction.member_update: "timed-out", discord.AuditLogAction.kick: "kicked", discord.AuditLogAction.ban: "banned", @@ -83,7 +96,7 @@ async def perform_moderation_action(strike_user: discord.Member, strikes: int, c if strikes == 1: await strike_user.timeout_for( - datetime.timedelta(hours=24), + settings["STRIKE_COMMAND_TIMEOUT_DURATION"], reason=MODERATION_ACTION_REASON, ) @@ -250,7 +263,7 @@ async def _send_strike_user_message(self, strike_user: discord.User | discord.Me "To find what moderation action corresponds to which strike level, " "you can view " f"the {self.bot.group_short_name} Discord server moderation document " - f"[here](<{settings.MODERATION_DOCUMENT_URL}>)\nPlease ensure you have read " + f"[here](<{settings.MODERATION_DOCUMENT_LINK}>)\nPlease ensure you have read " f"the rules in {rules_channel_mention} so that your future behaviour adheres " f"to them.{includes_ban_message}\n\nA committee member will be in contact " "with you shortly, to discuss this further.", @@ -273,34 +286,35 @@ async def _confirm_perform_moderation_action(self, message_sender_component: Mes ), ) - if button_interaction.data["custom_id"] == "no_strike_member": # type: ignore[index, typeddict-item] - await button_interaction.edit_original_response( - content=( - "Aborted performing " - f"{self.SUGGESTED_ACTIONS[actual_strike_amount]} action " - f"on {strike_user.mention}." - ), - view=None, - ) - return + match button_interaction.data["custom_id"]: # type: ignore[index, typeddict-item] + case "no_strike_member": + await button_interaction.edit_original_response( + content=( + "Aborted performing " + f"{self.SUGGESTED_ACTIONS[actual_strike_amount]} action " + f"on {strike_user.mention}." + ), + view=None, + ) - if button_interaction.data["custom_id"] == "yes_strike_member": # type: ignore[index, typeddict-item] - await perform_moderation_action( - strike_user, - actual_strike_amount, - committee_member=interaction_user, - ) + case "yes_strike_member": + await perform_moderation_action( + strike_user, + actual_strike_amount, + committee_member=interaction_user, + ) - await button_interaction.edit_original_response( - content=( - f"Successfully performed {self.SUGGESTED_ACTIONS[actual_strike_amount]} " - f"action on {strike_user.mention}." - ), - view=None, - ) - return + await button_interaction.edit_original_response( + content=( + "Successfully performed " + f"{self.SUGGESTED_ACTIONS[actual_strike_amount]} " + f"action on {strike_user.mention}." + ), + view=None, + ) - raise ValueError + case _: + raise ValueError async def _confirm_increase_strike(self, message_sender_component: MessageSavingSenderComponent, interaction_user: discord.User, strike_user: discord.User | discord.Member, member_strikes: DiscordMemberStrikes, button_callback_channel: discord.TextChannel | discord.DMChannel, *, perform_action: bool) -> None: # noqa: E501 if perform_action and isinstance(strike_user, discord.User): @@ -458,7 +472,7 @@ async def get_confirmation_message_channel(self, user: discord.User | discord.Me # noinspection PyTypeHints @capture_strike_tracking_error - async def _confirm_manual_add_strike(self, strike_user: discord.User | discord.Member, action: discord.AuditLogAction) -> None: # noqa: E501 + async def _confirm_manual_add_strike(self, strike_user: discord.User | discord.Member, action: KnownModerationActions) -> None: # noqa: E501 # NOTE: Shortcut accessors are placed at the top of the function, so that the exceptions they raise are displayed before any further errors may be sent main_guild: discord.Guild = self.bot.main_guild committee_role: discord.Role = await self.bot.committee_role @@ -539,74 +553,73 @@ async def _confirm_manual_add_strike(self, strike_user: discord.User | discord.M view=ConfirmStrikesOutOfSyncWithBanView(), ) - out_of_sync_ban_button_interaction: discord.Interaction = ( - await self.bot.wait_for( - "interaction", - check=lambda interaction: ( - interaction.type == discord.InteractionType.component - and ( - (interaction.user == applied_action_user) - if not applied_action_user.bot - else (committee_role in interaction.user.roles) - ) - and interaction.channel == confirmation_message_channel - and "custom_id" in interaction.data - and interaction.data["custom_id"] in { - "yes_out_of_sync_ban_member", - "no_out_of_sync_ban_member", - } - ), - ) + out_of_sync_ban_button_interaction: discord.Interaction = await self.bot.wait_for( + "interaction", + check=lambda interaction: ( + interaction.type == discord.InteractionType.component + and ( + (interaction.user == applied_action_user) + if not applied_action_user.bot + else (committee_role in interaction.user.roles) + ) + and interaction.channel == confirmation_message_channel + and "custom_id" in interaction.data + and interaction.data["custom_id"] in { + "yes_out_of_sync_ban_member", + "no_out_of_sync_ban_member", + } + ), ) - if out_of_sync_ban_button_interaction.data["custom_id"] == "no_out_of_sync_ban_member": # type: ignore[index, typeddict-item] # noqa: E501 - await out_of_sync_ban_confirmation_message.edit( - content=( - f"Aborted performing ban action upon {strike_user.mention}. " - "(This manual moderation action has not been tracked.)\n" - "ᴛʜɪs ᴍᴇssᴀɢᴇ ᴡɪʟʟ ʙᴇ ᴅᴇʟᴇᴛᴇᴅ" - f"""{ - discord.utils.format_dt( - discord.utils.utcnow() + datetime.timedelta(minutes=2), - "R" - ) - }""" - ), - view=None, - ) - await asyncio.sleep(118) - await out_of_sync_ban_confirmation_message.delete() - return + match out_of_sync_ban_button_interaction.data["custom_id"]: # type: ignore[index, typeddict-item] + case "no_out_of_sync_ban_member": + # noinspection SpellCheckingInspection + await out_of_sync_ban_confirmation_message.edit( + content=( + f"Aborted performing ban action upon {strike_user.mention}. " + "(This manual moderation action has not been tracked.)\n" + "ᴛʜɪs ᴍᴇssᴀɢᴇ ᴡɪʟʟ ʙᴇ ᴅᴇʟᴇᴛᴇᴅ" + f"""{ + discord.utils.format_dt( + discord.utils.utcnow() + datetime.timedelta(minutes=2), + "R" + ) + }""" + ), + view=None, + ) + + case "yes_out_of_sync_ban_member": + await self._send_strike_user_message(strike_user, member_strikes) + await main_guild.ban( + strike_user, + reason=( + f"**{applied_action_user.display_name} synced moderation action " + "with number of strikes**" + ), + ) + # noinspection SpellCheckingInspection + await out_of_sync_ban_confirmation_message.edit( + content=( + f"Successfully banned {strike_user.mention}.\n" + "**Please ensure you use the `/strike` command in future!**" + "\nᴛʜɪs ᴍᴇssᴀɢᴇ ᴡɪʟʟ ʙᴇ ᴅᴇʟᴇᴛᴇᴅ" + f"""{ + discord.utils.format_dt( + discord.utils.utcnow() + datetime.timedelta(minutes=2), + "R" + ) + }""" + ), + view=None, + ) + + case _: + raise ValueError - if out_of_sync_ban_button_interaction.data["custom_id"] == "yes_out_of_sync_ban_member": # type: ignore[index, typeddict-item] # noqa: E501 - await self._send_strike_user_message(strike_user, member_strikes) - await main_guild.ban( - strike_user, - reason=( - f"**{applied_action_user.display_name} synced moderation action " - "with number of strikes**" - ), - ) - # noinspection SpellCheckingInspection - await out_of_sync_ban_confirmation_message.edit( - content=( - f"Successfully banned {strike_user.mention}.\n" - "**Please ensure you use the `/strike` command in future!**" - "\nᴛʜɪs ᴍᴇssᴀɢᴇ ᴡɪʟʟ ʙᴇ ᴅᴇʟᴇᴛᴇᴅ" - f"""{ - discord.utils.format_dt( - discord.utils.utcnow() + datetime.timedelta(minutes=2), - "R" - ) - }""" - ), - view=None, - ) - await asyncio.sleep(118) - await out_of_sync_ban_confirmation_message.delete() - return - - raise ValueError + await asyncio.sleep(118) + await out_of_sync_ban_confirmation_message.delete() + return confirmation_message: discord.Message = await confirmation_message_channel.send( content=( @@ -648,44 +661,46 @@ async def _confirm_manual_add_strike(self, strike_user: discord.User | discord.M ), ) - if button_interaction.data["custom_id"] == "no_manual_moderation_action": # type: ignore[index, typeddict-item] - # noinspection SpellCheckingInspection - await confirmation_message.edit( - content=( - f"Aborted increasing {strike_user.mention}'s strikes " - "& sending moderation alert message. " - "(This manual moderation action has not been tracked.)\n" - "ᴛʜɪs ᴍᴇssᴀɢᴇ ᴡɪʟʟ ʙᴇ ᴅᴇʟᴇᴛᴇᴅ" - f"""{ - discord.utils.format_dt( - discord.utils.utcnow() + datetime.timedelta(minutes=2), - "R" - ) - }""" - ), - view=None, - ) - await asyncio.sleep(118) - await confirmation_message.delete() - return - - if button_interaction.data["custom_id"] == "yes_manual_moderation_action": # type: ignore[index, typeddict-item] - interaction_user: discord.User | None = self.bot.get_user( - applied_action_user.id, - ) - if not interaction_user: - raise StrikeTrackingError + match button_interaction.data["custom_id"]: # type: ignore[index, typeddict-item] + case "no_manual_moderation_action": + # noinspection SpellCheckingInspection + await confirmation_message.edit( + content=( + f"Aborted increasing {strike_user.mention}'s strikes " + "& sending moderation alert message. " + "(This manual moderation action has not been tracked.)\n" + "ᴛʜɪs ᴍᴇssᴀɢᴇ ᴡɪʟʟ ʙᴇ ᴅᴇʟᴇᴛᴇᴅ" + f"""{ + discord.utils.format_dt( + discord.utils.utcnow() + datetime.timedelta(minutes=2), + "R" + ) + }""" + ), + view=None, + ) + await asyncio.sleep(118) + await confirmation_message.delete() - await self._confirm_increase_strike( - message_sender_component=ChannelMessageSender(confirmation_message_channel), - interaction_user=interaction_user, - strike_user=strike_user, - member_strikes=member_strikes, - button_callback_channel=confirmation_message_channel, - perform_action=False, - ) + case "yes_manual_moderation_action": + interaction_user: discord.User | None = self.bot.get_user( + applied_action_user.id, + ) + if not interaction_user: + raise StrikeTrackingError + + await self._confirm_increase_strike( + message_sender_component=ChannelMessageSender(confirmation_message_channel), + interaction_user=interaction_user, + strike_user=strike_user, + member_strikes=member_strikes, + button_callback_channel=confirmation_message_channel, + perform_action=False, + ) + # NOTE: Message deletion is performed within self._confirm_increase_strike() - raise ValueError + case _: + raise ValueError @TeXBotBaseCog.listener() @capture_guild_does_not_exist_error @@ -700,23 +715,19 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) if not after.timed_out or before.timed_out == after.timed_out: return + mute_action_type: KnownModerationActions = discord.AuditLogAction.member_update + audit_log_entry: discord.AuditLogEntry async for audit_log_entry in main_guild.audit_logs(limit=5): FOUND_CORRECT_AUDIT_LOG_ENTRY: bool = ( audit_log_entry.target.id == after.id - and audit_log_entry.action == (discord.AuditLogAction.auto_moderation_user_communication_disabled) # noqa: E501 + and audit_log_entry.action == discord.AuditLogAction.auto_moderation_user_communication_disabled # noqa: E501 ) if FOUND_CORRECT_AUDIT_LOG_ENTRY: - await self._confirm_manual_add_strike( - strike_user=after, - action=audit_log_entry.action, - ) - return + mute_action_type = audit_log_entry.action # type: ignore[assignment] + break - await self._confirm_manual_add_strike( - strike_user=after, - action=discord.AuditLogAction.member_update, - ) + await self._confirm_manual_add_strike(strike_user=after, action=mute_action_type) @TeXBotBaseCog.listener() @capture_guild_does_not_exist_error @@ -768,18 +779,17 @@ async def autocomplete_get_members(ctx: TeXBotAutocompleteContext) -> Set[discor except GuildDoesNotExistError: return set() - members: set[discord.Member] = { - member for member in main_guild.members if not member.bot - } - - if not ctx.value or re.fullmatch(r"\A@.*\Z", ctx.value): - return { - discord.OptionChoice(name=f"@{member.name}", value=str(member.id)) - for member in members - } - return { - discord.OptionChoice(name=member.name, value=str(member.id)) for member in members + discord.OptionChoice( + name=( + f"@{member.name}" + if not ctx.value or re.fullmatch(r"\A@.*\Z", ctx.value) + else member.name + ), + value=str(member.id), + ) + for member in main_guild.members + if not member.bot } @discord.slash_command( # type: ignore[no-untyped-call, misc] @@ -808,7 +818,7 @@ async def strike(self, ctx: TeXBotApplicationContext, str_strike_member_id: str) """ member_id_not_integer_error: ValueError try: - strike_member: discord.Member = await self.bot.get_member_from_str_id( + strike_member: discord.Member = await self.bot.get_main_guild_member( str_strike_member_id, ) except ValueError as member_id_not_integer_error: diff --git a/cogs/write_roles.py b/cogs/write_roles.py index 8c3503c9d..700cc062a 100644 --- a/cogs/write_roles.py +++ b/cogs/write_roles.py @@ -7,7 +7,7 @@ import discord -from config import settings +from config import messages from utils import CommandChecks, TeXBotApplicationContext, TeXBotBaseCog @@ -33,7 +33,7 @@ async def write_roles(self, ctx: TeXBotApplicationContext) -> None: roles_channel: discord.TextChannel = await self.bot.roles_channel roles_message: str - for roles_message in settings["ROLES_MESSAGES"]: + for roles_message in messages["OPT_IN_ROLES_SELECTORS"]: await roles_channel.send( roles_message.replace("", self.bot.group_short_name), ) diff --git a/config.py b/config.py deleted file mode 100644 index 460f598ce..000000000 --- a/config.py +++ /dev/null @@ -1,759 +0,0 @@ -""" -Contains settings values and import & setup functions. - -Settings values are imported from the .env file or the current environment variables. -These values are used to configure the functionality of the bot at run-time. -""" - -from collections.abc import Sequence - -__all__: Sequence[str] = ( - "TRUE_VALUES", - "FALSE_VALUES", - "VALID_SEND_INTRODUCTION_REMINDERS_VALUES", - "DEFAULT_STATISTICS_ROLES", - "LOG_LEVEL_CHOICES", - "run_setup", - "settings", -) - - -import abc -import functools -import importlib -import json -import logging -import os -import re -from collections.abc import Iterable, Mapping -from datetime import timedelta -from logging import Logger -from pathlib import Path -from re import Match -from typing import IO, Any, ClassVar, Final, final - -import dotenv -import validators - -from exceptions import ( - ImproperlyConfiguredError, - MessagesJSONFileMissingKeyError, - MessagesJSONFileValueError, -) - -PROJECT_ROOT: Final[Path] = Path(__file__).parent.resolve() - -TRUE_VALUES: Final[frozenset[str]] = frozenset({"true", "1", "t", "y", "yes", "on"}) -FALSE_VALUES: Final[frozenset[str]] = frozenset({"false", "0", "f", "n", "no", "off"}) -VALID_SEND_INTRODUCTION_REMINDERS_VALUES: Final[frozenset[str]] = frozenset( - {"once"} | TRUE_VALUES | FALSE_VALUES, -) -DEFAULT_STATISTICS_ROLES: Final[frozenset[str]] = frozenset( - { - "Committee", - "Committee-Elect", - "Student Rep", - "Member", - "Guest", - "Server Booster", - "Foundation Year", - "First Year", - "Second Year", - "Final Year", - "Year In Industry", - "Year Abroad", - "PGT", - "PGR", - "Alumnus/Alumna", - "Postdoc", - "Quiz Victor", - }, -) -LOG_LEVEL_CHOICES: Final[Sequence[str]] = ( - "DEBUG", - "INFO", - "WARNING", - "ERROR", - "CRITICAL", -) - -logger: Final[Logger] = logging.getLogger("TeX-Bot") - - -class Settings(abc.ABC): - """ - Settings class that provides access to all settings values. - - Settings values can be accessed via key (like a dictionary) or via class attribute. - """ - - _is_env_variables_setup: ClassVar[bool] - _settings: ClassVar[dict[str, object]] - - @classmethod - def get_invalid_settings_key_message(cls, item: str) -> str: - """Return the message to state that the given settings key is invalid.""" - return f"{item!r} is not a valid settings key." - - def __getattr__(self, item: str) -> Any: # type: ignore[misc] # noqa: ANN401 - """Retrieve settings value by attribute lookup.""" - MISSING_ATTRIBUTE_MESSAGE: Final[str] = ( - f"{type(self).__name__!r} object has no attribute {item!r}" - ) - - if "_pytest" in item or item in ("__bases__", "__test__"): # NOTE: Overriding __getattr__() leads to many edge-case issues where external libraries will attempt to call getattr() with peculiar values - raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) - - if not self._is_env_variables_setup: - self._setup_env_variables() - - if item in self._settings: - return self._settings[item] - - if re.fullmatch(r"\A[A-Z](?:[A-Z_]*[A-Z])?\Z", item): - INVALID_SETTINGS_KEY_MESSAGE: Final[str] = self.get_invalid_settings_key_message( - item, - ) - raise AttributeError(INVALID_SETTINGS_KEY_MESSAGE) - - raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) - - def __getitem__(self, item: str) -> Any: # type: ignore[misc] # noqa: ANN401 - """Retrieve settings value by key lookup.""" - attribute_not_exist_error: AttributeError - try: - return getattr(self, item) - except AttributeError as attribute_not_exist_error: - key_error_message: str = item - - if self.get_invalid_settings_key_message(item) in str(attribute_not_exist_error): - key_error_message = str(attribute_not_exist_error) - - raise KeyError(key_error_message) from None - - @staticmethod - def _setup_logging() -> None: - raw_console_log_level: str = str(os.getenv("CONSOLE_LOG_LEVEL", "INFO")).upper() - - if raw_console_log_level not in LOG_LEVEL_CHOICES: - INVALID_LOG_LEVEL_MESSAGE: Final[str] = f"""LOG_LEVEL must be one of { - ",".join(f"{log_level_choice!r}" - for log_level_choice - in LOG_LEVEL_CHOICES[:-1]) - } or {LOG_LEVEL_CHOICES[-1]!r}.""" - raise ImproperlyConfiguredError(INVALID_LOG_LEVEL_MESSAGE) - - logger.setLevel(getattr(logging, raw_console_log_level)) - - console_logging_handler: logging.Handler = logging.StreamHandler() - # noinspection SpellCheckingInspection - console_logging_handler.setFormatter( - logging.Formatter("{asctime} | {name} | {levelname:^8} - {message}", style="{"), - ) - - logger.addHandler(console_logging_handler) - logger.propagate = False - - @classmethod - def _setup_discord_bot_token(cls) -> None: - raw_discord_bot_token: str | None = os.getenv("DISCORD_BOT_TOKEN") - - DISCORD_BOT_TOKEN_IS_VALID: Final[bool] = bool( - raw_discord_bot_token - and re.fullmatch( - r"\A([A-Za-z0-9_-]{24,26})\.([A-Za-z0-9_-]{6})\.([A-Za-z0-9_-]{27,38})\Z", - raw_discord_bot_token, - ), - ) - if not DISCORD_BOT_TOKEN_IS_VALID: - INVALID_DISCORD_BOT_TOKEN_MESSAGE: Final[str] = ( - "DISCORD_BOT_TOKEN must be a valid Discord bot token " - "(see https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts)." - ) - raise ImproperlyConfiguredError(INVALID_DISCORD_BOT_TOKEN_MESSAGE) - - cls._settings["DISCORD_BOT_TOKEN"] = raw_discord_bot_token - - @classmethod - def _setup_discord_log_channel_webhook_url(cls) -> None: - raw_discord_log_channel_webhook_url: str = os.getenv( - "DISCORD_LOG_CHANNEL_WEBHOOK_URL", - "", - ) - - DISCORD_LOG_CHANNEL_WEBHOOK_URL_IS_VALID: Final[bool] = bool( - not raw_discord_log_channel_webhook_url - or ( - validators.url(raw_discord_log_channel_webhook_url) - and raw_discord_log_channel_webhook_url.startswith( - "https://discord.com/api/webhooks/", - ) - ), - ) - if not DISCORD_LOG_CHANNEL_WEBHOOK_URL_IS_VALID: - INVALID_DISCORD_LOG_CHANNEL_WEBHOOK_URL_MESSAGE: Final[str] = ( - "DISCORD_LOG_CHANNEL_WEBHOOK_URL must be a valid webhook URL " - "that points to a discord channel where logs should be displayed." - ) - raise ImproperlyConfiguredError(INVALID_DISCORD_LOG_CHANNEL_WEBHOOK_URL_MESSAGE) - - cls._settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"] = raw_discord_log_channel_webhook_url - - @classmethod - def _setup_discord_guild_id(cls) -> None: - raw_discord_guild_id: str | None = os.getenv("DISCORD_GUILD_ID") - - DISCORD_GUILD_ID_IS_VALID: Final[bool] = bool( - raw_discord_guild_id - and re.fullmatch(r"\A\d{17,20}\Z", raw_discord_guild_id), - ) - if not DISCORD_GUILD_ID_IS_VALID: - INVALID_DISCORD_GUILD_ID_MESSAGE: Final[str] = ( - "DISCORD_GUILD_ID must be a valid Discord guild ID " - "(see https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id)." - ) - raise ImproperlyConfiguredError(INVALID_DISCORD_GUILD_ID_MESSAGE) - - cls._settings["_DISCORD_MAIN_GUILD_ID"] = int(raw_discord_guild_id) # type: ignore[arg-type] - - @classmethod - def _setup_group_full_name(cls) -> None: - raw_group_full_name: str | None = os.getenv("GROUP_NAME") - - GROUP_FULL_NAME_IS_VALID: Final[bool] = bool( - not raw_group_full_name - or re.fullmatch(r"\A[A-Za-z0-9 '&!?:,.#%\"-]+\Z", raw_group_full_name), - ) - if not GROUP_FULL_NAME_IS_VALID: - INVALID_GROUP_FULL_NAME: Final[str] = ( - "GROUP_NAME must not contain any invalid characters." - ) - raise ImproperlyConfiguredError(INVALID_GROUP_FULL_NAME) - cls._settings["_GROUP_FULL_NAME"] = raw_group_full_name - - @classmethod - def _setup_group_short_name(cls) -> None: - raw_group_short_name: str | None = os.getenv("GROUP_SHORT_NAME") - - GROUP_SHORT_NAME_IS_VALID: Final[bool] = bool( - not raw_group_short_name - or re.fullmatch(r"\A[A-Za-z0-9'&!?:,.#%\"-]+\Z", raw_group_short_name), - ) - if not GROUP_SHORT_NAME_IS_VALID: - INVALID_GROUP_SHORT_NAME: Final[str] = ( - "GROUP_SHORT_NAME must not contain any invalid characters." - ) - raise ImproperlyConfiguredError(INVALID_GROUP_SHORT_NAME) - cls._settings["_GROUP_SHORT_NAME"] = raw_group_short_name - - @classmethod - def _setup_purchase_membership_url(cls) -> None: - raw_purchase_membership_url: str | None = os.getenv("PURCHASE_MEMBERSHIP_URL") - - PURCHASE_MEMBERSHIP_URL_IS_VALID: Final[bool] = bool( - not raw_purchase_membership_url - or validators.url(raw_purchase_membership_url), - ) - if not PURCHASE_MEMBERSHIP_URL_IS_VALID: - INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE: Final[str] = ( - "PURCHASE_MEMBERSHIP_URL must be a valid URL." - ) - raise ImproperlyConfiguredError(INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE) - - cls._settings["PURCHASE_MEMBERSHIP_URL"] = raw_purchase_membership_url - - @classmethod - def _setup_membership_perks_url(cls) -> None: - raw_membership_perks_url: str | None = os.getenv("MEMBERSHIP_PERKS_URL") - - MEMBERSHIP_PERKS_URL_IS_VALID: Final[bool] = bool( - not raw_membership_perks_url - or validators.url(raw_membership_perks_url), - ) - if not MEMBERSHIP_PERKS_URL_IS_VALID: - INVALID_MEMBERSHIP_PERKS_URL_MESSAGE: Final[str] = ( - "MEMBERSHIP_PERKS_URL must be a valid URL." - ) - raise ImproperlyConfiguredError(INVALID_MEMBERSHIP_PERKS_URL_MESSAGE) - - cls._settings["MEMBERSHIP_PERKS_URL"] = raw_membership_perks_url - - @classmethod - def _setup_ping_command_easter_egg_probability(cls) -> None: - INVALID_PING_COMMAND_EASTER_EGG_PROBABILITY_MESSAGE: Final[str] = ( - "PING_COMMAND_EASTER_EGG_PROBABILITY must be a float between & including 1 & 0." - ) - - e: ValueError - try: - raw_ping_command_easter_egg_probability: float = 100 * float( - os.getenv("PING_COMMAND_EASTER_EGG_PROBABILITY", "0.01"), - ) - except ValueError as e: - raise ( - ImproperlyConfiguredError(INVALID_PING_COMMAND_EASTER_EGG_PROBABILITY_MESSAGE) - ) from e - - if not 0 <= raw_ping_command_easter_egg_probability <= 100: - raise ImproperlyConfiguredError( - INVALID_PING_COMMAND_EASTER_EGG_PROBABILITY_MESSAGE, - ) - - cls._settings["PING_COMMAND_EASTER_EGG_PROBABILITY"] = ( - raw_ping_command_easter_egg_probability - ) - - @classmethod - @functools.lru_cache(maxsize=5) - def _get_messages_dict(cls, raw_messages_file_path: str | None) -> Mapping[str, object]: - JSON_DECODING_ERROR_MESSAGE: Final[str] = ( - "Messages JSON file must contain a JSON string that can be decoded " - "into a Python dict object." - ) - - messages_file_path: Path = ( - Path(raw_messages_file_path) - if raw_messages_file_path - else PROJECT_ROOT / Path("messages.json") - ) - - if not messages_file_path.is_file(): - MESSAGES_FILE_PATH_DOES_NOT_EXIST_MESSAGE: Final[str] = ( - "MESSAGES_FILE_PATH must be a path to a file that exists." - ) - raise ImproperlyConfiguredError(MESSAGES_FILE_PATH_DOES_NOT_EXIST_MESSAGE) - - messages_file: IO[str] - with messages_file_path.open(encoding="utf8") as messages_file: - e: json.JSONDecodeError - try: - messages_dict: object = json.load(messages_file) - except json.JSONDecodeError as e: - raise ImproperlyConfiguredError(JSON_DECODING_ERROR_MESSAGE) from e - - if not isinstance(messages_dict, Mapping): - raise ImproperlyConfiguredError(JSON_DECODING_ERROR_MESSAGE) - - return messages_dict - - @classmethod - def _setup_welcome_messages(cls) -> None: - messages_dict: Mapping[str, object] = cls._get_messages_dict( - os.getenv("MESSAGES_FILE_PATH"), - ) - - if "welcome_messages" not in messages_dict: - raise MessagesJSONFileMissingKeyError(missing_key="welcome_messages") - - WELCOME_MESSAGES_KEY_IS_VALID: Final[bool] = bool( - isinstance(messages_dict["welcome_messages"], Iterable) - and messages_dict["welcome_messages"], - ) - if not WELCOME_MESSAGES_KEY_IS_VALID: - raise MessagesJSONFileValueError( - dict_key="welcome_messages", - invalid_value=messages_dict["welcome_messages"], - ) - - cls._settings["WELCOME_MESSAGES"] = set(messages_dict["welcome_messages"]) # type: ignore[call-overload] - - @classmethod - def _setup_roles_messages(cls) -> None: - messages_dict: Mapping[str, object] = cls._get_messages_dict( - os.getenv("MESSAGES_FILE_PATH"), - ) - - if "roles_messages" not in messages_dict: - raise MessagesJSONFileMissingKeyError(missing_key="roles_messages") - - ROLES_MESSAGES_KEY_IS_VALID: Final[bool] = ( - isinstance(messages_dict["roles_messages"], Iterable) - and bool(messages_dict["roles_messages"]) - ) - if not ROLES_MESSAGES_KEY_IS_VALID: - raise MessagesJSONFileValueError( - dict_key="roles_messages", - invalid_value=messages_dict["roles_messages"], - ) - cls._settings["ROLES_MESSAGES"] = set(messages_dict["roles_messages"]) # type: ignore[call-overload] - - @classmethod - def _setup_members_list_url(cls) -> None: - raw_members_list_url: str | None = os.getenv("MEMBERS_LIST_URL") - - MEMBERS_LIST_URL_IS_VALID: Final[bool] = bool( - raw_members_list_url - and validators.url(raw_members_list_url), - ) - if not MEMBERS_LIST_URL_IS_VALID: - INVALID_MEMBERS_LIST_URL_MESSAGE: Final[str] = ( - "MEMBERS_LIST_URL must be a valid URL." - ) - raise ImproperlyConfiguredError(INVALID_MEMBERS_LIST_URL_MESSAGE) - - cls._settings["MEMBERS_LIST_URL"] = raw_members_list_url - - @classmethod - def _setup_members_list_auth_session_cookie(cls) -> None: - raw_members_list_auth_session_cookie: str | None = os.getenv( - "MEMBERS_LIST_URL_SESSION_COOKIE", - ) - - MEMBERS_LIST_AUTH_SESSION_COOKIE_IS_VALID: Final[bool] = bool( - raw_members_list_auth_session_cookie - and re.fullmatch(r"\A[A-Fa-f\d]{128,256}\Z", raw_members_list_auth_session_cookie), - ) - if not MEMBERS_LIST_AUTH_SESSION_COOKIE_IS_VALID: - INVALID_MEMBERS_LIST_AUTH_SESSION_COOKIE_MESSAGE: Final[str] = ( - "MEMBERS_LIST_URL_SESSION_COOKIE must be a valid .ASPXAUTH cookie." - ) - raise ImproperlyConfiguredError(INVALID_MEMBERS_LIST_AUTH_SESSION_COOKIE_MESSAGE) - - cls._settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] = ( - raw_members_list_auth_session_cookie - ) - - @classmethod - def _setup_send_introduction_reminders(cls) -> None: - raw_send_introduction_reminders: str | bool = str( - os.getenv("SEND_INTRODUCTION_REMINDERS", "Once"), - ).lower() - - if raw_send_introduction_reminders not in VALID_SEND_INTRODUCTION_REMINDERS_VALUES: - INVALID_SEND_INTRODUCTION_REMINDERS_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS must be one of: " - "\"Once\", \"Interval\" or \"False\"." - ) - raise ImproperlyConfiguredError(INVALID_SEND_INTRODUCTION_REMINDERS_MESSAGE) - - if raw_send_introduction_reminders in TRUE_VALUES: - raw_send_introduction_reminders = "once" - - elif raw_send_introduction_reminders not in ("once", "interval"): - raw_send_introduction_reminders = False - - cls._settings["SEND_INTRODUCTION_REMINDERS"] = raw_send_introduction_reminders - - @classmethod - def _setup_send_introduction_reminders_delay(cls) -> None: - if "SEND_INTRODUCTION_REMINDERS" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: SEND_INTRODUCTION_REMINDERS must be set up " - "before SEND_INTRODUCTION_REMINDERS_DELAY can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - raw_send_introduction_reminders_delay: Match[str] | None = re.fullmatch( - r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", - str(os.getenv("SEND_INTRODUCTION_REMINDERS_DELAY", "40h")), - ) - - raw_timedelta_send_introduction_reminders_delay: timedelta = timedelta() - - if cls._settings["SEND_INTRODUCTION_REMINDERS"]: - if not raw_send_introduction_reminders_delay: - INVALID_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS_DELAY must contain the delay " - "in any combination of seconds, minutes, hours, days or weeks." - ) - raise ImproperlyConfiguredError( - INVALID_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE, - ) - - raw_timedelta_send_introduction_reminders_delay = timedelta( - **{ - key: float(value) - for key, value - in raw_send_introduction_reminders_delay.groupdict().items() - if value - }, - ) - - if raw_timedelta_send_introduction_reminders_delay < timedelta(days=1): - TOO_SMALL_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS_DELAY must be longer than or equal to 1 day " - "(in any allowed format)." - ) - raise ImproperlyConfiguredError( - TOO_SMALL_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE, - ) - - cls._settings["SEND_INTRODUCTION_REMINDERS_DELAY"] = ( - raw_timedelta_send_introduction_reminders_delay - ) - - @classmethod - def _setup_send_introduction_reminders_interval(cls) -> None: - if "SEND_INTRODUCTION_REMINDERS" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: SEND_INTRODUCTION_REMINDERS must be set up " - "before SEND_INTRODUCTION_REMINDERS_INTERVAL can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - raw_send_introduction_reminders_interval: Match[str] | None = re.fullmatch( - r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - str(os.getenv("SEND_INTRODUCTION_REMINDERS_INTERVAL", "6h")), - ) - - raw_timedelta_details_send_introduction_reminders_interval: Mapping[str, float] = { - "hours": 6, - } - - if cls._settings["SEND_INTRODUCTION_REMINDERS"]: - if not raw_send_introduction_reminders_interval: - INVALID_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( - "SEND_INTRODUCTION_REMINDERS_INTERVAL must contain the interval " - "in any combination of seconds, minutes or hours." - ) - raise ImproperlyConfiguredError( - INVALID_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE, - ) - - raw_timedelta_details_send_introduction_reminders_interval = { - key: float(value) - for key, value - in raw_send_introduction_reminders_interval.groupdict().items() - if value - } - - cls._settings["SEND_INTRODUCTION_REMINDERS_INTERVAL"] = ( - raw_timedelta_details_send_introduction_reminders_interval - ) - - @classmethod - def _setup_send_get_roles_reminders(cls) -> None: - raw_send_get_roles_reminders: str = str( - os.getenv("SEND_GET_ROLES_REMINDERS", "True"), - ).lower() - - if raw_send_get_roles_reminders not in TRUE_VALUES | FALSE_VALUES: - INVALID_SEND_GET_ROLES_REMINDERS_MESSAGE: Final[str] = ( - "SEND_GET_ROLES_REMINDERS must be a boolean value." - ) - raise ImproperlyConfiguredError(INVALID_SEND_GET_ROLES_REMINDERS_MESSAGE) - - cls._settings["SEND_GET_ROLES_REMINDERS"] = ( - raw_send_get_roles_reminders in TRUE_VALUES - ) - - @classmethod - def _setup_send_get_roles_reminders_delay(cls) -> None: - if "SEND_GET_ROLES_REMINDERS" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: SEND_GET_ROLES_REMINDERS must be set up " - "before SEND_GET_ROLES_REMINDERS_DELAY can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - raw_send_get_roles_reminders_delay: Match[str] | None = re.fullmatch( - r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", - str(os.getenv("SEND_GET_ROLES_REMINDERS_DELAY", "40h")), - ) - - raw_timedelta_send_get_roles_reminders_delay: timedelta = timedelta() - - if cls._settings["SEND_GET_ROLES_REMINDERS"]: - if not raw_send_get_roles_reminders_delay: - INVALID_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_GET_ROLES_REMINDERS_DELAY must contain the delay " - "in any combination of seconds, minutes, hours, days or weeks." - ) - raise ImproperlyConfiguredError( - INVALID_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE, - ) - - raw_timedelta_send_get_roles_reminders_delay = timedelta( - **{ - key: float(value) - for key, value - in raw_send_get_roles_reminders_delay.groupdict().items() - if value - }, - ) - - if raw_timedelta_send_get_roles_reminders_delay < timedelta(days=1): - TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE: Final[str] = ( - "SEND_SEND_GET_ROLES_REMINDERS_DELAY " - "must be longer than or equal to 1 day (in any allowed format)." - ) - raise ImproperlyConfiguredError( - TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE, - ) - - cls._settings["SEND_GET_ROLES_REMINDERS_DELAY"] = ( - raw_timedelta_send_get_roles_reminders_delay - ) - - @classmethod - def _setup_advanced_send_get_roles_reminders_interval(cls) -> None: - if "SEND_GET_ROLES_REMINDERS" not in cls._settings: - INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( - "Invalid setup order: SEND_GET_ROLES_REMINDERS must be set up " - "before ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL can be set up." - ) - raise RuntimeError(INVALID_SETUP_ORDER_MESSAGE) - - raw_advanced_send_get_roles_reminders_interval: Match[str] | None = re.fullmatch( - r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?\Z", - str(os.getenv("ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL", "24h")), - ) - - raw_timedelta_details_advanced_send_get_roles_reminders_interval: Mapping[str, float] = { # noqa: E501 - "hours": 24, - } - - if cls._settings["SEND_GET_ROLES_REMINDERS"]: - if not raw_advanced_send_get_roles_reminders_interval: - INVALID_ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( - "ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL must contain the interval " - "in any combination of seconds, minutes or hours." - ) - raise ImproperlyConfiguredError( - INVALID_ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL_MESSAGE, - ) - - raw_timedelta_details_advanced_send_get_roles_reminders_interval = { - key: float(value) - for key, value - in raw_advanced_send_get_roles_reminders_interval.groupdict().items() - if value - } - - cls._settings["ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL"] = ( - raw_timedelta_details_advanced_send_get_roles_reminders_interval - ) - - @classmethod - def _setup_statistics_days(cls) -> None: - e: ValueError - try: - raw_statistics_days: float = float(os.getenv("STATISTICS_DAYS", "30")) - except ValueError as e: - INVALID_STATISTICS_DAYS_MESSAGE: Final[str] = ( - "STATISTICS_DAYS must contain the statistics period in days." - ) - raise ImproperlyConfiguredError(INVALID_STATISTICS_DAYS_MESSAGE) from e - - cls._settings["STATISTICS_DAYS"] = timedelta(days=raw_statistics_days) - - @classmethod - def _setup_statistics_roles(cls) -> None: - raw_statistics_roles: str | None = os.getenv("STATISTICS_ROLES") - - if not raw_statistics_roles: - cls._settings["STATISTICS_ROLES"] = DEFAULT_STATISTICS_ROLES - - else: - cls._settings["STATISTICS_ROLES"] = { - raw_statistics_role - for raw_statistics_role - in raw_statistics_roles.split(",") - if raw_statistics_role - } - - @classmethod - def _setup_moderation_document_url(cls) -> None: - raw_moderation_document_url: str | None = os.getenv("MODERATION_DOCUMENT_URL") - - MODERATION_DOCUMENT_URL_IS_VALID: Final[bool] = bool( - raw_moderation_document_url - and validators.url(raw_moderation_document_url), - ) - if not MODERATION_DOCUMENT_URL_IS_VALID: - MODERATION_DOCUMENT_URL_MESSAGE: Final[str] = ( - "MODERATION_DOCUMENT_URL must be a valid URL." - ) - raise ImproperlyConfiguredError(MODERATION_DOCUMENT_URL_MESSAGE) - - cls._settings["MODERATION_DOCUMENT_URL"] = raw_moderation_document_url - - @classmethod - def _setup_strike_performed_manually_warning_location(cls) -> None: - raw_strike_performed_manually_warning_location: str = os.getenv( - "MANUAL_MODERATION_WARNING_MESSAGE_LOCATION", - "DM", - ) - if not raw_strike_performed_manually_warning_location: - STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_MESSAGE: Final[str] = ( - "MANUAL_MODERATION_WARNING_MESSAGE_LOCATION must be a valid name " - "of a channel in your group's Discord guild." - ) - raise ImproperlyConfiguredError(STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_MESSAGE) - - cls._settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"] = ( - raw_strike_performed_manually_warning_location - ) - - @classmethod - def _setup_env_variables(cls) -> None: - """ - Load environment values into the settings dictionary. - - Environment values are loaded from the .env file/the current environment variables and - are only stored after the input values have been validated. - """ - if cls._is_env_variables_setup: - logging.warning("Environment variables have already been set up.") - return - - dotenv.load_dotenv() - - cls._setup_logging() - cls._setup_discord_bot_token() - cls._setup_discord_log_channel_webhook_url() - cls._setup_discord_guild_id() - cls._setup_group_full_name() - cls._setup_group_short_name() - cls._setup_ping_command_easter_egg_probability() - cls._setup_welcome_messages() - cls._setup_roles_messages() - cls._setup_members_list_url() - cls._setup_members_list_auth_session_cookie() - cls._setup_membership_perks_url() - cls._setup_purchase_membership_url() - cls._setup_send_introduction_reminders() - cls._setup_send_introduction_reminders_delay() - cls._setup_send_introduction_reminders_interval() - cls._setup_send_get_roles_reminders() - cls._setup_send_get_roles_reminders_delay() - cls._setup_advanced_send_get_roles_reminders_interval() - cls._setup_statistics_days() - cls._setup_statistics_roles() - cls._setup_moderation_document_url() - cls._setup_strike_performed_manually_warning_location() - - cls._is_env_variables_setup = True - - -def _settings_class_factory() -> type[Settings]: - @final - class RuntimeSettings(Settings): - """ - Settings class that provides access to all settings values. - - Settings values can be accessed via key (like a dictionary) or via class attribute. - """ - - _is_env_variables_setup: ClassVar[bool] = False - _settings: ClassVar[dict[str, object]] = {} - - return RuntimeSettings - - -settings: Final[Settings] = _settings_class_factory()() - - -def run_setup() -> None: - """Execute the setup functions required, before other modules can be run.""" - # noinspection PyProtectedMember - settings._setup_env_variables() # noqa: SLF001 - - logger.debug("Begin database setup") - - importlib.import_module("db") - from django.core import management - - management.call_command("migrate") - - logger.debug("Database setup completed") diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 000000000..29e2619b7 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,203 @@ +""" +Contains settings values and import & setup functions. + +Settings values are imported from the .env file or the current environment variables. +These values are used to configure the functionality of the bot at run-time. +""" + +from collections.abc import Sequence + +__all__: Sequence[str] = ( + "PROJECT_ROOT", + "MESSAGES_LOCALE_CODES", + "LogLevels", + "run_setup", + "reload_settings", + "load_messages", + "settings", + "check_for_deprecated_environment_variables", + "messages", + "CONFIG_SETTINGS_HELPS", + "ConfigSettingHelp", + "get_settings_file_path", + "view_single_config_setting_value", + "assign_single_config_setting_value", + "remove_single_config_setting_value", +) + + +import importlib +import logging +import os +from collections.abc import Iterable +from logging import Logger +from typing import Final + +from asgiref.sync import async_to_sync + +from ._messages import MessagesAccessor as _MessagesAccessor +from ._settings import SettingsAccessor as _SettingsAccessor +from ._settings import get_settings_file_path, utils +from .constants import ( + CONFIG_SETTINGS_HELPS, + MESSAGES_LOCALE_CODES, + PROJECT_ROOT, + ConfigSettingHelp, + LogLevels, +) + +logger: Final[Logger] = logging.getLogger("TeX-Bot") + +settings: Final[_SettingsAccessor] = _SettingsAccessor() +messages: Final[_MessagesAccessor] = _MessagesAccessor() + + +def run_setup() -> None: + """Execute the setup functions required before other modules can be run.""" + check_for_deprecated_environment_variables() + + async_to_sync(reload_settings)() + async_to_sync(load_messages)(settings["MESSAGES_LOCALE_CODE"]) + + logger.debug("Begin database setup") + + importlib.import_module("db") + from django.core import management + + management.call_command("migrate") + + logger.debug("Database setup completed") + + +def check_for_deprecated_environment_variables() -> None: + """Raise an error if the old method of configuration (environment variables) is used.""" + if utils.is_running_in_async(): + RUNNING_IN_ASYNC_MESSAGE: Final[str] = ( + "Cannot check for deprecated environment variables while TeX-Bot is running." + ) + raise RuntimeError(RUNNING_IN_ASYNC_MESSAGE) + + CONFIGURATION_VIA_ENVIRONMENT_VARIABLES_IS_DEPRECATED_ERROR: Final[DeprecationWarning] = ( + DeprecationWarning( + ( + "Configuration using environment variables is deprecated. " + "Use a `tex-bot-deployment.yaml` file instead." + ), + ) + ) + + if (PROJECT_ROOT / ".env").exists(): + raise CONFIGURATION_VIA_ENVIRONMENT_VARIABLES_IS_DEPRECATED_ERROR + + DEPRECATED_ENVIRONMENT_VARIABLE_NAMES: Final[Iterable[str]] = ( + "DISCORD_BOT_TOKEN", + "BOT_TOKEN", + "DISCORD_TOKEN", + "DISCORD_GUILD_ID", + "DISCORD_MAIN_GUILD_ID", + "MAIN_GUILD_ID", + "GUILD_ID", + "DISCORD_LOG_CHANNEL_WEBHOOK_URL", + "DISCORD_LOG_CHANNEL_WEBHOOK", + "DISCORD_LOGGING_WEBHOOK_URL", + "DISCORD_LOGGING_WEBHOOK", + "DISCORD_LOG_CHANNEL_LOCATION", + "GROUP_NAME", + "GROUP_FULL_NAME", + "GROUP_SHORT_NAME", + "PURCHASE_MEMBERSHIP_URL", + "PURCHASE_MEMBERSHIP_LINK", + "PURCHASE_MEMBERSHIP_WEBSITE", + "PURCHASE_MEMBERSHIP_INFO", + "MEMBERSHIP_PERKS_URL", + "MEMBERSHIP_PERKS_LINK", + "MEMBERSHIP_PERKS", + "MEMBERSHIP_PERKS_WEBSITE", + "MEMBERSHIP_PERKS_INFO", + "CONSOLE_LOG_LEVEL", + "MEMBERS_LIST_URL", + "MEMBERS_LIST_LIST", + "MEMBERS_LIST", + "MEMBERS_LIST_URL_SESSION_COOKIE", + "MEMBERS_LIST_AUTH_SESSION_COOKIE", + "MEMBERS_LIST_URL_AUTH_COOKIE", + "MEMBERS_LIST_SESSION_COOKIE", + "MEMBERS_LIST_URL_COOKIE", + "MEMBERS_LIST_AUTH_COOKIE", + "MEMBERS_LIST_COOKIE", + "PING_COMMAND_EASTER_EGG_PROBABILITY", + "PING_EASTER_EGG_PROBABILITY", + "MESSAGES_FILE_PATH", + "MESSAGES_FILE", + "SEND_INTRODUCTION_REMINDERS", + "SEND_INTRODUCTION_REMINDERS_DELAY", + "SEND_INTRODUCTION_REMINDERS_INTERVAL", + "SEND_GET_ROLES_REMINDERS", + "SEND_GET_ROLES_REMINDERS_DELAY", + "SEND_GET_ROLES_REMINDERS_INTERVAL", + "ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL", + "STATISTICS_DAYS", + "STATISTICS_ROLES", + "STATS_DAYS", + "STATS_ROLES", + "MODERATION_DOCUMENT_URL", + "MODERATION_DOCUMENT_LINK", + "MODERATION_DOCUMENT", + "MANUAL_MODERATION_WARNING_MESSAGE_LOCATION", + "MANUAL_MODERATION_WARNING_LOCATION", + "MANUAL_MODERATION_MESSAGE_LOCATION", + "STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION", + "STRIKE_PERFORMED_MANUALLY_WARNING_MESSAGE_LOCATION", + "STRIKE_PERFORMED_MANUALLY_MESSAGE_LOCATION", + "MANUAL_STRIKE_WARNING_MESSAGE_LOCATION", + "MANUAL_STRIKE_MESSAGE_LOCATION", + "MANUAL_STRIKE_WARNING_LOCATION", + ) + deprecated_environment_variable_name: str + for deprecated_environment_variable_name in DEPRECATED_ENVIRONMENT_VARIABLE_NAMES: + DEPRECATED_ENVIRONMENT_VARIABLE_FOUND: bool = bool( + deprecated_environment_variable_name.upper() in os.environ + or deprecated_environment_variable_name.lower() in os.environ + or f"TEX_BOT_{deprecated_environment_variable_name}".upper() in os.environ + or f"TEX_BOT_{deprecated_environment_variable_name}".lower() in os.environ # noqa: COM812 + ) + if DEPRECATED_ENVIRONMENT_VARIABLE_FOUND: + raise CONFIGURATION_VIA_ENVIRONMENT_VARIABLES_IS_DEPRECATED_ERROR + + +async def reload_settings() -> None: + """Reload any configuration settings into the settings tree.""" + # noinspection PyProtectedMember + await settings._public_reload() # noqa: SLF001 + + +def view_single_config_setting_value(config_setting_name: str) -> str | None: + # noinspection GrazieInspection + """Return the value of a single configuration setting from settings tree hierarchy.""" + # noinspection PyProtectedMember + return settings._public_view_single_raw_value(config_setting_name=config_setting_name) # noqa: SLF001 + + +async def assign_single_config_setting_value(config_setting_name: str, new_config_setting_value: str) -> None: # noqa: E501 + # noinspection GrazieInspection + """Set the value of a single configuration setting within settings tree hierarchy.""" + # noinspection PyProtectedMember + return await settings._public_assign_single_raw_value( # noqa: SLF001 + config_setting_name=config_setting_name, + new_config_setting_value=new_config_setting_value, + ) + + +async def remove_single_config_setting_value(config_setting_name: str) -> None: + # noinspection GrazieInspection + """Unset the value of a single configuration setting within settings tree hierarchy.""" + # noinspection PyProtectedMember + return await settings._public_remove_single_raw_value( # noqa: SLF001 + config_setting_name=config_setting_name, + ) + + +async def load_messages(messages_locale_code: str) -> None: + """Load the messages defined in the language file.""" + # noinspection PyProtectedMember + await messages._public_load(messages_locale_code=messages_locale_code) # noqa: SLF001 diff --git a/config/_messages/__init__.py b/config/_messages/__init__.py new file mode 100644 index 000000000..474a86ca0 --- /dev/null +++ b/config/_messages/__init__.py @@ -0,0 +1,115 @@ +from collections.abc import Sequence + +__all__: Sequence[str] = ("MessagesAccessor",) + + +import json +import re +from typing import Any, ClassVar, Final + +from aiopath import AsyncPath + +from config.constants import MESSAGES_LOCALE_CODES, PROJECT_ROOT + + +class MessagesAccessor: + _messages: ClassVar[dict[str, str | set[str] | Sequence[str]]] = {} + _messages_already_loaded: ClassVar[bool] = False + + @classmethod + def _format_invalid_message_id_message(cls, item: str) -> str: + """Return the message to state that the given message ID is invalid.""" + return f"{item!r} is not a valid message ID." + + def __getattr__(self, item: str) -> Any: # type: ignore[misc] # noqa: ANN401 + """Retrieve message(s) value by attribute lookup.""" + MISSING_ATTRIBUTE_MESSAGE: Final[str] = ( + f"{type(self).__name__!r} object has no attribute {item!r}" + ) + + if "_pytest" in item or item in ("__bases__", "__test__"): # NOTE: Overriding __getattr__() leads to many edge-case issues where external libraries will attempt to call getattr() with peculiar values + raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) + + IN_MESSAGE_KEY_FORMAT: Final[bool] = bool( + re.fullmatch(r"\A(?!.*__.*)(?:[A-Z]|[A-Z_][A-Z]|[A-Z_][A-Z][A-Z_]*[A-Z])\Z", item) # noqa: COM812 + ) + if not IN_MESSAGE_KEY_FORMAT: + raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) + + if item not in self._messages: + INVALID_MESSAGE_ID_MESSAGE: Final[str] = self.format_invalid_message_id_message( + item, + ) + raise AttributeError(INVALID_MESSAGE_ID_MESSAGE) + + return self._messages[item] + + def __getitem__(self, item: str) -> Any: # type: ignore[misc] # noqa: ANN401 + """Retrieve message(s) value by key lookup.""" + attribute_not_exist_error: AttributeError + try: + return getattr(self, item) + except AttributeError as attribute_not_exist_error: + key_error_message: str = item + + ERROR_WAS_FROM_INVALID_KEY_NAME: Final[bool] = self.format_invalid_message_id_message(item) in str( # noqa: E501 + attribute_not_exist_error, + ) + if ERROR_WAS_FROM_INVALID_KEY_NAME: + key_error_message = str(attribute_not_exist_error) + + raise KeyError(key_error_message) from None + + @classmethod + async def _public_load(cls, messages_locale_code: str) -> None: + if messages_locale_code not in MESSAGES_LOCALE_CODES: + INVALID_MESSAGES_LOCALE_CODE_MESSAGE: Final[str] = ( + f"{"messages_locale_code"!r} must be one of " + f"'{"', '".join(MESSAGES_LOCALE_CODES)}'" + ) + raise ValueError(INVALID_MESSAGES_LOCALE_CODE_MESSAGE) + + if cls._messages_already_loaded: + MESSAGES_ALREADY_LOADED_MESSAGE: Final[str] = "Messages have already been loaded." + raise RuntimeError(MESSAGES_ALREADY_LOADED_MESSAGE) + + NO_MESSAGES_FILE_FOUND_ERROR: Final[RuntimeError] = RuntimeError( + f"No messages file found for locale: {messages_locale_code!r}", + ) + + try: + # noinspection PyTypeChecker + messages_locale_file_path: AsyncPath = await anext( + path + async for path in ( + AsyncPath(PROJECT_ROOT) / "config/_messages/locales/" + ).iterdir() + if path.stem == messages_locale_code + ) + except StopIteration: + raise NO_MESSAGES_FILE_FOUND_ERROR from None + + if not await messages_locale_file_path.is_file(): + raise NO_MESSAGES_FILE_FOUND_ERROR + + messages_load_error: Exception + try: + raw_messages: object = json.loads(await messages_locale_file_path.read_text()) + + if not hasattr(raw_messages, "__getitem__"): + raise TypeError + + # noinspection PyUnresolvedReferences + cls._messages["WELCOME_MESSAGES"] = set(raw_messages["welcome-messages"]) + # noinspection PyUnresolvedReferences + cls._messages["OPT_IN_ROLES_SELECTORS"] = tuple( + raw_messages["opt-in-roles-selectors"], + ) + + except (json.JSONDecodeError, TypeError, KeyError) as messages_load_error: + INVALID_MESSAGES_FILE_MESSAGE: Final[str] = ( + "Messages file contained invalid contents." + ) + raise ValueError(INVALID_MESSAGES_FILE_MESSAGE) from messages_load_error + + cls._messages_already_loaded = True diff --git a/messages.json b/config/_messages/locales/en-GB.json similarity index 97% rename from messages.json rename to config/_messages/locales/en-GB.json index 2bfc30cd3..6b02eb088 100644 --- a/messages.json +++ b/config/_messages/locales/en-GB.json @@ -1,5 +1,5 @@ { - "welcome_messages": [ + "welcome-messages": [ " is the lisan al gaib. As it was written!", "Welcome, . We've been expecting you ( ͡° ͜ʖ ͡°)", "Welcome . Leave your weapons by the door.", @@ -15,7 +15,7 @@ " is here to kick butt and chew bubblegum. And is all out of gum.", "A Golden Spirit, blessed with radiance of justice. I saw it within and their friends... As long as it's there, they'll be fine.", "Behold, the saviour of all existence, !", - "? More like <>", + "? More like <>", "I think took a wrong turn to Cavern, so we pointed them here!", " used to be an adventurer until they took an arrow to the knee.", "Their name is, their name is, their name is !", @@ -51,7 +51,7 @@ "Did you ever hear the tragedy of Darth the Wise? I thought not, it's not a story the lecturers would tell you.", "``>`" ], - "roles_messages": [ + "opt-in-roles-selectors": [ "_ _\nReact to this message to get pronoun roles\n:regional_indicator_h: - He/Him\n:regional_indicator_s: - She/Her\n:regional_indicator_t: - They/Them", "_ _\nReact to this message to get year group roles\n:zero: - Foundation Year\n:one: - First Year\n:two: - Second Year\n:regional_indicator_f: - Final Year (incl. 3rd Year MSci/MEng)\n:regional_indicator_i: - Year in Industry\n:regional_indicator_a: - Year Abroad\n:regional_indicator_t: - Post-Graduate Taught (Masters/MSc)\n:regional_indicator_r: - Post-Graduate Research (PhD)\n:regional_indicator_j: - Joint Honours\n:a: - Alumnus\n:regional_indicator_d: - Postdoc", "_ _\nReact to this message to join the **opt in channels**\n:speech_balloon: - Serious Talk\n:house_with_garden: - Housing\n:video_game: - Gaming\n:tv: - Anime\n:soccer: - Sport\n:briefcase: - Industry\n:pick: - Minecraft\n:technologist: - GitHub\n:bookmark: - Archivist\n:ramen: - Rate My Meal", diff --git a/config/_settings/__init__.py b/config/_settings/__init__.py new file mode 100644 index 000000000..22e54294c --- /dev/null +++ b/config/_settings/__init__.py @@ -0,0 +1,1105 @@ +""" +Contains settings values and setup functions. + +Settings values are imported from the tex-bot-deployment.yaml file. +These values are used to configure the functionality of the bot at run-time. +""" + +from collections.abc import Sequence + +__all__: Sequence[str] = ("get_settings_file_path", "SettingsAccessor") + + +import logging +import re +from collections.abc import Iterable, Mapping +from datetime import timedelta +from logging import Logger +from typing import Any, ClassVar, Final, TextIO, TypeAlias + +import strictyaml +from aiopath import AsyncPath # noqa: TCH002 +from discord_logging.handler import DiscordHandler +from strictyaml import YAML + +from config.constants import DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME +from exceptions import ChangingSettingWithRequiredSiblingError + +from . import utils +from ._yaml import load_yaml +from .utils import get_settings_file_path + +NestedMapping: TypeAlias = Mapping[str, "NestedMapping | str"] + +logger: Final[Logger] = logging.getLogger("TeX-Bot") + + +class SettingsAccessor: + """ + Settings class that provides access to all settings values. + + Settings values can be accessed via key (like a dictionary) or via class attribute. + """ + + _settings: ClassVar[dict[str, object]] = {} + _most_recent_yaml: ClassVar[YAML | None] = None + + @classmethod + def _get_invalid_settings_key_message(cls, item: str) -> str: + """Return the message to state that the given settings key is invalid.""" + return f"{item!r} is not a valid settings key." + + def __getattr__(self, item: str) -> Any: # type: ignore[misc] # noqa: ANN401 + """Retrieve settings value by attribute lookup.""" + MISSING_ATTRIBUTE_MESSAGE: Final[str] = ( + f"{type(self).__name__!r} object has no attribute {item!r}" + ) + + if "_pytest" in item or item in ("__bases__", "__test__"): # NOTE: Overriding __getattr__() leads to many edge-case issues where external libraries will attempt to call getattr() with peculiar values + raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) + + IN_SETTING_KEY_FORMAT: Final[bool] = bool( + re.fullmatch(r"\A(?!.*__.*)(?:[A-Z]|[A-Z_][A-Z]|[A-Z_][A-Z][A-Z_]*[A-Z])\Z", item) # noqa: COM812 + ) + if not IN_SETTING_KEY_FORMAT: + raise AttributeError(MISSING_ATTRIBUTE_MESSAGE) + + if self._most_recent_yaml is None: + YAML_NOT_LOADED_MESSAGE: Final[str] = ( + "Configuration cannot be accessed before it is loaded." + ) + raise RuntimeError(YAML_NOT_LOADED_MESSAGE) + + if item not in self._settings: + INVALID_SETTINGS_KEY_MESSAGE: Final[str] = self._get_invalid_settings_key_message( + item, + ) + raise AttributeError(INVALID_SETTINGS_KEY_MESSAGE) + + ATTEMPTING_TO_ACCESS_BOT_TOKEN_WHEN_ALREADY_RUNNING: Final[bool] = bool( + "bot" in item.lower() and "token" in item.lower() and utils.is_running_in_async() # noqa: COM812 + ) + if ATTEMPTING_TO_ACCESS_BOT_TOKEN_WHEN_ALREADY_RUNNING: + TEX_BOT_ALREADY_RUNNING_MESSAGE: Final[str] = ( + f"Cannot access {item!r} when TeX-Bot is already running." + ) + raise RuntimeError(TEX_BOT_ALREADY_RUNNING_MESSAGE) + + return self._settings[item] + + def __getitem__(self, item: str) -> Any: # type: ignore[misc] # noqa: ANN401 + """Retrieve settings value by key lookup.""" + attribute_not_exist_error: AttributeError + try: + return getattr(self, item) + except AttributeError as attribute_not_exist_error: + key_error_message: str = item + + ERROR_WAS_FROM_INVALID_KEY_NAME: Final[bool] = ( + self._get_invalid_settings_key_message(item) in str( + attribute_not_exist_error, + ) + ) + if ERROR_WAS_FROM_INVALID_KEY_NAME: + key_error_message = str(attribute_not_exist_error) + + raise KeyError(key_error_message) from None + + @classmethod + async def _public_reload(cls) -> None: + SETTINGS_FILE_PATH: Final[AsyncPath] = await utils.get_settings_file_path() + current_yaml: YAML = load_yaml( + await SETTINGS_FILE_PATH.read_text(), + file_name=SETTINGS_FILE_PATH.name, + ) + + if current_yaml == cls._most_recent_yaml and cls._settings: + return + + changed_settings_keys: set[str] = set() + + changed_settings_keys.update( + cls._reload_console_logging(current_yaml["logging"]["console"]), + cls._reload_discord_log_channel_logging( + current_yaml["logging"].get("discord-channel", None), + ), + cls._reload_discord_bot_token(current_yaml["discord"]["bot-token"]), + cls._reload_discord_main_guild_id(current_yaml["discord"]["main-guild-id"]), + cls._reload_group_full_name( + current_yaml["community-group"].get("full-name", None), + ), + cls._reload_group_short_name( + current_yaml["community-group"].get("short-name", None), + ), + cls._reload_purchase_membership_link( + current_yaml["community-group"]["links"].get("purchase-membership", None), + ), + cls._reload_membership_perks_link( + current_yaml["community-group"]["links"].get("membership-perks", None), + ), + cls._reload_moderation_document_link( + current_yaml["community-group"]["links"]["moderation-document"], + ), + cls._reload_members_list_url( + current_yaml["community-group"]["members-list"]["url"], + ), + cls._reload_members_list_auth_session_cookie( + current_yaml["community-group"]["members-list"]["auth-session-cookie"], + ), + cls._reload_members_list_id_format( + current_yaml["community-group"]["members-list"]["id-format"], + ), + cls._reload_ping_command_easter_egg_probability( + current_yaml["commands"]["ping"]["easter-egg-probability"], + ), + cls._reload_stats_command_lookback_days( + current_yaml["commands"]["stats"]["lookback-days"], + ), + cls._reload_stats_command_displayed_roles( + current_yaml["commands"]["stats"]["displayed-roles"], + ), + cls._reload_stats_command_displayed_roles( + current_yaml["commands"]["strike"]["timeout-duration"], + ), + cls._reload_strike_performed_manually_warning_location( + current_yaml["commands"]["strike"]["performed-manually-warning-location"], + ), + cls._reload_messages_locale_code(current_yaml["messages-locale-code"]), + cls._reload_send_introduction_reminders_enabled( + current_yaml["reminders"]["send-introduction-reminders"]["enabled"], + ), + cls._reload_send_introduction_reminders_delay( + current_yaml["reminders"]["send-introduction-reminders"]["delay"], + ), + cls._reload_send_introduction_reminders_interval( + current_yaml["reminders"]["send-introduction-reminders"]["interval"], + ), + cls._reload_send_get_roles_reminders_enabled( + current_yaml["reminders"]["send-get-roles-reminders"]["enabled"], + ), + cls._reload_send_get_roles_reminders_delay( + current_yaml["reminders"]["send-get-roles-reminders"]["delay"], + ), + cls._reload_send_get_roles_reminders_interval( + current_yaml["reminders"]["send-get-roles-reminders"]["interval"], + ), + cls._reload_check_if_config_changed_interval( + current_yaml["check-if-config-changed-interval"], + ), + ) + + cls._most_recent_yaml = current_yaml + + @classmethod + def _reload_console_logging(cls, console_logging_settings: YAML) -> set[str]: # type: ignore[misc] + """ + Reload the console logging configuration with the new given log level. + + Returns the set of settings keys that have been changed. + """ + CONSOLE_LOGGING_SETTINGS_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or console_logging_settings != cls._most_recent_yaml["logging"]["console"] # noqa: COM812 + ) + if not CONSOLE_LOGGING_SETTINGS_CHANGED: + return set() + + ALL_HANDLERS: Final[Iterable[logging.Handler]] = logger.handlers # NOTE: The collection of handlers needs to be retrieved before the new StreamHandler is created + + console_logging_handler: logging.StreamHandler[TextIO] = logging.StreamHandler() + + stream_handlers: set[logging.StreamHandler[TextIO]] = { + handler + for handler in ALL_HANDLERS + if ( + isinstance(handler, type(console_logging_handler)) + and handler.stream == console_logging_handler.stream + ) + } + if len(stream_handlers) > 1: + CANNOT_DETERMINE_LOGGING_HANDLER_MESSAGE: Final[str] = ( + "Cannot determine which logging stream-handler to update." + ) + raise ValueError(CANNOT_DETERMINE_LOGGING_HANDLER_MESSAGE) + + if len(stream_handlers) == 0: + # noinspection SpellCheckingInspection + console_logging_handler.setFormatter( + logging.Formatter( + "{asctime} | {name} | {levelname:^8} - {message}", + style="{", + ), + ) + logger.setLevel(1) + logger.addHandler(console_logging_handler) + logger.propagate = False + + elif len(stream_handlers) == 1: + console_logging_handler = stream_handlers.pop() + + else: + raise ValueError + + console_logging_handler.setLevel( + getattr(logging, console_logging_settings["log-level"].data), + ) + + return {"logging:console:log-level"} + + @classmethod + def _reload_discord_log_channel_logging(cls, discord_channel_logging_settings: YAML | None) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the Discord log channel logging configuration. + + Returns the set of settings keys that have been changed. + """ + DISCORD_CHANNEL_LOGGING_SETTINGS_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "DISCORD_LOG_CHANNEL_WEBHOOK_URL" not in cls._settings + or discord_channel_logging_settings != cls._most_recent_yaml["logging"].get( + "discord-channel", + None, + ) # noqa: COM812 + ) + if not DISCORD_CHANNEL_LOGGING_SETTINGS_CHANGED: + return set() + + cls._settings["DISCORD_LOG_CHANNEL_WEBHOOK_URL"] = ( + discord_channel_logging_settings + if discord_channel_logging_settings is None + else discord_channel_logging_settings["webhook_url"].data + ) + + discord_logging_handlers: set[DiscordHandler] = { + handler for handler in logger.handlers if isinstance(handler, DiscordHandler) + } + if len(discord_logging_handlers) > 1: + CANNOT_DETERMINE_LOGGING_HANDLER_MESSAGE: Final[str] = ( + "Cannot determine which logging Discord-webhook-handler to update." + ) + raise ValueError(CANNOT_DETERMINE_LOGGING_HANDLER_MESSAGE) + + discord_logging_handler_display_name: str = ( + DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME + ) + discord_logging_handler_avatar_url: str | None = None + + if len(discord_logging_handlers) == 1: + existing_discord_logging_handler: DiscordHandler = discord_logging_handlers.pop() + + ONLY_DISCORD_LOG_CHANNEL_LOG_LEVEL_CHANGED: Final[bool] = bool( + discord_channel_logging_settings is not None + and cls._most_recent_yaml is not None + and cls._most_recent_yaml["logging"].get("discord-channel", None) is not None + and all( + value == cls._most_recent_yaml["logging"]["discord-channel"].get(key, None) + for key, value in discord_channel_logging_settings.items() + if key != "log-level" + ) # noqa: COM812 + ) + if ONLY_DISCORD_LOG_CHANNEL_LOG_LEVEL_CHANGED: + DISCORD_LOG_CHANNEL_LOG_LEVEL_IS_SAME: Final[bool] = bool( + discord_channel_logging_settings["log-level"] == cls._most_recent_yaml[ # type: ignore[index] + "logging" + ]["discord-channel"]["log-level"] # noqa: COM812 + ) + if DISCORD_LOG_CHANNEL_LOG_LEVEL_IS_SAME: + LOG_LEVEL_DIDNT_CHANGE_MESSAGE: Final[str] = ( + "Assumed Discord log channel log level had changed, but it hadn't." + ) + raise ValueError(LOG_LEVEL_DIDNT_CHANGE_MESSAGE) + + existing_discord_logging_handler.setLevel( + getattr(logging, discord_channel_logging_settings["log-level"].data), # type: ignore[index] + ) + return {"logging:discord-channel:log-level"} + + discord_logging_handler_display_name = existing_discord_logging_handler.name + discord_logging_handler_avatar_url = existing_discord_logging_handler.avatar_url + logger.removeHandler(existing_discord_logging_handler) + + if discord_channel_logging_settings is None: + return {"logging:discord-channel:webhook-url"} + + elif len(discord_logging_handlers) == 0 and discord_channel_logging_settings is None: + return set() + + if discord_channel_logging_settings is None: + raise RuntimeError + + discord_logging_handler: logging.Handler = DiscordHandler( + discord_logging_handler_display_name, + discord_channel_logging_settings["webhook-url"], + avatar_url=discord_logging_handler_avatar_url, + ) + discord_logging_handler.setLevel( + getattr(logging, discord_channel_logging_settings["log-level"].data), + ) + # noinspection SpellCheckingInspection + discord_logging_handler.setFormatter( + logging.Formatter("{levelname} | {message}", style="{"), + ) + + logger.addHandler(discord_logging_handler) + + changed_settings: set[str] = {"logging:discord-channel:webhook-url"} + + DISCORD_LOG_CHANNEL_LOG_LEVEL_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or cls._most_recent_yaml["logging"].get("discord-channel", None) is None + or discord_channel_logging_settings["log-level"] != cls._most_recent_yaml["logging"]["discord-channel"]["log-level"] # noqa: COM812, E501 + ) + if DISCORD_LOG_CHANNEL_LOG_LEVEL_CHANGED: + changed_settings.add("logging:discord-channel:log-level") + + return changed_settings + + @classmethod + def _reload_discord_bot_token(cls, discord_bot_token: YAML) -> set[str]: # type: ignore[misc] + """ + Reload the Discord bot-token. + + Returns the set of settings keys that have been changed. + """ + DISCORD_BOT_TOKEN_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "DISCORD_BOT_TOKEN" not in cls._settings + or discord_bot_token != cls._most_recent_yaml["discord"]["bot-token"] # noqa: COM812 + ) + if not DISCORD_BOT_TOKEN_CHANGED: + return set() + + cls._settings["DISCORD_BOT_TOKEN"] = discord_bot_token.data + + return {"discord:bot-token"} + + @classmethod + def _reload_discord_main_guild_id(cls, discord_main_guild_id: YAML) -> set[str]: # type: ignore[misc] + """ + Reload the Discord main-guild ID. + + Returns the set of settings keys that have been changed. + """ + DISCORD_MAIN_GUILD_ID_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "_DISCORD_MAIN_GUILD_ID" not in cls._settings + or discord_main_guild_id != cls._most_recent_yaml["discord"]["main-guild-id"] # noqa: COM812 + ) + if not DISCORD_MAIN_GUILD_ID_CHANGED: + return set() + + cls._settings["_DISCORD_MAIN_GUILD_ID"] = discord_main_guild_id.data + + return {"discord:main-guild-id"} + + @classmethod + def _reload_group_full_name(cls, group_full_name: YAML | None) -> set[str]: # type: ignore[misc] + """ + Reload the community-group full name. + + Returns the set of settings keys that have been changed. + """ + GROUP_FULL_NAME_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "_GROUP_FULL_NAME" not in cls._settings + or group_full_name != cls._most_recent_yaml["community-group"].get( + "full-name", + None, + ) # noqa: COM812 + ) + if not GROUP_FULL_NAME_CHANGED: + return set() + + cls._settings["_GROUP_FULL_NAME"] = ( + group_full_name if group_full_name is None else group_full_name.data + ) + + return {"community-group:full-name"} + + @classmethod + def _reload_group_short_name(cls, group_short_name: YAML | None) -> set[str]: # type: ignore[misc] + """ + Reload the community-group short name. + + Returns the set of settings keys that have been changed. + """ + GROUP_SHORT_NAME_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "_GROUP_SHORT_NAME" not in cls._settings + or group_short_name != cls._most_recent_yaml["community-group"].get( + "short-name", + None, + ) # noqa: COM812 + ) + if not GROUP_SHORT_NAME_CHANGED: + return set() + + cls._settings["_GROUP_SHORT_NAME"] = ( + group_short_name if group_short_name is None else group_short_name.data + ) + + return {"community-group:short-name"} + + @classmethod + def _reload_purchase_membership_link(cls, purchase_membership_link: YAML | None) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the link to allow people to purchase a membership. + + Returns the set of settings keys that have been changed. + """ + PURCHASE_MEMBERSHIP_LINK_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "PURCHASE_MEMBERSHIP_LINK" not in cls._settings + or purchase_membership_link != cls._most_recent_yaml["community-group"]["links"].get( # noqa: E501 + "purchase-membership", + None, + ) # noqa: COM812 + ) + if not PURCHASE_MEMBERSHIP_LINK_CHANGED: + return set() + + cls._settings["PURCHASE_MEMBERSHIP_LINK"] = ( + purchase_membership_link + if purchase_membership_link is None + else purchase_membership_link.data + ) + + return {"community-group:links:purchase-membership"} + + @classmethod + def _reload_membership_perks_link(cls, membership_perks_link: YAML | None) -> set[str]: # type: ignore[misc] + """ + Reload the link to view the perks of getting a membership to join your community group. + + Returns the set of settings keys that have been changed. + """ + MEMBERSHIP_PERKS_LINK_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "MEMBERSHIP_PERKS_LINK" not in cls._settings + or membership_perks_link != cls._most_recent_yaml["community-group"]["links"].get( + "membership-perks", + None, + ) # noqa: COM812 + ) + if not MEMBERSHIP_PERKS_LINK_CHANGED: + return set() + + cls._settings["MEMBERSHIP_PERKS_LINK"] = ( + membership_perks_link + if membership_perks_link is None + else membership_perks_link.data + ) + + return {"community-group:links:membership-perks"} + + @classmethod + def _reload_moderation_document_link(cls, moderation_document_link: YAML) -> set[str]: # type: ignore[misc] + """ + Reload the link to view your community group's moderation document. + + Returns the set of settings keys that have been changed. + """ + MODERATION_DOCUMENT_LINK_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "MODERATION_DOCUMENT_LINK" not in cls._settings + or moderation_document_link != cls._most_recent_yaml["community-group"]["links"]["moderation-document"] # noqa: COM812, E501 + ) + if not MODERATION_DOCUMENT_LINK_CHANGED: + return set() + + cls._settings["MODERATION_DOCUMENT_LINK"] = moderation_document_link.data + + return {"community-group:links:moderation-document"} + + @classmethod + def _reload_members_list_url(cls, members_list_url: YAML) -> set[str]: # type: ignore[misc] + """ + Reload the url that points to the location of your community group's members-list. + + Returns the set of settings keys that have been changed. + """ + MEMBERS_LIST_URL_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "MEMBERS_LIST_URL" not in cls._settings + or members_list_url != cls._most_recent_yaml["community-group"]["members-list"]["url"] # noqa: COM812, E501 + ) + if not MEMBERS_LIST_URL_CHANGED: + return set() + + cls._settings["MEMBERS_LIST_URL"] = members_list_url.data + + return {"community-group:members-list:url"} + + @classmethod + def _reload_members_list_auth_session_cookie(cls, members_list_auth_session_cookie: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the auth session cookie used to authenticate to access your members-list. + + Returns the set of settings keys that have been changed. + """ + MEMBERS_LIST_AUTH_SESSION_COOKIE_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "MEMBERS_LIST_AUTH_SESSION_COOKIE" not in cls._settings + or members_list_auth_session_cookie != cls._most_recent_yaml["community-group"]["members-list"]["auth-session-cookie"] # noqa: COM812, E501 + ) + if not MEMBERS_LIST_AUTH_SESSION_COOKIE_CHANGED: + return set() + + cls._settings["MEMBERS_LIST_AUTH_SESSION_COOKIE"] = ( + members_list_auth_session_cookie.data + ) + + return {"community-group:members-list:auth-session-cookie"} + + @classmethod + def _reload_members_list_id_format(cls, members_list_id_format: YAML) -> set[str]: # type: ignore[misc] + """ + Reload the format regex matcher for IDs in your community group's members-list. + + Returns the set of settings keys that have been changed. + """ + MEMBERS_LIST_ID_FORMAT_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "MEMBERS_LIST_ID_FORMAT" not in cls._settings + or members_list_id_format != cls._most_recent_yaml["community-group"]["members-list"]["id-format"] # noqa: COM812, E501 + ) + if not MEMBERS_LIST_ID_FORMAT_CHANGED: + return set() + + cls._settings["MEMBERS_LIST_ID_FORMAT"] = members_list_id_format.data + + return {"community-group:members-list:id-format"} + + @classmethod + def _reload_ping_command_easter_egg_probability(cls, ping_command_easter_egg_probability: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the probability that the rarer response will show when using the ping command. + + Returns the set of settings keys that have been changed. + """ + PING_COMMAND_EASTER_EGG_PROBABILITY_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "PING_COMMAND_EASTER_EGG_PROBABILITY" not in cls._settings + or ping_command_easter_egg_probability != cls._most_recent_yaml["commands"]["ping"]["easter-egg-probability"] # noqa: COM812, E501 + ) + if not PING_COMMAND_EASTER_EGG_PROBABILITY_CHANGED: + return set() + + cls._settings["PING_COMMAND_EASTER_EGG_PROBABILITY"] = ( + ping_command_easter_egg_probability.data + ) + + return {"commands:ping:easter-egg-probability"} + + @classmethod + def _reload_stats_command_lookback_days(cls, stats_command_lookback_days: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the number of days to lookback for statistics. + + Returns the set of settings keys that have been changed. + """ + STATS_COMMAND_LOOKBACK_DAYS_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "STATS_COMMAND_LOOKBACK_DAYS" not in cls._settings + or stats_command_lookback_days != cls._most_recent_yaml["commands"]["stats"]["lookback-days"] # noqa: COM812, E501 + ) + if not STATS_COMMAND_LOOKBACK_DAYS_CHANGED: + return set() + + cls._settings["STATS_COMMAND_LOOKBACK_DAYS"] = timedelta( + days=stats_command_lookback_days.data, + ) + + return {"commands:stats:lookback-days"} + + @classmethod + def _reload_stats_command_displayed_roles(cls, stats_command_displayed_roles: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the set of roles used to display statistics about. + + Returns the set of settings keys that have been changed. + """ + STATS_COMMAND_DISPLAYED_ROLES_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "STATS_COMMAND_DISPLAYED_ROLES" not in cls._settings + or stats_command_displayed_roles != cls._most_recent_yaml["commands"]["stats"]["displayed-roles"] # noqa: COM812, E501 + ) + if not STATS_COMMAND_DISPLAYED_ROLES_CHANGED: + return set() + + cls._settings["STATS_COMMAND_DISPLAYED_ROLES"] = stats_command_displayed_roles.data + + return {"commands:stats:displayed-roles"} + + @classmethod + def _reload_strike_command_timeout_duration(cls, strike_command_timeout_duration: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the duration to use when applying a timeout action for a strike increase. + + Returns the set of settings keys that have been changed. + """ + STRIKE_COMMAND_TIMEOUT_DURATION_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "STRIKE_COMMAND_TIMEOUT_DURATION" not in cls._settings + or strike_command_timeout_duration != cls._most_recent_yaml["commands"]["strike"]["timeout-duration"] # noqa: COM812, E501 + ) + if not STRIKE_COMMAND_TIMEOUT_DURATION_CHANGED: + return set() + + cls._settings["STRIKE_COMMAND_TIMEOUT_DURATION"] = strike_command_timeout_duration.data + + return {"commands:strike:timeout-duration"} + + @classmethod + def _reload_strike_performed_manually_warning_location(cls, strike_performed_manually_warning_location: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the location to send warning messages when strikes are performed manually. + + Returns the set of settings keys that have been changed. + """ + STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION" not in cls._settings + or strike_performed_manually_warning_location != cls._most_recent_yaml["commands"]["strike"]["performed-manually-warning-location"] # noqa: COM812, E501 + ) + if not STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION_CHANGED: + return set() + + cls._settings["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"] = ( + strike_performed_manually_warning_location.data + ) + + return {"commands:strike:performed-manually-warning-location"} + + @classmethod + def _reload_messages_locale_code(cls, messages_locale_code: YAML) -> set[str]: # type: ignore[misc] + """ + Reload the selected locale for messages to be sent in. + + Returns the set of settings keys that have been changed. + """ + MESSAGES_LOCALE_CODE_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "MESSAGES_LOCALE_CODE" not in cls._settings + or messages_locale_code != cls._most_recent_yaml["messages-locale-code"] # noqa: COM812 + ) + if not MESSAGES_LOCALE_CODE_CHANGED: + return set() + + cls._settings["MESSAGES_LOCALE_CODE"] = messages_locale_code.data + + return {"messages-locale-code"} + + @classmethod + def _reload_send_introduction_reminders_enabled(cls, send_introduction_reminders_enabled: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the flag for whether the "send-introduction-reminders" task is enabled. + + Returns the set of settings keys that have been changed. + """ + SEND_INTRODUCTION_REMINDERS_ENABLED_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "SEND_INTRODUCTION_REMINDERS_ENABLED" not in cls._settings + or send_introduction_reminders_enabled != cls._most_recent_yaml["reminders"]["send-introduction-reminders"]["enabled"] # noqa: COM812, E501 + ) + if not SEND_INTRODUCTION_REMINDERS_ENABLED_CHANGED: + return set() + + cls._settings["SEND_INTRODUCTION_REMINDERS_ENABLED"] = ( + send_introduction_reminders_enabled.data + ) + + return {"reminders:send-introduction-reminders:enabled"} + + @classmethod + def _reload_send_introduction_reminders_delay(cls, send_introduction_reminders_delay: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the amount of time to wait before sending introduction-reminders to a user. + + Returns the set of settings keys that have been changed. + + Waiting begins from the time that the user joined your community group's Discord guild. + """ + SEND_INTRODUCTION_REMINDERS_DELAY_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "SEND_INTRODUCTION_REMINDERS_DELAY" not in cls._settings + or send_introduction_reminders_delay != cls._most_recent_yaml["reminders"]["send-introduction-reminders"]["delay"] # noqa: COM812, E501 + ) + if not SEND_INTRODUCTION_REMINDERS_DELAY_CHANGED: + return set() + + cls._settings["SEND_INTRODUCTION_REMINDERS_DELAY"] = ( + send_introduction_reminders_delay.data + ) + + return {"reminders:send-introduction-reminders:delay"} + + @classmethod + def _reload_send_introduction_reminders_interval(cls, send_introduction_reminders_interval: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the interval of time between executing the task to send introduction-reminders. + + Returns the set of settings keys that have been changed. + """ + SEND_INTRODUCTION_REMINDERS_INTERVAL_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "SEND_INTRODUCTION_REMINDERS_INTERVAL_SECONDS" not in cls._settings + or send_introduction_reminders_interval != cls._most_recent_yaml["reminders"]["send-introduction-reminders"]["interval"] # noqa: COM812, E501 + ) + if not SEND_INTRODUCTION_REMINDERS_INTERVAL_CHANGED: + return set() + + cls._settings["SEND_INTRODUCTION_REMINDERS_INTERVAL_SECONDS"] = ( + send_introduction_reminders_interval.data.total_seconds() + ) + + return {"reminders:send-introduction-reminders:interval"} + + @classmethod + def _reload_send_get_roles_reminders_enabled(cls, send_get_roles_reminders_enabled: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the flag for whether the "send-get-roles-reminders" task is enabled. + + Returns the set of settings keys that have been changed. + """ + SEND_GET_ROLES_REMINDERS_ENABLED_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "SEND_GET_ROLES_REMINDERS_ENABLED" not in cls._settings + or send_get_roles_reminders_enabled != cls._most_recent_yaml["reminders"]["send-get-roles-reminders"]["enabled"] # noqa: COM812, E501 + ) + if not SEND_GET_ROLES_REMINDERS_ENABLED_CHANGED: + return set() + + cls._settings["SEND_GET_ROLES_REMINDERS_ENABLED"] = ( + send_get_roles_reminders_enabled.data + ) + + return {"reminders:send-get-roles-reminders:enabled"} + + @classmethod + def _reload_send_get_roles_reminders_delay(cls, send_get_roles_reminders_delay: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the amount of time to wait before sending get-roles-reminders to a user. + + Returns the set of settings keys that have been changed. + + Waiting begins from the time that the user was inducted as a guest + into your community group's Discord guild. + """ + SEND_GET_ROLES_REMINDERS_DELAY_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "SEND_GET_ROLES_REMINDERS_DELAY" not in cls._settings + or send_get_roles_reminders_delay != cls._most_recent_yaml["reminders"]["send-get-roles-reminders"]["delay"] # noqa: COM812, E501 + ) + if not SEND_GET_ROLES_REMINDERS_DELAY_CHANGED: + return set() + + cls._settings["SEND_GET_ROLES_REMINDERS_DELAY"] = send_get_roles_reminders_delay.data + + return {"reminders:send-get-roles-reminders:delay"} + + @classmethod + def _reload_send_get_roles_reminders_interval(cls, send_get_roles_reminders_interval: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the interval of time between executing the task to send get-roles-reminders. + + Returns the set of settings keys that have been changed. + """ + SEND_GET_ROLES_REMINDERS_INTERVAL_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "SEND_GET_ROLES_REMINDERS_INTERVAL_SECONDS" not in cls._settings + or send_get_roles_reminders_interval != cls._most_recent_yaml["reminders"]["send-get-roles-reminders"]["interval"] # noqa: COM812, E501 + ) + if not SEND_GET_ROLES_REMINDERS_INTERVAL_CHANGED: + return set() + + cls._settings["SEND_GET_ROLES_REMINDERS_INTERVAL_SECONDS"] = ( + send_get_roles_reminders_interval.data.total_seconds() + ) + + return {"reminders:send-get-roles-reminders:interval"} + + @classmethod + def _reload_check_if_config_changed_interval(cls, check_if_config_changed_interval: YAML) -> set[str]: # type: ignore[misc] # noqa: E501 + """ + Reload the interval of time between executing the task to send check if config changed. + + Returns the set of settings keys that have been changed. + """ + CHECK_IF_CONFIG_CHANGED_INTERVAL_CHANGED: Final[bool] = bool( + cls._most_recent_yaml is None + or "CHECK_CONFIG_FILE_CHANGED_INTERVAL_SECONDS" not in cls._settings + or check_if_config_changed_interval != cls._most_recent_yaml["check-if-config-changed-interval"] # noqa: COM812, E501 + ) + if not CHECK_IF_CONFIG_CHANGED_INTERVAL_CHANGED: + return set() + + cls._settings["CHECK_CONFIG_FILE_CHANGED_INTERVAL_SECONDS"] = ( + check_if_config_changed_interval.data.total_seconds() + ) + + return {"check-if-config-changed-interval"} + + @classmethod + def _get_scalar_value(cls, config_setting_name: str, yaml_settings_tree: YAML) -> str | None: # type: ignore[misc] # noqa: E501 + single_yaml_scalar_setting: YAML | None = yaml_settings_tree.get( + config_setting_name, + None, + ) + + if single_yaml_scalar_setting is None: + return single_yaml_scalar_setting + + CONFIG_SETTING_HAS_VALID_TYPE: Final[bool] = bool( + not single_yaml_scalar_setting.is_mapping() + and ( + single_yaml_scalar_setting.is_scalar() + or single_yaml_scalar_setting.is_sequence() + ) # noqa: COM812 + ) + if not CONFIG_SETTING_HAS_VALID_TYPE: + MAPPING_TYPE_MESSAGE: Final[str] = "Got config mapping when scalar expected." + raise RuntimeError(MAPPING_TYPE_MESSAGE) + + scalar_config_setting_value: object = single_yaml_scalar_setting.validator.to_yaml( + single_yaml_scalar_setting.data, + ) + + if isinstance(scalar_config_setting_value, str): + if not single_yaml_scalar_setting.is_scalar(): + SCALAR_TYPE_MESSAGE: Final[str] = ( + "Got invalid config type when scalar expected." + ) + raise RuntimeError(SCALAR_TYPE_MESSAGE) + + return scalar_config_setting_value + + if isinstance(scalar_config_setting_value, Iterable): + if not single_yaml_scalar_setting.is_sequence(): + SEQUENCE_TYPE_MESSAGE: Final[str] = ( + "Got invalid config type when sequence expected." + ) + raise RuntimeError(SEQUENCE_TYPE_MESSAGE) + + if not all(inner_value.is_scalar() for inner_value in single_yaml_scalar_setting): + ONLY_SCALAR_SEQUENCES_SUPPORTED_MESSAGE: Final[str] = ( + "Only sequences of scalars are currently supported " + "to be used in configuration." + ) + raise NotImplementedError(ONLY_SCALAR_SEQUENCES_SUPPORTED_MESSAGE) + + return ",".join(scalar_config_setting_value) + + raise NotImplementedError + + @classmethod + def _get_mapping_value(cls, partial_config_setting_name: str, partial_yaml_settings_tree: YAML) -> str | None: # type: ignore[misc] # noqa: E501 + if ":" not in partial_config_setting_name: + return cls._get_scalar_value( + partial_config_setting_name, + partial_yaml_settings_tree, + ) + + key: str + remainder: str + key, _, remainder = partial_config_setting_name.partition(":") + + single_yaml_mapping_setting: YAML | None = partial_yaml_settings_tree.get(key, None) + + YAML_CHILD_IS_MAPPING: Final[bool] = bool( + single_yaml_mapping_setting is not None + and single_yaml_mapping_setting.is_mapping() # noqa: COM812 + ) + if YAML_CHILD_IS_MAPPING: + return cls._get_mapping_value(remainder, single_yaml_mapping_setting) + + return cls._get_scalar_value(partial_config_setting_name, partial_yaml_settings_tree) + + @classmethod + def _public_view_single_raw_value(cls, config_setting_name: str) -> str | None: + # noinspection GrazieInspection + """Return the value of a single configuration setting from settings tree hierarchy.""" + current_yaml: YAML | None = cls._most_recent_yaml + if current_yaml is None: + YAML_NOT_LOADED_MESSAGE: Final[str] = ( + "Invalid state: Config YAML has not yet been loaded." + ) + raise RuntimeError(YAML_NOT_LOADED_MESSAGE) + + return cls._get_mapping_value(config_setting_name, current_yaml) + + @classmethod + def _set_scalar_or_sequence_value(cls, config_setting_name: str, new_config_setting_value: str, yaml_settings_tree: YAML) -> YAML: # type: ignore[misc] # noqa: E501 + if config_setting_name not in yaml_settings_tree: + yaml_settings_tree[config_setting_name] = new_config_setting_value + return yaml_settings_tree + + if yaml_settings_tree[config_setting_name].is_mapping(): + INVALID_MAPPING_CONFIG_TYPE_MESSAGE: Final[str] = ( + "Got incongruent YAML object. Expected sequence or scalar, got mapping." + ) + raise TypeError(INVALID_MAPPING_CONFIG_TYPE_MESSAGE) + + if yaml_settings_tree[config_setting_name].is_scalar(): + yaml_settings_tree[config_setting_name] = new_config_setting_value + return yaml_settings_tree + + if yaml_settings_tree[config_setting_name].is_sequence(): + yaml_settings_tree[config_setting_name] = [ + sequence_value.strip() + for sequence_value in new_config_setting_value.strip().split(",") + ] + return yaml_settings_tree + + UNKNOWN_CONFIG_TYPE_MESSAGE: Final[str] = ( + "Unknown YAML object type. Expected sequence or scalar." + ) + raise RuntimeError(UNKNOWN_CONFIG_TYPE_MESSAGE) + + @classmethod + def _set_mapping_value(cls, partial_config_setting_name: str, new_config_setting_value: str, partial_yaml_settings_tree: YAML) -> YAML: # type: ignore[misc] # noqa: E501 + if ":" not in partial_config_setting_name: + return cls._set_scalar_or_sequence_value( + partial_config_setting_name, + new_config_setting_value, + partial_yaml_settings_tree, + ) + + key: str + remainder: str + key, _, remainder = partial_config_setting_name.partition(":") + + if key not in partial_yaml_settings_tree: + partial_yaml_settings_tree[key] = cls._set_required_value_from_validator( + remainder if ":" in partial_config_setting_name else None, + new_config_setting_value, + partial_yaml_settings_tree.validator.get_validator(key), + ) + return partial_yaml_settings_tree + + if partial_yaml_settings_tree[key].is_mapping(): + partial_yaml_settings_tree[key] = cls._set_mapping_value( + remainder, + new_config_setting_value, + partial_yaml_settings_tree[key], + ) + return partial_yaml_settings_tree + + return cls._set_scalar_or_sequence_value( + partial_config_setting_name, + new_config_setting_value, + partial_yaml_settings_tree, + ) + + @classmethod + def _set_required_value_from_validator(cls, partial_config_setting_name: str | None, new_config_setting_value: str, yaml_validator: strictyaml.Validator) -> "NestedMapping | str | Sequence[str]": # type: ignore[misc] # noqa: E501 + VALIDATOR_IS_SCALAR_TYPE: Final[bool] = bool( + isinstance(yaml_validator, strictyaml.ScalarValidator) + and (partial_config_setting_name is None or ":" not in partial_config_setting_name) # noqa: COM812 + ) + if VALIDATOR_IS_SCALAR_TYPE: + return new_config_setting_value + + VALIDATOR_IS_SEQUENCE_TYPE: Final[bool] = bool( + isinstance(yaml_validator, strictyaml.validators.SeqValidator) + and (partial_config_setting_name is None or ":" not in partial_config_setting_name) # noqa: COM812 + ) + if VALIDATOR_IS_SEQUENCE_TYPE: + return [ + sequence_value.strip() + for sequence_value + in new_config_setting_value.strip().split(",") + ] + + VALIDATOR_IS_MAPPING_TYPE: Final[bool] = bool( + isinstance(yaml_validator, strictyaml.validators.MapValidator) + and hasattr(yaml_validator, "_required_keys") + and partial_config_setting_name is not None # noqa: COM812 + ) + if VALIDATOR_IS_MAPPING_TYPE: + key: str + remainder: str + key, _, remainder = partial_config_setting_name.partition(":") # type: ignore[union-attr] + + # noinspection PyProtectedMember,PyUnresolvedReferences + if set(yaml_validator._required_keys) - {key}: # noqa: SLF001 + raise ChangingSettingWithRequiredSiblingError + + # noinspection PyUnresolvedReferences + return { + key: cls._set_required_value_from_validator( # type: ignore[dict-item] + remainder if ":" in partial_config_setting_name else None, # type: ignore[operator] + new_config_setting_value, + yaml_validator.get_validator(key), + ), + } + + UNKNOWN_CONFIG_TYPE_MESSAGE: Final[str] = ( + "Unknown YAML validator type. Expected mapping, sequence or scalar." + ) + raise RuntimeError(UNKNOWN_CONFIG_TYPE_MESSAGE) + + @classmethod + async def _public_assign_single_raw_value(cls, config_setting_name: str, new_config_setting_value: str) -> None: # noqa: E501 + # noinspection GrazieInspection + """Set the value of a single configuration setting within settings tree hierarchy.""" + current_yaml: YAML | None = cls._most_recent_yaml + if current_yaml is None: + YAML_NOT_LOADED_MESSAGE: Final[str] = ( + "Invalid state: Config YAML has not yet been loaded." + ) + raise RuntimeError(YAML_NOT_LOADED_MESSAGE) + + config_setting_error: ChangingSettingWithRequiredSiblingError + try: + current_yaml = cls._set_mapping_value( + config_setting_name, + new_config_setting_value, + current_yaml, + ) + except ChangingSettingWithRequiredSiblingError as config_setting_error: + raise type(config_setting_error)( + config_setting_name=config_setting_name, + ) from config_setting_error + + await (await utils.get_settings_file_path()).write_text(current_yaml.as_yaml()) + + @classmethod + def _remove_value(cls, partial_config_setting_name: str, partial_yaml_settings_tree: YAML) -> YAML: # type: ignore[misc] # noqa: E501 + if ":" not in partial_config_setting_name: + del partial_yaml_settings_tree[partial_config_setting_name] + return partial_yaml_settings_tree + + key: str + remainder: str + key, _, remainder = partial_config_setting_name.partition(":") + + if not partial_yaml_settings_tree[key].is_mapping(): + EXPECTED_MAPPING_IN_YAML_MESSAGE: Final[str] = "Found non-mapping." + raise RuntimeError(EXPECTED_MAPPING_IN_YAML_MESSAGE) + + removed_value: YAML = cls._remove_value( + remainder, + partial_yaml_settings_tree[key], + ) + + if not removed_value.data: + del partial_yaml_settings_tree[key] + else: + partial_yaml_settings_tree[key] = removed_value.data + return partial_yaml_settings_tree + + @classmethod + async def _public_remove_single_raw_value(cls, config_setting_name: str) -> None: + # noinspection GrazieInspection + """Unset the value of a single configuration setting within settings tree hierarchy.""" + current_yaml: YAML | None = cls._most_recent_yaml + if current_yaml is None: + YAML_NOT_LOADED_MESSAGE: Final[str] = ( + "Invalid state: Config YAML has not yet been loaded." + ) + raise RuntimeError(YAML_NOT_LOADED_MESSAGE) + + current_yaml = cls._remove_value(config_setting_name, current_yaml) + + await (await utils.get_settings_file_path()).write_text(current_yaml.as_yaml()) diff --git a/config/_settings/_yaml/__init__.py b/config/_settings/_yaml/__init__.py new file mode 100644 index 000000000..f04575e60 --- /dev/null +++ b/config/_settings/_yaml/__init__.py @@ -0,0 +1,236 @@ +from collections.abc import Sequence + +__all__: Sequence[str] = ( + "DiscordWebhookURLValidator", + "LogLevelValidator", + "DiscordSnowflakeValidator", + "BoundedFloatValidator", + "SendIntroductionRemindersFlagValidator", + "SETTINGS_YAML_SCHEMA", + "load_yaml", +) + + +from collections.abc import Mapping +from typing import Final + +import strictyaml +from strictyaml import YAML + +from config.constants import ( + DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL, + DEFAULT_CONSOLE_LOG_LEVEL, + DEFAULT_DISCORD_LOGGING_LOG_LEVEL, + DEFAULT_MEMBERS_LIST_ID_FORMAT, + DEFAULT_MESSAGE_LOCALE_CODE, + DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, + DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, + DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, + DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, + DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, + DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, + DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, + DEFAULT_STATS_COMMAND_DISPLAYED_ROLES, + DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, + DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, + DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, + MESSAGES_LOCALE_CODES, + LogLevels, + SendIntroductionRemindersFlagType, +) + +from .custom_map_validator import SlugKeyMap +from .custom_scalar_validators import ( + BoundedFloatValidator, + CustomBoolValidator, + DiscordSnowflakeValidator, + DiscordWebhookURLValidator, + LogLevelValidator, + RegexMatcher, + SendIntroductionRemindersFlagValidator, + TimeDeltaValidator, +) + +_DEFAULT_CONSOLE_LOGGING_SETTINGS: Final[Mapping[str, LogLevels]] = { + "log-level": DEFAULT_CONSOLE_LOG_LEVEL, +} +_DEFAULT_LOGGING_SETTINGS: Final[Mapping[str, Mapping[str, LogLevels]]] = { + "console": _DEFAULT_CONSOLE_LOGGING_SETTINGS, +} +_DEFAULT_PING_COMMAND_SETTINGS: Final[Mapping[str, float]] = { + "easter-egg-probability": DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY, +} +_DEFAULT_STATS_COMMAND_SETTINGS: Final[Mapping[str, float | Sequence[str]]] = { + "lookback-days": DEFAULT_STATS_COMMAND_LOOKBACK_DAYS, + "displayed-roles": DEFAULT_STATS_COMMAND_DISPLAYED_ROLES, +} +_DEFAULT_STRIKE_COMMAND_SETTINGS: Final[Mapping[str, str]] = { + "timeout-duration": DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, + "performed-manually-warning-location": DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, +} +_DEFAULT_COMMANDS_SETTINGS: Final[Mapping[str, Mapping[str, float] | Mapping[str, float | Sequence[str]] | Mapping[str, str]]] = { # noqa: E501 + "ping": _DEFAULT_PING_COMMAND_SETTINGS, + "stats": _DEFAULT_STATS_COMMAND_SETTINGS, + "strike": _DEFAULT_STRIKE_COMMAND_SETTINGS, +} +_DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS: Final[Mapping[str, SendIntroductionRemindersFlagType | str]] = { # noqa: E501 + "enabled": DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED, + "delay": DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, + "interval": DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, +} +_DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS: Final[Mapping[str, bool | str]] = { + "enabled": DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED, + "delay": DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, + "interval": DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, +} +_DEFAULT_REMINDERS_SETTINGS: Final[Mapping[str, Mapping[str, bool | str] | Mapping[str, SendIntroductionRemindersFlagType | str]]] = { # noqa: E501 + "send-introduction-reminders": _DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS, + "send-get-roles-reminders": _DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS, +} + +SETTINGS_YAML_SCHEMA: Final[strictyaml.Validator] = SlugKeyMap( + { + strictyaml.Optional("logging", default=_DEFAULT_LOGGING_SETTINGS): SlugKeyMap( + { + strictyaml.Optional("console", default=_DEFAULT_CONSOLE_LOGGING_SETTINGS): ( + SlugKeyMap( + { + strictyaml.Optional("log-level", default=DEFAULT_CONSOLE_LOG_LEVEL): ( # noqa: E501 + LogLevelValidator() + ), + }, + ) + ), + strictyaml.Optional("discord-channel"): SlugKeyMap( + { + "webhook-url": DiscordWebhookURLValidator(), + strictyaml.Optional("log-level", default=DEFAULT_DISCORD_LOGGING_LOG_LEVEL): ( # noqa: E501 + LogLevelValidator() + ), + }, + ), + }, + ), + "discord": SlugKeyMap( + { + "bot-token": strictyaml.Regex( + r"\A(?!.*__.*)(?!.*--.*)(?:([A-Za-z0-9]{24,26})\.([A-Za-z0-9]{6})\.([A-Za-z0-9_-]{27,38}))\Z", + ), + "main-guild-id": DiscordSnowflakeValidator(), + }, + ), + "community-group": SlugKeyMap( + { + strictyaml.Optional("full-name"): strictyaml.Regex( + ( + r"\A(?!.*['&!?:,.#%\"-]['&!?:,.#%\"-].*)(?!.* .*)" + r"(?:[A-Za-z0-9 '&!?:,.#%\"-]+)\Z" + ), + ), + strictyaml.Optional("short-name"): strictyaml.Regex( + r"\A(?!.*['&!?:,.#%\"-]['&!?:,.#%\"-].*)(?:[A-Za-z0-9'&!?:,.#%\"-]+)\Z", + ), + "links": SlugKeyMap( + { + strictyaml.Optional("purchase-membership"): strictyaml.Url(), + strictyaml.Optional("membership-perks"): strictyaml.Url(), + "moderation-document": strictyaml.Url(), + }, + ), + "members-list": SlugKeyMap( + { + "url": strictyaml.Url(), + "auth-session-cookie": strictyaml.Str(), + strictyaml.Optional("id-format", default=DEFAULT_MEMBERS_LIST_ID_FORMAT): ( # noqa: E501 + RegexMatcher() + ), + }, + ), + }, + ), + strictyaml.Optional("commands", default=_DEFAULT_COMMANDS_SETTINGS): SlugKeyMap( + { + strictyaml.Optional("ping", default=_DEFAULT_PING_COMMAND_SETTINGS): SlugKeyMap( # noqa: E501 + { + strictyaml.Optional("easter-egg-probability", default=DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY): ( # noqa: E501 + BoundedFloatValidator(0, 1) + ), + }, + ), + strictyaml.Optional("stats", default=_DEFAULT_STATS_COMMAND_SETTINGS): ( + SlugKeyMap( + { + strictyaml.Optional("lookback-days", default=DEFAULT_STATS_COMMAND_LOOKBACK_DAYS): ( # noqa: E501 + BoundedFloatValidator(5, 1826) + ), + strictyaml.Optional("displayed-roles", default=DEFAULT_STATS_COMMAND_DISPLAYED_ROLES): ( # noqa: E501 + strictyaml.UniqueSeq(strictyaml.Str()) + ), + }, + ) + ), + strictyaml.Optional("strike", default=_DEFAULT_STRIKE_COMMAND_SETTINGS): ( + SlugKeyMap( + { + strictyaml.Optional("timeout-duration", default=DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION): ( # noqa: E501 + TimeDeltaValidator( + minutes=True, + hours=True, + days=True, + weeks=True, + ) + ), + strictyaml.Optional("performed-manually-warning-location", default=DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION): ( # noqa: E501 + strictyaml.Str() + ), + }, + ) + ), + }, + ), + strictyaml.Optional("messages-locale-code", default=DEFAULT_MESSAGE_LOCALE_CODE): ( + strictyaml.Enum(MESSAGES_LOCALE_CODES) + ), + strictyaml.Optional("reminders", default=_DEFAULT_REMINDERS_SETTINGS): SlugKeyMap( + { + strictyaml.Optional("send-introduction-reminders", default=_DEFAULT_SEND_INTRODUCTION_REMINDERS_SETTINGS): SlugKeyMap( # noqa: E501 + { + "enabled": SendIntroductionRemindersFlagValidator(), + strictyaml.Optional("delay", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY): ( # noqa: E501 + TimeDeltaValidator(minutes=True, hours=True, days=True, weeks=True) + ), + strictyaml.Optional("interval", default=DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL): ( # noqa: E501 + TimeDeltaValidator(minutes=True, hours=True) + ), + }, + ), + strictyaml.Optional("send-get-roles-reminders", default=_DEFAULT_SEND_GET_ROLES_REMINDERS_SETTINGS): SlugKeyMap( # noqa: E501 + { + "enabled": CustomBoolValidator(), + strictyaml.Optional("delay", default=DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY): ( # noqa: E501 + TimeDeltaValidator(minutes=True, hours=True, days=True, weeks=True) + ), + strictyaml.Optional("interval", default=DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL): ( # noqa: E501 + TimeDeltaValidator(minutes=True, hours=True) + ), + }, + ), + }, + ), + strictyaml.Optional("check-if-config-changed-interval", default=DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL): ( # noqa: E501 + TimeDeltaValidator(minutes=True) + ), + }, +) + + +def load_yaml(raw_yaml: str, file_name: str = "tex-bot-deployment.yaml") -> YAML: + parsed_yaml: YAML = strictyaml.load(raw_yaml, SETTINGS_YAML_SCHEMA, label=file_name) + + # noinspection SpellCheckingInspection + if "guildofstudents" in parsed_yaml["community-group"]["members-list"]["url"]: + parsed_yaml["community-group"]["members-list"]["auth-session-cookie"].revalidate( + strictyaml.Regex(r"\A[A-Fa-f\d]{128,256}\Z"), + ) + + return parsed_yaml diff --git a/config/_settings/_yaml/custom_map_validator.py b/config/_settings/_yaml/custom_map_validator.py new file mode 100644 index 000000000..d6d486a67 --- /dev/null +++ b/config/_settings/_yaml/custom_map_validator.py @@ -0,0 +1,25 @@ +from collections.abc import Sequence + +__all__: Sequence[str] = ("SlugKeyValidator", "SlugKeyMap") + + +from typing import override + +import slugify +import strictyaml +from strictyaml.yamllocation import YAMLChunk + + +class SlugKeyValidator(strictyaml.ScalarValidator): # type: ignore[misc] + @override + def validate_scalar(self, chunk: YAMLChunk) -> str: # type: ignore[misc] + return slugify.slugify(str(chunk.contents)) + + +class SlugKeyMap(strictyaml.Map): # type: ignore[misc] + @override + def __init__(self, validator: dict[object, object], key_validator: strictyaml.Validator | None = None) -> None: # type: ignore[misc] # noqa: E501 + super().__init__( + validator=validator, + key_validator=key_validator if key_validator is not None else SlugKeyValidator(), + ) diff --git a/config/_settings/_yaml/custom_scalar_validators.py b/config/_settings/_yaml/custom_scalar_validators.py new file mode 100644 index 000000000..72477a9e8 --- /dev/null +++ b/config/_settings/_yaml/custom_scalar_validators.py @@ -0,0 +1,353 @@ +from collections.abc import Sequence + +__all__: Sequence[str] = ( + "DiscordWebhookURLValidator", + "LogLevelValidator", + "DiscordSnowflakeValidator", + "RegexMatcher", + "BoundedFloatValidator", + "TimeDeltaValidator", + "SendIntroductionRemindersFlagValidator", + "CustomBoolValidator", +) + + +import functools +import math +import re +from collections.abc import Callable +from datetime import timedelta +from re import Match +from typing import Final, Literal, NoReturn, override + +import strictyaml +from strictyaml import constants as strictyaml_constants +from strictyaml import utils as strictyaml_utils +from strictyaml.exceptions import YAMLSerializationError +from strictyaml.yamllocation import YAMLChunk + +from config.constants import ( + VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES, + LogLevels, + SendIntroductionRemindersFlagType, +) + + +class LogLevelValidator(strictyaml.ScalarValidator): # type: ignore[misc] + @override + def validate_scalar(self, chunk: YAMLChunk) -> LogLevels: # type: ignore[misc] + val: str = str(chunk.contents).upper().strip(" \n\t-_.") + + if val not in LogLevels: + chunk.expecting_but_found( + "when expecting a valid log-level " f"(one of: '{"', '".join(LogLevels)}')", + ) + raise RuntimeError + + return val # type: ignore[return-value] + + # noinspection PyOverrides + @override + def to_yaml(self, data: object) -> str: # type: ignore[misc] + self.should_be_string(data, "expected a valid log-level.") + str_data: str = data.upper().strip(" \n\t-_.") # type: ignore[attr-defined] + + if str_data not in LogLevels: + INVALID_DATA_MESSAGE: Final[str] = ( + f"Got '{data}' when expecting one of: '{"', '".join(LogLevels)}'." + ) + raise YAMLSerializationError(INVALID_DATA_MESSAGE) + + return str_data + + +class DiscordWebhookURLValidator(strictyaml.Url): # type: ignore[misc] + @override + def validate_scalar(self, chunk: YAMLChunk) -> str: # type: ignore[misc] + # noinspection PyUnresolvedReferences + CHUNK_IS_VALID: Final[bool] = bool( + self._Url__is_absolute_url(chunk.contents) + and chunk.contents.startswith("https://discord.com/api/webhooks/") # noqa: COM812 + ) + if not CHUNK_IS_VALID: + chunk.expecting_but_found("when expecting a Discord webhook URL") + raise RuntimeError + + return chunk.contents # type: ignore[no-any-return] + + @override + def to_yaml(self, data: object) -> str: # type: ignore[misc] + self.should_be_string(data, "expected a URL,") + + # noinspection PyUnresolvedReferences + DATA_IS_VALID: Final[bool] = bool( + self._Url__is_absolute_url(data) + and data.startswith("https://discord.com/api/webhooks/") # type: ignore[attr-defined] # noqa: COM812 + ) + if not DATA_IS_VALID: + INVALID_DATA_MESSAGE: Final[str] = f"'{data}' is not a Discord webhook URL." + raise YAMLSerializationError(INVALID_DATA_MESSAGE) + + return data # type: ignore[return-value] + + +class DiscordSnowflakeValidator(strictyaml.Int): # type: ignore[misc] + @override + def validate_scalar(self, chunk: YAMLChunk) -> int: # type: ignore[misc] + val: int = super().validate_scalar(chunk) + + if not re.fullmatch(r"\A\d{17,20}\Z", str(val)): + chunk.expecting_but_found("when expecting a Discord snowflake ID") + raise RuntimeError + + return val + + @override + def to_yaml(self, data: object) -> str: # type: ignore[misc] + DATA_IS_VALID: Final[bool] = bool( + (strictyaml_utils.is_string(data) or isinstance(data, int)) + and strictyaml_utils.is_integer(str(data)) + and re.fullmatch(r"\A\d{17,20}\Z", str(data)) # noqa: COM812 + ) + if not DATA_IS_VALID: + INVALID_DATA_MESSAGE: Final[str] = f"'{data}' is not a Discord snowflake ID." + raise YAMLSerializationError(INVALID_DATA_MESSAGE) + + return str(data) + + +class RegexMatcher(strictyaml.ScalarValidator): # type: ignore[misc] + MATCHING_MESSAGE: str = "when expecting a regular expression matcher" + + @override + def validate_scalar(self, chunk: YAMLChunk) -> str: # type: ignore[misc] + try: + re.compile(chunk.contents) + except re.error: + chunk.expecting_but_found( + self.MATCHING_MESSAGE, + "found arbitrary string", + ) + + return chunk.contents # type: ignore[no-any-return] + + # noinspection PyOverrides + @override + def to_yaml(self, data: object) -> str: # type: ignore[misc] + self.should_be_string(data, self.MATCHING_MESSAGE) + + regex_error: re.error + try: + re.compile(data) # type: ignore[call-overload] + except re.error as regex_error: + INVALID_DATA_MESSAGE: Final[str] = f"{self.MATCHING_MESSAGE} found '{data}'" + raise YAMLSerializationError(INVALID_DATA_MESSAGE) from regex_error + + return data # type: ignore[return-value] + + +class BoundedFloatValidator(strictyaml.Float): # type: ignore[misc] + @override + def __init__(self, inclusive_minimum: float, inclusive_maximum: float) -> None: + self.inclusive_minimum: float = inclusive_minimum + self.inclusive_maximum: float = inclusive_maximum + + super().__init__() + + @override + def validate_scalar(self, chunk: YAMLChunk) -> float: # type: ignore[misc] + val: float = super().validate_scalar(chunk) + + if not self.inclusive_minimum <= val <= self.inclusive_maximum: + chunk.expecting_but_found( + ( + "when expecting a float " + f"between {self.inclusive_minimum} & {self.inclusive_maximum}" + ), + ) + raise RuntimeError + + return val + + @override + def to_yaml(self, data: object) -> str: # type: ignore[misc] + YAML_SERIALIZATION_ERROR: Final[YAMLSerializationError] = YAMLSerializationError( + ( + f"'{data}' is not a float " + f"between {self.inclusive_minimum} & {self.inclusive_maximum}." + ), + ) + + if strictyaml_utils.is_string(data) and strictyaml_utils.is_decimal(data): + data = float(str(data)) + + if not strictyaml_utils.has_number_type(data): + raise YAML_SERIALIZATION_ERROR + + if not self.inclusive_minimum <= data <= self.inclusive_maximum: # type: ignore[operator] + raise YAML_SERIALIZATION_ERROR + + if math.isnan(data): # type: ignore[arg-type] + return "nan" + if data == float("inf"): + return "inf" + if data == float("-inf"): + return "-inf" + + return str(data) + + +class TimeDeltaValidator(strictyaml.ScalarValidator): # type: ignore[misc] + @override + def __init__(self, *, seconds: Literal[True] = True, minutes: bool = True, hours: bool = True, days: bool = False, weeks: bool = False) -> None: # noqa: E501 + regex_matcher: str = r"\A" + + time_resolution_name: str + for time_resolution_name in ("seconds", "minutes", "hours", "days", "weeks"): + formatted_time_resolution_name: str = time_resolution_name.lower().strip() + time_resolution: object = locals()[formatted_time_resolution_name] + + if not isinstance(time_resolution, bool): + raise TypeError + + if not time_resolution: + continue + + regex_matcher += ( + r"(?:(?P<" + + formatted_time_resolution_name + + r">(?:\d*\.)?\d+)" + + formatted_time_resolution_name[0] + + ")?" + ) + + regex_matcher += r"\Z" + + self.regex_matcher: re.Pattern[str] = re.compile(regex_matcher) + + def _get_value_from_match(self, match: Match[str], key: str) -> float: + if key not in self.regex_matcher.groupindex: + return 0.0 + + value: str | None = match.group(key) + + if not value: + return 0.0 + + float_conversion_error: ValueError + try: + return float(value) + except ValueError as float_conversion_error: + raise float_conversion_error from float_conversion_error + + @override + def validate_scalar(self, chunk: YAMLChunk) -> timedelta: # type: ignore[misc] + chunk_error_func: Callable[[], NoReturn] = functools.partial( + chunk.expecting_but_found, + expecting="when expecting a delay/interval string", + found="found non-matching string", + ) + + match: Match[str] | None = self.regex_matcher.fullmatch(chunk.contents) + if match is None: + chunk_error_func() + + try: + return timedelta( + seconds=self._get_value_from_match(match, "seconds"), + minutes=self._get_value_from_match(match, "minutes"), + hours=self._get_value_from_match(match, "hours"), + days=self._get_value_from_match(match, "days"), + weeks=self._get_value_from_match(match, "weeks"), + ) + except ValueError: + chunk_error_func() + + # noinspection PyOverrides + @override + def to_yaml(self, data: object) -> str: # type: ignore[misc] + if strictyaml_utils.is_string(data): + match: Match[str] | None = self.regex_matcher.fullmatch(str(data)) + if match is None: + INVALID_STRING_DATA_MESSAGE: Final[str] = ( + f"when expecting a delay/interval string found {str(data)!r}." + ) + raise YAMLSerializationError(INVALID_STRING_DATA_MESSAGE) + return str(data) + + if not hasattr(data, "total_seconds") or not callable(getattr(data, "total_seconds")): # noqa: B009 + INVALID_TIMEDELTA_DATA_MESSAGE: Final[str] = ( + f"when expecting a time delta object found {str(data)!r}." + ) + raise YAMLSerializationError(INVALID_TIMEDELTA_DATA_MESSAGE) + + total_seconds: object = getattr(data, "total_seconds")() # noqa: B009 + if not isinstance(total_seconds, float): + raise TypeError + + if (total_seconds / 3600) % 1 == 0: + return f"{int(total_seconds / 3600)}h" + + if total_seconds % 1 == 0: + return f"{int(total_seconds)}s" + + return f"{total_seconds}s" + + +class SendIntroductionRemindersFlagValidator(strictyaml.ScalarValidator): # type: ignore[misc] + @override + def validate_scalar(self, chunk: YAMLChunk) -> SendIntroductionRemindersFlagType: # type: ignore[misc] + val: str = str(chunk.contents).lower() + + if val not in VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: + chunk.expecting_but_found( + ( + "when expecting a send-introduction-reminders-flag " + "(one of: 'once', 'interval' or 'false')" + ), + ) + raise RuntimeError + + if val in strictyaml_constants.TRUE_VALUES: + return "once" + + if val not in ("once", "interval"): + return False + + return val # type: ignore[return-value] + + # noinspection PyOverrides + @override + def to_yaml(self, data: object) -> str: # type: ignore[misc] + if isinstance(data, bool): + return "once" if data else "false" + + if str(data).lower() not in VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: + INVALID_DATA_MESSAGE: Final[str] = ( + f"Got '{data}' when expecting one of: 'once', 'interval' or 'false'." + ) + raise YAMLSerializationError(INVALID_DATA_MESSAGE) + + if str(data).lower() in strictyaml_constants.TRUE_VALUES: + return "once" + + if str(data).lower() in strictyaml_constants.FALSE_VALUES: + return "false" + + return str(data).lower() + + +class CustomBoolValidator(strictyaml.Bool): # type: ignore[misc] + @override + def to_yaml(self, data: object) -> str: # type: ignore[misc] + if isinstance(data, bool): + return "true" if data else "false" + + if str(data).lower() in strictyaml_constants.TRUE_VALUES: + return "true" + + if str(data).lower() in strictyaml_constants.FALSE_VALUES: + return "false" + + INVALID_TYPE_MESSAGE: Final[str] = "Not a boolean" + raise YAMLSerializationError(INVALID_TYPE_MESSAGE) diff --git a/config/_settings/utils.py b/config/_settings/utils.py new file mode 100644 index 000000000..427227e01 --- /dev/null +++ b/config/_settings/utils.py @@ -0,0 +1,74 @@ +from collections.abc import Sequence + +__all__: Sequence[str] = ("is_running_in_async", "get_settings_file_path") + + +import asyncio +import logging +import os +from logging import Logger +from typing import Final + +from aiopath import AsyncPath + +from config.constants import PROJECT_ROOT + +logger: Final[Logger] = logging.getLogger("TeX-Bot") + + +async def get_settings_file_path() -> AsyncPath: + settings_file_not_found_message: str = ( + "No settings file was found. " + "Please make sure you have created a `tex-bot-deployment.yaml` file." + ) + + raw_settings_file_path: str | None = ( + os.getenv("TEX_BOT_SETTINGS_FILE_PATH", None) + or os.getenv("TEX_BOT_SETTINGS_FILE", None) + or os.getenv("TEX_BOT_SETTINGS_PATH", None) + or os.getenv("TEX_BOT_SETTINGS", None) + or os.getenv("TEX_BOT_CONFIG_FILE_PATH", None) + or os.getenv("TEX_BOT_CONFIG_FILE", None) + or os.getenv("TEX_BOT_CONFIG_PATH", None) + or os.getenv("TEX_BOT_CONFIG", None) + or os.getenv("TEX_BOT_DEPLOYMENT_FILE_PATH", None) + or os.getenv("TEX_BOT_DEPLOYMENT_FILE", None) + or os.getenv("TEX_BOT_DEPLOYMENT_PATH", None) + or os.getenv("TEX_BOT_DEPLOYMENT", None) + ) + + if raw_settings_file_path: + settings_file_not_found_message = ( + "A path to the settings file location was provided by environment variable, " + "however this path does not refer to an existing file." + ) + else: + logger.debug( + ( + "Settings file location not supplied by environment variable, " + "falling back to `tex-bot-deployment.yaml`." + ), + ) + raw_settings_file_path = "tex-bot-deployment.yaml" + if not await (AsyncPath(PROJECT_ROOT) / raw_settings_file_path).exists(): + raw_settings_file_path = "tex-bot-settings.yaml" + + if not await (AsyncPath(PROJECT_ROOT) / raw_settings_file_path).exists(): + raw_settings_file_path = "tex-bot-config.yaml" + + settings_file_path: AsyncPath = AsyncPath(raw_settings_file_path) + + if not await settings_file_path.is_file(): + raise FileNotFoundError(settings_file_not_found_message) + + return settings_file_path + + +def is_running_in_async() -> bool: + """Determine whether the current context is asynchronous or not.""" + try: + asyncio.get_running_loop() + except RuntimeError: + return False + else: + return True diff --git a/config/constants.py b/config/constants.py new file mode 100644 index 000000000..014a8edeb --- /dev/null +++ b/config/constants.py @@ -0,0 +1,484 @@ +"""Constant values that are defined for quick access.""" + +from collections.abc import Sequence + +__all__: Sequence[str] = ( + "SendIntroductionRemindersFlagType", + "LogLevels", + "ConfigSettingHelp", + "PROJECT_ROOT", + "VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES", + "MESSAGES_LOCALE_CODES", + "DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME", + "DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY", + "DEFAULT_DISCORD_LOGGING_LOG_LEVEL", + "DEFAULT_CONSOLE_LOG_LEVEL", + "DEFAULT_MEMBERS_LIST_ID_FORMAT", + "DEFAULT_STATS_COMMAND_LOOKBACK_DAYS", + "DEFAULT_STATS_COMMAND_DISPLAYED_ROLES", + "DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION", + "DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION", + "DEFAULT_MESSAGE_LOCALE_CODE", + "DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED", + "DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY", + "DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL", + "DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED", + "DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY", + "DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL", + "DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL", + "CONFIG_SETTINGS_HELPS", +) + + +from collections.abc import Iterable, Mapping +from enum import Enum, EnumMeta +from pathlib import Path +from typing import Final, Literal, NamedTuple, TypeAlias + +from strictyaml import constants as strictyaml_constants + +SendIntroductionRemindersFlagType: TypeAlias = Literal["once", "interval", False] + + +class MetaEnum(EnumMeta): + def __contains__(cls, item: object) -> bool: # noqa: N805 + try: + cls(item) + except ValueError: + return False + return True + + +class LogLevels(str, Enum, metaclass=MetaEnum): + """Set of valid string values used for logging log-levels.""" + + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class ConfigSettingHelp(NamedTuple): + """Container to hold help information about a single configuration setting.""" + + description: str + value_type_message: str | None + requires_restart_after_changed: bool + required: bool = True + default: str | None = None + + +def _selectable_required_format_message(options: Iterable[str]) -> str: + return f"Must be one of: `{"`, `".join(options)}`." + + +def _custom_required_format_message(type_value: str, info_link: str | None = None) -> str: + return f"Must be a valid { + type_value.lower().replace("discord", "Discord").replace( + "id", + "ID", + ).replace("url", "URL").replace("dm", "DM").strip(".") + }{f" (see <{info_link}>)" if info_link else ""}." + + +PROJECT_ROOT: Final[Path] = Path(__file__).parent.parent.resolve() + +MESSAGES_LOCALE_CODES: Final[frozenset[str]] = frozenset({"en-GB"}) + + +VALID_SEND_INTRODUCTION_REMINDERS_RAW_VALUES: Final[frozenset[str]] = frozenset( + ({"once", "interval"} | set(strictyaml_constants.BOOL_VALUES)), +) + +DEFAULT_DISCORD_LOGGING_HANDLER_DISPLAY_NAME: Final[str] = "TeX-Bot" + + +DEFAULT_CONSOLE_LOG_LEVEL: Final[LogLevels] = LogLevels.INFO +DEFAULT_DISCORD_LOGGING_LOG_LEVEL: Final[LogLevels] = LogLevels.WARNING +DEFAULT_MEMBERS_LIST_ID_FORMAT: Final[str] = r"\A\d{6,7}\Z" +DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY: Final[float] = 0.01 +DEFAULT_STATS_COMMAND_LOOKBACK_DAYS: Final[float] = 30.0 +DEFAULT_STATS_COMMAND_DISPLAYED_ROLES: Final[Sequence[str]] = [ + "Committee", + "Committee-Elect", + "Student Rep", + "Member", + "Guest", + "Server Booster", + "Foundation Year", + "First Year", + "Second Year", + "Final Year", + "Year In Industry", + "Year Abroad", + "PGT", + "PGR", + "Alumnus/Alumna", + "Postdoc", + "Quiz Victor", +] +DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION: Final[str] = "24h" +DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION: Final[str] = "DM" +DEFAULT_MESSAGE_LOCALE_CODE: Final[str] = "en-GB" +DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED: Final[SendIntroductionRemindersFlagType] = "once" +DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY: Final[str] = "40h" +DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL: Final[str] = "6h" +DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED: Final[bool] = True +DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY: Final[str] = "40h" +DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL: Final[str] = "6h" +DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL: Final[str] = "30s" + +CONFIG_SETTINGS_HELPS: Mapping[str, ConfigSettingHelp] = { + "logging:console:log-level": ConfigSettingHelp( + description=( + "The minimum level that logs must meet in order to be logged " + "to the console output stream." + ), + value_type_message=_selectable_required_format_message(LogLevels), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_CONSOLE_LOG_LEVEL, + ), + "logging:discord-channel:log-level": ConfigSettingHelp( + description=( + "The minimum level that logs must meet in order to be logged " + "to the Discord log channel." + ), + value_type_message=_selectable_required_format_message(LogLevels), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_DISCORD_LOGGING_LOG_LEVEL, + ), + "logging:discord-channel:webhook-url": ConfigSettingHelp( + description=( + "The webhook URL of the Discord text channel where error logs should be sent.\n" + "Error logs will always be sent to the console, " + "this setting allows them to also be sent to a Discord log channel." + ), + value_type_message=_custom_required_format_message( + "Discord webhook URL", + "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks", + ), + requires_restart_after_changed=False, + required=False, + default=None, + ), + "discord:bot-token": ConfigSettingHelp( + description=( + "The Discord token for the bot you created " + "(available on your bot page in the developer portal: )." + ), + value_type_message=_custom_required_format_message( + "Discord bot token", + "https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts", + ), + requires_restart_after_changed=True, + required=True, + default=None, + ), + "discord:main-guild-id": ConfigSettingHelp( + description="The ID of your community group's main Discord guild.", + value_type_message=_custom_required_format_message( + "Discord guild ID", + "https://docs.pycord.dev/en/stable/api/abcs.html#discord.abc.Snowflake.id", + ), + requires_restart_after_changed=True, + required=True, + default=None, + ), + "community-group:full-name": ConfigSettingHelp( + description=( + "The full name of your community group, do **NOT** use an abbreviation.\n" + "This is substituted into many error/welcome messages " + "sent into your Discord guild, by **`@TeX-Bot`**.\n" + "If this is not set the group-full-name will be retrieved " + "from the name of your group's Discord guild." + ), + requires_restart_after_changed=False, + value_type_message=None, + required=False, + default=None, + ), + "community-group:short-name": ConfigSettingHelp( + description=( + "The short colloquial name of your community group, " + "it is recommended that you set this to be an abbreviation of your group's name.\n" + "If this is not set the group-short-name will be determined " + "from your group's full name." + ), + requires_restart_after_changed=False, + value_type_message=None, + required=False, + default=None, + ), + "community-group:links:purchase-membership": ConfigSettingHelp( + description=( + "The link to the page where guests can purchase a full membership " + "to join your community group." + ), + requires_restart_after_changed=False, + value_type_message=_custom_required_format_message("URL"), + required=False, + default=None, + ), + "community-group:links:membership-perks": ConfigSettingHelp( + description=( + "The link to the page where guests can find out information " + "about the perks that they will receive " + "once they purchase a membership to your community group." + ), + requires_restart_after_changed=False, + value_type_message=_custom_required_format_message("URL"), + required=False, + default=None, + ), + "community-group:links:moderation-document": ConfigSettingHelp( + description="The link to your group's Discord guild moderation document.", + value_type_message=_custom_required_format_message("URL"), + requires_restart_after_changed=False, + required=True, + default=None, + ), + "community-group:members-list:url": ConfigSettingHelp( + description=( + "The URL to retrieve the list of IDs of people that have purchased a membership " + "to your community group.\n" + "Ensure that all members are visible without pagination, " + "(for example, " + "if your members-list is found on the UoB Guild of Students website, " + "ensure the URL includes the \"sort by groups\" option)." + ), + requires_restart_after_changed=False, + value_type_message=_custom_required_format_message("URL"), + required=True, + default=None, + ), + "community-group:members-list:auth-session-cookie": ConfigSettingHelp( + description=( + "The members-list authentication session cookie.\n" + "If your group's members-list is stored at a URL that requires authentication, " + "this session cookie should authenticate **`@TeX-Bot`** " + "to view your group's members-list, " + "as if it were logged in to the website as a Committee member.\n" + "If your members-list is found on the UoB Guild of Students website, " + "this can be extracted from your web-browser: " + "after manually logging in to view your members-list, " + "it will probably be listed as a cookie named `.ASPXAUTH`." + ), + requires_restart_after_changed=False, + value_type_message=None, + required=True, + default=None, + ), + "community-group:members-list:id-format": ConfigSettingHelp( + description=( + "The format that IDs are stored in within your members-list.\n" + "Remember to double escape `\\` characters where necessary." + ), + value_type_message=_custom_required_format_message( + "regex matcher string", + ), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_MEMBERS_LIST_ID_FORMAT, + ), + "commands:ping:easter-egg-probability": ConfigSettingHelp( + description=( + "The probability that the more rare ping command response will be sent " + "instead of the normal one." + ), + value_type_message=_custom_required_format_message( + "float, inclusively between 1 & 0", + ), + requires_restart_after_changed=False, + required=False, + default=str(DEFAULT_PING_COMMAND_EASTER_EGG_PROBABILITY), + ), + "commands:stats:lookback-days": ConfigSettingHelp( + description=( + "The number of days to look over messages sent, to generate statistics data." + ), + value_type_message=_custom_required_format_message( + "float representing the number of days to look back through", + ), + requires_restart_after_changed=False, + required=False, + default=str(DEFAULT_STATS_COMMAND_LOOKBACK_DAYS), + ), + "commands:stats:displayed-roles": ConfigSettingHelp( + description=( + "The names of the roles to gather statistics about, " + "to display in bar chart graphs." + ), + value_type_message=_custom_required_format_message( + "comma seperated list of strings of role names", + ), + requires_restart_after_changed=False, + required=False, + default=",".join(DEFAULT_STATS_COMMAND_DISPLAYED_ROLES), + ), + "commands:strike:timeout-duration": ConfigSettingHelp( + description=( + "The amount of time to timeout a user when using the **`/strike`** command." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, hours, days or weeks " + "to timeout a user (format: `smhdw`)" + ), + ), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_STRIKE_COMMAND_TIMEOUT_DURATION, + ), + "commands:strike:performed-manually-warning-location": ConfigSettingHelp( + description=( + "The name of the channel, that warning messages will be sent to " + "when a committee-member manually applies a moderation action " + "(instead of using the `/strike` command).\n" + "This can be the name of **ANY** Discord channel " + "(so the offending person *will* be able to see these messages " + "if a public channel is chosen)." + ), + value_type_message=_custom_required_format_message( + ( + "name of a Discord channel in your group's Discord guild, " + "or the value `DM` " + "(which indicates that the messages will be sent " + "in the committee-member's DMs)" + ), + ), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION, + ), + "messages-locale-code": ConfigSettingHelp( + description=( + "The locale code used to select the language response messages will be given in." + ), + value_type_message=_selectable_required_format_message( + MESSAGES_LOCALE_CODES, + ), + requires_restart_after_changed=False, + required=False, + default=DEFAULT_MESSAGE_LOCALE_CODE, + ), + "reminders:send-introduction-reminders:enabled": ConfigSettingHelp( + description=( + "Whether introduction reminders will be sent to Discord members " + "that are not inducted, " + "saying that they need to send an introduction to be allowed access." + ), + value_type_message=_selectable_required_format_message( + ( + str(flag_value).lower() + for flag_value in getattr(SendIntroductionRemindersFlagType, "__args__") # noqa: B009 + ), + ), + requires_restart_after_changed=True, + required=False, + default=str(DEFAULT_SEND_INTRODUCTION_REMINDERS_ENABLED).lower(), + ), + "reminders:send-introduction-reminders:delay": ConfigSettingHelp( + description=( + "How long to wait after a user joins your guild " + "before sending them the first/only message " + "to remind them to send an introduction.\n" + "Is ignored if `reminders:send-introduction-reminders:enabled` **=** `false`.\n" + "The delay must be longer than or equal to 1 day (in any allowed format)." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, hours, days or weeks " + "before the first/only reminder is sent " + "(format: `smhdw`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_SEND_INTRODUCTION_REMINDERS_DELAY, + ), + "reminders:send-introduction-reminders:interval": ConfigSettingHelp( + description=( + "The interval of time between sending out reminders " + "to Discord members that are not inducted, " + "saying that they need to send an introduction to be allowed access.\n" + "Is ignored if `reminders:send-introduction-reminders:enabled` **=** `false`." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, or hours between reminders " + "(format: `smh`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_SEND_INTRODUCTION_REMINDERS_INTERVAL, + ), + "reminders:send-get-roles-reminders:enabled": ConfigSettingHelp( + description=( + "Whether reminders will be sent to Discord members that have been inducted, " + "saying that they can get opt-in roles. " + "(This message will be only sent once per Discord member)." + ), + value_type_message=_custom_required_format_message( + "boolean value (either `true` or `false`)", + ), + requires_restart_after_changed=True, + required=False, + default=str(DEFAULT_SEND_GET_ROLES_REMINDERS_ENABLED).lower(), + ), + "reminders:send-get-roles-reminders:delay": ConfigSettingHelp( + description=( + "How long to wait after a user is inducted " + "before sending them the message to get some opt-in roles.\n" + "Is ignored if `reminders:send-get-roles-reminders:enabled` **=** `false`.\n" + "The delay must be longer than or equal to 1 day (in any allowed format)." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, hours, days or weeks " + "before the first/only reminder is sent " + "(format: `smhdw`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_SEND_GET_ROLES_REMINDERS_DELAY, + ), + "reminders:send-get-roles-reminders:interval": ConfigSettingHelp( + description=( + "The interval of time between sending out reminders " + "to Discord members that have been inducted, " + "saying that they can get opt-in roles. " + "(This message will be only sent once, " + "the interval is just how often to check for new guests).\n" + "Is ignored if `reminders:send-get-roles-reminders:enabled` **=** `false`." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds, minutes, or hours between reminders " + "(format: `smh`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_SEND_GET_ROLES_REMINDERS_INTERVAL, + ), + "check-if-config-changed-interval": ConfigSettingHelp( + description=( + "The interval of time between checking whether the config values, " + "defined in the settings file, have changed." + ), + value_type_message=_custom_required_format_message( + ( + "string of the seconds or minutes between checks " + "(format: `sm`)" + ), + ), + requires_restart_after_changed=True, + required=False, + default=DEFAULT_CHECK_IF_CONFIG_CHANGED_INTERVAL, + ), +} diff --git a/db/_settings.py b/db/_settings.py index 3eadc4df7..4ece05e3f 100644 --- a/db/_settings.py +++ b/db/_settings.py @@ -19,18 +19,20 @@ # NOTE: settings.py is called when setting up the mypy_django_plugin & when running Pytest. When mypy/Pytest runs no config settings variables are set, so they should not be accessed IMPORTED_BY_MYPY_OR_PYTEST: Final[bool] = any( "mypy_django_plugin" in frame.filename or "pytest" in frame.filename - for frame - in inspect.stack()[1:] + for frame in inspect.stack()[1:] if not frame.filename.startswith("<") ) if IMPORTED_BY_MYPY_OR_PYTEST: SECRET_KEY = "unsecure-secret-key" # noqa: S105 + LANGUAGE_CODE = "en-gb" else: from config import settings # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = settings.DISCORD_BOT_TOKEN + LANGUAGE_CODE = settings.MESSAGES_LOCALE_CODE + # Application definition @@ -53,7 +55,6 @@ # Internationalization # https://docs.djangoproject.com/en/stable/topics/i18n/ -LANGUAGE_CODE = "en-gb" TIME_ZONE = "Europe/London" diff --git a/db/core/models/__init__.py b/db/core/models/__init__.py index bf57415d4..8ec207f7c 100644 --- a/db/core/models/__init__.py +++ b/db/core/models/__init__.py @@ -226,9 +226,7 @@ class DiscordReminder(BaseDiscordMemberWrapper): _channel_type = models.IntegerField( "Discord Channel Type of the channel that the reminder needs to be sent in", choices=[ - (channel_type.value, channel_type.name) - for channel_type - in discord.ChannelType + (channel_type.value, channel_type.name) for channel_type in discord.ChannelType ], null=True, blank=True, diff --git a/db/core/models/managers.py b/db/core/models/managers.py index eb0275bd4..9313a4193 100644 --- a/db/core/models/managers.py +++ b/db/core/models/managers.py @@ -22,10 +22,7 @@ T_model = TypeVar("T_model", bound=AsyncBaseModel) -Defaults: TypeAlias = ( - MutableMapping[str, object | Callable[[], object]] - | None -) +Defaults: TypeAlias = MutableMapping[str, object | Callable[[], object]] | None logger: Final[Logger] = logging.getLogger("TeX-Bot") diff --git a/db/core/models/utils.py b/db/core/models/utils.py index 1645756d5..7d2acf290 100644 --- a/db/core/models/utils.py +++ b/db/core/models/utils.py @@ -41,8 +41,7 @@ def save(self, *, force_insert: bool = False, force_update: bool = False, using: def __init__(self, *args: object, **kwargs: object) -> None: proxy_fields: dict[str, object] = { field_name: kwargs.pop(field_name) - for field_name - in set(kwargs.keys()) & self.get_proxy_field_names() + for field_name in set(kwargs.keys()) & self.get_proxy_field_names() } super().__init__(*args, **kwargs) diff --git a/exceptions/__init__.py b/exceptions/__init__.py index 8f2e2446b..d90214e64 100644 --- a/exceptions/__init__.py +++ b/exceptions/__init__.py @@ -19,18 +19,18 @@ "EveryoneRoleCouldNotBeRetrievedError", "StrikeTrackingError", "NoAuditLogsStrikeTrackingError", - "MessagesJSONFileMissingKeyError", - "MessagesJSONFileValueError", - "InvalidMessagesJSONFileError", - "ImproperlyConfiguredError", "RestartRequiredDueToConfigChange", + "ChangingSettingWithRequiredSiblingError", + "ErrorCodeCouldNotBeIdentifiedError", + "UnknownDjangoError", ) from .config_changes import ( - ImproperlyConfiguredError, + ChangingSettingWithRequiredSiblingError, RestartRequiredDueToConfigChange, ) +from .custom_django import UnknownDjangoError from .does_not_exist import ( ApplicantRoleDoesNotExistError, ArchivistRoleDoesNotExistError, @@ -45,13 +45,9 @@ RolesChannelDoesNotExistError, RulesChannelDoesNotExistError, ) +from .error_message_generation import ErrorCodeCouldNotBeIdentifiedError from .guild import ( DiscordMemberNotInMainGuildError, EveryoneRoleCouldNotBeRetrievedError, ) -from .messages import ( - InvalidMessagesJSONFileError, - MessagesJSONFileMissingKeyError, - MessagesJSONFileValueError, -) from .strike import NoAuditLogsStrikeTrackingError, StrikeTrackingError diff --git a/exceptions/config_changes.py b/exceptions/config_changes.py index d1d5914b1..5de0eb7a1 100644 --- a/exceptions/config_changes.py +++ b/exceptions/config_changes.py @@ -3,8 +3,8 @@ from collections.abc import Sequence __all__: Sequence[str] = ( - "ImproperlyConfiguredError", "RestartRequiredDueToConfigChange", + "ChangingSettingWithRequiredSiblingError", ) @@ -16,16 +16,6 @@ from .base import BaseTeXBotError -class ImproperlyConfiguredError(BaseTeXBotError, Exception): - """Exception class to raise when environment variables are not correctly provided.""" - - # noinspection PyMethodParameters,PyPep8Naming - @classproperty - @override - def DEFAULT_MESSAGE(cls) -> str: # noqa: N805 - return "One or more provided environment variable values are invalid." - - class RestartRequiredDueToConfigChange(BaseTeXBotError, Exception): """Exception class to raise when a restart is required to apply config changes.""" @@ -43,3 +33,31 @@ def __init__(self, message: str | None = None, changed_settings: Set[str] | None ) super().__init__(message) + + +class ChangingSettingWithRequiredSiblingError(BaseTeXBotError, ValueError): + """Exception class for when a setting cannot be changed because of required siblings.""" + + # noinspection PyMethodParameters,PyPep8Naming + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: # noqa: N805 + """The message to be displayed alongside this exception class if none is provided.""" # noqa: D401 + return ( + "The given setting cannot be changed " + "because it has one or more required sibling settings that must be set first." + ) + + @override + def __init__(self, message: str | None = None, config_setting_name: str | None = None) -> None: # noqa: E501 + self.config_setting_name: str | None = config_setting_name + + super().__init__( + message + or ( + f"Cannot assign value to config setting '{config_setting_name}' " + f"because it has one or more required sibling settings that must be set first." + if config_setting_name + else message + ) # noqa: COM812 + ) diff --git a/exceptions/custom_django.py b/exceptions/custom_django.py new file mode 100644 index 000000000..da3b90c9e --- /dev/null +++ b/exceptions/custom_django.py @@ -0,0 +1,22 @@ +"""Custom exception classes related to Django processes.""" + +from collections.abc import Sequence + +__all__: Sequence[str] = ("UnknownDjangoError",) + + +from typing import override + +from classproperties import classproperty + +from .base import BaseTeXBotError + + +class UnknownDjangoError(BaseTeXBotError, RuntimeError): + """Exception class to raise when an unknown Django error occurs.""" + + # noinspection PyMethodParameters,PyPep8Naming + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: # noqa: N805 + return "An unknown Django error occurred." diff --git a/exceptions/does_not_exist.py b/exceptions/does_not_exist.py index afa764512..6f215887a 100644 --- a/exceptions/does_not_exist.py +++ b/exceptions/does_not_exist.py @@ -255,13 +255,13 @@ def ERROR_CODE(cls) -> str: # noqa: N805 # noinspection PyMethodParameters @classproperty @override - def DEPENDENT_COMMANDS(cls) -> frozenset[str]: # noqa: N805 + def DEPENDENT_COMMANDS(cls) -> frozenset[str]: # noqa: N805 return frozenset({"make_applicant"}) # noinspection PyMethodParameters @classproperty @override - def ROLE_NAME(cls) -> str: # noqa: N805 + def ROLE_NAME(cls) -> str: # noqa: N805 return "Applicant" diff --git a/exceptions/error_message_generation.py b/exceptions/error_message_generation.py new file mode 100644 index 000000000..64812e030 --- /dev/null +++ b/exceptions/error_message_generation.py @@ -0,0 +1,29 @@ +"""Custom exception classes related to generating error messages to send to the user.""" + +from collections.abc import Sequence + +__all__: Sequence[str] = ("ErrorCodeCouldNotBeIdentifiedError",) + + +from typing import override + +from classproperties import classproperty + +from .base import BaseTeXBotError + + +class ErrorCodeCouldNotBeIdentifiedError(BaseTeXBotError, Exception): + """Exception class to raise when the error code could not be identified from an error.""" + + # noinspection PyMethodParameters,PyPep8Naming + @classproperty + @override + def DEFAULT_MESSAGE(cls) -> str: # noqa: N805 + return "The error code could not be retrieved from the given error." + + @override + def __init__(self, message: str | None = None, other_error: Exception | type[Exception] | None = None) -> None: # noqa: E501 + """Initialize an exception for a non-existent error code.""" + self.other_error: Exception | type[Exception] | None = other_error + + super().__init__(message) diff --git a/exceptions/messages.py b/exceptions/messages.py deleted file mode 100644 index 049f55d9b..000000000 --- a/exceptions/messages.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Custom exception classes raised when errors occur with retrieving messages from the file.""" - -from collections.abc import Sequence - -__all__: Sequence[str] = ( - "InvalidMessagesJSONFileError", - "MessagesJSONFileMissingKeyError", - "MessagesJSONFileValueError", -) - - -from typing import override - -from classproperties import classproperty - -from .config_changes import ImproperlyConfiguredError - - -class InvalidMessagesJSONFileError(ImproperlyConfiguredError): - """Exception class to raise when the messages.json file has an invalid structure.""" - - # noinspection PyMethodParameters,PyPep8Naming - @classproperty - @override - def DEFAULT_MESSAGE(cls) -> str: # noqa: N805 - return "The messages JSON file has an invalid structure at the given key." - - @override - def __init__(self, message: str | None = None, dict_key: str | None = None) -> None: - """Initialise an ImproperlyConfigured exception for an invalid messages.json file.""" - self.dict_key: str | None = dict_key - - super().__init__(message) - - -class MessagesJSONFileMissingKeyError(InvalidMessagesJSONFileError): - """Exception class to raise when a key in the messages.json file is missing.""" - - # noinspection PyMethodParameters,PyPep8Naming - @classproperty - @override - def DEFAULT_MESSAGE(cls) -> str: # noqa: N805 - return "The messages JSON file is missing a required key." - - @override - def __init__(self, message: str | None = None, missing_key: str | None = None) -> None: - """Initialise a new InvalidMessagesJSONFile exception for a missing key.""" - super().__init__(message, dict_key=missing_key) - - @property - def missing_key(self) -> str | None: - """The key that was missing from the messages.json file.""" - return self.dict_key - - @missing_key.setter - def missing_key(self, value: str | None) -> None: - self.dict_key = value - - -class MessagesJSONFileValueError(InvalidMessagesJSONFileError): - """Exception class to raise when a key in the messages.json file has an invalid value.""" - - # noinspection PyMethodParameters,PyPep8Naming - @classproperty - @override - def DEFAULT_MESSAGE(cls) -> str: # noqa: N805 - return "The messages JSON file has an invalid value." - - @override - def __init__(self, message: str | None = None, dict_key: str | None = None, invalid_value: object | None = None) -> None: # noqa: E501 - """Initialise a new InvalidMessagesJSONFile exception for a key's invalid value.""" - self.invalid_value: object | None = invalid_value - - super().__init__(message, dict_key) diff --git a/main.py b/main.py index c321572d9..0a13230e4 100644 --- a/main.py +++ b/main.py @@ -18,14 +18,13 @@ import config from config import settings -from utils import SuppressTraceback, TeXBot +from utils import SuppressTraceback, TeXBot, TeXBotExitReason with SuppressTraceback(): config.run_setup() intents: discord.Intents = discord.Intents.default() - # noinspection PyDunderSlots,PyUnresolvedReferences - intents.members = True + setattr(intents, "members", True) # noqa: B010 bot: TeXBot = TeXBot(intents=intents) # NOTE: See https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/261 @@ -35,10 +34,14 @@ def _run_bot() -> NoReturn: # NOTE: See https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/261 bot.run(settings["DISCORD_BOT_TOKEN"]) - if bot.EXIT_WAS_DUE_TO_KILL_COMMAND: - raise SystemExit(0) + if bot.EXIT_REASON is TeXBotExitReason.RESTART_REQUIRED_DUE_TO_CHANGED_CONFIG: + with SuppressTraceback(): + bot.reset_exit_reason() + config.run_setup() + bot.reload_extension("cogs") + _run_bot() - raise SystemExit(1) + raise SystemExit(bot.EXIT_REASON.value) if __name__ == "__main__": diff --git a/poetry.lock b/poetry.lock index 695a74842..1fa9d2e64 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,22 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "aiofile" +version = "3.8.8" +description = "Asynchronous file operations." +optional = false +python-versions = ">=3.7, <4" +files = [ + {file = "aiofile-3.8.8-py3-none-any.whl", hash = "sha256:41e8845cce055779cd77713d949a339deb012eab605b857765e8f8e52a5ed811"}, + {file = "aiofile-3.8.8.tar.gz", hash = "sha256:41f3dc40bd730459d58610476e82e5efb2f84ae6e9fa088a9545385d838b8a43"}, +] + +[package.dependencies] +caio = ">=0.9.0,<0.10.0" + +[package.extras] +develop = ["aiomisc-pytest", "coveralls", "pytest", "pytest-cov", "pytest-rst"] + [[package]] name = "aiohttp" version = "3.9.5" @@ -95,6 +112,21 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns", "brotlicffi"] +[[package]] +name = "aiopath" +version = "0.7.7" +description = "📁 Async pathlib for Python" +optional = false +python-versions = ">=3.12" +files = [ + {file = "aiopath-0.7.7-py2.py3-none-any.whl", hash = "sha256:cd5d18de8ede167e1db659f02ee448fe085f923cb8e194407ccc568bffc4fe4e"}, + {file = "aiopath-0.7.7.tar.gz", hash = "sha256:ad4b9d09ae08ddf6d39dd06e7b0a353939e89528da571c0cd4f3fe071aefad4f"}, +] + +[package.dependencies] +aiofile = ">=3.8.8,<4" +anyio = ">=4.0.0,<5" + [[package]] name = "aiosignal" version = "1.3.1" @@ -109,6 +141,26 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + [[package]] name = "application-properties" version = "0.8.2" @@ -195,6 +247,30 @@ charset-normalizer = ["charset-normalizer"] html5lib = ["html5lib"] lxml = ["lxml"] +[[package]] +name = "caio" +version = "0.9.17" +description = "Asynchronous file IO for Linux MacOS or Windows." +optional = false +python-versions = "<4,>=3.7" +files = [ + {file = "caio-0.9.17-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3f69395fdd45c115b2ef59732e3c8664722a2b51de2d6eedb3d354b2f5f3be3c"}, + {file = "caio-0.9.17-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3028b746e9ec7f6d6ebb386a7fd8caf0eebed5d6e6b4f18c8ef25861934b1673"}, + {file = "caio-0.9.17-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:079730a353bbde03796fab681e969472eace09ffbe5000e584868a7fe389ba6f"}, + {file = "caio-0.9.17-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:549caa51b475877fe32856a26fe937366ae7a1c23a9727005b441db9abb12bcc"}, + {file = "caio-0.9.17-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0ddb253b145a53ecca76381677ce465bc5efeaecb6aaf493fac43ae79659f0fb"}, + {file = "caio-0.9.17-cp312-cp312-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3e320b0ea371c810359934f8e8fe81777c493cc5fb4d41de44277cbe7336e74"}, + {file = "caio-0.9.17-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:a39a49e279f82aa022f0786339d45d9550b5aa3e46eec7d08e0f351c503df0a5"}, + {file = "caio-0.9.17-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3e96925b9f15f43e6ef1d42a83edfd937eb11a984cb6ef7c10527e963595497"}, + {file = "caio-0.9.17-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fca916240597005d2b734f1442fa3c3cfb612bf46e0978b5232e5492a371de38"}, + {file = "caio-0.9.17-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40bd0afbd3491d1e407bcf74e3a9e9cc67a7f290ed29518325194184d63cc2b6"}, + {file = "caio-0.9.17-py3-none-any.whl", hash = "sha256:c55d4dc6b3a36f93237ecd6360e1c131c3808bc47d4191a130148a99b80bb311"}, + {file = "caio-0.9.17.tar.gz", hash = "sha256:8f30511526814d961aeef389ea6885273abe6c655f1e08abbadb95d12fdd9b4f"}, +] + +[package.extras] +develop = ["aiomisc-pytest", "pytest", "pytest-cov"] + [[package]] name = "ccft-pymarkdown" version = "1.1.2" @@ -1441,20 +1517,6 @@ files = [ [package.dependencies] six = ">=1.5" -[[package]] -name = "python-dotenv" -version = "1.0.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.8" -files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - [[package]] name = "python-logging-discord-handler" version = "0.1.4" @@ -1472,6 +1534,23 @@ discord-webhook = ">=1.0.0,<2.0.0" [package.extras] docs = ["Sphinx (>=4.4.0,<5.0.0)", "sphinx-autodoc-typehints[docs] (>=1.16.0,<2.0.0)", "sphinx-rtd-theme (>=1.0.0,<2.0.0)", "sphinx-sitemap (>=2.2.0,<3.0.0)"] +[[package]] +name = "python-slugify" +version = "8.0.4" +description = "A Python slugify application that also handles Unicode" +optional = false +python-versions = ">=3.7" +files = [ + {file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"}, + {file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"}, +] + +[package.dependencies] +text-unidecode = ">=1.3" + +[package.extras] +unidecode = ["Unidecode (>=1.1.1)"] + [[package]] name = "pyyaml" version = "6.0.1" @@ -1617,6 +1696,17 @@ files = [ {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "soupsieve" version = "2.5" @@ -1643,6 +1733,31 @@ files = [ dev = ["build", "hatch"] doc = ["sphinx"] +[[package]] +name = "strictyaml" +version = "1.7.3" +description = "Strict, typed YAML parser" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7"}, + {file = "strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407"}, +] + +[package.dependencies] +python-dateutil = ">=2.6.0" + +[[package]] +name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +optional = false +python-versions = "*" +files = [ + {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, + {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, +] + [[package]] name = "tomli" version = "2.0.1" @@ -1740,20 +1855,6 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] -[[package]] -name = "validators" -version = "0.33.0" -description = "Python Data Validation for Humans™" -optional = false -python-versions = ">=3.8" -files = [ - {file = "validators-0.33.0-py3-none-any.whl", hash = "sha256:134b586a98894f8139865953899fc2daeb3d0c35569552c5518f089ae43ed075"}, - {file = "validators-0.33.0.tar.gz", hash = "sha256:535867e9617f0100e676a1257ba1e206b9bfd847ddc171e4d44811f07ff0bfbf"}, -] - -[package.extras] -crypto-eth-addresses = ["eth-hash[pycryptodome] (>=0.7.0)"] - [[package]] name = "virtualenv" version = "20.26.3" @@ -1891,4 +1992,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "247f394ba9db556f1db3e5cb3051a617618f4d74736eba9cea280f8cc4df43f8" +content-hash = "4a5bccba45f5df588edefe2d970c3e7cccd439978268e792dae7e315e84e3ced" diff --git a/pyproject.toml b/pyproject.toml index c39069751..f18d6c57b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,6 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.12" py-cord = "~2.6" -python-dotenv = "^1.0" -validators = "^0.33" beautifulsoup4 = "^4.12" emoji = "^2.12" parsedatetime = "^2.6" @@ -42,6 +40,9 @@ python-logging-discord-handler = "^0.1" classproperties = {git = "https://github.com/hottwaj/classproperties.git"} asyncstdlib = "~3.12" setuptools = "^70.3" +strictyaml = "^1.7" +python-slugify = "^8.0" +aiopath = "^0.7" [tool.poetry.group.dev.dependencies] pre-commit = "^3.8" @@ -50,8 +51,6 @@ django-stubs = {extras = ["compatible-mypy"], version = "~5.0"} types-beautifulsoup4 = "^4.12" pytest = "^8.3" ruff = "^0.5" -gitpython = "^3.1" -pymarkdownlnt = "^0.9" ccft-pymarkdown = "^1.1" @@ -87,7 +86,10 @@ module = [ "mplcyberpunk", "discord_logging.handler", "parsedatetime", - "validators", + "strictyaml", + "strictyaml.exceptions", + "strictyaml.yamllocation", + "aiopath", ] ignore_missing_imports = true @@ -173,6 +175,7 @@ ignore = [ "UP040", # NOTE: Mypy does not currently support PEP 695 type aliases, so they should not be used "PT009", "PT027", + "TRY301", ] task-tags = [ "TODO", diff --git a/tests/test_utils.py b/tests/test_utils.py index a10ed97fa..5718d9543 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,8 +11,6 @@ import utils -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 # class TestPlotBarChart: # """Test case to unit-test the plot_bar_chart function.""" # @@ -36,8 +34,6 @@ # assert bool(bar_chart_image.fp.read()) is True # noqa: ERA001 -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 # class TestAmountOfTimeFormatter: # """Test case to unit-test the amount_of_time_formatter function.""" # @@ -85,14 +81,17 @@ class TestGenerateInviteURL: def test_url_generates() -> None: """Test that the invite URL generates successfully when valid arguments are passed.""" DISCORD_BOT_APPLICATION_ID: Final[int] = random.randint( - 10000000000000000, 99999999999999999999, + 10000000000000000, + 99999999999999999999, ) DISCORD_MAIN_GUILD_ID: Final[int] = random.randint( - 10000000000000000, 99999999999999999999, + 10000000000000000, + 99999999999999999999, ) invite_url: str = utils.generate_invite_url( - DISCORD_BOT_APPLICATION_ID, DISCORD_MAIN_GUILD_ID, + DISCORD_BOT_APPLICATION_ID, + DISCORD_MAIN_GUILD_ID, ) assert re.fullmatch( diff --git a/utils/__init__.py b/utils/__init__.py index 4f536d2d4..177d0605c 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -2,30 +2,44 @@ from collections.abc import Sequence -__all__: Sequence[str] = ( +# noinspection PyProtectedMember +from config._settings import utils as config_utils + +__all__: Sequence[str] = ( # noqa: PLE0604 "CommandChecks", "MessageSavingSenderComponent", + "GenericResponderComponent", + "SenderResponseComponent", + "EditorResponseComponent", "SuppressTraceback", "TeXBot", + "TeXBotExitReason", "TeXBotBaseCog", "TeXBotApplicationContext", "TeXBotAutocompleteContext", "AllChannelTypes", "generate_invite_url", "is_member_inducted", - "is_running_in_async", + *config_utils.__all__, ) -import asyncio from typing import TypeAlias import discord +# noinspection PyUnresolvedReferences,PyProtectedMember +from config._settings.utils import * # noqa: F403 + from .command_checks import CommandChecks -from .message_sender_components import MessageSavingSenderComponent +from .message_sender_components import ( + EditorResponseComponent, + GenericResponderComponent, + MessageSavingSenderComponent, + SenderResponseComponent, +) from .suppress_traceback import SuppressTraceback -from .tex_bot import TeXBot +from .tex_bot import TeXBot, TeXBotExitReason from .tex_bot_base_cog import TeXBotBaseCog from .tex_bot_contexts import TeXBotApplicationContext, TeXBotAutocompleteContext @@ -71,16 +85,4 @@ def is_member_inducted(member: discord.Member) -> bool: Returns True if the member has any role other than "@News". The set of ignored roles is a tuple to make the set easily expandable. """ - return any( - role.name.lower().strip("@ \n\t") not in ("news",) for role in member.roles - ) - - -def is_running_in_async() -> bool: - """Determine whether the current context is asynchronous or not.""" - try: - asyncio.get_running_loop() - except RuntimeError: - return False - else: - return True + return any(role.name.lower().strip("@ \n\t") not in ("news",) for role in member.roles) diff --git a/utils/message_sender_components.py b/utils/message_sender_components.py index 45f444468..771d002bd 100644 --- a/utils/message_sender_components.py +++ b/utils/message_sender_components.py @@ -3,6 +3,9 @@ from collections.abc import Sequence __all__: Sequence[str] = ( + "GenericResponderComponent", + "SenderResponseComponent", + "EditorResponseComponent", "MessageSavingSenderComponent", "ChannelMessageSender", "ResponseMessageSender", @@ -18,6 +21,72 @@ from .tex_bot_contexts import TeXBotApplicationContext +# noinspection PyPep8Naming +class _VIEW_NOT_PROVIDED: # noqa: N801 + pass + + +class GenericResponderComponent(abc.ABC): + """Abstract protocol definition of a component that responds in some way.""" + + @override + def __init__(self, interaction: discord.Interaction) -> None: + self.interaction: discord.Interaction = interaction + + super().__init__() + + @abc.abstractmethod + async def respond(self, content: str, *, view: View | None | type[_VIEW_NOT_PROVIDED] = _VIEW_NOT_PROVIDED) -> None: # noqa: E501 + """Respond in some way to the user with the given content & view.""" + + +class SenderResponseComponent(GenericResponderComponent): + """ + Concrete definition of a message-sending response component. + + Defines the way to send a provided message content & optional view. + """ + + @override + def __init__(self, interaction: discord.Interaction, *, ephemeral: bool) -> None: + self.ephemeral: bool = ephemeral + + super().__init__(interaction=interaction) + + @override + async def respond(self, content: str, *, view: View | None | type[_VIEW_NOT_PROVIDED] = _VIEW_NOT_PROVIDED) -> None: # noqa: E501 + if view is _VIEW_NOT_PROVIDED: + await self.interaction.respond(content=content, ephemeral=self.ephemeral) + return + + if view is not None and not isinstance(view, View): + raise TypeError + + await self.interaction.respond( + content=content, + view=view, + ephemeral=self.ephemeral, + ) + + +class EditorResponseComponent(GenericResponderComponent): + """ + Concrete definition of a message editing response component. + + Defines the way to edit a previous message to the given content & optional view. + """ + + @override + async def respond(self, content: str, *, view: View | None | type[_VIEW_NOT_PROVIDED] = _VIEW_NOT_PROVIDED) -> None: # noqa: E501 + if view is _VIEW_NOT_PROVIDED: + await self.interaction.edit_original_response(content=content) + + if view is not None and not isinstance(view, View): + raise TypeError + + await self.interaction.edit_original_response(content=content, view=view) + + class MessageSavingSenderComponent(abc.ABC): """ Abstract protocol definition of a sending component that saves the sent-message. diff --git a/utils/tex_bot.py b/utils/tex_bot.py index 1b1902d84..b98ff1c36 100644 --- a/utils/tex_bot.py +++ b/utils/tex_bot.py @@ -2,11 +2,13 @@ from collections.abc import Sequence -__all__: Sequence[str] = ("TeXBot",) +__all__: Sequence[str] = ("TeXBot", "TeXBotExitReason") import logging import re +from collections.abc import Collection +from enum import IntEnum from logging import Logger from typing import TYPE_CHECKING, Final, NoReturn, override @@ -14,6 +16,7 @@ import discord from discord import Webhook +import utils from config import settings from exceptions import ( ApplicantRoleDoesNotExistError, @@ -36,6 +39,14 @@ logger: Final[Logger] = logging.getLogger("TeX-Bot") +class TeXBotExitReason(IntEnum): + """Enum flag for the reason for TeX-Bot exiting.""" + + UNKNOWN_ERROR = -1 + KILL_COMMAND_USED = 0 + RESTART_REQUIRED_DUE_TO_CHANGED_CONFIG = 1 + + class TeXBot(discord.Bot): """ Subclass of the default Bot class provided by Pycord. @@ -58,7 +69,7 @@ def __init__(self, *args: object, **options: object) -> None: self._roles_channel: discord.TextChannel | None = None self._general_channel: discord.TextChannel | None = None self._rules_channel: discord.TextChannel | None = None - self._exit_was_due_to_kill_command: bool = False + self._exit_reason: TeXBotExitReason = TeXBotExitReason.UNKNOWN_ERROR self._main_guild_set: bool = False @@ -72,9 +83,9 @@ async def close(self) -> NoReturn: # type: ignore[misc] # noinspection PyPep8Naming @property - def EXIT_WAS_DUE_TO_KILL_COMMAND(self) -> bool: # noqa: N802 - """Return whether the TeX-Bot exited due to the kill command being used.""" - return self._exit_was_due_to_kill_command + def EXIT_REASON(self) -> TeXBotExitReason: # noqa: N802 + """Return the reason for TeX-Bot's last exit.""" + return self._exit_reason @property def main_guild(self) -> discord.Guild: @@ -410,18 +421,51 @@ async def perform_kill_and_close(self, initiated_by_user: discord.User | discord A log message will also be sent, announcing the user that requested the shutdown. """ - if self.EXIT_WAS_DUE_TO_KILL_COMMAND: - EXIT_FLAG_ALREADY_SET_MESSAGE: Final[str] = ( + if self.EXIT_REASON is not TeXBotExitReason.UNKNOWN_ERROR: + EXIT_REASON_ALREADY_SET_MESSAGE: Final[str] = ( "The kill & close command has already been used. Invalid state." ) - raise RuntimeError(EXIT_FLAG_ALREADY_SET_MESSAGE) + raise RuntimeError(EXIT_REASON_ALREADY_SET_MESSAGE) if initiated_by_user: logger.info("Manual shutdown initiated by %s.", initiated_by_user) - self._exit_was_due_to_kill_command = True + self._exit_reason = TeXBotExitReason.KILL_COMMAND_USED + await self.close() + + async def perform_restart_after_config_changes(self) -> NoReturn: + """Restart TeX-Bot after the config changes.""" + if self.EXIT_REASON is not TeXBotExitReason.UNKNOWN_ERROR: + EXIT_REASON_ALREADY_SET_MESSAGE: Final[str] = ( + "TeX-Bot cannot be restarted as the exit reason has already been set. " + "Invalid state." + ) + raise RuntimeError(EXIT_REASON_ALREADY_SET_MESSAGE) + + self._exit_reason = TeXBotExitReason.RESTART_REQUIRED_DUE_TO_CHANGED_CONFIG await self.close() + def reset_exit_reason(self) -> None: + """Reset the exit reason of TeX-Bot back to `UNKNOWN_ERROR`.""" + if utils.is_running_in_async(): + TEX_BOT_STILL_RUNNING_MESSAGE: Final[str] = ( + "Cannot reset exit reason when TeX-Bot is currently running." + ) + raise RuntimeError(TEX_BOT_STILL_RUNNING_MESSAGE) + + RESETABLE_EXIT_REASONS: Collection[TeXBotExitReason] = ( + TeXBotExitReason.UNKNOWN_ERROR, + TeXBotExitReason.RESTART_REQUIRED_DUE_TO_CHANGED_CONFIG, + ) + if self.EXIT_REASON not in RESETABLE_EXIT_REASONS: + CURRENT_EXIT_REASON_IS_INVALID_MESSAGE: Final[str] = ( + "Cannot reset exit reason, due to incorrect current exit reason. " + "Invalid state." + ) + raise RuntimeError(CURRENT_EXIT_REASON_IS_INVALID_MESSAGE) + + self._exit_reason = TeXBotExitReason.UNKNOWN_ERROR + async def get_everyone_role(self) -> discord.Role: """ Util method to retrieve the "@everyone" role from your group's Discord guild. @@ -456,7 +500,7 @@ def set_main_guild(self, main_guild: discord.Guild) -> None: self._main_guild = main_guild self._main_guild_set = True - async def get_main_guild_member(self, user: discord.Member | discord.User) -> discord.Member: # noqa: E501 + async def _get_main_guild_member_from_user(self, user: discord.Member | discord.User) -> discord.Member: # noqa: E501 """ Util method to retrieve a member of your group's Discord guild from their User object. @@ -467,14 +511,35 @@ async def get_main_guild_member(self, user: discord.Member | discord.User) -> di raise DiscordMemberNotInMainGuildError(user_id=user.id) return main_guild_member - async def get_member_from_str_id(self, str_member_id: str) -> discord.Member: + async def _get_main_guild_member_from_id(self, member_id: int) -> discord.Member: + """ + Util method to retrieve a member of your group's Discord guild from their User ID. + + Raises `DiscordMemberNotInMainGuild` if the user is not in your group's Discord guild. + Raises `ValueError` if the provided ID is not a valid user ID. + """ + user: discord.User | None = self.get_user(member_id) + if not user: + raise ValueError( + DiscordMemberNotInMainGuildError(user_id=member_id).message, + ) + + return await self.get_main_guild_member(user) + + async def get_main_guild_member(self, user: discord.Member | discord.User | str | int) -> discord.Member: # noqa: E501 """ - Retrieve a member of your group's Discord guild by their ID. + Util method to retrieve a member of your group's Discord guild from their ID or User. - Raises `ValueError` if the provided ID does not represent any member - of your group's Discord guild. + Raises `DiscordMemberNotInMainGuild` if the user is not in your group's Discord guild. + Raises `ValueError` if the provided ID is not a valid user ID. """ - str_member_id = str_member_id.replace("<@", "").replace(">", "") + if isinstance(user, discord.Member | discord.User): + return await self._get_main_guild_member_from_user(user) + + if isinstance(user, int): + return await self._get_main_guild_member_from_id(user) + + str_member_id = user.replace("<@", "").replace(">", "") if not re.fullmatch(r"\A\d{17,20}\Z", str_member_id): INVALID_USER_ID_MESSAGE: Final[str] = ( @@ -482,19 +547,7 @@ async def get_member_from_str_id(self, str_member_id: str) -> discord.Member: ) raise ValueError(INVALID_USER_ID_MESSAGE) - user: discord.User | None = self.get_user(int(str_member_id)) - if not user: - raise ValueError( - DiscordMemberNotInMainGuildError(user_id=int(str_member_id)).message, - ) - - user_not_in_main_guild_error: DiscordMemberNotInMainGuildError - try: - member: discord.Member = await self.get_main_guild_member(user) - except DiscordMemberNotInMainGuildError as user_not_in_main_guild_error: - raise ValueError from user_not_in_main_guild_error - - return member + return await self._get_main_guild_member_from_id(int(user)) async def fetch_log_channel(self) -> discord.TextChannel: """ diff --git a/utils/tex_bot_base_cog.py b/utils/tex_bot_base_cog.py index 60da668c6..660cc79a9 100644 --- a/utils/tex_bot_base_cog.py +++ b/utils/tex_bot_base_cog.py @@ -20,6 +20,7 @@ BaseDoesNotExistError, ) +from .message_sender_components import GenericResponderComponent, SenderResponseComponent from .tex_bot import TeXBot from .tex_bot_contexts import TeXBotApplicationContext, TeXBotAutocompleteContext @@ -71,73 +72,109 @@ def __init__(self, bot: TeXBot) -> None: """ self.bot: TeXBot = bot # NOTE: See https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/261 - async def command_send_error(self, ctx: TeXBotApplicationContext, error_code: str | None = None, message: str | None = None, logging_message: str | BaseException | None = None) -> None: # noqa: E501 + async def command_send_error(self, ctx: TeXBotApplicationContext, *, error_code: str | None = None, message: str | None = None, logging_message: str | BaseException | None = None, is_fatal: bool = False, responder_component: GenericResponderComponent | None = None) -> None: # noqa: E501 """ Construct & format an error message from the given details. The constructed error message is then sent as the response to the given application command context. + If `is_fatal` is set to True, this suggests that the reason for the error is unknown + and the bot will shortly close. """ - COMMAND_NAME: Final[str] = ( - ctx.command.callback.__name__ - if ( - hasattr(ctx.command, "callback") - and not ctx.command.callback.__name__.startswith("_") - ) - else ctx.command.qualified_name - ) - - await self.send_error( + await self._respond_with_error( self.bot, - ctx.interaction, - interaction_name=COMMAND_NAME, + responder=( + responder_component or SenderResponseComponent(ctx.interaction, ephemeral=True) + ), + interaction_name=( + ctx.command.callback.__name__ + if ( + hasattr(ctx.command, "callback") + and not ctx.command.callback.__name__.startswith("_") + ) + else ctx.command.qualified_name + ), error_code=error_code, message=message, logging_message=logging_message, + is_fatal=is_fatal, ) @classmethod - async def send_error(cls, bot: TeXBot, interaction: discord.Interaction, interaction_name: str, error_code: str | None = None, message: str | None = None, logging_message: str | BaseException | None = None) -> None: # noqa: E501 + async def send_error(cls, bot: TeXBot, interaction: discord.Interaction, *, interaction_name: str, error_code: str | None = None, message: str | None = None, logging_message: str | BaseException | None = None, is_fatal: bool = False) -> None: # noqa: E501 """ Construct & format an error message from the given details. The constructed error message is then sent as the response to the given interaction. + If `is_fatal` is set to True, this suggests that the reason for the error is unknown + and the bot will shortly close. """ - construct_error_message: str = ":warning:There was an error" + await cls._respond_with_error( + bot=bot, + responder=SenderResponseComponent(interaction, ephemeral=True), + interaction_name=interaction_name, + error_code=error_code, + message=message, + logging_message=logging_message, + is_fatal=is_fatal, + ) + + @classmethod + async def _respond_with_error(cls, bot: TeXBot, responder: GenericResponderComponent, *, interaction_name: str, error_code: str | None = None, message: str | None = None, logging_message: str | BaseException | None = None, is_fatal: bool = False) -> None: # noqa: E501 + construct_error_message: str = ":warning:" - if error_code: + if is_fatal: # noinspection PyUnusedLocal - committee_mention: str = "committee" + fatal_committee_mention: str = "committee" with contextlib.suppress(CommitteeRoleDoesNotExistError): - committee_mention = (await bot.committee_role).mention + fatal_committee_mention = (await bot.committee_role).mention - construct_error_message = ( - f"**Contact a {committee_mention} member, referencing error code: " - f"{error_code}**\n" - ) + construct_error_message - - if interaction_name in cls.ERROR_ACTIVITIES: construct_error_message += ( - f" when trying to {cls.ERROR_ACTIVITIES[interaction_name]}" + "A fatal error occurred, " + f"please **contact a {fatal_committee_mention} member**.:warning:" ) - if message: - construct_error_message += ":" + if message: + construct_error_message += message.strip() + else: - construct_error_message += "." + construct_error_message += "There was an error" - construct_error_message += ":warning:" + if error_code: + # noinspection PyUnusedLocal + non_fatal_committee_mention: str = "committee" - if message: - message = re.sub( - r"<([@&#]?|(@[&#])?)\d+>", - lambda match: f"`{match.group(0)}`", - message.strip(), - ) - construct_error_message += f"\n`{message}`" + with contextlib.suppress(CommitteeRoleDoesNotExistError): + non_fatal_committee_mention = (await bot.committee_role).mention - await interaction.respond(construct_error_message, ephemeral=True) + construct_error_message = ( + f"**Contact a {non_fatal_committee_mention} member, " + f"referencing error code: {error_code}**\n" + ) + construct_error_message + + if interaction_name in cls.ERROR_ACTIVITIES: + construct_error_message += ( + f" when trying to {cls.ERROR_ACTIVITIES[interaction_name]}" + ) + + if message: + construct_error_message += ":" + else: + construct_error_message += "." + + construct_error_message += ":warning:" + + if message: + construct_error_message += f"\n`{ + re.sub( + r"<([@&#]?|(@[&#])?)\d+>", + lambda match: f"`{match.group(0)!s}`", + message.strip(), + ) + }`" + + await responder.respond(content=construct_error_message, view=None) if logging_message: logger.error( @@ -151,6 +188,13 @@ async def send_error(cls, bot: TeXBot, interaction: discord.Interaction, interac ).rstrip(": ;"), ) + if is_fatal and error_code: + FATAL_AND_ERROR_CODE_MESSAGE: Final[str] = ( + "Error message was requested to be sent with an error code, " + "despite being marked as a fatal error." + ) + raise ValueError(FATAL_AND_ERROR_CODE_MESSAGE) + @staticmethod async def autocomplete_get_text_channels(ctx: TeXBotAutocompleteContext) -> Set[discord.OptionChoice] | Set[str]: # noqa: E501 """ @@ -174,17 +218,15 @@ async def autocomplete_get_text_channels(ctx: TeXBotAutocompleteContext) -> Set[ ctx.interaction.user, ) - if not ctx.value or re.fullmatch(r"\A#.*\Z", ctx.value): - return { - discord.OptionChoice(name=f"#{channel.name}", value=str(channel.id)) - for channel in main_guild.text_channels - if channel.permissions_for(channel_permissions_limiter).is_superset( - discord.Permissions(send_messages=True, view_channel=True), - ) - } - return { - discord.OptionChoice(name=channel.name, value=str(channel.id)) + discord.OptionChoice( + name=( + f"#{channel.name}" + if not ctx.value or re.fullmatch(r"\A#.*\Z", ctx.value) + else channel.name + ), + value=str(channel.id), + ) for channel in main_guild.text_channels if channel.permissions_for(channel_permissions_limiter).is_superset( discord.Permissions(send_messages=True, view_channel=True), diff --git a/utils/tex_bot_contexts.py b/utils/tex_bot_contexts.py index 397036643..b4e53e39c 100644 --- a/utils/tex_bot_contexts.py +++ b/utils/tex_bot_contexts.py @@ -1,5 +1,5 @@ """ -Type-hinting classes that override the Pycord Context classes. +Type-hinting classes that override Pycord's Context classes. These custom, overridden classes contain a reference to the custom bot class TeXBot, rather than Pycord's default Bot class.