From aeef391ac9c6d60db71744e3ac68b88d0b393fb0 Mon Sep 17 00:00:00 2001 From: Fluxticks Date: Thu, 22 Dec 2022 03:18:42 +0100 Subject: [PATCH 001/404] Cleaned project structure and reset bot to clean slate --- .gitignore | 2 - Dockerfile | 2 +- src/requirements.txt => requirements.txt | 7 +- secrets.template | 64 +- .../DiscordReactableMenus => }/__init__.py | 0 src/bot.py | 29 + src/client.py | 39 + src/{esportsbot => database}/__init__.py | 0 src/database/gateway.py | 81 + src/database/models.py | 7 + .../DiscordReactableMenus/EmojiHandler.py | 153 -- .../DiscordReactableMenus/EventReactMenu.py | 86 -- .../DiscordReactableMenus/ExampleMenus.py | 360 ----- .../DiscordReactableMenus/PingableMenus.py | 160 -- .../DiscordReactableMenus/ReactableMenu.py | 273 ---- .../DiscordReactableMenus/reactable_lib.py | 80 - src/esportsbot/banned_words.txt | 1102 ------------- src/esportsbot/base_functions.py | 64 - src/esportsbot/bot.py | 143 -- src/esportsbot/cogs/AdminCog.py | 280 ---- src/esportsbot/cogs/DefaultRoleCog.py | 180 --- src/esportsbot/cogs/EventCategoriesCog.py | 476 ------ src/esportsbot/cogs/LogChannelCog.py | 82 - src/esportsbot/cogs/MusicCog.py | 1364 ----------------- src/esportsbot/cogs/PingableRolesCog.py | 1126 -------------- src/esportsbot/cogs/RoleReactCog.py | 280 ---- src/esportsbot/cogs/TwitchCog.py | 997 ------------ src/esportsbot/cogs/TwitterCog.py | 640 -------- src/esportsbot/cogs/VoicemasterCog.py | 435 ------ src/esportsbot/cogs/VotingCog.py | 240 --- src/esportsbot/db_gateway.py | 107 -- src/esportsbot/lib/CustomHelpCommand.py | 252 --- src/esportsbot/lib/__init__.py | 0 src/esportsbot/lib/client.py | 112 -- src/esportsbot/lib/discordUtil.py | 64 - src/esportsbot/lib/exceptions.py | 17 - src/esportsbot/lib/stringTyping.py | 60 - src/esportsbot/models.py | 126 -- src/esportsbot/user_strings.toml | 715 --------- .../cogs => extensions}/__init__.py | 0 src/main.py | 20 +- 41 files changed, 179 insertions(+), 10046 deletions(-) rename src/requirements.txt => requirements.txt (61%) rename src/{esportsbot/DiscordReactableMenus => }/__init__.py (100%) create mode 100644 src/bot.py create mode 100644 src/client.py rename src/{esportsbot => database}/__init__.py (100%) create mode 100644 src/database/gateway.py create mode 100644 src/database/models.py delete mode 100644 src/esportsbot/DiscordReactableMenus/EmojiHandler.py delete mode 100644 src/esportsbot/DiscordReactableMenus/EventReactMenu.py delete mode 100644 src/esportsbot/DiscordReactableMenus/ExampleMenus.py delete mode 100644 src/esportsbot/DiscordReactableMenus/PingableMenus.py delete mode 100644 src/esportsbot/DiscordReactableMenus/ReactableMenu.py delete mode 100644 src/esportsbot/DiscordReactableMenus/reactable_lib.py delete mode 100644 src/esportsbot/banned_words.txt delete mode 100644 src/esportsbot/base_functions.py delete mode 100644 src/esportsbot/bot.py delete mode 100644 src/esportsbot/cogs/AdminCog.py delete mode 100644 src/esportsbot/cogs/DefaultRoleCog.py delete mode 100644 src/esportsbot/cogs/EventCategoriesCog.py delete mode 100644 src/esportsbot/cogs/LogChannelCog.py delete mode 100644 src/esportsbot/cogs/MusicCog.py delete mode 100644 src/esportsbot/cogs/PingableRolesCog.py delete mode 100644 src/esportsbot/cogs/RoleReactCog.py delete mode 100644 src/esportsbot/cogs/TwitchCog.py delete mode 100644 src/esportsbot/cogs/TwitterCog.py delete mode 100644 src/esportsbot/cogs/VoicemasterCog.py delete mode 100644 src/esportsbot/cogs/VotingCog.py delete mode 100644 src/esportsbot/db_gateway.py delete mode 100644 src/esportsbot/lib/CustomHelpCommand.py delete mode 100644 src/esportsbot/lib/__init__.py delete mode 100644 src/esportsbot/lib/client.py delete mode 100644 src/esportsbot/lib/discordUtil.py delete mode 100644 src/esportsbot/lib/exceptions.py delete mode 100644 src/esportsbot/lib/stringTyping.py delete mode 100644 src/esportsbot/models.py delete mode 100644 src/esportsbot/user_strings.toml rename src/{esportsbot/cogs => extensions}/__init__.py (100%) diff --git a/.gitignore b/.gitignore index 176d046c..cc417615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ **/__pycache__ test-file.py -/src/cogs/TestCog.py -/src/esportsbot/version.txt .vscode .idea secrets.env diff --git a/Dockerfile b/Dockerfile index 5db64235..7eef1876 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ RUN apt-get update && \ apt-get install -y ffmpeg # Install requirements first to take advantage of docker build layer caching -COPY ./src/requirements.txt /tmp/requirements.txt +COPY ./requirements.txt /tmp/requirements.txt RUN pip install -r /tmp/requirements.txt && rm /tmp/requirements.txt COPY ./src /code diff --git a/src/requirements.txt b/requirements.txt similarity index 61% rename from src/requirements.txt rename to requirements.txt index 0fa3a5f3..d36d631f 100644 --- a/src/requirements.txt +++ b/requirements.txt @@ -5,12 +5,7 @@ discord.py[voice] python-dotenv emoji lxml -google-api-python-client -yt-dlp -youtube-search-python PyNaCl aiohttp[speedups] -toml -tornado -tweepy==3.10.0 coloredlogs +uvloop diff --git a/secrets.template b/secrets.template index 48839241..7839bbc2 100644 --- a/secrets.template +++ b/secrets.template @@ -3,7 +3,8 @@ DISCORD_TOKEN= COMMAND_PREFIX=! UNKNOWN_COMMAND_EMOJI=⁉ -DEV_IDS= +DEV_USER_IDS= +DEV_GUILD_ID= ################## ## Database Vars ## @@ -15,63 +16,6 @@ POSTGRES_PASSWORD= POSTGRES_DB= PGADMIN_DEFAULT_EMAIL= PGADMIN_DEFAULT_PASSWORD= +# This variable can be used to force the bot to use a specific DB. Must be a valid DB url, including protocol. +DB_OVERRIDE= ################### - -## Music Vars ## -# These are variables are used for the MusicCog to function. -# Only needed if the MusicCog is enabled using the ENABLE_MUSIC variable. -ENABLE_MUSIC=FALSE -GOOGLE_API= -################ - -## Twitch Vars ## -# These are variables are used for the TwitchCog to function. -# Only needed if the TwitchCog is enabled using the ENABLE_TWITCH variable. -ENABLE_TWITCH=FALSE -TWITCH_CLIENT_ID= -TWITCH_CLIENT_SECRET= -TWITCH_SUB_SECRET= -TWITCH_CALLBACK= -TEMP_BEARER_FILE= -SSL_CERT_FILE= -SSL_KEY_FILE= -################# - -## Twitter Vars ## -# These variables are used for the TwitterCog to function. -# Only needed if the TwitterCog is enabled using the ENABLE_TWITTTER variable. -ENABLE_TWITTER=FALSE -TWITTER_CONSUMER_KEY= -TWITTER_CONSUMER_SECRET= -TWITTER_ACCESS_TOKEN= -TWITTER_ACCESS_TOKEN_SECRET= -################## - -## Pingable Roles Vars ## -# These are variables are used for the PingableRolesCog to function. -# Only needed if the PingableRolesCog is enabled using the ENABLE_PINGME variable. -ENABLE_PINGME=FALSE -DEFAULT_POLL_LENGTH=300 -DEFAULT_POLL_THRESHOLD=6 -DEFAULT_COOLDOWN_LENGTH=120 -RUN_MONTHLY_REPORT=FALSE -DELETE_PINGABLE_CREATION=TRUE - -## Voting Menu Vars ## -# These are variables are used for the VotingCog to function. -# Only needed if the VotingCog is enabled using the ENABLE_VOTINGMENUS variable. -ENABLE_VOTINGMENUS=FALSE -DELETE_VOTING_CREATION=TRUE - -## Role Reaction Menu Vars ## -# These are variables are used for the RoleReactCog to function. -# Only needed if the RoleReactCog is enabled using the ENABLE_ROLEREACTIONS variable. -ENABLE_ROLEREACTIONS=FALSE -DELETE_ROLE_CREATION=TRUE - -## Other Cogs ## -ENABLE_VOICEMASTER=FALSE -ENABLE_DEFAULTROLE=FALSE -ENABLE_EVENTCATEGORIES=FALSE - - diff --git a/src/esportsbot/DiscordReactableMenus/__init__.py b/src/__init__.py similarity index 100% rename from src/esportsbot/DiscordReactableMenus/__init__.py rename to src/__init__.py diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 00000000..fd349394 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,29 @@ +import logging +import os +from time import sleep + +from client import EsportsBot + +__all__ = ["start_bot"] +logger = logging.getLogger(__name__) + + +def start_bot(): + logger.info("Loading bot...") + + if not os.getenv("DISCORD_TOKEN"): + raise RuntimeError("Missing required `DISCORD_TOKEN` environment variable!") + + if not os.getenv("DEV_GUILD_ID"): + logger.warning("No Dev guild specified, waiting 5s before starting...") + sleep(5.0) + logger.warning("Continuing with live launch!") + + EsportsBot.run(os.getenv("DISCORD_TOKEN")) + + +if __name__ == "__main__": + raise RuntimeError( + "This module should not be run directly. \ + Instead run `main.py` to ensure logging and environment variables are correctly loaded." + ) diff --git a/src/client.py b/src/client.py new file mode 100644 index 00000000..f20f18b7 --- /dev/null +++ b/src/client.py @@ -0,0 +1,39 @@ +import os +import logging +from typing import List, Dict, Union +from discord.ext.commands import Bot +from discord import Intents, Object + +__all__ = ["EsportsBot"] + + +class __EsportsBot(Bot): + + def __init__(self, command_prefix: str, *args, **kwargs): + super().__init__(command_prefix, *args, **kwargs) + self.logger = logging.getLogger(__name__) + + async def setup_hook(self) -> None: + # List of extensions to load. Initialised with default extensions. + enabled_extensions: List[str] = [] + # Dictionary of Environment variables -> extension name + MODULE_ENV_VARS: Dict[str, Union[str, None]] = {} + + # For each of the enabled Environment variables, add it's respective extension to the list. + for var in MODULE_ENV_VARS: + if MODULE_ENV_VARS.get(var) and os.getenv(f"ENABLE_{var.upper()}", "FALSE").upper() == "TRUE": + enabled_extensions.append(MODULE_ENV_VARS.get(var)) + + # Load the extensions from the generated list of enabled extensions. + for extension in enabled_extensions: + await self.load_extension(f"extensions.{extension}") + + # If in a dev environment, sync the commands to the dev guild. + if os.getenv("DEV_GUILD_ID"): + DEV_GUILD = Object(id=os.getenv("DEV_GUILD_ID")) + self.logger.warning(f"Using guild with id {DEV_GUILD.id} as Development guild!") + self.tree.copy_global_to(guild=DEV_GUILD) + await self.tree.sync(guild=DEV_GUILD) + + +EsportsBot = __EsportsBot(command_prefix=os.getenv("COMMAND_PREFIX"), intents=Intents.all()) diff --git a/src/esportsbot/__init__.py b/src/database/__init__.py similarity index 100% rename from src/esportsbot/__init__.py rename to src/database/__init__.py diff --git a/src/database/gateway.py b/src/database/gateway.py new file mode 100644 index 00000000..ebc21e4c --- /dev/null +++ b/src/database/gateway.py @@ -0,0 +1,81 @@ +import os +import logging +from typing import Any + +from sqlalchemy import create_engine, Table +from sqlalchemy.orm import sessionmaker +from sqlalchemy_utils import create_database, database_exists + +from database.models import base + +if os.getenv("DB_OVERRIDE"): + DB_STRING = os.getenv("DB_OVERRIDE") +else: + DB_STRING = f"postgresql://" \ + f"{os.getenv('POSTGRES_USER')}:{os.getenv('POSTGRES_PASSWORD')}@" \ + f"{os.getenv('POSTGRES_HOST')}:5432/{os.getenv('POSTGRES_DB')}" + +__all__ = ["DBSession"] + + +class __DBSession: + + def __init__(self): + self.logger = logging.getLogger(__name__) + self.db = create_engine(DB_STRING) + + if not database_exists(self.db.url): + create_database(self.db.url) + + __Session = sessionmaker(self.db) + self.session = __Session() + base.metadata.create_all(self.db) + self.logger.info("Created DB models!") + + def list(self, table: Table, **args): + try: + return self.session.query(table).filter_by(**args).all() + except Exception as error: + self.logger.error( + f"Encountered an exception while attempting to `list` {table.__class__.__name__} " + f"using the following args - {args}" + ) + raise Exception(f"Error occured when using DB list - {error}") + + def get(self, table: Table, **args): + try: + query = self.session.query(table).filter_by(**args).all() + return query[0] if query != [] else query + except Exception as error: + self.logger.error( + f"Encountered an exception while attempting to `get` {table.__class__.__name__} " + f"using the following args - {args}" + ) + raise Exception(f"Error occured when using DB get - {error}") + + def delete(self, record: Any): + try: + self.session.delete(record) + self.session.commit() + except Exception as error: + self.logger.error(f"Encountered an exception while attempting to `delete` {record}") + raise Exception(f"Error occured when using DB delete - {error}") + + def create(self, record: Any): + try: + self.session.add(record) + self.session.commit() + except Exception as error: + self.logger.error(f"Encountered an exception while attempting to `create` {record}") + raise Exception(f"Error occured when using DB create - {error}") + + def update(self, record: Any): + try: + self.session.add(record) + self.session.commit() + except Exception as error: + self.logger.error(f"Encountered an exception while attempting to `update` {record}") + raise Exception(f"Error occured when using DB update - {error}") + + +DBSession = __DBSession() diff --git a/src/database/models.py b/src/database/models.py new file mode 100644 index 00000000..4efe964d --- /dev/null +++ b/src/database/models.py @@ -0,0 +1,7 @@ +from sqlalchemy import Column, String, BigInteger, Boolean +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declarative_base + +base = declarative_base() + +__all__ = ["base"] diff --git a/src/esportsbot/DiscordReactableMenus/EmojiHandler.py b/src/esportsbot/DiscordReactableMenus/EmojiHandler.py deleted file mode 100644 index 9eed8d76..00000000 --- a/src/esportsbot/DiscordReactableMenus/EmojiHandler.py +++ /dev/null @@ -1,153 +0,0 @@ -from typing import Dict, Union - -import emoji -from discord import PartialEmoji, Emoji - - -def partial_from_emoji(full_emoji: Emoji) -> PartialEmoji: - """ - Create a partial emoji from a full emoji. - :param full_emoji: The full emoji to create a partial from. - :return: A Partial Emoji with the data from the full one. - """ - data = {"name": full_emoji.name, "id": full_emoji.id, "animated": full_emoji.animated} - return PartialEmoji.from_dict(data) - - -def partial_data_from_string(emoji_str: str) -> Dict: - """ - Get the dictionary representation of a partial emoji from a string representation of an emoji. This string will most - likely have come from the contents of a message. - :param emoji_str: The string of an emoji to convert. - :return: A dictionary that can be used to create a Partial Emoji. - """ - converted_emoji = emoji.demojize(emoji_str, use_aliases=True) - if converted_emoji != emoji_str: - return {"name": emoji_str, "id": None, "animated": False} - - if emoji_str.count(":") < 2: - return {} - - animated = " PartialEmoji: - """ - Create a partial emoji from a string.This string will most likely have come from the contents of a message. - :param emoji_str: The string representation of an emoji. - :return: A Partial Emoji. - """ - data = None - if isinstance(emoji_str, str): - data = partial_data_from_string(emoji_str) - - if not data: - raise ValueError("Unable to form emoji from given string") - return PartialEmoji.from_dict(data) - - -class MultiEmoji: - """ - This class is used to unify every kind of emoji to a generic class. Including Unicode Emojis, Discord Emojis, Static Custom - Discord Emojis and Animated Custom Discord Emojis. - """ - def __init__(self, emoji_input: Union[str, dict, Emoji, PartialEmoji, "MultiEmoji"]): - - if isinstance(emoji_input, str): - self._partial = partial_from_string(emoji_input) - elif isinstance(emoji_input, Emoji): - self._partial = partial_from_emoji(emoji_input) - elif isinstance(emoji_input, PartialEmoji): - self._partial = emoji_input - elif isinstance(emoji_input, MultiEmoji): - self._partial = emoji_input._partial - elif isinstance(emoji_input, dict): - self._partial = PartialEmoji.from_dict(emoji_input) - else: - raise ValueError("The given emoji input must of type str, discord.Emoji or discord.PartialEmoji") - - self._name = str(self._partial.name) - self._emoji_id = self._partial.id if self._partial.id else self._name - self._emoji_id = str(self._emoji_id) - self._animated = self._partial.animated - - @classmethod - def from_dict(cls, data): - """ - Create a MultiEmoji from a dictionary. - :param data: A MultiEmoji in the form of a dictionary. - :return: A MultiEmoji from the given data. - """ - return MultiEmoji(PartialEmoji.from_dict(data)) - - def __str__(self): - return emoji.emojize(self.name, use_aliases=True) - - def __repr__(self): - return emoji.demojize(self.name, use_aliases=True) - - def __eq__(self, other): - if not isinstance(other, MultiEmoji): - return False - else: - return self._emoji_id == other._emoji_id - - def __dict__(self): - return self.to_dict() - - @property - def name(self): - """ - Get the name of the emoji. - """ - return self._name - - @property - def emoji_id(self): - """ - Get the ID of the emoji. - """ - return self._emoji_id - - @property - def animated(self): - """ - Get whether or not the emoji is an animated emoji. - :return: True if the emoji is animated, False otherwise. - """ - return self._animated - - @property - def discord_emoji(self): - """ - Get a discord compatible version of the emoji. Can be used in things such as reactions. - :return: A discord Partial Emoji. - """ - return self._partial - - def to_dict(self): - """ - Get the dictionary representation of a MultiEmoji. - :return: - """ - return self._partial.to_dict() - - def __hash__(self): - return self._emoji_id - - -class EmojiKeyError(Exception): - """ - An error raised when the same emoji is used in a dictionary. - """ - def __init__(self, emoji_id, *args): - super().__init__(*args) - self.message = f"There is already an emoji with the ID {emoji_id} as an option." - self.emoji = emoji_id diff --git a/src/esportsbot/DiscordReactableMenus/EventReactMenu.py b/src/esportsbot/DiscordReactableMenus/EventReactMenu.py deleted file mode 100644 index 62552145..00000000 --- a/src/esportsbot/DiscordReactableMenus/EventReactMenu.py +++ /dev/null @@ -1,86 +0,0 @@ -from typing import Dict - -from discord import Role, TextChannel - -from esportsbot.DiscordReactableMenus.ExampleMenus import RoleReactMenu -from esportsbot.DiscordReactableMenus.ReactableMenu import ReactableMenu -from esportsbot.DiscordReactableMenus.reactable_lib import clean_mentioned_role - - -class EventReactMenu(RoleReactMenu): - """ - A reaction menu used in the EventCategoriesCog. Is a modified version of a role reaction menu that stores a few more - attributes. - """ - @classmethod - async def from_dict(cls, bot, data) -> ReactableMenu: - """ - Create an EventReactMenu from a dictionary representation of one. - :param bot: The instance of the bot. - :param data: The data of a saved EventReactMenu. - :return: An EventReactMenu from the given data. - """ - try: - kwargs = await cls.load_dict(bot, data) - - menu = EventReactMenu(**kwargs) - if menu.enabled: - menu.enabled = False - await menu.enable_menu(bot) - - return menu - except AttributeError: - # Happens when one of the values could not be loaded from the dictionary - return data - - @classmethod - async def load_dict(cls, bot, data) -> Dict: - """ - Formats the incoming data to ensure it has the correct keys in kwargs. - :param bot: The instance of the bot. - :param data: The data of a saved EventReactMenu. - :return: A formatted dictionary that can be passed to the constructor of an EventReactMenu. - """ - kwargs = await super(EventReactMenu, cls).load_dict(bot, data) - - shared_role_mentionable = data.get("shared_role") - shared_role_id = clean_mentioned_role(shared_role_mentionable) - shared_role = bot.get_guild(data.get("guild_id")).get_role(shared_role_id) - kwargs["shared_role"] = shared_role - - role_mentionable = list(data.get("options").values())[0].get("descriptor") - role_id = clean_mentioned_role(role_mentionable) - event_role = bot.get_guild(data.get("guild_id")).get_role(role_id) - kwargs["event_role"] = event_role - - if kwargs["message"]: - kwargs["event_category"] = kwargs["message"].channel.category - - return kwargs - - def __str__(self): - return self.title - - def to_dict(self): - """ - Get the dictionary representation of an EventReactMenu. - :return: A dictionary of the saveable attributes of an EventReactMenu. - """ - kwargs = super(EventReactMenu, self).to_dict() - kwargs["shared_role"] = self.shared_role.mention - return kwargs - - def __init__(self, event_role: Role, shared_role: Role, **kwargs): - super(EventReactMenu, self).__init__(**kwargs) - self.event_role = event_role - self.shared_role = shared_role - self.event_category = kwargs.get("event_category", None) - - async def finalise_and_send(self, bot, channel: TextChannel): - """ - Create the actual menu and send it to the given channel. - :param bot: The instance of the bot. - :param channel: The channel in which to send the menu. - """ - await super(EventReactMenu, self).finalise_and_send(bot, channel) - self.event_category = self.message.channel.category diff --git a/src/esportsbot/DiscordReactableMenus/ExampleMenus.py b/src/esportsbot/DiscordReactableMenus/ExampleMenus.py deleted file mode 100644 index 5c39aa34..00000000 --- a/src/esportsbot/DiscordReactableMenus/ExampleMenus.py +++ /dev/null @@ -1,360 +0,0 @@ -import datetime -import inspect -from collections import defaultdict -from typing import Dict - -import discord -from discord import Embed, PartialEmoji, RawReactionActionEvent, Role - -from esportsbot.DiscordReactableMenus.EmojiHandler import MultiEmoji -from esportsbot.DiscordReactableMenus.ReactableMenu import ReactableMenu -from esportsbot.DiscordReactableMenus.reactable_lib import clean_mentioned_role, get_role_from_id - -BAR_LENGTH = 10 -DEFAULT_ROLE_DESCRIPTION = "React with a specified emoji to receive a role!" -DEFAULT_ROLE_TITLE = "Role Reaction Menu" -AUTO_ENABLE_ROLE_REACT = True -DEFAULT_PING_DESCRIPTION = "React with the specified emoji to make a vote!" -DEFAULT_PING_TITLE = "Vote in This Poll" -NO_VOTES = "No votes received!" -AUTO_ENABLE_POLL_REACT = False -DATE_FORMAT = "%m-%d-%Y %H:%M:%S" - -CONFIRM_EMOJI = MultiEmoji("✅") -# CANCEL_EMOJI = MultiEmoji("❎") -CANCEL_EMOJI = MultiEmoji("❌") -CONFIRM_DESC = "Confirm" -CANCEL_DESC = "Cancel" - - -class RoleReactMenu(ReactableMenu): - """ - The base class for all Role giving reaction menus. - """ - @classmethod - async def from_dict(cls, bot, data) -> ReactableMenu: - kwargs = await super().load_dict(bot, data) - menu = RoleReactMenu(**kwargs) - if menu.enabled: - menu.enabled = False - await menu.enable_menu(bot) - return menu - - def __init__(self, **kwargs): - if kwargs.get("title") is None: - kwargs["title"] = DEFAULT_ROLE_TITLE - - if kwargs.get("description") is None: - kwargs["description"] = DEFAULT_ROLE_DESCRIPTION - if kwargs.get("add_func") is None: - kwargs["add_func"] = self.react_add_func - - if kwargs.get("remove_func") is None: - kwargs["remove_func"] = self.react_remove_func - - if kwargs.get("auto_enable") is None: - kwargs["auto_enable"] = AUTO_ENABLE_ROLE_REACT - - super().__init__(**kwargs) - - def generate_embed(self) -> Embed: - embed = Embed(title=f"{self.title} {self.title_suffix}", description=self.description, colour=self.colour) - for emoji_id in self.options: - emoji = self.options.get(emoji_id).get("emoji").discord_emoji - descriptor = self.options.get(emoji_id).get("descriptor") - embed.add_field(name="​", value=f"{emoji} — {descriptor}", inline=self.use_inline) - - return embed - - async def react_add_func(self, payload: RawReactionActionEvent) -> bool: - emoji_triggered = payload.emoji - member = payload.member - guild = self.message.guild - - if emoji_triggered in self: - if isinstance(self[emoji_triggered]["descriptor"], Role): - role_id = self[emoji_triggered]["descriptor"].id - else: - role_id = clean_mentioned_role(self[emoji_triggered]["descriptor"]) - else: - role_id = 0 - - if not role_id: - await self.message.clear_reaction(emoji_triggered) - return False - - role_to_add = get_role_from_id(guild, role_id) - await member.add_roles(role_to_add, reason="Added With Role Reaction Menu") - return True - - async def react_remove_func(self, payload: RawReactionActionEvent): - emoji_triggered: PartialEmoji = payload.emoji - guild = self.message.guild - member = guild.get_member(payload.user_id) - - if member is None: - member = await guild.fetch_member(payload.user_id) - - if emoji_triggered in self: - if isinstance(self[emoji_triggered]["descriptor"], Role): - role_id = self[emoji_triggered]["descriptor"].id - else: - role_id = clean_mentioned_role(self[emoji_triggered]["descriptor"]) - else: - role_id = 0 - - if not role_id: - return False - - role_to_remove = get_role_from_id(guild, role_id) - await member.remove_roles(role_to_remove, reason="Added With Role Reaction Menu") - return True - - -class PollReactMenu(ReactableMenu): - """ - The base class for all reaction menus that count the number of reactions in a reactable menu. - """ - def __init__(self, **kwargs): - if kwargs.get("title") is None: - kwargs["title"] = DEFAULT_PING_TITLE - - if kwargs.get("description") is None: - kwargs["description"] = DEFAULT_PING_DESCRIPTION - - if kwargs.get("add_func") is None: - kwargs["add_func"] = self.react_add_func - - if kwargs.get("remove_func") is None: - kwargs["remove_func"] = self.react_remove_func - - if kwargs.get("auto_enable") is None: - kwargs["auto_enable"] = AUTO_ENABLE_POLL_REACT - - super().__init__(**kwargs) - self.total_votes = 0 - self.poll_length = kwargs["poll_length"] - self.end_time = kwargs.get("end_time", datetime.datetime.now() + datetime.timedelta(seconds=self.poll_length)) - self.author = kwargs["author"] - - @classmethod - async def load_dict(cls, bot, data) -> Dict: - kwargs = await super(PollReactMenu, cls).load_dict(bot, data) - kwargs["poll_length"] = data.get("length") - kwargs["end_time"] = datetime.datetime.strptime(data.get("end_time"), DATE_FORMAT) - kwargs["author"] = bot.get_user(data.get("author_id")) - return kwargs - - @classmethod - async def from_dict(cls, bot, data): - kwargs = await cls.load_dict(bot, data) - menu = PollReactMenu(**kwargs) - if menu.enabled: - menu.enabled = False - await menu.enable_menu(bot) - return menu - - def to_dict(self) -> Dict: - kwargs = super(PollReactMenu, self).to_dict() - kwargs["end_time"] = self.end_time.strftime(DATE_FORMAT) - kwargs["length"] = self.poll_length - kwargs["author_id"] = self.author.id - return kwargs - - async def generate_results(self): - results = await self.get_results() - if self.total_votes > 0: - string = self.generate_results_string(results) - else: - string = NO_VOTES - - title = self.title - description = self.description - - embed = Embed(title=f"{title} Results", description=description) - embed.add_field(name="Results", value=string, inline=False) - - return embed - - def get_longest_option(self): - longest = -1 - for option in self.options: - if len(self.options.get(option).get("descriptor")) > longest: - longest = len(self.options.get(option).get("descriptor")) - return longest - - def get_winner(self): - winner = ([None], -1) - for react in self.message.reaction: - self.total_votes += react.count - if react.count > winner[-1]: - winner = ([react.emoji], react.count) - elif react.count == winner[-1]: - winner = (winner[0] + [react.emoji], winner[-1]) - return winner - - async def get_results(self): - await self.get_total_votes() - results = {"winner": [], "winner_count": -1, "reactions": defaultdict(list)} - """ - winner_count : count, - reactions : { - 0 : [reactions], - 1 : [reactions], - ... - } - """ - sorted_reactions = sorted(self.message.reactions, key=lambda x: x.count, reverse=True) - for reaction in sorted_reactions: - if reaction.count - 1 > results.get("winner_count"): - results["winner_count"] = reaction.count - 1 - results["reactions"][reaction.count - 1].append(reaction) - - return results - - def generate_results_string(self, results): - max_length = self.get_longest_option() - winning_votes = results.get("winner_count") - res_string = "" - for i in range(0, max(results.get("reactions").keys()) + 1): - reacts = results.get("reactions").get(i) - if reacts: - res_string = "\n".join(self.make_bar(x, max_length, winning_votes, i) for x in reacts) + "\n" + res_string - return f"```{res_string}```" - - async def get_total_votes(self): - updated_message = await self.message.channel.fetch_message(self.id) - self.total_votes = 0 - self.message = updated_message - for reaction in updated_message.reactions: - self.total_votes += reaction.count - 1 - return self.total_votes - - def make_bar(self, reaction, longest_descriptor, winning_votes, num_votes): - winner = winning_votes == num_votes - react_as_emoji = MultiEmoji(reaction.emoji) - descriptor = self.options.get(react_as_emoji.emoji_id).get("descriptor") - spacing = longest_descriptor - len(descriptor) - bar_length = int((num_votes / winning_votes) * BAR_LENGTH) - string = f"{descriptor}{' ' * spacing} | {'=' * bar_length}{'' if num_votes else ' '}" \ - f"{'🏆' if winner else ''} +{num_votes} Vote{'' if num_votes == 1 else 's'}" - return string - - async def enable_menu(self, bot) -> bool: - if await super().enable_menu(bot): - if not self.end_time: - self.end_time = datetime.datetime.now() + datetime.timedelta(seconds=self.poll_length) - return True - return False - - async def disable_menu(self, bot) -> bool: - if await super().disable_menu(bot): - self.end_time = None - return True - return False - - async def react_add_func(self, payload: RawReactionActionEvent) -> bool: - triggering_emoji = payload.emoji - - if triggering_emoji not in self: - await self.message.clear_reaction(triggering_emoji) - return False - - self_user = self.message.guild.me - - for reaction in self.message.reactions: - if reaction.count > 1 and reaction.me: - await self.message.remove_reaction(reaction, self_user) - - return True - - async def react_remove_func(self, payload: RawReactionActionEvent) -> bool: - if payload.user_id == self.message.guild.me.id: - return False - - reaction_emojis = [x.emoji for x in self.message.reactions] - event_emoji = payload.emoji - - if event_emoji not in reaction_emojis: - await self.message.add_reaction(event_emoji) - return True - - -class ActionConfirmationMenu(ReactableMenu): - """ - The reaction menu for confirming or cancelling any given action. - """ - def __init__(self, **kwargs): - if not kwargs.get("use_inline"): - kwargs["use_inline"] = True - - if not kwargs.get("add_func"): - kwargs["add_func"] = self.react_add_func - - super().__init__(**kwargs) - - self.confirm_func = None - self.confirm_args = None - self.confirm_kwargs = None - self.confirm_is_coro = False - - self.cancel_func = None - self.cancel_args = None - self.cancel_kwargs = None - self.cancel_is_coro = False - - self.was_confirmed = False - self.delete_after = kwargs.get("delete_after", False) - self.add_option(CONFIRM_EMOJI, CONFIRM_DESC) - self.add_option(CANCEL_EMOJI, CANCEL_DESC) - - async def update_visuals(self): - if self.enabled: - self.title_suffix = "" - self.colour = discord.Colour.green() - else: - self.title_suffix = "(Action Confirmed)" if self.was_confirmed else "(Action Cancelled)" - self.colour = discord.Colour.red() - await self.update_message() - - def set_confirm_func(self, func, *args, **kwargs): - self.confirm_func = func - self.confirm_is_coro = inspect.iscoroutinefunction(func) - self.confirm_args = args - self.confirm_kwargs = kwargs - - def set_cancel_func(self, func, *args, **kwargs): - self.cancel_func = func - self.cancel_is_coro = inspect.iscoroutinefunction(func) - self.cancel_args = args - self.cancel_kwargs = kwargs - - async def react_add_func(self, payload): - triggering_member = payload.member - triggering_emoji = payload.emoji - - formatted_emoji = MultiEmoji(triggering_emoji) - - if formatted_emoji not in self: - await self.message.clear_reaction(triggering_emoji) - return False - - if formatted_emoji == CONFIRM_EMOJI: - if self.confirm_is_coro: - await self.confirm_func(*self.confirm_args, **self.confirm_kwargs) - else: - self.confirm_func(*self.confirm_args, **self.confirm_kwargs) - self.description = f"Event deletion confirmed by {triggering_member.name}#{triggering_member.discriminator}" - self.was_confirmed = True - elif formatted_emoji == CANCEL_EMOJI: - if self.cancel_is_coro: - await self.cancel_func(*self.cancel_args, **self.cancel_kwargs) - else: - self.cancel_func(*self.cancel_args, **self.cancel_kwargs) - self.description = f"Event deletion cancelled by {triggering_member.name}#{triggering_member.discriminator}" - self.was_confirmed = False - - if self.delete_after: - await self.message.delete() - else: - await self.update_visuals() diff --git a/src/esportsbot/DiscordReactableMenus/PingableMenus.py b/src/esportsbot/DiscordReactableMenus/PingableMenus.py deleted file mode 100644 index 60ab4d27..00000000 --- a/src/esportsbot/DiscordReactableMenus/PingableMenus.py +++ /dev/null @@ -1,160 +0,0 @@ -import datetime -from typing import Dict - -from discord import Embed, Reaction, Role - -from esportsbot.DiscordReactableMenus.ExampleMenus import PollReactMenu, RoleReactMenu -from esportsbot.DiscordReactableMenus.ReactableMenu import ReactableMenu - -NO_VOTES = "No votes received!" - - -class PingableVoteMenu(PollReactMenu): - """ - A reaction menu used in the VotingCog. Is a modified Poll ReactionMenu that has a timer. - """ - def __init__(self, pingable_name: str, **kwargs): - super().__init__(**kwargs) - self.name = pingable_name - - def __str__(self): - return self.name - - @classmethod - async def from_dict(cls, bot, data) -> ReactableMenu: - """ - Create a PingableVoteMenu from a dictionary representation of one. - :param bot: The instance of the bot. - :param data: The data of a saved PingableVoteMenu - :return: A PingableVoteMenu from the given data. - """ - try: - kwargs = await cls.load_dict(bot, data) - - menu = PingableVoteMenu(**kwargs) - if menu.enabled: - menu.enabled = False - await menu.enable_menu(bot) - - return menu - except AttributeError: - return data - - @classmethod - async def load_dict(cls, bot, data) -> Dict: - """ - Formats the incoming data to ensure it has the correct keys in kwargs. - :param bot: The instance of the bot. - :param data: The data of a saved EventReactMenu. - :return: A formatted dictionary that can be passed to the constructor of a PingableVoteMenu - """ - kwargs = await super(PingableVoteMenu, cls).load_dict(bot, data) - - pingable_name = data.get("name") - kwargs["pingable_name"] = pingable_name - - return kwargs - - def to_dict(self): - """ - Get the dictionary representation of a PingableVoteMenu - :return: A dictionary of the saveable attributes of a PingableVoteMenu. - """ - kwargs = super(PingableVoteMenu, self).to_dict() - kwargs["name"] = self.name - return kwargs - - async def generate_result_embed(self, dummy_emoji, vote_threshold): - """ - Get the embed for the results of the polls. - :param dummy_emoji: The dummy emoji to be used as the vote threshold option. - :param vote_threshold: The number of votes required for a PingableVoteMenu to be successful. - :return: A discord Embed object. - """ - results = await self.get_results() - if self.total_votes <= 0: - string = NO_VOTES - else: - self.options[dummy_emoji.emoji_id] = {"emoji": dummy_emoji, "descriptor": "Vote Threshold"} - dummy_react = Reaction( - emoji=dummy_emoji.discord_emoji, - message=self.message, - data={ - "count": vote_threshold, - "me": True - } - ) - if vote_threshold > results.get("winner_count"): - results["winner_count"] = vote_threshold - results["reactions"][vote_threshold].append(dummy_react) - self.total_votes += vote_threshold - string = self.generate_results_string(results) - self.options.pop(dummy_emoji.emoji_id) - self.total_votes -= vote_threshold - - title = self.title - description = self.description - - embed = Embed(title=f"{title} Results", description=description) - embed.add_field(name="Results", value=string, inline=False) - - return embed - - -class PingableRoleMenu(RoleReactMenu): - """ - A reaction menu used in the PingableRoles. Is a modified RoleReactionMenu with a few extra attributes for storing cooldown - and when it was last pinged. - """ - def __init__(self, pingable_role: Role, ping_cooldown: int, **kwargs): - super(PingableRoleMenu, self).__init__(**kwargs) - self.role = pingable_role - self.last_pinged = datetime.datetime.now() - self.cooldown = ping_cooldown - - @classmethod - async def from_dict(cls, bot, data) -> ReactableMenu: - """ - Create a PingableRoleMenu from a dictionary representation of one. - :param bot: The instance of the bot. - :param data: The data of a saved EventReactMenu. - :return: A PingableRoleMenu from the given data. - """ - try: - kwargs = await cls.load_dict(bot, data) - - menu = PingableRoleMenu(**kwargs) - if menu.enabled: - menu.enabled = False - await menu.enable_menu(bot) - return menu - except AttributeError: - return data - - @classmethod - async def load_dict(cls, bot, data) -> Dict: - """ - Formats the incoming data to ensure it has the correct keys in kwargs. - :param bot: The instance of the bot. - :param data: The data of a saved EventReactMenu. - :return: A formatted dictionary that can be passed to the constructor of a PingableRoleMenu. - """ - kwargs = await super(PingableRoleMenu, cls).load_dict(bot, data) - - guild = bot.get_guild(data.get("guild_id")) - pingable_role = guild.get_role(data.get("role_id")) - await pingable_role.edit(mentionable=True) - kwargs["pingable_role"] = pingable_role - kwargs["ping_cooldown"] = int(data.get("cooldown_seconds")) - - return kwargs - - def to_dict(self): - """ - Get the dictionary representation of a PingableRoleMenu. - :return: A dictionary of the saveable attributes of a PingableRoleMenu. - """ - kwargs = super(PingableRoleMenu, self).to_dict() - kwargs["role_id"] = self.role.id - kwargs["cooldown_seconds"] = self.cooldown - return kwargs diff --git a/src/esportsbot/DiscordReactableMenus/ReactableMenu.py b/src/esportsbot/DiscordReactableMenus/ReactableMenu.py deleted file mode 100644 index 2b12797a..00000000 --- a/src/esportsbot/DiscordReactableMenus/ReactableMenu.py +++ /dev/null @@ -1,273 +0,0 @@ -import ast -from typing import Dict, List, Any, Union - -import discord -from discord import Embed, HTTPException, Message, Emoji, PartialEmoji, Role, TextChannel -from emoji import emojize - -from esportsbot.DiscordReactableMenus.EmojiHandler import MultiEmoji - -DISABLED_STRING = "(Currently Disabled)" - - -class ReactableMenu: - """ - The base class for all ReactionMenus. - """ - def __init__(self, add_func=None, remove_func=None, show_ids=True, auto_enable=False, **kwargs): - self.react_add_func = add_func - self.react_remove_func = remove_func - self.id = kwargs.pop("id", None) - self.message = kwargs.pop("message", None) - self.guild = None if not self.message else self.message.guild - self.channel = None if not self.message else self.message.channel - self.options = kwargs.pop("options", {}) - self.enabled = kwargs.pop("enabled", False) - self.use_inline = kwargs.pop("use_inline", False) - self.title = kwargs.pop("title", "Reactable Menu") - self.description = kwargs.pop("description", "") - self.title_suffix = "" if self.enabled else DISABLED_STRING - self.colour = discord.Colour.green() if self.enabled else discord.Colour.red() - self.show_ids = show_ids - self.auto_enable = auto_enable - - def __str__(self) -> str: - __str = f"Title:{self.title} | Description: {self.description}" - for emoji, descriptor in self.options.items(): - if isinstance(emoji, str): - __str += f"\nEmoji: {emojize(emoji)} | Descriptor: {descriptor}" - else: - __str += f"\nEmoji: {emoji.name} | Descriptor: {descriptor}" - return __str - - def __repr__(self): - return repr(self.options) - - def __contains__(self, item): - return self.__getitem__(item) is not None - - def __getitem__(self, item: Union[str, dict, Emoji, PartialEmoji, MultiEmoji]): - try: - p_emoji = MultiEmoji(item) - return self.options.get(p_emoji.emoji_id) - except ValueError: - return None - - def __dict__(self): - return self.to_dict() - - def to_dict(self): - data = { - "id": self.id, - "title": self.title, - "guild_id": self.message.guild.id, - "channel_id": self.message.channel.id, - "options": self.serialize_options(), - "enabled": self.enabled, - "show_ids": self.show_ids - } - return data - - def serialize_options(self): - data = {} - for option in self.options: - option_data = self.options.get(option) - emoji_data = option_data.get("emoji").to_dict() - descriptor = option_data.get("descriptor") - data[option] = {"emoji": emoji_data, "descriptor": descriptor} - return data - - @staticmethod - def deserialize_options(options) -> Dict[Union[Emoji, str], Any]: - data = {} - if isinstance(options, str): - options = ast.literal_eval(options) - for option in options: - option_data = options.get(option) - emoji = MultiEmoji(option_data.get("emoji")) - descriptor = option_data.get("descriptor") - data[option] = {"emoji": emoji, "descriptor": descriptor} - return data - - @classmethod - async def from_dict(cls, bot, data): - kwargs = await cls.load_dict(bot, data) - return ReactableMenu(**kwargs) - - @classmethod - async def load_dict(cls, bot, data) -> Dict: - kwargs = {"id": int(data.get("id"))} - - guild_id = int(data.get("guild_id")) - channel_id = int(data.get("channel_id")) - guild = bot.get_guild(guild_id) - channel = guild.get_channel(channel_id) - kwargs["message"] = await channel.fetch_message(kwargs["id"]) - if kwargs["message"] is None: - raise ValueError("The message for this reaction menu has been deleted!") - - if not kwargs["message"].embeds: - raise ValueError("The message for this reaction menu has no menu in it!") - - embed = kwargs["message"].embeds[0] - kwargs["description"] = embed.description - kwargs["title"] = data.get("title") - kwargs["options"] = cls.deserialize_options(data.get("options")) - kwargs["enabled"] = bool(data.get("enabled")) - kwargs["show_ids"] = bool(data.get("show_ids")) - - return kwargs - - def add_option(self, emoji: Union[Emoji, PartialEmoji, MultiEmoji, str], descriptor: Any) -> bool: - if isinstance(descriptor, Role): - descriptor = descriptor.mention - elif isinstance(descriptor, TextChannel): - descriptor = descriptor.mention - else: - descriptor = str(descriptor) - - try: - formatted_emoji = MultiEmoji(emoji) - - emoji_id = formatted_emoji.emoji_id if formatted_emoji.emoji_id else formatted_emoji.name - - if emoji_id in self.options: - return False - - self.options[emoji_id] = {"emoji": formatted_emoji, "descriptor": descriptor} - return True - except ValueError: - return False - - def remove_option(self, emoji: Union[Emoji, PartialEmoji, MultiEmoji, str]) -> bool: - try: - formatted_emoji = MultiEmoji(emoji) - return self.options.pop(formatted_emoji.emoji_id, None) is not None - except ValueError: - return False - - def add_many(self, options: Dict[Union[Emoji, PartialEmoji, str], Any]) -> List[Dict[str, str]]: - failed = [] - for emoji, descriptor in options.items(): - if not self.add_option(emoji, descriptor): - failed.append({emoji: descriptor}) - return failed - - def remove_many(self, emojis: List[Union[Emoji, PartialEmoji, str]]) -> List[str]: - failed = [] - for emoji in emojis: - if not self.remove_option(emoji): - failed.append(str(emoji)) - return failed - - def generate_embed(self) -> Embed: - embed = Embed(title=f"{self.title} {self.title_suffix}", description=self.description, colour=self.colour) - for emoji_id in self.options: - emoji = self.options.get(emoji_id).get("emoji").discord_emoji - descriptor = self.options.get(emoji_id).get("descriptor") - embed.add_field(name=emoji, value=descriptor, inline=self.use_inline) - - return embed - - def add_footer(self, embed): - if self.show_ids and self.id: - embed.set_footer(text=f"Menu message id: {self.id}") - - def toggle_footer(self): - self.show_ids = not self.show_ids - - async def update_visuals(self): - if self.enabled: - self.title_suffix = "" - self.colour = discord.Colour.green() - else: - self.title_suffix = DISABLED_STRING - self.colour = discord.Colour.red() - await self.update_message() - - async def enable_menu(self, bot) -> bool: - if not self.enabled: - self.enabled = True - await self.update_visuals() - bot.add_listener(self.on_react_add, "on_raw_reaction_add") - bot.add_listener(self.on_react_remove, "on_raw_reaction_remove") - return True - return False - - async def disable_menu(self, bot) -> bool: - if self.enabled: - self.enabled = False - await self.update_visuals() - bot.remove_listener(self.on_react_add, "on_raw_reaction_add") - bot.remove_listener(self.on_react_remove, "on_raw_reaction_remove") - return True - return False - - async def toggle_menu(self, bot) -> bool: - if not self.enabled: - return await self.enable_menu(bot) - else: - return await self.disable_menu(bot) - - async def finalise_and_send(self, bot, channel: TextChannel): - embed = self.generate_embed() - await self.send_to_channel(channel, embed) - self.add_footer(embed) - await self.message.edit(embed=embed) - if self.auto_enable: - await self.enable_menu(bot) - - async def update_message(self): - embed = self.generate_embed() - self.add_footer(embed) - await self.message.edit(embed=embed) - self.message = await self.message.channel.fetch_message(self.id) - if self.enabled: - await self.add_reactions() - - async def send_to_channel(self, channel: TextChannel, embed: Embed = None) -> Message: - if embed is None: - embed = self.generate_embed() - self.message: Message = await channel.send(embed=embed) - self.guild = self.message.guild - self.channel = self.message.channel - self.id = self.message.id - return self.message - - async def add_reactions(self, message: Message = None): - if message is None: - message = self.message - - if message is None: - raise ValueError("There is no message to add reactions to") - - emojis_to_add = list(self.options.keys()) - for react in self.message.reactions: - react_emoji = MultiEmoji(react.emoji) - if react_emoji.emoji_id in emojis_to_add: - emojis_to_add.remove(react_emoji.emoji_id) - else: - await react.clear() - - for emoji_id in emojis_to_add: - emoji = self.options.get(emoji_id).get("emoji") - try: - await message.add_reaction(emoji.discord_emoji) - except HTTPException: - pass - - async def on_react_add(self, payload): - if payload is None: - return None - if self.enabled and self.react_add_func and not payload.member.bot and payload.message_id == self.id: - self.message = await self.channel.fetch_message(payload.message_id) - return await self.react_add_func(payload) - return None - - async def on_react_remove(self, payload): - if payload is None: - return None - if self.enabled and self.react_remove_func and payload.message_id == self.id: - self.message = await self.channel.fetch_message(payload.message_id) - return await self.react_remove_func(payload) - return None diff --git a/src/esportsbot/DiscordReactableMenus/reactable_lib.py b/src/esportsbot/DiscordReactableMenus/reactable_lib.py deleted file mode 100644 index d2a5a027..00000000 --- a/src/esportsbot/DiscordReactableMenus/reactable_lib.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Dict, List - - -def get_latest(all_menus): - """ - Get the latest created menu from the give menus. - :param all_menus: All menus to check through. - :return: The ReactableMeu that was created last. - """ - menus = list(all_menus.values()) - latest_menu = sorted(menus, key=lambda x: x.message.created_at) - if latest_menu: - return latest_menu[-1] - return None - - -def get_option(message_line: str) -> Dict[str, str]: - """ - Get a menu option from a string. - :param message_line: A single line in a message, or just a string. - :return: A dictionary of Emoji : descriptor of a ReactionMenu option. - """ - split_message = message_line.split(" ") - emoji_str = split_message[0] - descriptor = " ".join(split_message[1:]) - return {emoji_str: descriptor} - - -def get_all_options(message: List[str]) -> Dict[str, str]: - """ - Get all the ReactionMenu options from a multiline message. - :param message: The list of strings representing the lines in a message. - :return: A dictionary of Emoji : descriptor of all the ReactionMenu options. - """ - options = {} - for line in message: - options = {**options, **get_option(line)} - return options - - -def clean_mentioned_role(role: str) -> int: - """ - Get the ID of a role from a role mention string. - :param role: The role mention string. - :return: An integer of the role ID or 0 if the ID given is not an int. - """ - role = str(role) - role = role.strip() - try: - return int(role.lstrip("<@&").rstrip(">")) - except ValueError: - return 0 - - -def get_role_from_id(guild, role_id): - """ - Get a Role in a guild from its an ID. - :param guild: The guild to get the role from. - :param role_id: The ID of the role to get. - :return: A discord Role object if the role exists, else None. - """ - for role in guild.roles: - if role.id == role_id: - return role - return None - - -def get_menu(all_menus, menu_id): - """ - Get a menu from the given `all_menus` given a menu ID. - :param all_menus: All the menus to search in. - :param menu_id: THe ID of the menu to get. - :return: A ReactionMenu if there is a menu with that ID, else None - """ - menu = None - if menu_id is None: - menu = get_latest(all_menus) - elif menu_id.isdigit(): - menu = all_menus.get(int(menu_id), None) - return menu diff --git a/src/esportsbot/banned_words.txt b/src/esportsbot/banned_words.txt deleted file mode 100644 index a471c5a1..00000000 --- a/src/esportsbot/banned_words.txt +++ /dev/null @@ -1,1102 +0,0 @@ -# Originally Obtained From: https://www.cs.cmu.edu/~biglou/resources/bad-words.txt -abbo -abo -abortion -abuse -alligatorbait -anal -analannie -analsex -anus -argie -aroused -arse -arsehole -assbagger -ass -asses -assfuck -assfucker -asshore -assklown -asslick -asslicker -asslover -assman -assmonkey -assmunch -assmuncher -asspacker -asspirate -asspuppies -assranger -asswhore -asswipe -babe -babies -backdoorman -badfuck -balllicker -balls -ballsack -banging -barelylegal -bast -bastard -bazongas -bazooms -beaner -beastality -beastial -beastiality -beatoff -beat-off -beatyourmeat -bestial -bestiality -biatch -bigass -bigbastard -bigbutt -bitch -bitcher -bitches -bitchez -bitchin -bitching -bitchslap -bitchy -biteme -blowjob -boang -bogan -bohunk -bollick -bollock -bondage -boner -boob -boobies -boobs -booby -boody -boong -boonga -boonie -booty -bootycall -bra -breast -breastjob -breastlover -breastman -brothel -bulldike -bulldyke -bullshit -bumblefuck -bumfuck -bunga -bunghole -buried -butchbabes -butchdike -butchdyke -butt -buttbang -butt-bang -buttface -buttfuck -butt-fuck -buttfucker -butt-fucker -buttfuckers -butt-fuckers -butthead -buttman -buttmunch -buttmuncher -buttpirate -buttplug -buttstain -byatch -cacker -cameljockey -cameltoe -carpetmuncher -carruth -cherrypopper -chickslick -chink -chinky -choad -chode -clamdigger -clamdiver -clit -clitoris -clogwog -cocaine -cock -cockblock -cockblocker -cockcowboy -cockfight -cockhead -cockknob -cocklicker -cocklover -cocknob -cockqueen -cockrider -cocksman -cocksmith -cocksmoker -cocksucer -cocksuck -cocksucked -cocksucker -cocksucking -cocktease -cocky -cohee -coitus -colored -coloured -coom -coomer -coon -coondog -copulate -cornhole -crackpipe -crackwhore -crack-whore -crapola -crapper -crappy -crime -crimes -criminal -criminals -crotch -crotchjockey -crotchmonkey -crotchrot -cum -cumbubble -cumfest -cumjockey -cumm -cummer -cumming -cumquat -cumqueen -cumshot -cunilingus -cunillingus -cunn -cunnilingus -cunntt -cunt -cunteyed -cuntfuck -cuntfucker -cuntlick -cuntlicker -cuntlicking -cuntsucker -cybersex -cyberslimer -dago -dahmer -darkie -darky -datnigga -deapthroat -deepthroat -defecate -dego -dick -dickbrain -dickforbrains -dickhead -dickless -dicklick -dicklicker -dickman -dickwad -dickweed -diddle -dike -dildo -dipshit -dipstick -dix -dixiedike -dixiedyke -doggiestyle -doggystyle -dong -doodoo -doo-doo -dripdick -dumbass -dumbbitch -dumbfuck -dyefly -dyke -easyslut -eatballs -eatme -eatpussy -ecstacy -ejaculate -ejaculated -ejaculating -ejaculation -enema -erect -erection -ero -escort -ethnic -excrement -facefucker -faeces -fag -fagging -faggot -fagot -fannyfucker -farty -fastfuck -fatah -fatass -fatfuck -fatfucker -fatso -fckcum -feces -felatio -felch -felcher -felching -fellatio -feltch -feltcher -feltching -fetish -fingerfood -fingerfuck -fingerfucked -fingerfucker -fingerfuckers -fingerfucking -fister -fistfuck -fistfucked -fistfucker -fistfucking -fisting -flange -flatulence -floo -flydie -flydye -fok -fondle -footaction -footfuck -footfucker -footlicker -footstar -foreskin -forni -fornicate -foursome -fourtwenty -freakfuck -freakyfucker -freefuck -fu -fubar -fuc -fucck -fuck -fucka -fuckable -fuckbag -fuckbuddy -fucked -fuckedup -fucker -fuckers -fuckface -fuckfest -fuckfreak -fuckfriend -fuckhead -fuckher -fuckin -fuckina -fucking -fuckingbitch -fuckinnuts -fuckinright -fuckit -fuckknob -fuckme -fuckmehard -fuckmonkey -fuckoff -fuckpig -fucks -fucktard -fuckwhore -fuckyou -fudgepacker -fugly -fuk -fuks -funfuck -fuuck -gangbang -gangbanged -gangbanger -gangsta -gatorbait -gaymuthafuckinwhore -gaysex -geni -genital -getiton -ginzo -gipp -givehead -goddamnmuthafucker -goldenshower -gonorrehea -gonzagas -gook -gotohell -goy -goyim -greaseball -gringo -groe -grostulation -gubba -gummer -gyp -gypo -gypp -gyppie -gyppo -gyppy -hamas -handjob -hapa -hardon -harem -headfuck -hebe -heeb -henhouse -herpes -hijack -hijacker -hijacking -hillbillies -hindoo -hiscock -hitler -hitlerism -hitlerist -hiv -hobo -hodgie -hoes -holestuffer -homicide -homobangers -honger -honkers -hook -hooker -hookers -hooters -hore -hork -horney -horniest -horny -horseshit -hosejob -hoser -hotpussy -hottotrot -hummer -hussy -hustler -hymen -hymie -iblowu -ikey -incest -insest -intercourse -interracial -intheass -inthebuff -jackass -jackoff -jackshit -japcrap -jerkoff -jew -jewish -jiga -jigaboo -jigg -jigga -jiggabo -jigger -jiggy -jihad -jijjiboo -jimfish -jism -jiz -jizim -jizjuice -jizm -jizz -jizzim -jizzum -joint -juggalo -jugs -junglebunny -kaffer -kaffir -kaffre -kafir -kanake -kigger -kike -kissass -kkk -knockers -kock -kondum -koom -koomer -koon -kotex -krap -krappy -kum -kumbubble -kumbullbe -kummer -kumming -kumquat -kums -kunilingus -kunnilingus -kunt -kys -kyke -lactate -lapdance -lesbayn -lesbin -lesbo -lez -lezbe -lezbefriends -lezbo -lezz -lezzo -liberal -libido -licker -lickme -limey -limpdick -limy -lingerie -liquor -livesex -loadedgun -loli -lolita -looser -lotion -lovebone -lovegoo -lovegun -lovejuice -lovemuscle -lovepistol -loverocket -lowlife -lsd -lubejob -luckycammeltoe -lugan -lynch -macaca -magicwand -mams -manhater -manpaste -mastabate -mastabater -masterbate -masterblaster -mastrabator -masturbate -masturbating -mattressprincess -meatbeatter -meatrack -meth -mgger -mggor -mickeyfinn -mideast -milf -mockey -mockie -mocky -mofo -moky -molest -molestation -molester -molestor -moneyshot -mooncricket -mormon -moslem -mosshead -mothafuck -mothafucka -mothafuckaz -mothafucked -mothafucker -mothafuckin -mothafucking -mothafuckings -motherfuck -motherfucked -motherfucker -motherfuckin -motherfucking -motherfuckings -motherlovebone -muff -muffdive -muffdiver -muffindiver -mufflikcer -mulatto -muncher -munt -nastybitch -nastyho -nastyslut -nastywhore -nazi -negro -negroes -negroid -negro's -nig -niger -nigg -nigga -niggah -niggaracci -niggard -niggarded -niggarding -niggardliness -niggardliness's -niggardly -niggards -niggard's -niggaz -nigger -niggerhead -niggerhole -niggers -nigger's -niggle -niggled -niggles -niggling -nigglings -niggor -niggur -niglet -nignog -nigr -nigra -nigre -nipple -nipplering -nittit -nlgger -nlggor -nofuckingway -nook -nookey -nookie -noonan -nooner -nudger -nutfucker -nymph -ontherag -oral -orga -orgasim -orgasm -orgies -orgy -osama -paki -palesimian -palestinian -pansies -pansy -panti -panties -payo -pearlnecklace -peck -pecker -peckerwood -pee -peehole -pee-pee -peepshow -peepshpw -pendy -penetration -peni5 -penile -penis -penises -penthouse -period -perv -phonesex -phuk -phuked -phuking -phukked -phukking -phungky -phuq -pi55 -picaninny -piccaninny -pickaninny -piker -pikey -piky -pimper -pimpjuic -pimpjuice -pimpsimp -pindick -piss -pissed -pisser -pisses -pisshead -pissin -pissing -pissoff -pistol -pixie -pixy -playboy -playgirl -pocha -pocho -pocketpool -pohm -polack -pom -pommie -pommy -poo -poon -poontang -poop -pooper -pooperscooper -pooping -poorwhitetrash -popimp -porchmonkey -porn -pornflick -pornking -porno -pornography -pornprincess -premature -pric -prick -prickhead -primetime -prostitute -protestant -pu55i -pu55y -pube -pubic -pubiclice -pud -pudboy -pudd -puddboy -puke -puntang -purinapricness -puss -pussie -pussies -pussy -pussycat -pussyeater -pussyfucker -pussylicker -pussylips -pussylover -pussypounder -pusy -quashie -queef -queer -quickie -quim -ra8s -rabbi -racial -racist -radical -radicals -raghead -randy -rape -raped -raper -rapist -rearend -rearentry -rectum -redlight -reestie -rentafuck -republican -rere -retard -retarded -ribbed -rigger -rimjob -rimming -roundeye -rump -sadis -sadom -samckdaddy -sandm -sandnigger -scag -scallywag -scat -schlong -screw -screwyou -scrotum -scum -semen -seppo -servant -sex -sexed -sexfarm -sexhound -sexhouse -sexing -sexkitten -sexpot -sexslave -sextogo -sextoy -sextoys -sexual -sexually -sexwhore -sexy -sexymoma -sexy-slim -shag -shaggin -shagging -shat -shav -shawtypimp -sheeney -shhit -shinola -shit -shitcan -shitdick -shite -shiteater -shited -shitface -shitfaced -shitfit -shitforbrains -shitfuck -shitfucker -shitfull -shithapens -shithappens -shithead -shithouse -shiting -shitlist -shitola -shitoutofluck -shits -shitstain -shitted -shitter -shitting -shitty -shortfuck -sissy -sixtynine -sixtyniner -skank -skankbitch -skankfuck -skankwhore -skanky -skankybitch -skankywhore -skinflute -skum -skumbag -slant -slanteye -slapper -slaughter -slav -slave -slavedriver -sleezebag -sleezeball -slideitin -slimebucket -slopehead -slopey -slopy -slut -sluts -slutt -slutting -slutty -slutwear -slutwhore -smack -smackthemonkey -smut -snatch -snatchpatch -snigger -sniggered -sniggering -sniggers -snigger's -snot -snowback -snownigger -sodom -sodomise -sodomite -sodomize -sodomy -sonofabitch -sonofbitch -sooty -spaghettibender -spaghettinigger -spank -spankthemonkey -sperm -spermacide -spermbag -spermhearder -spermherder -spic -spick -spig -spigotty -spik -spitter -splittail -spooge -spreadeagle -spunk -spunky -squaw -stagg -stiffy -strapon -stringer -stripclub -stroking -stupidfuck -stupidfucker -suckdick -sucker -suckme -suckmyass -suckmydick -suckmytit -suckoff -suicide -swallow -swallower -swalow -swastika -syphilis -taff -tampon -tang -tantra -tarbaby -tard -teat -teste -testicle -testicles -thicklips -thirdeye -thirdleg -threesome -threeway -timbernigger -tinkle -tit -titbitnipply -titfuck -titfucker -titfuckin -titjob -titlicker -titlover -tits -tittie -titties -titty -tongethruster -tonguethrust -tonguetramp -tortur -tosser -towelhead -trailertrash -tramp -trannie -tranny -transexual -transsexual -transvestite -triplex -trisexual -trots -tuckahoe -tunneloflove -turd -turnon -twink -twinkie -twobitwhore -uck -uk -unfuckable -upskirt -uptheass -upthebutt -urinary -urinate -urine -usama -uterus -vagina -vaginal -vatican -vibr -vibrater -vibrator -vietcong -violence -virgin -virginbreaker -vomit -vulva -wab -wank -wanker -wanking -waysted -weenie -weewee -welcher -welfare -wetb -wetback -wetspot -whacker -whash -whigger -whiskeydick -whiskydick -whit -whitenigger -whites -whitetrash -whitey -whiz -whop -whore -whorefucker -whorehouse -wigger -willie -williewanker -willy -wn -wog -wop -wtf -wuss -wuzzie -xtc -yankee -yellowman -zigabo -zipperhead diff --git a/src/esportsbot/base_functions.py b/src/esportsbot/base_functions.py deleted file mode 100644 index e80485d2..00000000 --- a/src/esportsbot/base_functions.py +++ /dev/null @@ -1,64 +0,0 @@ -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.models import VoicemasterMaster, VoicemasterSlave - - -def role_id_from_mention(pre_clean_data: str) -> int: - """Extracts the ID of a role from a role mention. - Will also accept strings containing a role ID, and will reject invalid integers with a ValueError. - This does validate the ID further, e.g the size of the ID, or the existence of a role with the ID. - - :param str pre_clean_data: A string containing either a role mention or ID - :return: The ID quoted in pre_clean_data - :rtype: int - :raise ValueError: When given an ID containing non-integer characters - """ - return int(pre_clean_data.lstrip("<@&").rstrip(">")) - - -def channel_id_from_mention(pre_clean_data: str) -> int: - """Extracts the ID of a channel from a channel mention. - Will also accept strings containing a channel ID, and will reject invalid integers with a ValueError. - This does validate the ID further, e.g the size of the ID, or the existence of a channel with the ID. - - :param str pre_clean_data: A string containing either a channel mention or ID - :return: The ID quoted in pre_clean_data - :rtype: int - :raise ValueError: When given an ID containing non-integer characters - """ - return int(pre_clean_data.lstrip("<#").rstrip(">")) - - -def user_id_from_mention(pre_clean_data: str) -> int: - """Extracts the ID of a user from a user mention. - Will also accept strings containing a user ID, and will reject invalid integers with a ValueError. - This does validate the ID further, e.g the size of the ID, or the existence of a user with the ID. - Accepting ! characters also accounts for member mentions where the member has a nickname. - - :param str pre_clean_data: A string containing either a user mention or ID - :return: The ID quoted in pre_clean_data - :rtype: int - :raise ValueError: When given an ID containing non-integer characters - """ - return int(pre_clean_data.lstrip("<@!").rstrip(">")) - - -def get_whether_in_vm_parent(guild_id, channel_id): - """ - Get if the given channel is a voicemaster parent channel. - :param guild_id: The ID of the guild to check in. - :param channel_id: The ID of the channel to check if it is a parent channel. - :return: True if the given channel ID is for a parent channel, False otherwise. - """ - in_parent = DBGatewayActions().get(VoicemasterMaster, guild_id=guild_id, channel_id=channel_id) - return bool(in_parent) - - -def get_whether_in_vm_child(guild_id, channel_id): - """ - Get if the given channel is a voicemaster child channel. - :param guild_id: The ID of the guild to check in. - :param channel_id: The ID of the channel to check if it is a child channel. - :return: True if the given channel ID is for a child channel, False otherwise. - """ - in_child = DBGatewayActions().get(VoicemasterSlave, guild_id=guild_id, channel_id=channel_id) - return bool(in_child) diff --git a/src/esportsbot/bot.py b/src/esportsbot/bot.py deleted file mode 100644 index eed711e1..00000000 --- a/src/esportsbot/bot.py +++ /dev/null @@ -1,143 +0,0 @@ -from esportsbot.lib import client, exceptions - -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.models import GuildInfo - -from discord.ext.commands import CommandNotFound, MissingRequiredArgument -from discord.ext.commands.context import Context -from discord import NotFound, HTTPException, Forbidden -import os -import discord -from datetime import datetime - -# EsportsBot client instance -client = client.instance() - - -@client.event -async def on_ready(): - """Initialize the reactionMenuDB and pingme role cooldowns, since this can't be done synchronously - """ - await client.change_presence( - status=discord.Status.dnd, - activity=discord.Activity(type=discord.ActivityType.listening, - name=f"commands using {os.getenv('COMMAND_PREFIX')}") - ) - - -@client.event -async def on_guild_join(guild): - """ - When the bot joins a new server, initialise the DB entry for that guild in the GuildInfo table in the DB. - :param guild: The server the bot just joined. - """ - exists = DBGatewayActions().get(GuildInfo, guild_id=guild.id) - if not exists: - db_item = GuildInfo(guild_id=guild.id) - DBGatewayActions().create(db_item) - - -@client.event -async def on_guild_remove(guild): - """ - When the bot leaves a server, remove the data in the GuildInfo table in the DB. - :param guild: The server the bot just left. - """ - guild_from_db = DBGatewayActions().get(GuildInfo, guild_id=guild.id) - if guild_from_db: - DBGatewayActions().delete(guild_from_db) - print(client.STRINGS["guild_leave"].format(guild_name=guild.name)) - - -@client.event -async def on_command_error(ctx: Context, exception: Exception): - """Handles printing errors to users if their command failed to call, E.g incorrect number of arguments - Also prints exceptions to stdout, since the event loop usually consumes these. - - :param Context ctx: A context summarising the message which caused the error - :param Exception exception: The exception caused by the message in ctx - """ - if isinstance(exception, MissingRequiredArgument): - await ctx.message.reply( - client.STRINGS["command_error_required_arguments"].format( - command_prefix=client.command_prefix, - command_used=ctx.invoked_with - ) - ) - - elif isinstance(exception, CommandNotFound): - try: - await ctx.message.add_reaction(client.unknown_command_emoji.discord_emoji) - except (Forbidden, HTTPException): - pass - except NotFound: - raise ValueError("Invalid unknownCommandEmoji: " + client.unknown_command_emoji.discord_emoji) - else: - source_str = str(ctx.message.id) - try: - source_str += "/" + ctx.channel.name + "#" + str(ctx.channel.id) \ - + "/" + ctx.guild.name + "#" + str(ctx.guild.id) - await client.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "command": ctx.message, - "Error Name": exception.__class__.__name__, - "Error Message": str(exception) - }, - colour=discord.Colour.red() - ) - except AttributeError: - source_str += "/DM@" + ctx.author.name + "#" + str(ctx.author.id) - print( - datetime.now().strftime("%m/%d/%Y %H:%M:%S - Caught " + type(exception).__name__ + " '") + str(exception) - + "' from message " + source_str - ) - exceptions.print_exception_trace(exception) - - -@client.event -async def on_message(message): - """ - When a message is sent, and it is not from a bot, check if the message was a command and if it was, execute the command. - :param message: The message that was sent. - """ - if not message.author.bot: - await client.process_commands(message) - - -def launch(): - """ - Load all the enabled cogs, and start the bot. - """ - if os.getenv("ENABLE_MUSIC", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.MusicCog") - - if os.getenv("ENABLE_TWITCH", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.TwitchCog") - - if os.getenv("ENABLE_TWITTER", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.TwitterCog") - - if os.getenv("ENABLE_PINGME", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.PingableRolesCog") - - if os.getenv("ENABLE_VOICEMASTER", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.VoicemasterCog") - - if os.getenv("ENABLE_DEFAULTROLE", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.DefaultRoleCog") - - if os.getenv("ENABLE_EVENTCATEGORIES", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.EventCategoriesCog") - - if os.getenv("ENABLE_ROLEREACTIONS", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.RoleReactCog") - - if os.getenv("ENABLE_VOTINGMENUS", "FALSE").lower() == "true": - client.load_extension("esportsbot.cogs.VotingCog") - - client.load_extension("esportsbot.cogs.AdminCog") - client.load_extension("esportsbot.cogs.LogChannelCog") - - client.run(os.getenv("DISCORD_TOKEN")) diff --git a/src/esportsbot/cogs/AdminCog.py b/src/esportsbot/cogs/AdminCog.py deleted file mode 100644 index 7974d5d5..00000000 --- a/src/esportsbot/cogs/AdminCog.py +++ /dev/null @@ -1,280 +0,0 @@ -import os -from datetime import datetime - -from discord import Member, TextChannel, CategoryChannel, PermissionOverwrite, Embed, Color -from discord.ext import commands - -devs = os.getenv("DEV_IDS").replace(" ", "").split(",") - - -class AdminCog(commands.Cog): - """ - Adds a few commands useful for admin operations. - - This module makes use of a custom command check for if the user is a develop of the bot. Dev users are defined in the - *.env file. - """ - def __init__(self, bot): - self.bot = bot - self.STRINGS = bot.STRINGS["admin"] - - # Get bot version from text file - try: - version_file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "version.txt") - with open(version_file_path, "rt") as version_file: - self.bot_version = "`" + version_file.readline().strip() + "`" - except FileNotFoundError: - self.bot_version = self.STRINGS['no_version'] - - def is_dev(ctx): - """ - The command check used to check if a user executing the command is a developer of the bot. - :return: - """ - if not devs: - return ctx.author.guild_permissions.administrator - return str(ctx.author.id) in devs - - @commands.group(name="admin") - @commands.has_permissions(administrator=True) - async def admin_group(self, context): - pass - - @commands.group(name="dev") - @commands.check(is_dev) - async def dev_group(self, context): - pass - - @admin_group.command( - name="clear", - aliases=['cls', - 'purge', - 'delete', - 'Cls', - 'Purge', - 'Delete'] - ) - async def clear_messages(self, ctx, amount=5): - """ - Clears the given number of messages from the current channel. If no number is given, this command will delete 5 - messages. - :param ctx: The context of the command. - :param amount: The number of messages to delete. - """ - await ctx.channel.purge(limit=int(amount) + 1) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "command": ctx.message, - "Message": self.STRINGS["channel_cleared"].format(author_mention=ctx.author.mention, - message_amount=amount) - } - ) - - @dev_group.command(name="version", hidden=True) - async def print_version(self, ctx): - """ - Get the version the bot is running on. - :param ctx: The context of the command. - """ - await ctx.channel.send(self.bot_version) - - @admin_group.command( - name="members", - aliases=['Members'] - ) - async def members(self, ctx): - """ - Get the number of members in the current server. - :param ctx: The context of the command. - """ - await ctx.channel.send(self.STRINGS['members'].format(member_count=ctx.guild.member_count)) - - @admin_group.command(name="user-info", aliases=["info", "get-user", "user"]) - async def get_user_info(self, context, user: Member): - user_embed = Embed( - title=f"{user.name} — User Info", - description=f"Showing the user info for {user.mention}\n", - colour=Color.random() - ) - - user_embed.add_field(name="​", value=f"• Pending Status? `{user.pending}`", inline=False) - user_embed.add_field(name="​", value=f"• Current Display Name — {user.display_name}", inline=False) - user_embed.add_field(name="​", value=f"• Date Joined — {user.joined_at.strftime('%m/%d/%Y, %H:%M:%S')}", inline=False) - user_embed.add_field(name="​", value=f"• Account Creation Date — {user.created_at.strftime('%m/%d/%Y, %H:%M:%S')}", inline=False) - - user_embed.set_thumbnail(url=user.default_avatar_url) - - user_embed.set_footer(text=datetime.now().strftime("%m/%d/%Y, %H:%M:%S")) - - await context.send(embed=user_embed) - - @dev_group.command(name="remove-cog", hidden=True) - async def remove_cog(self, context: commands.Context, cog_name: str): - """ - Unloads a cog. This removes all commands/functionality associated with that cog until the bot is restarted. - :param context: The context of the command. - :param cog_name: The name of the cog to disable. - """ - if "AdminCog" in cog_name: - return - try: - package = "esportsbot.cogs." - if package not in cog_name: - self.bot.unload_extension(package + cog_name) - else: - self.bot.unload_extension(cog_name) - await context.send(f"Unloaded cog with name `{cog_name}`") - except commands.ExtensionNotFound: - await context.send(f"There is no cog with the name `{cog_name}`.") - except commands.ExtensionNotLoaded: - await context.send(f"The cog with name `{cog_name}` is not loaded.") - - @dev_group.command(name="add-cog", hidden=True) - async def add_cog(self, context: commands.Context, cog_name: str): - """ - Loads a cog. This adds a cogs commands/functionality to the bot dynamically. This lasts until the bot is restarted. - If a cog makes use of `on_ready` it will not run, which can cause issues for those that load data in that method. - :param context: The context of the command. - :param cog_name: The name of the cog to enable. - """ - if "AdminCog" in cog_name: - return - try: - package = "esportsbot.cogs." - if package not in cog_name: - self.bot.load_extension(package + cog_name) - else: - self.bot.load_extension(cog_name) - await context.send(f"Loaded cog with name `{cog_name}`") - except commands.ExtensionNotFound: - await context.send(f"There is no cog with the name `{cog_name}`.") - except commands.ExtensionAlreadyLoaded: - await context.send(f"The cog with name `{cog_name}` is already loaded.") - - @dev_group.command(name="reload-cog", hidden=True) - async def reload_cog(self, context: commands.Context, cog_name: str): - """ - Reload a cog. Firsts unloads, then loads the cog. If a cog makes use of `on_ready` it will not run, which can cause - issues for those that load data in that method. - :param context: The context of the command. - :param cog_name: The name of the command to reload. - """ - try: - package = "esportsbot.cogs." - if package not in cog_name: - self.bot.reload_extension(package + cog_name) - else: - self.bot.reload_extension(cog_name) - await context.send(f"Reloaded cog with name `{cog_name}`") - except commands.ExtensionNotFound: - await context.send(f"There is no cog with the name `{cog_name}`.") - except commands.ExtensionNotLoaded: - await context.send(f"The cog with name `{cog_name}` is not loaded.") - - @admin_group.command(name="set-rep") - async def set_rep_perms(self, context: commands.Context, user: Member, *args): - """ - Sets the permissions for a game rep given a list of category or channel ids. - :param context: The context of the command. - :param user: The user to give the permissions to. - """ - - channel_names = [] - - for category in args: - try: - category_id = int(category) - discord_category = context.guild.get_channel(category_id) - if not discord_category: - discord_category = await self.bot.fetch_channel(category_id) - # First remove any existing reps/overwrites. - await self.remove_user_permissions(discord_category) - # Then add the new user's permissions. - if await self.set_rep_permissions(user, discord_category): - channel_names.append(discord_category.name) - except ValueError: - continue - - response_string = str(channel_names).replace("[", "").replace("]", "").strip() - await context.send( - f"Successfully set the permissions for `{user.display_name}#{user.discriminator}` " - f"in the following channels/categories: `{response_string}`" - ) - - async def remove_user_permissions(self, guild_channel): - """ - Removes permission overrides that are for specific users for a given GuildChannel. - :param guild_channel: The channel to remove any user-based permission overrides. - :return True if any user-based permissions were removed, False if this process failed. - """ - if not await self.check_editable(guild_channel): - return False - - for permission_group in guild_channel.overwrites: - if isinstance(permission_group, Member): - await guild_channel.set_permissions(target=permission_group, overwrite=None) - - # If the channel provided is category, go through the channels inside the category and remove the permissions. - if not isinstance(guild_channel, CategoryChannel): - return True - - for channel in guild_channel.channels: - await self.remove_user_permissions(channel) - - return True - - async def set_rep_permissions(self, user, guild_channel): - """ - Sets the permissions of a user to those that a rep would need in the given category/channel. - :param user: The user to give the permissions to. - :param guild_channel: The GuildChannel to set the permissions of. - :return True if the permissions were set for the given user, False otherwise. - """ - if not await self.check_editable(guild_channel): - return False - - overwrite = PermissionOverwrite( - view_channel=True, - manage_channels=True, - manage_permissions=True, - send_messages=True, - manage_messages=True, - connect=True, - speak=True, - mute_members=True, - deafen_members=True, - move_members=True, - ) - await guild_channel.set_permissions(target=user, overwrite=overwrite) - - # If the channel provided is a category, ensure that the rep can type in any announcement channels. - if not isinstance(guild_channel, CategoryChannel): - return True - - for channel in guild_channel.channels: - if isinstance(channel, TextChannel) and channel.is_news(): - await channel.set_permissions(target=user, send_messages=True) - - return True - - @staticmethod - async def check_editable(guild_channel): - """ - Checks if the bot has permission to edit the permissions of a channel. - :param guild_channel: The channel to check the permissions of. - :return: True if the bot is able to edit the permissions of the channel, else False. - """ - bot_perms = guild_channel.permissions_for(guild_channel.guild.me) - bot_overwrites = guild_channel.overwrites_for(guild_channel.guild.me) - if not bot_perms.manage_permissions: - return False - # Explicitly check for False, as None means no overwrite. - if bot_overwrites.manage_permissions is False: - return False - return True - - -def setup(bot): - bot.add_cog(AdminCog(bot)) diff --git a/src/esportsbot/cogs/DefaultRoleCog.py b/src/esportsbot/cogs/DefaultRoleCog.py deleted file mode 100644 index 392701a2..00000000 --- a/src/esportsbot/cogs/DefaultRoleCog.py +++ /dev/null @@ -1,180 +0,0 @@ -from discord.ext import commands, tasks -from discord import Embed -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.models import GuildInfo, DefaultRoles -from esportsbot.base_functions import role_id_from_mention - - -class DefaultRoleCog(commands.Cog): - """ - This module enables the functionality to automatically assign roles to new users when they join a server. - """ - def __init__(self, bot): - self.bot = bot - self.pending_members = [] - self.STRINGS = bot.STRINGS["default_role"] - - @commands.Cog.listener() - async def on_member_join(self, member): - """ - When a member joins the server, get the currently set list of default roles and give the new user that set of roles. - :param member: The member that joined the server. - """ - self.pending_members.append(member) - if not self.check_pending_members.is_running() or self.check_pending_members.is_being_cancelled(): - self.check_pending_members.start() - - @commands.Cog.listener() - async def on_member_remove(self, member): - """ - When a member leaves, ensure that they are not a pending member, and if they were, remove them from the list. - :param member: The member that left. - """ - if member in self.pending_members: - self.pending_members.remove(member) - - @tasks.loop(seconds=1) - async def check_pending_members(self): - """ - Check the members that have recently joined to see if they accepted the rules. If they have, give the roles. If the - list of pending members is empty, this check won't run. - """ - if not self.pending_members: - self.check_pending_members.cancel() - self.check_pending_members.stop() - return - - members_to_remove = [] - - for member in self.pending_members: - if not member.pending: - await self.apply_roles(member) - members_to_remove.append(member) - - for member in members_to_remove: - self.pending_members.remove(member) - - async def apply_roles(self, member): - # Get all the default role for the server from database - guild_default_roles = DBGatewayActions().list(DefaultRoles, guild_id=member.guild.id) - # Check to see if any roles exist - if guild_default_roles: - # Create list of roles from database response - apply_roles = [member.guild.get_role(role.role_id) for role in guild_default_roles] - # Add all the roles to the user, we don't check if they're valid as we do this on input - await member.add_roles(*apply_roles) - await self.bot.admin_log( - guild_id=member.guild.id, - actions={ - "Cog": - self.__class__.__name__, - "Action": - self.STRINGS["default_role_join"].format( - member_name=member.mention, - role_ids=" ".join(x.mention for x in apply_roles) - ) - } - ) - else: - await self.bot.admin_log( - guild_id=member.guild.id, - actions={ - "Cog": self.__class__.__name__, - "Action": self.STRINGS["default_role_join_no_role"].format(member_name=member.mention) - } - ) - - @commands.command(name="setdefaultroles") - @commands.has_permissions(administrator=True) - async def setdefaultroles(self, ctx, *, args: str): - """ - Set the list of default roles. There must be a space between each role mention. - :param ctx: The context of the command. - :param args: The list of roles to set as default roles. - """ - role_list = args.split(" ") - if len(role_list) == 0: - await ctx.channel.send(self.STRINGS['default_roles_set_empty']) - else: - checked_roles = [] - checking_error = False - # Loop through the roles to check the input formatting is correct and that roles exist - for role in role_list: - try: - # Clean the inputted role to just the id - cleaned_role_id = role_id_from_mention(role) - # Obtain role object from the guild to check it exists - ctx.author.guild.get_role(cleaned_role_id) - # Add role to array to add post checks - checked_roles.append(cleaned_role_id) - except Exception as err: - print(err) - checking_error = True - if not checking_error: - for role in checked_roles: - DBGatewayActions().create(DefaultRoles(guild_id=ctx.author.guild.id, role_id=role)) - await ctx.channel.send(self.STRINGS['default_roles_set'].format(roles=args)) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS["default_roles_set_log"].format(author_mention=ctx.author.mention, - roles=args) - } - ) - else: - await ctx.channel.send(self.STRINGS['default_roles_set_error']) - - @commands.command(name="getdefaultroles") - @commands.has_permissions(administrator=True) - async def getdefaultroles(self, ctx): - """ - Get the current list of default roles. - :param ctx: The context of the command. - """ - # Get all the default role for the server from database - guild_default_roles = DBGatewayActions().list(DefaultRoles, guild_id=ctx.author.guild.id) - # Check to see if any roles exist - if guild_default_roles: - # Create list of roles from database response - apply_roles = [ctx.author.guild.get_role(role.role_id) for role in guild_default_roles] - # Return all the default roles to the user - await ctx.channel.send( - embed=Embed(title=self.STRINGS['default_role_get'], description="— "+('\n— '.join(f'<@&{x.id}>' for x in apply_roles))) - ) - else: - await ctx.channel.send(self.STRINGS['default_role_missing']) - - @commands.command(name="removedefaultroles") - @commands.has_permissions(administrator=True) - async def removedefaultroles(self, ctx): - """ - Remove all of the currently set default roles in the current server. - :param ctx: The context of the command. - """ - # Get all the default role for the server from database - guild_default_roles = DBGatewayActions().list(DefaultRoles, guild_id=ctx.author.guild.id) - # Check to see if any roles exist - if guild_default_roles: - for default_role in guild_default_roles: - # Remove the current role - DBGatewayActions().delete(default_role) - # Return a response to the user - await ctx.channel.send(self.STRINGS['default_role_removed']) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS["default_role_removed_log"].format(author_mention=ctx.author.mention) - } - ) - else: - await ctx.channel.send(self.STRINGS['default_role_missing']) - - -def setup(bot): - bot.add_cog(DefaultRoleCog(bot)) diff --git a/src/esportsbot/cogs/EventCategoriesCog.py b/src/esportsbot/cogs/EventCategoriesCog.py deleted file mode 100644 index f72c4b6e..00000000 --- a/src/esportsbot/cogs/EventCategoriesCog.py +++ /dev/null @@ -1,476 +0,0 @@ -import asyncio -import logging -from collections import defaultdict -from enum import IntEnum - -from discord import Forbidden, PermissionOverwrite, Role -from discord.ext import commands - -from esportsbot.DiscordReactableMenus.EventReactMenu import EventReactMenu -from esportsbot.DiscordReactableMenus.ExampleMenus import ActionConfirmationMenu -from esportsbot.DiscordReactableMenus.reactable_lib import get_menu -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.lib.discordUtil import get_attempted_arg -from esportsbot.models import EventCategories, DefaultRoles - -denied_perms = PermissionOverwrite(read_messages=False, send_messages=False, connect=False, view_channel=False) -read_only_perms = PermissionOverwrite(read_messages=True, send_messages=False, connect=False, view_channel=True) -writable_perms = PermissionOverwrite(read_messages=True, send_messages=True, connect=True, view_channel=True) -SIGN_IN_EMOJI = "✅" -SIGN_IN_DESCRIPTION = "Welcome to {}, react to this message to join the event so that you " \ - "receive notifications for when things are happening!" - -GENERAL_CHANNEL_SUFFIX = "general-chat" -SIGN_IN_CHANNEL_SUFFIX = "sign-in" -VOICE_CHANNEL_SUFFIX = "VC" - - -class RoleTypeEnum(IntEnum): - DEFAULT = 0 # The Default role - SHARED = 1 # The Shared role users receive when joining the server - EVENT = 2 # The Event role - TOP = 3 # The Top role the bot has - - -class EventCategoriesCog(commands.Cog): - """ - An event category is used to manage a group of event channels. When an event is created, it creates a Discord Category - and inside the category it creates a sign-in menu/channel, a general event channel and a general event voice channel. - - This module implements the ability to create and manage events, all the commands requiring administrator privileges to run. - """ - def __init__(self, bot): - self.bot = bot - self.user_strings = bot.STRINGS["event_categories"] - self.db = DBGatewayActions() - self.event_menus = defaultdict(dict) - self.logger = logging.getLogger(__name__) - self.logger.info(f"Loaded {__name__}!") - - @commands.Cog.listener() - async def on_ready(self): - """ - When bot discord client is ready and has logged into the discord API, this function runs and is used to load and - initialise events, which include initialising the sign-in menus used to get the event roles. - """ - await self.load_event_menus() - self.logger.info(f"{__name__} is now ready!") - - async def load_event_menus(self): - """ - Loads any event menus saved in the DB for all guilds . - """ - bot_guilds = [x.id for x in self.bot.guilds] - - to_load = [] - - for guild in bot_guilds: - to_load.append(self.load_events_in_guild(guild)) - - loaded_guilds = await asyncio.gather(*to_load) - - self.event_menus = dict(zip(bot_guilds, loaded_guilds)) - - async def load_events_in_guild(self, guild_id): - """ - Loads any event menus saved in the DB for a specific guild . - :param guild_id: The ID of the guild to load the event menus of . - :return: A Dictionary of the event menus in the guild . - """ - raw_events = self.db.list(EventCategories, guild_id=guild_id) - - to_load = [] - - for event in raw_events: - event_menu = event.event_menu - to_load.append(EventReactMenu.from_dict(self.bot, event_menu)) - - loaded_events = await asyncio.gather(*to_load) - - # Any menu that has failed to load will not have been initialised to a menu and will still be a dict, - # so should be deleted from the DB. - events = {} - for event in loaded_events: - if isinstance(event, dict): - self.delete_event_data(event.get("guild_id"), event.get("id")) - elif isinstance(event, EventReactMenu): - events[event.id] = event - - return events - - async def send_current_events(self, context: commands.Context): - """ - Sends a list of the currently active events in a guild . - :param context: The context of the command . - """ - guild_events = self.event_menus[context.guild.id] - events = str([str(x.title) for x in guild_events.values()]).replace("[", "").replace("]", "") - if len(events) > 0: - reply = self.user_strings["unrecognised_event"].format(events=events) - else: - reply = self.user_strings["no_events"] - await context.reply(reply) - - def get_event_by_name(self, guild_id, event_name): - """ - Gets an event menu given a guild and the event's name . - :param guild_id: The ID of the guild to find the event in . - :param event_name: The name of the event to find the menu of . - :return: An event menu if there is one with that name in the guild . - """ - guild_events = self.event_menus[guild_id] - if event_name: - for event_id in guild_events: - if event_name.lower() in guild_events.get(event_id).title.lower(): - return guild_events.get(event_id) - else: - # IF the event name given is None, try to find the latest menu. - return get_menu(guild_events, event_name) - - def update_event(self, guild_id, event_menu): - """ - Updates the DB with the latest event data . - :param guild_id: The ID of the guild the event is in . - :param event_menu: The Reaction Menu instance of the event that has been updated . - """ - db_item = self.db.get(EventCategories, guild_id=guild_id, event_id=event_menu.id) - db_item.event_menu = event_menu.to_dict() - self.db.update(db_item) - - def delete_event_data(self, guild_id, event_id): - """ - Deletes an event menu's data from the DB. - :param guild_id: The ID of the guild where the event is in . - :param event_id: The ID of the event in the guild . - """ - db_item = self.db.get(EventCategories, guild_id=guild_id, event_id=event_id) - self.db.delete(db_item) - - @commands.group(name="events", invoke_without_command=True) - async def event_command_group(self, context: commands.context): - """ - The command group used to make all commands sub-commands . - :param context: The context of the command . - """ - pass - - @event_command_group.command(name="create-event") - @commands.has_permissions(administrator=True) - async def create_event(self, context: commands.Context, event_name: str, shared_role: Role = None): - """ - Creates a new event with the given name and using the shared role in the server to stop users from seeing the - event early . - :param context: The context of the command . - :param event_name: The name of the event to create . - :param shared_role: The shared role that all users have . - """ - self.logger.info(f"Creating a new Event with name {event_name}") - audit_reason = "Done with `create-event` command" - - if not shared_role: - db_data = self.db.get(DefaultRoles, guild_id=context.guild.id) - if not db_data or not db_data.role_id: - shared_role = context.guild.default_role - else: - shared_role = context.guild.get_role(db_data.role_id) - if not shared_role: - shared_role = context.guild.default_role - - guild_events = self.event_menus[context.guild.id] - - # Check if an event already exists with the given name. - for event_id in guild_events: - if event_name.lower() in guild_events.get(event_id).title.lower(): - self.logger.warning(f"There is already an event with the name {event_name} in {context.guild.name}") - await context.reply(self.user_strings["event_exists"].format(event_name=event_name)) - return - - event_role = await context.guild.create_role(name=event_name, reason=audit_reason) - - category_overwrites = { - context.me: writable_perms, - event_role: writable_perms, - shared_role: denied_perms, - context.guild.default_role: denied_perms - } - - signin_overwrites = { - context.me: writable_perms, - event_role: read_only_perms, - shared_role: denied_perms, - context.guild.default_role: denied_perms - } - - # Create the channels for the event: - event_category = await context.guild.create_category( - name=event_name, - overwrites=category_overwrites, - reason=audit_reason - ) - event_sign_in_channel = await event_category.create_text_channel( - name=f"{event_name} {SIGN_IN_CHANNEL_SUFFIX}", - sync_permissions=False, - overwrites=signin_overwrites, - reason=audit_reason - ) - await event_category.create_text_channel( - name=f"{event_name} {GENERAL_CHANNEL_SUFFIX}", - sync_permissions=True, - reason=audit_reason - ) - await event_category.create_voice_channel( - name=f"{event_name} {VOICE_CHANNEL_SUFFIX}", - sync_permissions=True, - reason=audit_reason - ) - - # Create the sign-in message: - event_menu = EventReactMenu( - shared_role=shared_role, - event_role=event_role, - title=event_name, - description=SIGN_IN_DESCRIPTION.format(event_name), - auto_enable=False - ) - - event_menu.add_option(SIGN_IN_EMOJI, event_role) - - await event_menu.finalise_and_send(self.bot, event_sign_in_channel) - - db_item = EventCategories( - guild_id=context.guild.id, - event_id=event_menu.id, - event_name=event_menu.title, - event_menu=event_menu.to_dict() - ) - self.db.create(db_item) - - self.event_menus[context.guild.id][event_menu.id] = event_menu - self.logger.info(f"Successfully created an event with the name {event_name} in {context.guild.name}!") - await context.reply( - self.user_strings["success_event"].format( - event_name=event_name, - event_role_mention=event_role.mention, - sign_in_menu_id=event_menu.id, - sign_in_channel_mention=event_sign_in_channel.mention, - shared_role_name=shared_role.name, - command_prefix=self.bot.command_prefix - ) - ) - - @event_command_group.command(name="open-event") - @commands.has_permissions(administrator=True) - async def open_event(self, context: commands.Context, event_name: str): - """ - Opens the sign-in channel for the event so that users with the shared role given in the - create-event command can see it . - :param context: The context of the command . - :param event_name: The name of the event to open . - """ - self.logger.info(f"Attempting to open event with name {event_name}, if this is none, searching for latest event menu") - - audit_reason = "Done with `open-event` command" - - event_menu = self.get_event_by_name(context.guild.id, event_name) - - # If there no event with the given name, exit: - if not event_menu: - self.logger.warning(f"There is no event to open with the name {event_name} in {context.guild.name}") - await self.send_current_events(context) - return - - await event_menu.enable_menu(self.bot) - self.update_event(context.guild.id, event_menu) - - signin_channel = event_menu.message.channel - current_perms = signin_channel.overwrites - current_perms[event_menu.shared_role] = read_only_perms - await signin_channel.edit(overwrites=current_perms, reason=audit_reason) - - self.logger.info(f"Successfully opened an event with the name {event_name} in {context.guild.name}") - await context.reply( - self.user_strings["success_channel"].format( - channel_id=event_menu.message.channel.id, - role_name=event_menu.shared_role.name - ) - ) - return - - @event_command_group.command(name="close-event") - @commands.has_permissions(administrator=True) - async def close_event(self, context: commands.Context, event_name: str): - """ - Closes all the channels so that no users can see any of the event channels, - including the general, voice and sign in channels . - :param context: The context of the command . - :param event_name: The name of the event to close . - """ - self.logger.info(f"Attempting to close event with name {event_name}, if this is none, searching for latest event menu") - - audit_reason = "Done with `close-event` command" - - event_menu = self.get_event_by_name(context.guild.id, event_name) - - # If there no event with the given name, exit: - if not event_menu: - self.logger.warning(f"There is no event to close with the name {event_name} in {context.guild.id}") - await self.send_current_events(context) - return - - await event_menu.disable_menu(self.bot) - self.update_event(context.guild.id, event_menu) - - signin_channel = event_menu.message.channel - current_perms = signin_channel.overwrites - current_perms[event_menu.shared_role] = denied_perms - await signin_channel.edit(overwrites=current_perms, reason=audit_reason) - - await self.remove_react_roles(context, event_menu, event_name) - - self.logger.info(f"Successfully closed an event with the name {event_name} in {context.guild.name}") - await context.reply(self.user_strings["success_event_closed"]) - return - - @event_command_group.command(name="delete-event") - @commands.has_permissions(administrator=True) - async def delete_event(self, context: commands.Context, event_name: str): - """ - Deletes an event. This includes all the channels in the category and the role created for the event . - :param context: The context of the command . - :param event_name: The name of the event to delete . - """ - self.logger.info(f"Attempting to close event with name {event_name}, if this is none, searching for latest event menu") - - event_menu = self.get_event_by_name(context.guild.id, event_name) - - # If there no event with the given name, exit: - if not event_menu: - self.logger.warning(f"There is no event to delete with the name {event_name} in {context.guild.id}") - await self.send_current_events(context) - return False - - confirm_menu = ActionConfirmationMenu(title=f"Confirm that you want to delete {event_name} event", auto_enable=True) - confirm_menu.set_confirm_func(self.confirm_delete_event, event_menu, confirm_menu, context) - confirm_menu.set_cancel_func(self.cancel_delete_event, event_menu.title, confirm_menu, context) - await confirm_menu.finalise_and_send(self.bot, context.channel) - - async def confirm_delete_event(self, event_menu, confirm_menu, context): - """ - Used in the deletion confirmation reaction menu so that an admin can confirm the decision to delete an event . - :param event_menu: The event menu that will be deleted . - :param confirm_menu: The menu used to confirm the decision . - :param context: The context of the command . - """ - audit_reason = "Done with `delete-event` command" - event_category = event_menu.event_category - event_role = event_menu.event_role - - # Delete all the channels in the category: - for channel in event_category.channels: - await channel.delete(reason=audit_reason) - await event_category.delete(reason=audit_reason) - - await event_role.delete(reason=audit_reason) - self.event_menus[context.guild.id].pop(event_menu.id) - self.delete_event_data(guild_id=context.guild.id, event_id=event_menu.id) - - self.logger.info(f"Successfully deleted an event with the name {event_menu.title} in {context.guild.name}") - await context.reply(self.user_strings["success_event_deleted"].format(event_name=event_menu.title)) - - if not confirm_menu.delete_after: - await confirm_menu.disable_menu(self.bot) - - async def cancel_delete_event(self, event_name, confirm_menu, context): - """ - Used in the deletion confirmation reaction menu so that an admin can cancel the decision to delete an event . - :param event_name: The name of the event that didn't get deleted . - :param confirm_menu: The menu used to confirm the decision . - :param context: The context of the command . - """ - if not confirm_menu.delete_after: - await confirm_menu.disable_menu(self.bot) - - self.logger.info(f"Deletion of {event_name} menu cancelled by {context.author.name}#{context.author.discriminator}") - await context.reply(self.user_strings["delete_cancelled"].format(event_name=event_name)) - - @staticmethod - async def remove_react_roles(context, event_menu, event_name): - all_members = context.guild.members - for member in all_members: - if event_menu.event_role in member.roles: - await member.remove_roles(event_menu.event_role, reason=f"{event_name} Event Closed") - - reactions = event_menu.message.reactions - - for reaction in reactions: - await reaction.clear() - - @create_event.error - async def on_create_event_error(self, context: commands.Context, error: commands.CommandError): - """ - The error handler for the create_event command . - :param context: The context of the command . - :param error: The error that occurred when the command was executed . - """ - # This can occur if the Role given is as an ID or just invalid: - if isinstance(error, commands.RoleNotFound): - self.logger.warning("The argument parsed was not a Role, trying to find a role with the given value") - arg_index = 1 - attempted_role, command_args = get_attempted_arg(context.message.content, arg_index) - try: - role_id = int(attempted_role) - for role in context.guild.roles: - if role.id == role_id: - # Retry the command and parse the given role_id as an actual role object. - self.logger.info(f"Retrying {context.command.name} with found role: {role.name}") - command_args[arg_index] = role - await self.create_event(context, *command_args) - return - raise ValueError() - except ValueError: - self.logger.error(f"Unable to find a role with id: {attempted_role}") - await context.reply(self.user_strings["invalid_role"]) - return - - @create_event.error - @open_event.error - @close_event.error - @delete_event.error - async def generic_error_handler(self, context: commands.Context, error: commands.CommandError): - """ - A more generic error handler for the rest of the commands . - :param context: The context of the command . - :param error: The error that occurred . - """ - self.logger.warning( - f"There was an error while performing the '{context.command.name}' " - f"command: {error.__class__.__name__}" - ) - # When the user forgets to supply required arguments. - if isinstance(error, commands.MissingRequiredArgument): - self.logger.warning(f"Unable to perform {context.command.name} as the command lacked sufficient arguments") - command_name = context.command.full_parent_name + " " + context.command.name - await context.reply( - self.user_strings["missing_arguments"].format(prefix=self.bot.command_prefix, - command=command_name) - ) - return - - # When the user does not have the correct permissions to perform the command. - if isinstance(error, commands.MissingPermissions): - permission = error.missing_perms[0].replace("_", " ").replace("guild", "server") - self.logger.error(f"Unable to perform {context.command.name} as you lack the permissions: {permission}") - await context.reply(self.user_strings["user_missing_perms"].format(permission=permission)) - return - - # When the bot does not have the correct permissions to perform the command. - if isinstance(error, Forbidden): - self.logger.error(f"Unable to perform {context.command.name} as the bot lacks permissions") - # A list of permissions known to potentially cause issues: - permissions = "view channel, send messages, manage channels, manage roles" - await context.reply(self.user_strings["bot_missing_perms"].format(permissions=permissions)) - return - - -def setup(bot): - bot.add_cog(EventCategoriesCog(bot)) diff --git a/src/esportsbot/cogs/LogChannelCog.py b/src/esportsbot/cogs/LogChannelCog.py deleted file mode 100644 index ef4f6b34..00000000 --- a/src/esportsbot/cogs/LogChannelCog.py +++ /dev/null @@ -1,82 +0,0 @@ -from discord.ext import commands -from esportsbot.base_functions import channel_id_from_mention -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.models import GuildInfo - - -class LogChannelCog(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.STRINGS = bot.STRINGS["logging"] - - @commands.command( - name="setlogchannel", - usage=" or <@channel>", - help="Sets the server logging channel for bot actions" - ) - @commands.has_permissions(administrator=True) - async def setlogchannel(self, ctx, given_channel_id=None): - cleaned_channel_id = channel_id_from_mention(given_channel_id) if given_channel_id else ctx.channel.id - guild = DBGatewayActions().get(GuildInfo, guild_id=ctx.author.guild.id) - if not guild: - db_item = GuildInfo(guild_id=ctx.guild.id, log_channel_id=cleaned_channel_id) - DBGatewayActions().create(db_item) - else: - current_log_channel_id = guild.log_channel_id - if current_log_channel_id == cleaned_channel_id: - await ctx.channel.send(self.STRINGS["channel_set_already"]) - return - guild.log_channel_id = cleaned_channel_id - DBGatewayActions().update(guild) - - await ctx.channel.send(self.STRINGS["channel_set"].format(channel_id=cleaned_channel_id)) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS["channel_set_notify_in_channel"].format(author_mention=ctx.author.mention) - } - ) - - @commands.command(name="getlogchannel", usage="", help="Gets the server logging channel for bot actions") - @commands.has_permissions(administrator=True) - async def getlogchannel(self, ctx): - guild = DBGatewayActions().get(GuildInfo, guild_id=ctx.author.guild.id) - if not guild: - await ctx.channel.send(self.STRINGS["channel_get_notfound"]) - return - - if guild.log_channel_id: - await ctx.channel.send(self.STRINGS["channel_get"].format(channel_id=guild.log_channel_id)) - else: - await ctx.channel.send(self.STRINGS["channel_get_notfound"]) - - @commands.command(name="removelogchannel", usage="", help="Removes the server logging channel for bot actions") - @commands.has_permissions(administrator=True) - async def removelogchannel(self, ctx): - guild = DBGatewayActions().get(GuildInfo, guild_id=ctx.author.guild.id) - if not guild: - await ctx.channel.send(self.STRINGS["channel_get_notfound"]) - return - - if guild.log_channel_id: - guild.log_channel_id = None - DBGatewayActions().update(guild) - await ctx.channel.send(self.STRINGS["channel_removed"]) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS["channel_removed_log"].format(author_mention=ctx.author.mention) - } - ) - else: - await ctx.channel.send(self.STRINGS["channel_get_notfound"]) - - -def setup(bot): - bot.add_cog(LogChannelCog(bot)) diff --git a/src/esportsbot/cogs/MusicCog.py b/src/esportsbot/cogs/MusicCog.py deleted file mode 100644 index 459a3266..00000000 --- a/src/esportsbot/cogs/MusicCog.py +++ /dev/null @@ -1,1364 +0,0 @@ -import datetime -import functools -import logging -import os -import re -import sys -import time -from enum import IntEnum -from random import shuffle -from urllib.parse import parse_qs, urlparse - -import googleapiclient.discovery -from yt_dlp import YoutubeDL -from discord import (ClientException, Colour, Embed, FFmpegPCMAudio, PCMVolumeTransformer, TextChannel) -from discord.ext import commands, tasks -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.lib.discordUtil import send_timed_message -from esportsbot.models import MusicChannels -from youtubesearchpython import VideosSearch - - -# A discord command check that the command is in the music channel: -def check_music_channel(context): - guild_id = context.guild.id - if guild_data := DBGatewayActions().get(MusicChannels, guild_id=guild_id): - if channel_id := guild_data.channel_id: - return context.channel.id == channel_id - return False - - -# A delete after done command wrapper: -def delete_after(): - def wrapper(func): - @functools.wraps(func) - async def wrapped(*args, **kwargs): - context = args[1] - if not isinstance(context, commands.Context): - raise ValueError("The second arg for a command should be a commands.Context object") - res = await func(*args, **kwargs) - await context.message.delete() - return res - - return wrapped - - return wrapper - - -class EmbedColours: - green = Colour(0x1f8b4c) - orange = Colour(0xe67e22) - red = Colour(0xe74c3c) - music = Colour(0xd462fd) - - -class MessageTypeEnum(IntEnum): - youtube_url = 0 - youtube_playlist = 1 - youtube_thumbnail = 2 - string = 3 - invalid = 4 - - -EMPTY_QUEUE_MESSAGE = "Join a Voice Channel and search a song by name or paste a YouTube url.\n" \ - "**__Current Queue:__**\n" - -ESPORTS_LOGO_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" - -EMPTY_PREVIEW_MESSAGE = Embed( - title="No song currently playing", - colour=EmbedColours.music, - footer="Use the prefix ! for commands" -) -EMPTY_PREVIEW_MESSAGE.set_image(url=ESPORTS_LOGO_URL) -EMPTY_PREVIEW_MESSAGE.set_footer(text="Definitely not made by fuxticks#1809 on discord") - -GOOGLE_API_KEY = os.getenv("GOOGLE_API") -YOUTUBE_API = googleapiclient.discovery.build("youtube", "v3", developerKey=GOOGLE_API_KEY) - -FFMPEG_BEFORE_OPT = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" - -TIMEOUT_DELAY = 60 - - -class MusicCog(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.logger = logging.getLogger(__name__) - self.db = DBGatewayActions() - self.user_strings = bot.STRINGS["music"] - self.unhandled_error_string = bot.STRINGS["command_error_generic"] - self.music_channels = self.load_channels() - self.active_guilds = {} - self.playing_guilds = [] - self.inactive_guilds = {} - self.logger.info(f"Finished loading {__name__}... cog is ready!") - - def load_channels(self): - """ - Loads the currently set music channels from the DB. - :return: A dictionary of the guild and its music channel id. - """ - channels = self.db.list(MusicChannels) - channels_dict = {} - for channel in channels: - channels_dict[channel.guild_id] = channel.channel_id - return channels_dict - - @commands.Cog.listener() - async def on_message(self, message): - """ - Handles messages that are not sent by a bot or that are Direct Messages. - :param message: The message received by the bot. - """ - if not message.author.bot and message.guild: - guild_id = message.guild.id - music_channel = self.music_channels.get(guild_id) - if music_channel and message.channel.id == music_channel: - if await self.on_message_handle(message): - await message.delete() - - @commands.Cog.listener() - async def on_voice_state_update(self, member, before, after): - """ - If the bot is forcefully removed from the channel by an admin, we want to ensure that the bot doesn't think it is - still in a voice channel. - :param member: The member triggering the change. - :param before: The voice state before. - :param after: The voice state after. - """ - if member.id != self.bot.user.id: - if not after.channel: - # If the user has left a voice channel - if member.guild.id in self.active_guilds: - guild_vc = self.active_guilds.get(member.guild.id).get("voice_channel") - if before.channel.id == guild_vc.id: - # And that voice channel is the one we are in - # And the only users in the channel are bots - non_bots = [x for x in guild_vc.members if not x.bot] - if not non_bots: - # Leave the channel - await self.disconnect_from_guild(member.guild) - await self.remove_active_guild(member.guild) - return - - if not before.channel and not after.channel: - # This should never happen but is here to ensure it won't cause an issue. - return - - if not before.channel and after.channel: - # Bot has joined a voice channel. - self.new_active_guild(after.channel.guild) - return - - if before.channel and not after.channel: - # Bot has left a voice channel. - await self.remove_active_guild(before.channel.guild) - return - - if before.channel and after.channel: - # Bot has been moved to another voice channel. - self.update_voice_client(after.channel.guild) - return - - def run_tasks(self): - if not self.check_inactive_guilds.is_running() or self.check_inactive_guilds.is_being_cancelled(): - self.check_inactive_guilds.start() - - if not self.check_playing_guilds.is_running() or self.check_playing_guilds.is_being_cancelled(): - self.check_playing_guilds.start() - - @tasks.loop(seconds=5) - async def check_playing_guilds(self): - """ - Check the guilds who's voice client status is playing. - """ - if not self.playing_guilds: - # Stop running if no guilds playing. - self.check_playing_guilds.cancel() - self.check_playing_guilds.stop() - return - - to_remove = [] - - now = datetime.datetime.now() - - for guild_id in self.playing_guilds: - if guild_id not in self.active_guilds: - # If the guild has been stopped from playing elsewhere it will no longer be in active guilds. - to_remove.append(guild_id) - continue - voice_client = self.active_guilds.get(guild_id).get("voice_client") - if not voice_client.is_playing() and not voice_client.is_paused(): - if not await self.play_queue(guild_id): - # play queue will return False if there is nothing to play or if it was unable to play something. - self.inactive_guilds[guild_id] = now - to_remove.append(guild_id) - self.run_tasks() - - for guild_id in to_remove: - self.playing_guilds.remove(guild_id) - - @tasks.loop(seconds=60) - async def check_inactive_guilds(self): - """ - Check the guilds that are in a voice channel but not playing anything. - """ - if not self.inactive_guilds: - # Stop running if no guilds active. - self.check_inactive_guilds.cancel() - self.check_inactive_guilds.stop() - return - - to_remove = [] - - now = datetime.datetime.now() - - for guild_id in self.inactive_guilds: - if (now - self.inactive_guilds.get(guild_id)).seconds > TIMEOUT_DELAY: - # If the bot has been inactive for the given timeout delay. - to_remove.append(guild_id) - - for guild_id in to_remove: - # These guilds have reached the timeout and should be disconnected. - self.inactive_guilds.pop(guild_id) - guild = self.active_guilds.get(guild_id).get("voice_channel").guild - await self.disconnect_from_guild(guild) - await self.remove_active_guild(guild) - - def new_active_guild(self, guild): - """ - Add a new guild to the ones that are currently active. - :param guild: The that now has an active instance of the bot in a voice channel. - :return: A dictionary of the data stored about the current playback status in the guild. - """ - self.logger.info(f"Adding an active channel in {guild.name}") - guild_id = guild.id - guild_data = { - "voice_channel": guild.me.voice.channel, - "voice_client": self.get_guild_client(guild), - "queue": [], - "current_song": None, - "volume": 1 - } - self.active_guilds[guild_id] = guild_data - return guild_data - - def update_voice_client(self, guild): - """ - Update the voice channel and voice client of the bot if it has become disconnected or been moved to a different - voice channel. - :param guild: The guild that needs updating. - :return: A dictionary of the data stored about the current playback status in the guild. - """ - self.logger.info(f"Updating the voice client for {guild.name}") - if guild.id not in self.active_guilds: - # If it has been removed from the active guilds dict, create a new one. - return self.new_active_guild(guild) - else: - # Otherwise keep the rest of the data and just update the voice channel and voice client. - guild_id = guild.id - guild_data = { - "voice_channel": guild.me.voice.channel, - "voice_client": self.get_guild_client(guild), - "queue": self.active_guilds.get(guild_id).get("queue"), - "current_song": self.active_guilds.get(guild_id).get("current_song"), - "volume": self.active_guilds.get(guild_id).get("volume") - } - self.active_guilds[guild_id] = guild_data - return guild_data - - def get_guild_client(self, guild): - """ - Get a voice client of the bot in a given guild. - :param guild: The guild to find the voice client of. - :return: A voice client if there is one, else None. - """ - voice_clients = self.bot.voice_clients - for client in voice_clients: - if client.guild.id == guild.id: - return client - return None - - async def remove_active_guild(self, guild): - """ - Remove a guild from being active. - :param guild: The guild to remove activity from. - :return: A boolean if the removal was successful. - """ - self.logger.info(f"Removing active channel for {guild.name}") - try: - self.active_guilds.pop(guild.id) - await self.update_messages(guild.id) - return True - except AttributeError: - return False - except KeyError: - return False - - async def disconnect_from_guild(self, guild): - guild_data = self.active_guilds.get(guild.id) - if guild_data: - await guild_data["voice_client"].disconnect() - else: - my_voice = guild.voice_client - if my_voice: - await my_voice.disconnect() - - async def find_music_channel_instance(self, guild): - """ - Find the instance of the music channel in a given guild. - :param guild: The guild to find the music channel in. - :return: A text channel if the text channel exists, else None. - """ - current_music_channel = self.db.get(MusicChannels, guild_id=guild.id) - if not current_music_channel: - return None - - channel_instance = guild.get_channel(current_music_channel.channel_id) - if not channel_instance: - channel_instance = await guild.fetch_channel(current_music_channel.channel_id) - - if not channel_instance: - # Remove the currently set music channel as it doesn't exist anymore. - current_music_channel.channel_id = None - self.db.update(current_music_channel) - return None - - return channel_instance - - async def on_message_handle(self, message): - """ - Handles when a message is sent to a music channel. - :param message: The message sent to the music channel. - :return: True if the message was handled by this function. False if the message was a command. - """ - try: - if message.content.startswith(self.bot.command_prefix): - # Allow commands to be handled by the bot command handler. - return False - - if not await self.join_member(message.author): - # If we were unable to join the member, tell them why: - if not message.author.voice: - await send_timed_message(channel=message.channel, content=self.user_strings["unable_to_join"]) - return True - if not message.author.voice.channel.permissions_for(message.guild.me).connect: - await send_timed_message(channel=message.channel, content=self.user_strings["no_connect_perms"]) - return True - - # Split the message into it's lines and treat each non-blank line as a request. - message_content = re.sub(r"(`)+", "", message.content) - request_options = message_content.split("\n") - cleaned_requests = [k for k in request_options if k not in ('', ' ')] - - debug_start_time = time.time() - results = [] - for request in cleaned_requests: - results.append(await self.process_request(message.guild.id, request)) - - debug_end_time = time.time() - - self.logger.info( - f"Processed {len(cleaned_requests)} song(s) in {debug_end_time - debug_start_time} seconds for " - f"{message.guild.name} and got {results.count(True)} successful result(s)" - ) - - failed_songs = "" - - for i in range(len(results)): - if not results[i]: - failed_songs += f"{i + 1}. {cleaned_requests[i]}\n" - - # If any of the songs had errors, send a message: - if results.count(False) >= 1: - await send_timed_message( - channel=message.channel, - content=self.user_strings["song_process_failed"].format(songs=failed_songs), - timer=10 - ) - - # If any songs succeeded, ensure that the bot is not marked as inactive. - if results.count(True) >= 1: - if message.guild.id in self.inactive_guilds: - self.inactive_guilds.pop(message.guild.id) - - self.run_tasks() - - return True - except Exception as e: - await send_timed_message(message.channel, content=self.unhandled_error_string, timer=120) - self.logger.error(f"There was an error handling the following message: {message.content} \n {e!s}") - return True - - async def process_request(self, guild_id, request): - """ - Processes a song request and adds it to the queue. - :param guild_id: The ID of the guild the song request is in. - :param request: The song requested. - :return: True if the song was added to the queue successfully, else False. - """ - request_type = self.find_request_type(request) - if request_type == MessageTypeEnum.youtube_url or request_type == MessageTypeEnum.youtube_playlist: - # The request was a YouTube video or playlist - youtube_api_response = self.get_youtube_request(request, request_type) - formatted_response = self.format_youtube_response(youtube_api_response) - elif request_type == MessageTypeEnum.string: - # The request was a string - query_response = self.query_request(request) - formatted_response = self.format_query_response(query_response) - else: - # The request was in an invalid format - return False - res = await self.add_songs_to_queue(formatted_response, guild_id) - await self.update_messages(guild_id) - return res - - def find_request_type(self, request): - """ - Find what kind of string the request is. If the string is a URL, determine what kind of URL it is. - :param request: The request to identify. - :return: A MessageTypeEnum depicting the type of string. - """ - if request.startswith("https://") or request.startswith("http://"): - return self.find_url_type(request) - else: - return MessageTypeEnum.string - - @staticmethod - def find_url_type(url): - """ - Finds what kind of url the given url is: - :param url: The url to identify. - :return: A MessageTypeEnum depicting what type of url the given url is. - """ - youtube_desktop_signature = r"(http[s]?://)?youtube.com/watch\?v=" - if re.search(youtube_desktop_signature, url): - return MessageTypeEnum.youtube_url - - youtube_playlist_signature = r"(http[s]?://)?youtube.com/playlist\?list=" - if re.search(youtube_playlist_signature, url): - return MessageTypeEnum.youtube_playlist - - youtube_mobile_signature = r"(http[s]?://)?youtu.be/([a-zA-Z]|[0-9])+" - if re.search(youtube_mobile_signature, url): - return MessageTypeEnum.youtube_url - - youtube_thumbnail_signature = r"(http[s]?://)?i.ytimg.com/vi/([a-zA-Z]|[0-9])+" - if re.search(youtube_thumbnail_signature, url): - return MessageTypeEnum.youtube_thumbnail - - return MessageTypeEnum.invalid - - @staticmethod - def get_youtube_request(request, request_type): - """ - Get the data for a given request, when that request is a YouTube URL of some kind. - :param request: The request to find the data of. - :param request_type: The type of YouTube request the request is. - :return: A list of dictionaries for each video found that fits the request. - """ - api_func = YOUTUBE_API.videos() if request_type == MessageTypeEnum.youtube_url else YOUTUBE_API.playlistItems() - key = "v" if request_type == MessageTypeEnum.youtube_url else "list" - - query = parse_qs(urlparse(request).query, keep_blank_values=True) - if not query: - youtube_id = request.split("/")[-1] - else: - youtube_id = query[key][0] - - api_args = {"part": "snippet", "maxResults": 1 if request_type == MessageTypeEnum.youtube_url else 50} - - if request_type == MessageTypeEnum.youtube_url: - api_args["id"] = youtube_id - else: - api_args["playlistId"] = youtube_id - - api_request = api_func.list(**api_args) - - video_responses = [] - while api_request: - response = api_request.execute() - video_responses += response["items"] - api_request = api_func.list_next(api_request, response) - - return video_responses - - def format_youtube_response(self, response): - """ - Formats a list of dicts that were gained from a YouTube request to be in a format that is the same across - request types. - :param response: The response from the YouTube request. - :return: A list of dictionaries formatted from the incoming list of dictionaries. - """ - formatted_response = [] - for item in response: - snippet = item.get("snippet") - video_info = { - "title": snippet.get("title"), - "thumbnail": self.thumbnail_from_snippet(snippet), - "link": self.url_from_response(item) - } - formatted_response.append(video_info) - return formatted_response - - @staticmethod - def thumbnail_from_snippet(snippet): - """ - Get the thumbnail url from a YouTube `snippet` data-type. - :param snippet: The snippet from the YouTube response - :return: A string representing the URL of the thumbnail. - """ - all_thumbnails = snippet.get("thumbnails") - if "maxres" in all_thumbnails: - return all_thumbnails.get("maxres").get("url") - else: - any_thumbnail_res = list(all_thumbnails)[0] - return all_thumbnails.get(any_thumbnail_res).get("url") - - @staticmethod - def url_from_response(response): - """ - Create a YouTube URL from the response using the response ID. - :param response: The response from a YouTube request. - :return: A string representing the YouTube URL of the video. - """ - if response.get("kind") == "youtube#video": - video_id = response.get("id") - else: - video_id = response.get("snippet").get("resourceId").get("videoId") - return "https://youtube.com/watch?v={}".format(video_id) - - @staticmethod - def query_request(request): - """ - Finds the information for a request that is a string. - :param request: The request to find. - :return: A 2-tuple of dictionaries. - """ - results = VideosSearch(request, limit=50).result().get("result") - - if not results: - # If unable to find any results - return {}, {} - - # The music result is what will be playing, while official will be used for the title and thumbnail. - music_result = None - official_result = None - for result in results: - title_lower = result.get("title").lower() - if not music_result and "lyric" in title_lower or "audio" in title_lower: - music_result = result - if not official_result and "official" in title_lower: - official_result = result - if official_result and music_result: - # Break once a video has been found for both. - break - - ret_val = official_result, music_result - - # If one of them is not found just use the top result. - if not music_result: - ret_val = ret_val[0], results[0] - if not official_result: - ret_val = results[0], ret_val[1] - return ret_val - - @staticmethod - def format_query_response(response): - """ - Formats the 2-tuple that was gained from a queried request to be in a format that is the same across request types. - :param response: The response from the query. - :return: A list of dictionaries of song data. - """ - official_result, music_result = response - - if not official_result or not music_result: - # If either of the dictionaries are emtpy, return an empty list with an empty dictionary. - return [{}] - - official_views = re.sub(r"view(s)?", "", official_result.get("viewCount").get("text").replace(",", "")) - music_views = re.sub(r"view(s)?", "", music_result.get("viewCount").get("text").replace(",", "")) - - official_views = 0 if official_views is None or "No" in official_views else int(official_views) - music_views = 0 if music_views is None or "No" in music_views else int(music_views) - - formatted_query = { - "title": - official_result.get("title") if official_views > music_views else music_result.get("title"), - "thumbnail": - official_result.get("thumbnails")[-1].get("url") - if official_views > music_views else music_result.get("thumbnails")[-1].get("url"), - "link": - music_result.get("link") - } - return [formatted_query] - - async def add_songs_to_queue(self, songs, guild_id): - """ - Add a list of dictionaries representing songs to the queue of a guild. - :param songs: The list of song dictionaries to add to the the guild. - :param guild_id: The ID of the guild to add the songs to. - :return: True if the song was added successfully, False otherwise. - """ - try: - if guild_id not in self.active_guilds: - return False - - ret_val = True - for song in songs: - if len(song) != 0: - self.active_guilds.get(guild_id)["queue"].append(song) - else: - ret_val = False - - # If we are not currently playing, start playing. - if not self.active_guilds.get(guild_id).get("voice_client").is_playing(): - return await self.play_queue(guild_id) - return ret_val - except Exception as e: - self.logger.error(f"There was an error adding a song to the queue for guild {guild_id}: {e!s}") - return False - - async def __play_queue(self, guild_id): - """ - The internal function for playing the current queue. Should not ever call this function explicitly. - :param guild_id: The ID of the guild to play the queue of. - :return: True if playback was started successfully, False otherwise. - """ - if guild_id not in self.active_guilds: - return False - - if not len(self.active_guilds.get(guild_id).get("queue")) > 0: - self.active_guilds.get(guild_id)["current_song"] = None - return False - - voice_client = self.active_guilds.get(guild_id).get("voice_client") - - # Voice client cannot be playing when calling play(), so must be stopped first. - if voice_client.is_playing(): - voice_client.stop() - - try: - next_song = self.set_next_song(guild_id) - source = PCMVolumeTransformer( - FFmpegPCMAudio(next_song.get("url"), - before_options=FFMPEG_BEFORE_OPT, - options="-vn"), - volume=self.active_guilds.get(guild_id).get("volume") - ) - voice_client.play(source) - self.playing_guilds.append(guild_id) - return True - except AttributeError: - return False - except KeyError: - return False - except TypeError: - return False - except ClientException: - return False - - async def play_queue(self, guild_id): - """ - The function that calls the internal __play_queue, and should be used to initiate the queue playback. - :param guild_id: The ID of the guild to start the playback in . - :return: True if playback was started successfully, else False. - """ - res = await self.__play_queue(guild_id) - await self.update_messages(guild_id) - return res - - def set_next_song(self, guild_id): - """ - Sets the current song to the next song in the queue. - :param guild_id: The ID of the guild to set the next song of. - :return: The next song in the queue. - """ - next_song = self.active_guilds.get(guild_id).get("queue").pop(0) - current_song = {**self.get_youtube_info(next_song.get("link")), **next_song} - self.active_guilds.get(guild_id)["current_song"] = current_song - return current_song - - @staticmethod - def get_youtube_info(url): - """ - Download the information required to stream the audio to the voice client. - :param url: The URL to find the information of. - :return: A dictionary of data which contains the stream data. - """ - ydl_opts = { - "quiet": "true", - "nowarning": "true", - "format": "bestaudio/best", - "outtmpl": "%(title)s-%(id)s.mp3", - "postprocessors": [{ - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": "192", - }], - } - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - return info - - async def update_messages(self, guild_id): - """ - Update the queue and preview messages with the current queue and currently playing song in a given guild. - :param guild_id: The ID of the guild to update the messages of. - """ - queue_message_content = self.get_updated_queue_message(guild_id) - preview_message_content = self.get_updated_preview_message(guild_id) - - music_channel_instance = self.bot.get_channel(self.music_channels.get(guild_id)) - if not music_channel_instance: - music_channel_instance = await self.bot.fetch_channel(self.music_channels.get(guild_id)) - - db_item = self.db.get(MusicChannels, guild_id=guild_id) - - if not db_item: - return - - queue_message_instance = await music_channel_instance.fetch_message(db_item.queue_message_id) - preview_message_instance = await music_channel_instance.fetch_message(db_item.preview_message_id) - - await queue_message_instance.edit(content=queue_message_content) - await preview_message_instance.edit(embed=preview_message_content) - - def get_updated_queue_message(self, guild_id, complete_list=False): - """ - Gets the most up-to-date message for the current queue information of a given guild. - :param guild_id: The ID of the guild to get the queue information of. - :param complete_list: If the string should be a complete list or a truncated list. - :return: A string representing the queue in the guild. - """ - if guild_id not in self.active_guilds: - return EMPTY_QUEUE_MESSAGE - else: - return self.make_queue_text(guild_id, complete_list=complete_list) - - def make_queue_text(self, guild_id, complete_list=False): - """ - Creates the text of the current queue in a given guild. - :param guild_id: The ID of the guild to get the queue of. - :param complete_list: If the string should be a complete list or a truncated list. - :return: A string representing the queue in a given guild. - """ - queue_string = EMPTY_QUEUE_MESSAGE - queue = self.active_guilds.get(guild_id).get("queue") - if not queue: - return queue_string - elif complete_list: - queue_string = self.reversed_numbered_list(queue) - elif len(queue) > 25: - # If the queue is long, truncate it using the start and end with an indicator of how many extra songs are hidden. - first_ten = queue[:10] - last_ten = queue[-10:] - remainder = len(queue) - 20 - - first_string = self.reversed_numbered_list(first_ten) - last_string = self.reversed_numbered_list(last_ten, offset=remainder + 10) - - queue_string += f"{last_string}\n\n... and **`{remainder}`** more ... \n\n{first_string}" - else: - queue_string += self.reversed_numbered_list(queue) - return queue_string - - @staticmethod - def reversed_numbered_list(list_data, offset=0): - """ - Get a list as a numbered string, where the first index is at the bottom of the string. - :param list_data: The song data to turn into a string. - :param offset: The song index offset to start at. - :return: A string representing a list. - """ - reversed_list = list(reversed(list_data)) - biggest = len(list_data) + offset - return "\n".join(f"{biggest - song_num}. {song.get('title')}" for song_num, song in enumerate(reversed_list)) - - @staticmethod - def numbered_list(list_data, offset=0): - """ - Get a list as a numbered string, where the first index is at the top of the string. - :param list_data: The song data to turn into a string. - :param offset: The song index offset to start at. - :return: A string representing a list. - """ - return "\n".join(f"{song_num + 1 + offset}. {song.get('title')}" for song_num, song in enumerate(list_data)) - - def get_updated_preview_message(self, guild_id): - """ - Gets the most up-to-date preview message for a given guild and its currently playing song. - :param guild_id: The ID of the guild to get the current song of. - :return: An Embed object of the currently playing song. - """ - if guild_id not in self.active_guilds: - return EMPTY_PREVIEW_MESSAGE - elif not self.active_guilds.get(guild_id).get("current_song"): - return EMPTY_PREVIEW_MESSAGE - else: - current_song = self.active_guilds.get(guild_id).get("current_song") - updated_message = Embed( - title=f"Currently Playing: {current_song.get('title')}", - description=f"Current Volume: {int(self.active_guilds.get(guild_id).get('volume') * 100)}%", - colour=EmbedColours.music, - url=current_song.get("link"), - video=current_song.get("link") - ) - thumbnail = current_song.get("thumbnail") - if self.find_url_type(thumbnail) != MessageTypeEnum.youtube_thumbnail: - thumbnail = ESPORTS_LOGO_URL - updated_message.set_image(url=thumbnail) - updated_message.set_footer(text="Definitely not made by fuxticks#1809 on discord") - return updated_message - - @staticmethod - async def join_member(member): - """ - Join a member's voice channel. - :param member: The member to join. - :return: A boolean if the bot was able to join the channel. - """ - try: - client = await member.voice.channel.connect() - await member.guild.change_voice_state(channel=client.channel, self_mute=False, self_deaf=True) - return True - except ClientException: - return False - except AttributeError: - return False - - @commands.group(name="music") - @commands.check(check_music_channel) - @delete_after() - async def command_group(self, context: commands.Context): - """ - This is the command group for all commands that are meant to be performed in the music channel. - :param context: The context of the command. - """ - pass - - @command_group.error - async def check_failed_error(self, context: commands.Context, error: commands.CheckFailure): - """ - Handles when the @commands.check fails so that the log is not clogged with pseudo errors. - :param context: The context of the command that failed. - :param error: The error that occurred. - """ - if isinstance(error, commands.CheckFailure): - await send_timed_message( - channel=context.channel, - content=self.user_strings["music_channel_wrong_channel"].format(command=context.command.name), - timer=10 - ) - await context.message.delete() - self.logger.debug(f"The check for command '{context.command.name}' failed") - return - - # If the error was some other error, raise it so we know about it. - await context.send(self.unhandled_error_string) - raise error - - @command_group.command(name="queue", aliases=["songqueue", "songs", "songlist", "songslist"]) - @delete_after() - async def get_current_queue(self, context: commands.Context): - """ - Get the current queue as a string. - :param context: The context of the command. - """ - if context.guild.id not in self.active_guilds: - await send_timed_message(channel=context.channel, content=self.user_strings["bot_inactive"], timer=20) - return - - if not self.active_guilds.get(context.guild.id).get("queue"): - await send_timed_message(channel=context.channel, content=self.user_strings["bot_inactive"], timer=20) - return - - queue_string = self.get_updated_queue_message(context.guild.id, complete_list=True) - await send_timed_message(channel=context.channel, content=queue_string, timer=60) - - @command_group.command(name="join", aliases=["connect"]) - async def join_channel_command(self, context: commands.Context, force: str = ""): - """ - Makes the bot join the channel of the author of the command. If the bot is in another channel and the author is an - administrator, they can force it to join the channel with -f or force. - :param context: The context of the command. - :param force: Whether or the author is forcing the bot to join the channel. - """ - disable_checks = force.lower() == "-f" or force.lower() == "force" - if disable_checks: - if not context.author.guild_permissions.administrator: - await send_timed_message(context.channel, content=self.user_strings["not_admin"], timer=10) - return - await self.remove_active_guild(context.guild) - if not await self.join_member(context.author): - await send_timed_message(content=self.user_strings["unable_to_join"], channel=context.channel, timer=10) - return - else: - if not await self.join_member(context.author): - await send_timed_message(content=self.user_strings["unable_to_join"], channel=context.channel, timer=10) - return - - @command_group.command(name="kick", aliases=["leave"]) - async def leave_channel_command(self, context: commands.Context, force: str = ""): - """ - Make the bot leave the voice channel of the author. If the author is not in the same voice channel as the bot and they - are an administrator, they can force the bot to leave with -f or force. - :param context: The context of the command. - :param force: Whether or the author is forcing the bot to leave the channel. - """ - disable_checks = force.lower() == "-f" or force.lower() == "force" - if disable_checks: - if not context.author.guild_permissions.administrator: - await send_timed_message(context.channel, content=self.user_strings["not_admin"], timer=10) - return - await self.disconnect_from_guild(context.guild) - await self.remove_active_guild(context.guild) - else: - if context.author in self.active_guilds.get(context.guild.id).get("voice_channel").members: - await self.disconnect_from_guild(context.guild) - await self.remove_active_guild(context.guild) - await self.update_messages(context.guild.id) - - @command_group.command(name="skip") - async def skip_song(self, context: commands.Context, skip_count=1): - """ - The command used to skip the currently playing song. If the user also specifies a skip count, - it will skip n-1 songs in the queue as well. - :param context: The context of the command. - :param skip_count: The amount of songs to skip + 1 in the queue. - """ - try: - skip_count = int(skip_count) - 1 - except ValueError: - skip_count = 0 - - if context.guild.id not in self.active_guilds: - return - - if context.author in self.active_guilds.get(context.guild.id).get("voice_channel").members: - await self.__skip_song(context.guild.id, skip_count) - - async def __skip_song(self, guild_id, skip_count): - """ - The function that actually performs the song skipping. - :param guild_id: The ID of the guild to skip the songs in. - :param skip_count: The amount of extra songs to skip. - """ - if guild_id not in self.active_guilds: - return - - self.active_guilds.get(guild_id).get("voice_client").stop() - self.active_guilds.get(guild_id)["current_song"] = None - if skip_count > len(self.active_guilds.get(guild_id).get("queue")) or skip_count < 0: - await self.play_queue(guild_id) - else: - self.active_guilds.get(guild_id)["queue"] = self.active_guilds.get(guild_id)["queue"][skip_count:] - await self.play_queue(guild_id) - - @command_group.command(name="volume") - async def set_volume(self, context: commands.Context, volume_level): - """ - Set the volume level of the current playback of the bot. This volume level will persist until the bot disconnects from - a voice channel. - :param context: The context of the command. - :param volume_level: The level to set the volume to. A value between 0 and 100 inclusive. - """ - if context.guild.id not in self.active_guilds: - return - - volume_level = str(volume_level) - if not volume_level.isdigit(): - await send_timed_message(channel=context.channel, content=self.user_strings["volume_set_invalid_value"], timer=10) - return - - if context.author in self.active_guilds.get(context.guild.id).get("voice_channel").members: - await self.__set_volume(context.guild.id, int(volume_level)) - - async def __set_volume(self, guild_id, volume_level): - """ - The function that actually performs the volume change for a given guild. - :param guild_id: The ID of the guild to change the volume of. - :param volume_level: The level to set the volume to. A value between 0 and 100 inclusive. - """ - if guild_id not in self.active_guilds: - return - - if volume_level < 0: - volume_level = 0 - - if volume_level > 100: - volume_level = 100 - - self.active_guilds.get(guild_id).get("voice_client").source.volume = float(volume_level) / float(100) - self.active_guilds.get(guild_id)["volume"] = float(volume_level) / float(100) - await self.update_messages(guild_id) - - @command_group.command(name="shuffle") - async def shuffle_queue(self, context: commands.Context): - """ - Shuffles the current queue in a given guild. - :param context: The context of the command. - """ - if context.guild.id not in self.active_guilds: - return - if context.author in self.active_guilds.get(context.guild.id).get("voice_channel").members: - await self.__shuffle_queue(context.guild.id) - await send_timed_message(channel=context.channel, content=self.user_strings["shuffle_queue_success"], timer=10) - - async def __shuffle_queue(self, guild_id): - """ - The function that actually performs the shuffle in a given guild. - :param guild_id: The ID of the guild to shuffle the queue of. - """ - if guild_id not in self.active_guilds: - return - - shuffle(self.active_guilds.get(guild_id).get("queue")) - await self.update_messages(guild_id) - - @command_group.command(name="clear", aliases=["purge", "empty"]) - async def clear_queue(self, context: commands.context): - """ - Clear the current queue in a guild. - :param context: The context of the command. - """ - if context.guild.id in self.active_guilds: - if context.author not in self.active_guilds.get(context.guild.id).get("voice_channel").members: - if not context.author.guild_permissions.administrator: - return - - await self.__clear_queue(context.guild.id) - - async def __clear_queue(self, guild_id): - """ - The actual function that performs the clearing of the current queue in a given guild. - :param guild_id: The ID of the guild to clear the queue of. - """ - if guild_id in self.active_guilds: - self.active_guilds.get(guild_id)["queue"] = [] - await self.update_messages(guild_id) - - @command_group.command(name="resume", aliases=["play"]) - async def play_song(self, context: commands.Context, song_to_play=""): - """ - Either resumes the current playback, adds a song to the queue or starts playback depending on the stats of the bot in - the given guild context. - :param context: The context of the guild. - :param song_to_play: The song to play. If none specified, the playback will be resumed and no song will be added. - """ - if context.guild.id in self.active_guilds: - if context.author not in self.active_guilds.get(context.guild.id).get("voice_channel").members: - return - - await self.__play_song(context.author, song_to_play) - await send_timed_message(channel=context.channel, content=self.user_strings["song_resume_success"], timer=10) - - async def __play_song(self, member, song_to_play=""): - """ - The function that actually performs the play_song command and handles the logic of if to play, resume or - queue the song. - :param member: The member requesting to play the song. - :param song_to_play: The song to play, if any. - """ - if member.guild.id not in self.active_guilds and song_to_play == "": - return - - if song_to_play != "": - if member.guild.id not in self.active_guilds: - await self.join_member(member) - await self.process_request(member.guild.id, song_to_play) - else: - if self.active_guilds.get(member.guild.id).get("voice_client").is_paused(): - self.active_guilds.get(member.guild.id).get("voice_client").resume() - else: - await self.play_queue(member.guild.id) - - if member.guild.id in self.inactive_guilds: - self.inactive_guilds.pop(member.guild.id) - - if member.guild.id not in self.playing_guilds: - self.playing_guilds.append(member.guild.id) - - @command_group.command(name="pause") - async def pause_song(self, context: commands.Context): - """ - Pauses the current playback of the bot in a given guild. - :param context: The context of the command. - """ - if context.guild.id in self.active_guilds: - if context.author not in self.active_guilds.get(context.guild.id).get("voice_channel").members: - return - - self.__pause_song(context.guild.id) - await send_timed_message(channel=context.channel, content=self.user_strings["song_pause_success"], timer=10) - - def __pause_song(self, guild_id): - """ - The actual function that performs the pause in a given guild. - :param guild_id: The ID of the guild to pause the playback of the current song in. - """ - if guild_id not in self.active_guilds: - return - - if self.active_guilds.get(guild_id)["voice_client"].is_playing(): - self.active_guilds.get(guild_id)["voice_client"].pause() - - @command_group.command(name="remove", aliases=["removeat"]) - async def remove_song(self, context: commands.Context, song_index: str = 1): - """ - Removes a song from the queue using it's index. The index given as a param is 1-indexed, instead of 0-indexed. - :param context: The context of the command. - :param song_index: The 1-indexed index of the song to remove. - """ - if context.guild.id not in self.active_guilds: - return - - song_index = await self.song_index_str_to_int(context, song_index) - if song_index is None: - return - - removed_song = await self.__remove_song(context.guild.id, song_index) - if removed_song: - await send_timed_message( - channel=context.channel, - content=self.user_strings["song_remove_success"].format( - song_title=removed_song.get("title"), - song_position=song_index + 1 - ) - ) - - async def __remove_song(self, guild_id, song_index): - """ - The actual function that performs the removal of the song_index from the queue. The song index is 0-indexed. - :param guild_id: The ID of the guild to remove the song in. - :param song_index: The 0-indexed index of the song to remove. - :return: The song removed if one was removed, else None. - """ - if guild_id not in self.active_guilds: - return None - - try: - song = self.active_guilds.get(guild_id)["queue"].pop(song_index) - await self.update_messages(guild_id) - return song - except IndexError: - return None - - @command_group.command(name="move") - async def move_song(self, context: commands.context, from_pos: str, to_pos: str): - """ - Moves a song from one position in the queue to another position. The positions given as params are 1-indexed. - :param context: The context of the command. - :param from_pos: The 1-indexed position of the song to move. - :param to_pos: The 1-indexed position of the index to move to. - """ - if context.guild.id not in self.active_guilds: - return - else: - if context.author not in self.active_guilds.get(context.guild.id).get("voice_channel").members: - return - - from_pos = await self.song_index_str_to_int(context, from_pos) - # Explicitly check for None, as index 0 is counted as False - if from_pos is None: - return - to_pos = await self.song_index_str_to_int(context, to_pos) - if to_pos is None: - return - - song_at_pos = self.active_guilds.get(context.guild.id).get("queue")[from_pos] - - if await self.__move_song(context.guild.id, from_pos, to_pos): - await send_timed_message( - channel=context.channel, - content=self.user_strings["song_moved_success"].format( - from_pos=from_pos + 1, - to_pos=to_pos + 1, - title=song_at_pos.get("title") - ) - ) - - async def __move_song(self, guild_id, from_pos, to_pos): - """ - The actual function that performs song move. - :param guild_id: The ID of the guild to move the song in. - :param from_pos: The 0-indexed position of the song to move. - :param to_pos: The 0-indexed position of where to move the song to. - :return: True if the song was moved, False otherwise. - """ - if guild_id not in self.active_guilds: - return False - - if from_pos == to_pos: - return True - - queue = self.active_guilds.get(guild_id).get("queue") - - if from_pos > to_pos: - queue_top = queue[:to_pos] - inserted_song = [queue[from_pos]] - queue_middle = queue[to_pos:from_pos] - queue_end = queue[from_pos + 1:] - new_queue = queue_top + inserted_song + queue_middle + queue_end - else: - queue_top = queue[:from_pos] - inserted_song = [queue[from_pos]] - queue_middle = queue[from_pos + 1:to_pos + 1] - queue_end = queue[to_pos + 1:] - new_queue = queue_top + queue_middle + inserted_song + queue_end - - self.active_guilds.get(guild_id)["queue"] = new_queue - await self.update_messages(guild_id) - return True - - @commands.group(name="musicadmin") - @commands.has_permissions(administrator=True) - async def music_channel_group(self, context: commands.Context): - """ - The command group for the music channel management. - :param context: The context of the command. - """ - pass - - @music_channel_group.command(name="fix") - async def guild_bot_reset_command(self, context: commands.Context): - """ - Resets the music channel as well as attempts to disconnect the bot. This is to be used in-case there was an error - and the bot was not able to reset itself. - :param context: The context of the command. - """ - await self.remove_active_guild(context.guild) - await self.disconnect_from_guild(context.guild) - await self.reset_music_channel(context) - - @music_channel_group.command(name="set") - async def set_music_channel_command(self, context: commands.Context, text_channel: TextChannel): - """ - Sets the music channel for a given guild to the channel channel mentioned in the command. Extra args can be given to - indicate some extra process to perform while setting up the channel. - :param context: The context of the command. - :param text_channel: The text channel to set the music channel to. - """ - # Using the text channel as the last official arg in the command, find any extras that occur after with a `-` - text_channel_str = str(text_channel.mention) - end_index = context.message.content.index(text_channel_str) + len(text_channel_str) - args = context.message.content[end_index:].strip().split("-") - args.pop(0) - args = [arg.lower() for arg in args] - if "c" in args: - # Use -c to clear the channel. - await self.clear_music_channel(text_channel) - - await self.setup_music_channel(text_channel) - await context.send(self.user_strings["music_channel_set"].format(channel=text_channel.mention)) - - @music_channel_group.command(name="get") - async def get_music_channel_command(self, context: commands.Context): - """ - Gets the current channel that is set as the music channel. - If there is no channel set it will return a message saying so. - :param context: The context of the command. - """ - channel = await self.find_music_channel_instance(context.guild) - if channel: - await context.send(self.user_strings["music_channel_get"].format(channel=channel.mention)) - else: - await context.send(self.user_strings["music_channel_missing"]) - - @music_channel_group.command(name="reset") - async def reset_music_channel_command(self, context: commands.Context): - """ - Resets the music channel to clear all the text and re-send the preview and queue messages. - :param context: The context of the command. - """ - await self.reset_music_channel(context) - - @music_channel_group.command(name="remove") - async def unlink_music_channel_command(self, context: commands.Context): - if not self.music_channels.get(context.guild.id): - await context.send(self.user_strings["music_channel_missing"]) - return - - music_channel_instance = await self.find_music_channel_instance(context.guild) - self.music_channels.pop(context.guild.id) - db_item = self.db.get(MusicChannels, guild_id=context.guild.id) - self.db.delete(db_item) - await context.send(self.user_strings["music_channel_removed"].format(channel=music_channel_instance.mention)) - - async def song_index_str_to_int(self, context, song_index): - """ - Convert a 1-indexed song index as a string to a 0-indexed index as an int. - :param context: The context of the command. - :param song_index: The 1-indexed song index. - :return: A 0-indexed song index if it is valid, else None. - """ - song_index = str(song_index) - try: - song_index = int(song_index) - 1 - queue_length = len(self.active_guilds.get(context.guild.id).get("queue")) - if song_index > queue_length or song_index < 0: - raise ValueError - return song_index - except ValueError: - if len(self.active_guilds.get(context.guild.id).get("queue")) == 0: - return None - help_string = self.user_strings["song_remove_valid_options"].format( - end_index=len(self.active_guilds.get(context.guild.id).get("queue")) - ) - helpful_error = f"{self.user_strings['song_remove_invalid_value']}:\n{help_string}" - await send_timed_message(channel=context.channel, content=helpful_error, timer=10) - return None - - @staticmethod - async def clear_music_channel(channel): - """ - A function used to purge a channel. - :param channel: The channel to purge. - """ - await channel.purge(limit=int(sys.maxsize)) - - async def setup_music_channel(self, channel): - """ - Setup a new channel as the music channel for a guild. - :param channel: The channel to set as the music channel. - """ - self.logger.info(f"Setting up {channel.name} as the music channel in {channel.guild.name}") - default_preview = EMPTY_PREVIEW_MESSAGE.copy() - - queue_message = await channel.send(EMPTY_QUEUE_MESSAGE) - preview_message = await channel.send(embed=default_preview) - - db_item = self.db.get(MusicChannels, guild_id=channel.guild.id) - if not db_item: - db_item = MusicChannels( - guild_id=channel.guild.id, - channel_id=channel.id, - queue_message_id=queue_message.id, - preview_message_id=preview_message.id - ) - self.db.create(db_item) - else: - db_item.queue_message_id = queue_message.id - db_item.preview_message_id = preview_message.id - db_item.channel_id = channel.id - self.db.update(db_item) - self.music_channels[channel.guild.id] = channel.id - - async def reset_music_channel(self, context): - """ - Resets the contents of the music channel. - :param context: The context of the command. - """ - channel = await self.find_music_channel_instance(context.guild) - if channel: - self.logger.info(f"Resetting music channel in {context.guild.name}") - await self.clear_music_channel(channel) - await self.setup_music_channel(channel) - await context.send(self.user_strings["music_channel_reset"].format(channel=channel.mention)) - else: - await context.send(self.user_strings["music_channel_missing"]) - - -def setup(bot): - bot.add_cog(MusicCog(bot)) diff --git a/src/esportsbot/cogs/PingableRolesCog.py b/src/esportsbot/cogs/PingableRolesCog.py deleted file mode 100644 index 19e4dbb1..00000000 --- a/src/esportsbot/cogs/PingableRolesCog.py +++ /dev/null @@ -1,1126 +0,0 @@ -import datetime -import logging -import os -from collections import defaultdict -from typing import Dict, List - -from discord import Colour, Embed, Role -from discord.ext import commands, tasks -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.DiscordReactableMenus.EmojiHandler import MultiEmoji -from esportsbot.DiscordReactableMenus.PingableMenus import (PingableRoleMenu, PingableVoteMenu) -from esportsbot.lib.discordUtil import get_attempted_arg -from esportsbot.models import (GuildInfo, PingablePolls, PingableRoles, PingableSettings) - -# The default role emoji to use on the role react menus: -PINGABLE_ROLE_EMOJI = MultiEmoji("💎") -# The title and description of the role react: -PINGABLE_ROLE_TITLE = "Pingable Role: {}" -PINGABLE_ROLE_DESCRIPTION = "React to this message to receive this pingable role" -# The suffix of the pingable role when the role gets created -PINGABLE_ROLE_SUFFIX = "(Pingable)" - -# The default emoji to use in the poll: -PINGABLE_POLL_EMOJI = MultiEmoji("📋") -# The emoji used to mock the vote threshold: -THRESHOLD_EMOJI = MultiEmoji("🏆") -# The title and description of the poll: -PINGABLE_POLL_TITLE = "Vote to create {} Pingable Role" -PINGABLE_POLL_DESCRIPTION = "The number of votes required to make this role is: `>= {vote_num} votes`. " \ - "If the number of votes is reached and you have voted, you will be given the role automatically " \ - "when the poll finishes." - -TASK_INTERVAL = 10 - - -class PingableRolesCog(commands.Cog): - """ - Pingable roles are roles that can be voted in to be created by any user, and that once created have a cooldown tied to how - often that role can be pinged. - - A user can create a poll where if there are enough votes by the time the poll ends, a role will be created. - The length of the poll and the number of votes required are customisable by server admins. - - After the poll finishes, a reaction menu gets created, allowing any user to react and receive the role. - Initially the role will have the default cooldown of the server, but can be overridden. - - This module implements the above features through a set of commands and making use of a Pingable DB to store and load roles - between shutdowns. - """ - def __init__(self, bot): - self.bot = bot - self.db = DBGatewayActions() - self.user_strings = self.bot.STRINGS["pingable_roles"] - self.logger = logging.getLogger(__name__) - - self.guild_settings = self.load_guild_settings() # Guild ID: Pingable_settings as dict - self.polls = None # Menu ID: {name: pingable name, menu: poll menu instance} - self.roles = None # Menu ID: {role id: pingable role id, menu: role menu instance} - self.all_role_ids = None # Guild ID: {role id : menu id} - self.roles_on_cooldown = [] # List of roles that are on cooldown - - self.init_command_string = "pingme settings default-settings" - self.logger.info(f"Finished loading {__name__}... waiting for ready") - - @commands.Cog.listener() - async def on_ready(self): - """ - When bot discord client is ready and has logged into the discord API, this function runs and is used to load and - initialise any saved Pingable Roles, as well as their reaction menus. - """ - guild_ids = [x.id for x in self.bot.guilds] - self.roles = self.load_all_roles(guild_ids) - self.polls = self.load_all_polls(guild_ids) - self.all_role_ids = self.all_roles_from_guild_data(self.roles) - await self.delete_missing_roles() - await self.initialise_menus() - self.ensure_tasks() - if os.getenv("RUN_MONTHLY_REPORT", "FALSE").lower() == "true": - self.monthly_ping_report.start() - self.logger.info(f"{__name__} is now ready!") - - @commands.Cog.listener() - async def on_message(self, message): - """ - When a message is sent in a channel the bot is able to see, check if a Pingable Role was mentioned in the message, and - if so put it on cooldown. - :param message: The message sent. - """ - # Ignore messages that don't have mentions in them. - if not message.role_mentions: - return - - # Ignore pings from admins, would trust them to not abuse the ping power, but can be removed for safety. - if message.author.guild_permissions.administrator: - return - - # Check each role mentioned in the message: - for role in message.role_mentions: - if role.id in self.all_role_ids[message.guild.id]: - self.logger.debug(f"{role.name} pingable role was just mentioned in {message.guild.name}") - menu_id = self.all_role_ids.get(message.guild.id).get(role.id) - menu = self.roles.get(menu_id).get("menu") - menu.last_pinged = datetime.datetime.now() - await role.edit(mentionable=False) - self.roles_on_cooldown.append(role) - self.ensure_tasks() - db_item = self.db.get(PingableRoles, guild_id=role.guild.id, role_id=role.id) - if db_item: - db_item.total_pings += 1 - db_item.monthly_pings += 1 - self.db.update(db_item) - - @commands.Cog.listener() - async def on_guild_role_delete(self, role): - """ - When a role is deleted by a server administrator, check if the role deleted was a Pingable Role, and if it was, remove - the Pingable Role from the DB so it is not loaded again when the bot starts. - :param role: The role that was deleted. - """ - guild_roles = self.all_role_ids.get(role.guild.id) - if not guild_roles: - return - - menu_id = guild_roles.get(role.id) - if not menu_id: - return - - await self.remove_pingable_role(role) - - self.logger.info(f"Deleted {role.name} for the guild {role.guild.name} from DB") - - @commands.Cog.listener() - async def on_guild_join(self, guild): - """ - When the bot joins a server, initialise the default settings used when creating a Pingable Role in the DB. - :param guild: The server the bot joined. - """ - if guild not in self.guild_settings: - db_item = PingableSettings( - guild_id=guild.id, - default_poll_length=int(os.getenv("DEFAULT_POLL_LENGTH")), - default_poll_threshold=int(os.getenv("DEFAULT_POLL_THRESHOLD")), - default_cooldown_length=int(os.getenv("DEFAULT_COOLDOWN_LENGTH")), - default_poll_emoji=PINGABLE_POLL_EMOJI.to_dict(), - default_role_emoji=PINGABLE_ROLE_EMOJI.to_dict() - ) - self.db.create(db_item) - self.logger.info(f"Joined new guild: {guild.name} ; Set default pingable settings") - - async def delete_missing_roles(self): - """ - Check every role loaded from the DB that it still exists at once loaded. If the role does not exist it will be - deleted from the DB. - """ - bot_guild_ids = [x.id for x in self.bot.guilds] - guilds_to_remove = [] - - for guild_id in self.all_role_ids: - - if guild_id not in bot_guild_ids: - # If the guild is not in the bot's guilds, delete every role. - for role_id in self.all_role_ids.get(guild_id): - self.delete_role_from_db(guild_id, role_id) - - continue - - # Iterate through each role and check for its existence. - guild_role_ids = [x.id for x in await self.bot.get_guild(guild_id).fetch_roles()] - for role_id in self.all_role_ids.get(guild_id): - if role_id not in guild_role_ids: - self.delete_role_from_db(guild_id, role_id) - - if not self.all_role_ids.get(guild_id): - guilds_to_remove.append(guilds_to_remove) - - # Remove empty guilds from the dictionary. - for guild_id in guilds_to_remove: - self.all_role_ids.pop(guild_id) - - def delete_role_from_db(self, guild_id, role_id): - """ - Deletes a role from the DB and ensures that the role is not in the internal dicts. - :param guild_id: The ID of the guild of the role to delete. - :param role_id: The ID of the role to delete. - """ - menu_id = self.all_role_ids.get(guild_id, {}).get(role_id) - - if not menu_id or menu_id not in self.roles: - return - - self.roles.pop(menu_id) - self.all_role_ids.get(guild_id).pop(role_id) - - db_item = self.db.get(PingableRoles, guild_id=guild_id, role_id=role_id, menu_id=menu_id) - self.db.delete(db_item) - - async def remove_pingable_role(self, role): - """ - Deletes a pingable role from the DB and from the cog dictionaries . - :param role: The role to delete . - """ - self.logger.debug(f"{role.name} pingable role was just removed from {role.guild.name}") - db_item = self.db.get(PingableRoles, guild_id=role.guild.id, role_id=role.id) - menu_id = db_item.menu_id - menu_data = self.roles.pop(menu_id) - menu = menu_data.get("menu") - await menu.message.delete() - self.db.delete(db_item) - self.all_role_ids.get(role.guild.id).pop(role.id) - - if role.id in self.roles_on_cooldown: - self.roles_on_cooldown.remove(role.id) - - if len(self.all_role_ids.get(role.guild.id)) == 0: - self.all_role_ids.pop(role.guild.id) - - async def initialise_menus(self): - """ - Once the data has been loaded from the DB it must initialised to an actual reaction menu instance . - """ - self.logger.debug("Initialising menus into actual menu objects from data base info") - - to_pop = [] - - # Load role menus: - for menu_id in self.roles: - menu_data = self.roles.get(menu_id).get("menu") - loaded_menu = await PingableRoleMenu.from_dict(self.bot, menu_data) - if isinstance(loaded_menu, dict): - db_item = self.db.get(PingableRoles, guild_id=menu_data.get("guild_id"), role_id=menu_data.get("role_id")) - self.db.delete(db_item) - to_pop.append(menu_id) - else: - self.roles[menu_id]["menu"] = loaded_menu - - for menu in to_pop: - self.roles.pop(menu) - - to_pop = [] - - # Load poll menus: - for poll_id in self.polls: - menu_data = self.polls.get(poll_id).get("menu") - loaded_menu = await PingableVoteMenu.from_dict(self.bot, menu_data) - if isinstance(loaded_menu, dict): - db_item = self.db.get(PingablePolls, guild_id=menu_data.get("guild_id"), menu_id=poll_id) - self.db.delete(db_item) - to_pop.append(poll_id) - else: - self.polls[poll_id]["menu"] = loaded_menu - - for menu in to_pop: - self.polls.pop(menu) - - self.logger.info(f"Initialised {len(self.polls)} pingable polls, and {len(self.roles)} pingable roles") - - def load_guild_settings(self) -> Dict: - """ - Loads the default settings for all the guilds . - :return: - """ - self.logger.debug("Loading menu guild settings for all guilds") - db_data = self.db.list(PingableSettings) - - loaded_data = {} - - for item in db_data: - loaded_data[item.guild_id] = { - "poll_length": item.default_poll_length, - "poll_threshold": item.default_poll_threshold, - "poll_emoji": MultiEmoji.from_dict(item.default_poll_emoji), - "role_emoji": MultiEmoji.from_dict(item.default_role_emoji), - "role_cooldown": item.default_cooldown_length - } - - self.logger.info(f"Loaded settings for {len(loaded_data)} guild(s)!") - - return loaded_data - - def load_all_polls(self, guild_ids: List[int]) -> Dict: - """ - Loads any polls that were going on when the bot shutdown for all guilds . - :param guild_ids: The listr of guild ids that the bot should load . - :return: A dictionary of all the polls currently happening . - """ - self.logger.debug("Loading pingable polls interrupted by shutdown") - loaded_data = {} - - for guild in guild_ids: - guild_data = self.load_guild_polls(guild) - loaded_data = {**guild_data, **loaded_data} - - self.logger.info(f"Found {len(loaded_data)} pingable poll menu(s) in DB table") - - return loaded_data - - def load_guild_polls(self, guild_id: int) -> Dict: - """ - Loads any polls that were going on when the bot shutdown for a specific guild . - :param guild_id: The guild to load on-going polls for . - :return: A dictionary of all the polls happening in the guild specified . - """ - self.logger.debug(f"Loading pingable polls for guild with id: {guild_id}") - guild_polls: [PingablePolls] = self.db.list(PingablePolls, guild_id=guild_id) - - guild_data = {} - - for item in guild_polls: - guild_data[item.poll_id] = {"name": item.pingable_name, "menu": item.poll} - - self.logger.debug(f"Loaded {len(guild_data)} pingable polls for guild with id: {guild_id}") - - return guild_data - - def load_all_roles(self, guild_ids: List[int]) -> Dict: - """ - Loads all the pingable roles for all guilds . - :param guild_ids: The list of guild ids that the bot is in . - :return: A dictionary of pingable role reaction menus . - """ - self.logger.debug("Loading pingable react menus from DB") - loaded_data = {} - - for guild in guild_ids: - guild_data = self.load_guild_roles(guild) - loaded_data = {**guild_data, **loaded_data} - - self.logger.info(f"Found {len(loaded_data)} pingable react menu(s) in DB table") - - return loaded_data - - def load_guild_roles(self, guild_id: int) -> Dict: - """ - Loads all the pingable roles for a specific guild . - :param guild_id: The guild to load the roles from . - :return: A dictionary of pingable role reaction menus . - """ - self.logger.debug(f"Loading pingable react menus for guild with id: {guild_id}") - guild_roles: [PingableRoles] = self.db.list(PingableRoles, guild_id=guild_id) - - guild_data = {} - - for item in guild_roles: - guild_data[item.menu_id] = {"role_id": item.role_id, "menu": item.menu} - - self.logger.debug(f"Loaded {len(guild_data)} pingable reaction menus for guild with id {guild_id}") - - return guild_data - - def all_roles_from_guild_data(self, role_data: Dict) -> Dict: - """ - Gets a dictionary of guilds and their pingable role ids and the pingable role menus for that role . - :param role_data: The role data gathered from the DB . - :return: A dictionary of guilds to pingable role ids and role menu ids . - """ - self.logger.debug("Getting all pingable roles as dict of Guild->[Role->Menu ID]") - roles = defaultdict(dict) - - for menu_id in role_data: - menu_data = role_data.get(menu_id) - guild_id = menu_data.get("menu").get("guild_id") - role_id = menu_data.get("role_id") - roles[guild_id][role_id] = menu_id - - self.logger.info(f"Found pingable roles in {len(roles)} guild(s)") - - return roles - - async def get_menu_from_role_ping(self, context: commands.Context, role: Role): - """ - Get a reaction menu from a role mention . - :param context: The context of the command . - :param role: The role that was mentioned . - :return: The reaction menu of the role . - """ - guild_id = context.guild.id - if not self.all_role_ids.get(guild_id): - await context.reply(self.user_strings["no_pingable_roles"]) - return None - - menu_id = self.all_role_ids.get(guild_id).get(role.id) - if not menu_id: - await context.reply(self.user_strings["invalid_role"]) - return None - - role_menu = self.roles.get(menu_id).get("menu") - return role_menu - - async def role_mentions_are_valid(self, context: commands.Context): - """ - Checks if the role mentions in the message are valid mentions or if they contain mentions that are not pingable roles . - :param context: The context of the command . - :return: A boolean of if the mentioned roles are valid pingable roles . - """ - role_mentions = context.message.role_mentions - if not role_mentions: - await context.reply(self.user_strings["no_roles_given"]) - return False - - guild_roles = self.all_role_ids.get(context.guild.id) - if not guild_roles: - await context.reply(self.user_strings["no_pingable_roles"]) - return False - return True - - def ensure_tasks(self): - """ - Ensure that the repeatable tasks the bot needs to run are running. - """ - if not self.check_poll.is_running() or self.check_poll.is_being_cancelled(): - self.check_poll.start() - - if not self.check_cooldown.is_running() or self.check_cooldown.is_being_cancelled(): - self.check_cooldown.start() - - @tasks.loop(seconds=TASK_INTERVAL) - async def check_poll(self): - """ - Checks active polls to see if they have passed their poll length and should be finished . - """ - if len(self.polls) == 0: - self.check_poll.cancel() - self.check_poll.stop() - return - - current_time = datetime.datetime.now() - - polls_ids_to_remove = [] - - for poll_id in self.polls: - if self.polls.get(poll_id).get("menu").end_time <= current_time: - self.logger.info( - f"Poll for pingable role {self.polls.get(poll_id).get('menu').name} is over, checking results!" - ) - polls_ids_to_remove.append(poll_id) - await self.finish_poll(self.polls.get(poll_id).get("menu")) - - for poll_id in polls_ids_to_remove: - self.polls.pop(poll_id) - - @tasks.loop(seconds=TASK_INTERVAL) - async def check_cooldown(self): - """ - Checks roles that are currently on cooldown and if they should come off cooldown . - """ - if not self.roles_on_cooldown: - self.check_cooldown.cancel() - self.check_cooldown.stop() - return - - current_time = datetime.datetime.now() - - roles_to_remove = [] - - for role in self.roles_on_cooldown: - menu_id = self.all_role_ids.get(role.guild.id).get(role.id) - menu = self.roles.get(menu_id).get("menu") - if current_time - menu.last_pinged >= datetime.timedelta(seconds=menu.cooldown): - roles_to_remove.append(role) - self.logger.info(f"{role.name} role is no longer on cooldown!") - await role.edit(mentionable=True) - - for role in roles_to_remove: - self.roles_on_cooldown.remove(role) - - @tasks.loop(hours=24) - async def monthly_ping_report(self): - """ - Runs the metrics for all the pingable roles for the last month . - """ - today = datetime.datetime.today() - - if today.day != 1: - return - - embed_base = Embed( - title="Monthly !pingme Report", - description="The number of times each !pingme role was pinged in the last month" - ) - embed_base.colour = Colour.random() - embed_base.footer(text=f"Ping report for {today.strftime('%B %Y')}") - - for guild in self.bot.guilds: - guild_info = self.db.get(GuildInfo, guild_id=guild.id) - if not guild_info or not guild_info.log_channel_id: - continue - - guild_roles = self.db.list(PingableRoles, guild_id=guild.id) - guild_embed = embed_base.copy() - for pingable_role in guild_roles: - role_instance = guild.get_role(pingable_role.role_id) - guild_embed.add_field( - name=role_instance.name, - value=f"{role_instance.mention}\n{pingable_role.monthly_pings} pings" - ) - pingable_role.monthly_pings = 0 - - if guild_roles: - log_channel = guild.get_channel(guild_info.log_channel_id) - if not log_channel: - log_channel = await guild.fetch_channel(guild_info.log_channel_id) - await log_channel.send(embed=guild_embed) - - async def finish_poll(self, poll_to_finish): - """ - Finalises a poll and checks if the role that is for should be created or if the poll should just be deleted . - :param poll_to_finish: The poll that has finished . - """ - channel = poll_to_finish.message.channel - threshold = self.guild_settings.get(channel.guild.id).get("poll_threshold") - embed = await poll_to_finish.generate_result_embed(THRESHOLD_EMOJI, threshold) - - await channel.send(embed=embed) - - total_votes = await poll_to_finish.get_total_votes() - - if total_votes >= threshold: - self.logger.info(f"Pingable poll with name {poll_to_finish.name} had more votes than the voting threshold!") - role = await channel.guild.create_role(name=poll_to_finish.name + PINGABLE_ROLE_SUFFIX, mentionable=True) - await self.create_reaction_menu(role, channel) - await self.give_roles_to_reacts(poll_to_finish.message, role) - self.logger.debug(f"Saved new pingable role information for {role.name} to DB!") - - db_item = self.db.get(PingablePolls, guild_id=channel.guild.id, poll_id=poll_to_finish.id) - self.db.delete(db_item) - await poll_to_finish.message.delete() - - async def create_reaction_menu(self, role, channel): - """ - Creates a reaction menu for a given role and in a given channel . - :param role: The role to create the reaction menu for . - :param channel: The channel to post the reaction menu to . - """ - current_menu = PingableRoleMenu( - pingable_role=role, - ping_cooldown=self.guild_settings.get(channel.guild.id).get("role_cooldown"), - title=f"{role.name} Role React", - description="React to this message to get this pingable role." - ) - - current_menu.add_option(self.guild_settings.get(channel.guild.id).get("role_emoji"), role) - await current_menu.finalise_and_send(self.bot, channel) - self.logger.info(f"Created a new reaction menu and role for the role: {role.name}") - - if not self.all_role_ids.get(channel.guild.id): - self.all_role_ids[channel.guild.id] = {} - - self.all_role_ids[channel.guild.id][role.id] = current_menu.id - self.roles[current_menu.id] = {"role_id": role.id, "menu": current_menu} - - db_item = PingableRoles( - guild_id=channel.guild.id, - role_id=role.id, - menu_id=current_menu.id, - menu=current_menu.to_dict(), - monthly_pings=0, - total_pings=0 - ) - self.db.create(db_item) - - @staticmethod - async def give_roles_to_reacts(message, role): - """ - Gives the given role to the reactees of a message. - :param message: The message to get the user reactions from. - :param role: The role to give the users. - """ - for react in message.reactions: - async for user in react.users(): - if not user.bot: - await user.add_roles(role) - - def role_exists(self, name: str, guild_id: int) -> bool: - """ - Checks if there is a role with the name given as a pingable role . - :param name: The name of the role to check for existence. - :param guild_id: The ID of the guild to check in. - :return: - """ - # Check current polls: - for menu_id in self.polls: - menu_name = self.polls.get(menu_id).get("name") - if guild_id != self.polls.get(menu_id).guild.id: - continue - if name.lower() in menu_name.lower(): - return True - - for menu_id in self.roles: - menu = self.roles.get(menu_id).get("menu") - if guild_id != self.roles.get(menu_id).guild.id: - continue - if name.lower() in menu.role.name.lower(): - return True - - return False - - async def get_guild_in_settings(self, context): - """ - Gets the current guild settings for a guild. If the guild is not in the settings DB, returns None. - :param context: The context of the command. - :return: A Pingable_settings DB item if the guild is in the DB, else None. - """ - db_item = self.db.get(PingableSettings, guild_id=context.guild.id) - if not db_item: - await context.send( - self.user_strings["needs_initialising"].format( - prefix=self.bot.command_prefix, - command=self.init_command_string - ) - ) - return None - return db_item - - @commands.group(name="pingme", invoke_without_command=True) - async def ping_me(self, context: commands.Context): - """ - The command group used to make all commands sub-commands . - :param context: The context of the command . - """ - pass - - @ping_me.group(name="settings") - @commands.has_permissions(administrator=True) - async def ping_me_settings(self, context: commands.Context): - """ - The command group used to make all settings commands into sub-commands . - :param context: The context of the command . - """ - pass - - @ping_me_settings.command(name="get-settings") - async def get_guild_settings(self, context: commands.Context): - """ - Returns a list of the current settings in a guild . - :param context: The context of the command . - """ - guild_settings = self.guild_settings.get(context.guild.id) - if not guild_settings: - await context.send( - self.user_strings["needs_initialising"].format(prefix=self.bot.command_prefix, - command=self.init_command_string) - ) - return - - embed = Embed( - title="Current Pingable Roles Settings", - description="These are the current pingable settings for this server" - ) - # An alternative visual option for displaying the settings: - # e.add_field( - # name=f"• Poll Emoji: {guild_settings.get('poll_emoji').discord_emoji}", - # value=f"**• Role Emoji: {guild_settings.get('role_emoji').discord_emoji}**", - # inline=False - # ) - # e.add_field( - # name=f"• Poll Length Seconds: {guild_settings.get('poll_length')}", - # value=f"**• Poll Vote Threshold: {guild_settings.get('vote_threshold')}**", - # inline=False - # ) - # e.add_field( - # name=f"• Role Cooldown Seconds: {guild_settings.get('role_cooldown')}", - # value="​", - # inline=False - # ) - embed.add_field(name=f"• Poll Emoji: {guild_settings.get('poll_emoji').discord_emoji}", value="​", inline=False) - embed.add_field(name=f"• Role Emoji: {guild_settings.get('role_emoji').discord_emoji}", value="​", inline=False) - embed.add_field(name=f"• Poll Length Seconds: {guild_settings.get('poll_length')}", value="​", inline=False) - embed.add_field(name=f"• Poll Vote Threshold: {guild_settings.get('poll_threshold')}", value="​", inline=False) - embed.add_field(name=f"• Role Cooldown Seconds: {guild_settings.get('role_cooldown')}", value="​", inline=False) - await context.send(embed=embed) - - @ping_me_settings.command(name="default-settings") - async def default_settings(self, context: commands.Context): - """ - Sets the settings for a guild back to the default settings . - :param context: The context of the command . - """ - guild_id = context.guild.id - - exists = self.db.get(PingableSettings, guild_id=guild_id) - - if exists: - exists.default_poll_length = int(os.getenv("DEFAULT_POLL_LENGTH")) - exists.default_poll_threshold = int(os.getenv("DEFAULT_POLL_THRESHOLD")) - exists.default_cooldown_length = int(os.getenv("DEFAULT_COOLDOWN_LENGTH")) - exists.default_poll_emoji = PINGABLE_POLL_EMOJI.to_dict() - exists.default_role_emoji = PINGABLE_ROLE_EMOJI.to_dict() - self.db.update(exists) - else: - current_item = PingableSettings( - guild_id=guild_id, - default_poll_length=int(os.getenv("DEFAULT_POLL_LENGTH")), - default_poll_threshold=int(os.getenv("DEFAULT_POLL_THRESHOLD")), - default_cooldown_length=int(os.getenv("DEFAULT_COOLDOWN_LENGTH")), - default_poll_emoji=PINGABLE_POLL_EMOJI.to_dict(), - default_role_emoji=PINGABLE_ROLE_EMOJI.to_dict() - ) - self.db.create(current_item) - - self.guild_settings[context.guild.id] = {} - self.guild_settings[context.guild.id]["poll_length"] = int(os.getenv("DEFAULT_POLL_LENGTH")) - self.guild_settings[context.guild.id]["poll_threshold"] = int(os.getenv("DEFAULT_POLL_THRESHOLD")) - self.guild_settings[context.guild.id]["role_cooldown"] = int(os.getenv("DEFAULT_COOLDOWN_LENGTH")) - self.guild_settings[context.guild.id]["poll_emoji"] = PINGABLE_POLL_EMOJI - self.guild_settings[context.guild.id]["role_emoji"] = PINGABLE_ROLE_EMOJI - - self.logger.info(f"{context.guild.name} has had its pingable settings set back to defaults!") - await context.reply(self.user_strings["default_settings_set"]) - - @ping_me_settings.command(name="poll-length") - async def set_poll_length(self, context: commands.Context, poll_length: int): - """ - Sets the default poll length setting for a guild to the given value . - :param context: The context of the command . - :param poll_length: The number of seconds to set the default poll length to . - """ - db_item = await self.get_guild_in_settings(context) - if not db_item: - return - db_item.default_poll_length = poll_length - self.db.update(db_item) - - self.guild_settings[context.guild.id]["poll_length"] = poll_length - - await context.reply(self.user_strings["set_poll_length"].format(poll_length=poll_length)) - self.logger.info(f"Set {context.guild.name} default poll length to {poll_length}s") - - @ping_me_settings.command(name="poll-threshold") - async def set_poll_threshold(self, context: commands.Context, vote_threshold: int): - """ - Sets the poll vote threshold setting for a guild to the given value . - :param context: The context of the command . - :param vote_threshold: The number of votes needed to create a role . - """ - db_item = await self.get_guild_in_settings(context) - if not db_item: - return - db_item.default_poll_threshold = vote_threshold - self.db.update(db_item) - - self.guild_settings[context.guild.id]["poll_threshold"] = vote_threshold - - await context.reply(self.user_strings["set_poll_threshold"].format(vote_threshold=vote_threshold)) - self.logger.info(f"Set {context.guild.name} poll threshold to {vote_threshold} votes") - - @ping_me_settings.command(name="ping-cooldown") - async def set_role_cooldown(self, context: commands.Context, role_cooldown: int): - """ - Sets the default role ping cooldown setting for a guild to the given value . - :param context: The context of the command . - :param role_cooldown: The number of seconds a role will be on cooldown for . - """ - db_item = self.db.get(PingableSettings, guild_id=context.guild.id) - if not db_item: - await context.send( - self.user_strings["needs_initialising"].format( - prefix=self.bot.command_prefix, - command=self.init_command_string - ) - ) - return - db_item.default_cooldown_length = role_cooldown - self.db.update(db_item) - - self.guild_settings[context.guild.id]["role_cooldown"] = role_cooldown - - await context.reply(self.user_strings["set_role_cooldown"].format(cooldown=role_cooldown)) - self.logger.info(f"Set {context.guild.name} pingable role cooldown to {role_cooldown}s") - - @ping_me_settings.command(name="poll-emoji") - async def set_poll_emoji(self, context: commands.Context, poll_emoji: MultiEmoji): - """ - Sets the poll voting emoji for a guild to the given emoji . - :param context: The context of the command . - :param poll_emoji: The emoji to use in the role polls . - """ - if poll_emoji == THRESHOLD_EMOJI: - # Can't use the threshold emoji as the poll emoji as it is used to count votes . - await context.reply(self.user_strings["reserved_emoji"].format(poll_emoji.discord_emoji)) - return - - db_item = await self.get_guild_in_settings(context) - if not db_item: - return - db_item.default_poll_emoji = poll_emoji.to_dict() - self.db.update(db_item) - - self.guild_settings[context.guild.id]["poll_emoji"] = poll_emoji - - await context.reply(self.user_strings["set_poll_emoji"].format(emoji=poll_emoji.discord_emoji)) - self.logger.info(f"Set {context.guild.name} poll emoji to {poll_emoji.name}") - - @ping_me_settings.command(name="role-emoji") - async def set_role_emoji(self, context: commands.Context, role_emoji: MultiEmoji): - """ - Sets the default role reaction emoji for a guild to the given emoji . - :param context: The context of the command . - :param role_emoji: The emoji to use in the role reaction menus . - """ - db_item = await self.get_guild_in_settings(context) - if not db_item: - return - db_item.default_role_emoji = role_emoji.to_dict() - self.db.update(db_item) - - self.guild_settings[context.guild.id]["role_emoji"] = role_emoji - - await context.reply(self.user_strings["set_role_emoji"].format(emoji=role_emoji.discord_emoji)) - self.logger.info(f"Set {context.guild.name} role emoji to {role_emoji.name}") - - @ping_me.command(name="create-role") - async def create_role(self, context: commands.Context, role_name: str, poll_length: int = None): - """ - Creates a new role poll for a role with the name given . If no poll length is given, the guild default - poll length is used . - :param context: The context of the command . - :param role_name: The name of the role to create . - :param poll_length: The number of seconds to run the poll for . - """ - guild_settings = self.guild_settings.get(context.guild.id) - - if not guild_settings: - await context.send( - self.user_strings["needs_initialising"].format( - prefix=self.bot.command_prefix, - command=self.init_command_string - ) - ) - return - - if self.role_exists(role_name, context.guild.id): - await context.reply(self.user_strings["already_exists"].format(role=role_name)) - return - - if poll_length is None: - poll_length = guild_settings.get("poll_length") - - vote_threshold = guild_settings.get("poll_threshold") - - role_poll = PingableVoteMenu( - pingable_name=role_name, - auto_enable=True, - title=PINGABLE_POLL_TITLE.format(role_name), - description=PINGABLE_POLL_DESCRIPTION.format(vote_num=vote_threshold), - poll_length=poll_length, - author=context.author - ) - - role_poll.add_option(self.guild_settings.get(context.guild.id).get("poll_emoji"), role_name) - await role_poll.finalise_and_send(self.bot, context.channel) - db_item = PingablePolls( - guild_id=context.guild.id, - pingable_name=role_name, - poll_id=role_poll.id, - poll=role_poll.to_dict() - ) - self.db.create(db_item) - self.polls[role_poll.id] = {"name": role_name, "menu": role_poll} - self.ensure_tasks() - await context.reply(self.user_strings["create_success"]) - self.logger.info(f"Created a new poll for a pingable role with the name {role_name} in guild {context.guild.name}") - - @ping_me.command(name="delete-role") - @commands.has_permissions(administrator=True) - async def delete_role(self, context: commands.Context): - """ - Deletes one or many pingable roles and their role reaction menus . This is done using the message.role_mentions attr - instead of using function params. - :param context: The context of the command . - """ - if not await self.role_mentions_are_valid(context): - return - - deleted_roles = [] - - for role in context.message.role_mentions: - db_item = self.db.get(PingableRoles, guild_id=context.guild.id, role_id=role.id) - if not db_item: - await context.send(self.user_strings["not_pingable_role"].format(role=role.name)) - else: - deleted_roles.append(role.name) - await role.delete() - - if not deleted_roles: - return - - deleted_string = str(deleted_roles).replace("]", "").replace("[", "") - - await context.reply(self.user_strings["role_delete_success"].format(deleted_roles=deleted_string)) - self.logger.info(f"Deleted pingable roles: {deleted_string} in guild {context.guild.name}") - - @ping_me.command(name="convert-role") - @commands.has_permissions(administrator=True) - async def convert_role(self, context: commands.Context): - """ - Converts an existing non-pingable role into a pingable role with a reaction menu for it .This is done using the - message.role_mentions attr instead of using function params. - :param context: The context of the command . - """ - if not context.message.role_mentions: - await context.reply(self.user_strings["no_roles_given"]) - return - - converted_roles = [] - - for role in context.message.role_mentions: - db_item = self.db.get(PingableRoles, guild_id=context.guild.id, role_id=role.id) - if db_item: - await context.send(self.user_strings["already_exists"].format(role=role.name)) - else: - await self.create_reaction_menu(role, context.channel) - converted_roles.append(role.name) - - if not converted_roles: - return - converted_string = str(converted_roles).replace("]", "").replace("[", "") - - await context.reply(self.user_strings["role_convert_success"].format(converted_roles=converted_string)) - self.logger.info(f"Converted pingable roles: {converted_string} in guild {context.guild.name}") - - @ping_me.command(name="convert-pingable") - @commands.has_permissions(administrator=True) - async def convert_pingable(self, context: commands.Context): - """ - Converts a pingable role into a non-cooldown limited regular role . This is done using the message.role_mentions attr - instead of using function params. - :param context: The context of the command . - :return: - """ - if not await self.role_mentions_are_valid(context): - return - - converted_roles = [] - - for role in context.message.role_mentions: - pingable_role = self.all_role_ids.get(context.guild.id).get(role.id) - if not pingable_role: - await context.send(self.user_strings["not_pingable_role"].format(role=role.name)) - else: - converted_roles.append(role.name) - await self.remove_pingable_role(role) - - if not converted_roles: - return - - converted_string = str(converted_roles).replace("]", "").replace("[", "") - - await context.reply(self.user_strings["pingable_convert_success"].format(converted_roles=converted_string)) - self.logger.info(f"Converted pingable roles: {converted_string} in guild {context.guild.name}") - - @ping_me.command(name="role-cooldown") - @commands.has_permissions(administrator=True) - async def change_pingable_role_cooldown(self, context: commands.Context, pingable_role: Role, cooldown_seconds: int): - """ - Changes the number of seconds a role will be on cooldown if it is mentioned . - :param context: The context of the command . - :param pingable_role: The role to change the cooldown for . - :param cooldown_seconds: The number of seconds for the command to be on cooldown for . - """ - role_menu = await self.get_menu_from_role_ping(context, pingable_role) - role_menu.cooldown = cooldown_seconds - - db_item = self.db.get(PingableRoles, guild_id=context.guild.id, role_id=pingable_role.id) - if db_item: - db_item.menu = role_menu.to_dict() - self.db.update(db_item) - else: - db_item = PingableRoles( - guild_id=context.guild.id, - role_id=pingable_role.id, - menu_id=role_menu.id, - menu=role_menu.to_dict(), - monthly_pings=0, - total_pings=0 - ) - self.db.create(db_item) - - await context.reply( - self.user_strings["role_cooldown_updated"].format(role=pingable_role.name, - seconds=cooldown_seconds) - ) - - @ping_me.command(name="role-emoji") - @commands.has_permissions(administrator=True) - async def change_pingable_role_emoji(self, context: commands.Context, pingable_role: Role, role_emoji: MultiEmoji): - """ - Change the emoji used in the reaction menu of a pingable role . - :param context: The context of the command . - :param pingable_role: The pingable role to change the emoji of . - :param role_emoji: The emoji to set the reaction to . - """ - role_menu = await self.get_menu_from_role_ping(context, pingable_role) - if not role_menu: - return - await role_menu.disable_menu(self.bot) - current_emoji_id = list(role_menu.options.keys())[0] - current_emoji = role_menu.options.get(current_emoji_id).get("emoji") - role_menu.remove_option(current_emoji) - role_menu.add_option(role_emoji, role_menu.role) - await role_menu.enable_menu(self.bot) - await context.reply( - self.user_strings["role_emoji_updated"].format(role=pingable_role.name, - emoji=role_emoji.discord_emoji) - ) - - @ping_me.command(name="disable-role") - @commands.has_permissions(administrator=True) - async def disable_pingable_role(self, context: commands.Context): - """ - Stops a pingable role from being mentioned and from users getting the pingable role . This is done using the - message.role_mentions attr instead of using function params. - :param context: The context of the command . - """ - if not await self.role_mentions_are_valid(context): - return - - disabled_roles = [] - - for role in context.message.role_mentions: - menu_id = self.all_role_ids.get(context.guild.id).get(role.id) - if not menu_id: - await context.send(self.user_strings["not_pingable_role"].format(role=role.name)) - else: - await role.edit(mentionable=False) - await self.roles.get(menu_id).get("menu").disable_menu(self.bot) - disabled_roles.append(role.name) - - disabled_string = str(disabled_roles).replace("[", "").replace("]", "") - await context.reply(self.user_strings["roles_disabled"].format(disabled_roles=disabled_string)) - - @ping_me.command(name="enable-role") - @commands.has_permissions(administrator=True) - async def enabled_pingable_roles(self, context: commands.Context): - """ - Allows a pingable role to be mentioned and for users to be able to get the pingable role . This is done using the - message.role_mentions attr instead of using function params. - :param context: - :return: - """ - if not await self.role_mentions_are_valid(context): - return - - enabled_roles = [] - - for role in context.message.role_mentions: - menu_id = self.all_role_ids.get(context.guild.id).get(role.id) - if not menu_id: - await context.send(self.user_strings["not_pingable_role"].format(role=role.name)) - else: - await role.edit(mentionable=True) - await self.roles.get(menu_id).get("menu").enable_menu(self.bot) - enabled_roles.append(role.name) - - enabled_string = str(enabled_roles).replace("[", "").replace("]", "") - await context.reply(self.user_strings["roles_enabled"].format(enabled_roles=enabled_string)) - - @change_pingable_role_cooldown.error - @set_poll_threshold.error - @set_poll_length.error - @create_role.error - async def integer_parse_error(self, context: commands.Context, error: commands.CommandError): - """ - Error handling for any integer parsing . - :param context: The context of the command . - :param error: The error that occurred . - """ - if isinstance(error, ValueError): - self.logger.warning( - "User attempted to give non-integer value as poll length in the following message: %s", - context.message.content - ) - await context.reply(self.user_strings["invalid_argument"]) - return - - @change_pingable_role_cooldown.error - async def role_cooldown_error(self, context: commands.Context, error: commands.CommandError): - """ - Occurs when the role parsed to change pingable role cooldown command is not a role . - :param context: The context of the command . - :param error: The error that occurred . - """ - if isinstance(error, commands.RoleNotFound): - # The position of the role arg in the change_pingable_role_cooldown command - role_arg_index = 0 - await self.invalid_role_error(context, role_arg_index, self.change_pingable_role_cooldown) - - @change_pingable_role_emoji.error - async def role_emoji_error(self, context: commands.Context, error: commands.CommandError): - """ - Occurs when the role parsed to change pingable role emoji command is not a role . - :param context: The context of the command . - :param error: The error that occurred . - """ - if isinstance(error, commands.RoleNotFound): - # The position of the role arg in the change_pingable_role_emoji command - role_arg_index = 0 - await self.invalid_role_error(context, role_arg_index, self.change_pingable_role_emoji) - - async def invalid_role_error(self, context: commands.Context, role_arg_index: int, command): - """ - Handles when a role given to a command is not a role . - :param context: The context of the failed command . - :param role_arg_index: The index of the role argument in the failed command . - :param command: The command function that failed . - """ - self.logger.warning("The argument parsed was not a Role, trying to find a role with the given value") - - attempted_arg, command_args = get_attempted_arg(context.args, role_arg_index) - try: - role_id = int(attempted_arg) - for role in context.guild.roles: - if role.id == role_id: - # Retry the command and parse the given role_id as an actual role object. - self.logger.info(f"Retrying {context.command.name} with found role: {role.name}") - command_args[role_arg_index] = role - await command(context, *command_args) - return - raise ValueError - except ValueError: - self.logger.error(f"Unable to find a role with id: {attempted_arg}") - await context.reply(self.user_strings["invalid_role"]) - return - - -def setup(bot): - bot.add_cog(PingableRolesCog(bot)) diff --git a/src/esportsbot/cogs/RoleReactCog.py b/src/esportsbot/cogs/RoleReactCog.py deleted file mode 100644 index befbad6a..00000000 --- a/src/esportsbot/cogs/RoleReactCog.py +++ /dev/null @@ -1,280 +0,0 @@ -import logging -import os -import shlex - -from discord.ext import commands -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.DiscordReactableMenus.EmojiHandler import (EmojiKeyError, MultiEmoji) -from esportsbot.DiscordReactableMenus.ExampleMenus import RoleReactMenu -from esportsbot.DiscordReactableMenus.reactable_lib import get_menu -from esportsbot.models import RoleMenus - -DELETE_ON_CREATE = os.getenv("DELETE_ROLE_CREATION", "FALSE").lower() == "true" - - -class RoleReactCog(commands.Cog): - """ - Role reaction menus allow admins to create reactable menus that when reacted to grant defined roles to the user. - - This module implements the ability to create and manage role menus so that users can receive roles by reacting. - """ - def __init__(self, bot): - self.bot = bot - self.user_strings = self.bot.STRINGS["role_reacts"] - self.db = DBGatewayActions() - self.reaction_menus = {} - self.logger = logging.getLogger(__name__) - self.logger.info(f"Finished loading {__name__}... waiting for ready") - - @commands.Cog.listener() - async def on_ready(self): - """ - When bot discord client is ready and has logged into the discord API, this function runs and is used to load and - initialise the existing reaction menus. - """ - self.reaction_menus = await self.load_menus() - self.logger.info(f"{__name__} is now ready!") - - async def load_menus(self): - """ - Loads saved role reaction menus from the DB for all guilds . - :return: A dictionary of reaction menu IDs and their reaction menus . - """ - all_menus = self.db.list(RoleMenus) - loaded_menus = {} - for menu in all_menus: - loaded_menus[menu.menu_id] = await RoleReactMenu.from_dict(self.bot, menu.menu) - return loaded_menus - - def add_or_update_db(self, menu_id): - """ - Creates a new DB item or updates an existing one for a given menu id . - :param menu_id: The menu id to create or update . - """ - db_item = self.db.get(RoleMenus, menu_id=menu_id) - if db_item: - db_item.menu = self.reaction_menus.get(menu_id).to_dict() - self.db.update(db_item) - else: - db_item = RoleMenus(menu_id=menu_id, menu=self.reaction_menus.get(menu_id).to_dict()) - self.db.create(db_item) - - @staticmethod - def options_from_strings(message, roles): - """ - Gets the role/emoji pairs for the options to add to the role reaction menu from the message contents . - :param message: The message contents . - :param roles: The list of roles mentioned in the message in the order they were mentioned in . - :return: A dictionary of emoji to role. - """ - options = {} - for i in range(len(roles)): - if i == len(roles) - 1: - emoji_end_index = len(message) - else: - emoji_end_index = message.index(roles[i + 1]) - - emoji_str = message[message.index(roles[i]) + len(roles[i]):emoji_end_index] - emoji_str = emoji_str.strip() - - if emoji_str in options: - raise EmojiKeyError(emoji_str) - else: - options[emoji_str] = roles[i] - - return options - - @staticmethod - def title_and_description(message): - """ - Get the title and description of a reaction menu from the creation command . - :param message: The message contents . - :return: A tuple of Title, Description - """ - quote_last_index = message.rfind('"') - quote_first_index = message.index('"') - short_message = message[quote_first_index:quote_last_index + 1] - split_message = shlex.split(short_message) - return split_message[0], split_message[1] - - @commands.group(name="roles", help="Create reaction menus that can be used to get roles.") - async def command_group(self, context: commands.Context): - """ - The command group used to make all commands sub-commands . - :param context: The context of the command . - """ - pass - - @command_group.command(name="make-menu") - @commands.has_permissions(administrator=True) - async def create_role_menu(self, context: commands.Context): - """ - Creates a new role reaction menu with the options provided in the command . - :param context: The context of the command . - """ - roles = context.message.role_mentions - role_strings = [f"<@&{x.id}>" for x in roles] - # The mentioned roles in the correct order. - sorted_strings = sorted(role_strings, key=lambda x: context.message.content.index(x)) - - try: - menu_options = self.options_from_strings(context.message.content, sorted_strings) - except EmojiKeyError as e: - # The emoji is already in the reaction menu . - await context.reply(self.user_strings["duplicate_emoji"].format(emoji=e.emoji)) - return - - try: - title, description = self.title_and_description(context.message.content) - except ValueError: - # The user missed some quotes around their title/description. - await context.reply(self.user_strings["missing_quotes"]) - return - - role_menu = RoleReactMenu(title=title, description=description, auto_enable=True, use_inline=False) - role_menu.add_many(menu_options) - await role_menu.finalise_and_send(self.bot, context.channel) - - self.reaction_menus[role_menu.id] = role_menu - self.add_or_update_db(role_menu.id) - if DELETE_ON_CREATE: - await context.message.delete() - - @command_group.command(name="add-option") - @commands.has_permissions(administrator=True) - async def add_menu_option(self, context: commands.Context, menu_id: str = None): - """ - Adds more roles to the role reaction menu . This is done using the message.role_mentions attr instead of - using function params. - :param context: The context of the command . - :param menu_id: The ID of the menu to add the roles to . - """ - roles = context.message.role_mentions - role_strings = [f"<@&{x.id}>" for x in roles] - # The mentioned roles in the correct order. - sorted_strings = sorted(role_strings, key=lambda x: context.message.content.index(x)) - - # If the user hasn't supplied a menu ID the menu ID var will be the role mention: - if menu_id in sorted_strings: - menu_id = None - - found_menu = get_menu(self.reaction_menus, menu_id) - - if not found_menu: - await context.reply(self.user_strings["invalid_id"].format(given_id=menu_id)) - return - - try: - options_to_add = self.options_from_strings(context.message.content, sorted_strings) - except EmojiKeyError as e: - await context.reply(self.user_strings["duplicate_emoji"].format(emoji=e.emoji)) - return - - found_menu.add_many(options_to_add) - await found_menu.update_message() - self.add_or_update_db(found_menu.id) - - @command_group.command(name="remove-option") - @commands.has_permissions(administrator=True) - async def remove_menu_option(self, context: commands.Context, option_key: MultiEmoji, menu_id=None): - """ - Removes an role option from a reaction menu . - :param context: The context of the command . - :param option_key: The emoji used to get the role to remove from the menu . - :param menu_id: The ID of the menu to remove the option from . - :return: - """ - menu = get_menu(self.reaction_menus, menu_id) - if not menu: - await context.reply(self.user_strings["invalid_id"].format(given_id=menu_id)) - return - - menu.remove_option(option_key) - await menu.update_message() - self.add_or_update_db(menu.id) - - @command_group.command(name="disable-menu") - @commands.has_permissions(administrator=True) - async def disable_menu(self, context: commands.Context, menu_id=None): - """ - Disables a reaction menu to stop users from being able to get roles from it . - :param context: The context of the command . - :param menu_id: The ID of the menu to disable . - """ - menu = get_menu(self.reaction_menus, menu_id) - if not menu: - await context.reply(self.user_strings["invalid_id"].format(given_id=None)) - return - - await menu.disable_menu(self.bot) - self.add_or_update_db(menu.id) - await context.reply(self.user_strings["disable_menu"].format(menu_id=menu.id)) - - @command_group.command(name="enable-menu") - @commands.has_permissions(administrator=True) - async def enable_menu(self, context: commands.Context, menu_id=None): - """ - Allows users to react to a message and get roles from it . - :param context: The context of the command . - :param menu_id: The menu ID to enable . - """ - menu = get_menu(self.reaction_menus, menu_id) - if not menu: - await context.reply(self.user_strings["invalid_id"].format(given_id=None)) - return - - await menu.enable_menu(self.bot) - self.add_or_update_db(menu.id) - await context.reply(self.user_strings["enable_menu"].format(menu_id=menu.id)) - - @command_group.command(name="delete-menu") - @commands.has_permissions(administrator=True) - async def delete_menu(self, context: commands.Context, menu_id): - """ - Deletes a reaction menu entirely . - :param context: The context of the command . - :param menu_id: The menu ID to delete . - """ - menu = get_menu(self.reaction_menus, menu_id) - - if not menu: - await context.reply(self.user_strings["invalid_id"].format(given_id=menu_id)) - return - - await menu.message.delete() - self.reaction_menus.pop(menu.id) - db_item = self.db.get(RoleMenus, menu_id=menu.id) - self.db.delete(db_item) - await context.reply(self.user_strings["delete_menu"].format(menu_id=menu.id)) - - @command_group.command(name="toggle-ids") - @commands.has_permissions(administrator=True) - async def toggle_show_ids(self, context: commands.Context): - """ - Toggles if the menu IDs are showing in the footer of all reaction menus . - :param context: The context of the command . - """ - for menu_id in self.reaction_menus: - menu = self.reaction_menus.get(menu_id) - if menu.guild.id != context.guild.id: - continue - menu.toggle_footer() - await menu.update_message() - self.add_or_update_db(menu_id) - - @remove_menu_option.error - async def remove_error(self, context: commands.Context, error): - """ - This is the error handling function that runs when the `remove_menu_option` function encounters an error. - :param context: The context of the command. - :param error: The error that occurred. - """ - if isinstance(error, commands.BadArgument): - await context.reply(self.user_strings["invalid_emoji"]) - return - - raise error - - -def setup(bot): - bot.add_cog(RoleReactCog(bot)) diff --git a/src/esportsbot/cogs/TwitchCog.py b/src/esportsbot/cogs/TwitchCog.py deleted file mode 100644 index a62bce61..00000000 --- a/src/esportsbot/cogs/TwitchCog.py +++ /dev/null @@ -1,997 +0,0 @@ -""" -The TwitchCog module implements a HTTP Server to listen for requests, as well as a Discord Cog to allow for changing of where -Twitch notifications get sent and which accounts notifications are sent for. - -.. codeauthor:: Fluxticks -""" - -import asyncio -import json -from datetime import datetime -import hashlib -import hmac -import os -from typing import Any - -import aiohttp -import discord -from discord import Webhook, Embed, AsyncWebhookAdapter -from tornado.httpserver import HTTPServer -import tornado.web - -import ast - -from discord.ext import commands -from tornado import httputil -from tornado.web import Application - -import logging - -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.lib.discordUtil import get_webhook_by_name, load_discord_hooks -from esportsbot.models import TwitchInfo - -SUBSCRIPTION_SECRET = os.getenv("TWITCH_SUB_SECRET") -CLIENT_ID = os.getenv("TWITCH_CLIENT_ID") -CLIENT_SECRET = os.getenv("TWITCH_CLIENT_SECRET") -BEARER_TEMP_FILE = os.getenv("TEMP_BEARER_FILE") -WEBHOOK_PREFIX = "TwitchHook-" -BEARER_PADDING = 2 * 60 # Number of minutes before expiration of bearer where the same bearer will still be used. -DATETIME_FMT = "%d/%m/%Y %H:%M:%S" -TWITCH_EMBED_COLOUR = 0x6441a4 -TWITCH_ICON = "https://pbs.twimg.com/profile_images/1189705970164875264/oXl0Jhyd_400x400.jpg" -CALLBACK_URL = os.getenv("TWITCH_CALLBACK") + "/webhook" # The URL to be used as for the event callback. -DEFAULT_HOOK_NAME = "DefaultTwitchHook" - -TWITCH_HELIX_BASE = "https://api.twitch.tv/helix" -TWITCH_EVENT_BASE = TWITCH_HELIX_BASE + "/eventsub" -TWITCH_SUB_BASE = TWITCH_EVENT_BASE + "/subscriptions" -TWITCH_ID_BASE = "https://id.twitch.tv" -TWITCH_BASE = "https://twitch.tv" - - -class TwitchApp(Application): - """ - This TwitchApp is the application which the TwitchListener is serving and handling requests for. - Mainly used to store data that is used across requests, as well as handling any API requests that need to be made. - """ - def __init__(self, handlers=None, default_host=None, transforms=None, **settings: Any): - super().__init__(handlers, default_host, transforms, **settings) - self.seen_ids = set() - self.hooks = {} # Hook ID: {"token": token, "guild id": guild id, "name": name} - self.bearer = None - self.tracked_channels = None # Channel ID : {Hook ID : message} - self.subscriptions = [] - self.logger = logging.getLogger(__name__) - - async def get_bearer(self): - """ - Gets the current bearer token and information or generates a new one if the current one has expired. - :return: A dictionary containing when the token was created, how long it lasts for and the token itself. - """ - - self.logger.debug("Checking Twitch bearer token status...") - current_time = datetime.now() - if self.bearer is not None: - # If there is a currently active bearer, check if it is still valid. - grant_time = datetime.strptime(self.bearer.get("granted_on"), DATETIME_FMT) - time_delta = current_time - grant_time - delta_seconds = time_delta.total_seconds() - expires_in = self.bearer.get("expires_in") # Number of seconds the token is valid for. - if delta_seconds + BEARER_PADDING < expires_in: - # The bearer is still valid, and will be still valid for the BEARER_PADDING time. - self.logger.debug( - "Current Twitch bearer token is still valid, there are %d seconds remaining!", - (expires_in - delta_seconds) - ) - return self.bearer - - bearer_url = TWITCH_ID_BASE + "/oauth2/token" - params = {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "grant_type": "client_credentials"} - - # Get a new bearer: - async with aiohttp.ClientSession() as session: - async with session.post(url=bearer_url, params=params) as response: - if response.status != 200: - self.bearer = None - else: - data = await response.json() - self.bearer = { - "granted_on": current_time.strftime(DATETIME_FMT), - "expires_in": data.get("expires_in"), - "access_token": data.get("access_token") - } - - self.save_bearer() - return self.bearer - - def load_bearer(self): - """ - Load an existing bearer token from a file, if it exists. - """ - try: - with open(BEARER_TEMP_FILE, "r") as f: - lines = f.readlines() - self.bearer = { - "granted_on": lines[0].replace("\n", - ""), - "expires_in": int(lines[1].replace("\n", - "")), - "access_token": lines[2].replace("\n", - "") - } - except FileNotFoundError: - self.bearer = None - - def save_bearer(self): - """ - Save the current bearer token to a file, so that it can be reused while still valid. - """ - if self.bearer is not None: - with open(BEARER_TEMP_FILE, "w") as f: - f.write(str(self.bearer.get("granted_on")) + "\n") - f.write(str(self.bearer.get("expires_in")) + "\n") - f.write(str(self.bearer.get("access_token"))) - - async def load_tracked_channels(self, db_channels): - """ - Set the tracked_channels attribute to db_channels param, and perform checks to ensure all the information is still - needed or if any information is missing: - - From the channel data gathered from the database, check that each of them are being tracked by a subscription and - remove any old subscriptions that are no longer being tracked. - :param db_channels: The dictionary of channel IDs to set of guild IDs - """ - - # Get the list of events we are subscribed to from Twitch's end. - subscribed_events = await self.get_subscribed_events() - self.subscriptions = subscribed_events - channels_not_tracked = list(db_channels.keys()) - - # Ensure that the events that are tracked by Twitch are still ones we want to track: - for event in subscribed_events: - if event.get("type") != "stream.online": - # Event isn't for a stream coming online, we don't want to track any other events so delete it... - self.logger.info( - "Twitch Event for %s is not a Stream Online event, deleting!", - event.get("condition").get("broadcaster_user_id") - ) - await self.delete_subscription(event.get("id")) - continue - channel_tracked = event.get("condition").get("broadcaster_user_id") - - if channel_tracked not in db_channels: - # The channel is no longer tracked in the DB, assume we no longer want to track the channel so delete it... - self.logger.info( - "Twitch Event for %s is no longer tracked, deleting!", - event.get("condition").get("broadcaster_user_id") - ) - await self.delete_subscription(event.get("id")) - else: - channels_not_tracked.remove(channel_tracked) - - # Any channels here are ones that we want to have tracked but there is no event we are subscribed to for it. - for channel in channels_not_tracked: - self.logger.warning("No Twitch event for channel with ID %s, subscribing to new event...", channel) - await self.create_subscription("stream.online", channel_id=channel) - - self.tracked_channels = db_channels - - async def delete_subscription(self, event_id): - """ - Deletes a Twitch Event Subscription given the Event's ID. - :param event_id: The ID of the event to delete. - """ - - delete_url = TWITCH_SUB_BASE - params = {"id": event_id} - bearer_info = await self.get_bearer() - headers = {"Client-ID": CLIENT_ID, "Authorization": "Bearer " + bearer_info.get("access_token")} - - async with aiohttp.ClientSession() as session: - async with session.delete(url=delete_url, params=params, headers=headers) as response: - if response.status == 204: - # Remove the event from the list: - self.subscriptions = [x for x in self.subscriptions if x.get("id") != event_id] - return True - return False - - async def delete_channel_subscription(self, channel_id): - """ - Delete the stream.online event subscription for the given channel. This means the bot will no longer receive - notifications when the given channel goes live. - :param channel_id: The ID of the channel to remove the subscription for. - """ - event = None - for subscription in self.subscriptions: - if subscription.get("condition").get("broadcaster_user_id") == channel_id: - event = subscription.get("id") - break - - if not event: - return False - - return await self.delete_subscription(event) - - async def create_subscription(self, event_type, channel_id=None, channel_name=None): - """ - Creates a new Event Subscription for a given channel ID for a given Event Type. - :param event_type: The Event to subscribe to. - :param channel_id: The ID of the channel. - :param channel_name: The name of the channel. - """ - - if channel_id is None and channel_name is None: - self.logger.error("A Twitch channel ID or Twitch channel name must be supplied. Both cannot be None.") - return False - - if channel_id is None: - # Get the channel ID from the channel name. - channel_info = await self.get_channel_info(channel_name) - - if len(channel_info) == 0: - return False - - channel_info = channel_info[0] - channel_id = channel_info.get("id") - - subscription_url = TWITCH_SUB_BASE - bearer_info = await self.get_bearer() - headers = { - "Client-ID": CLIENT_ID, - "Authorization": "Bearer " + bearer_info.get("access_token"), - "Content-Type": "application/json" - } - - # The required body to subscribe to an event: - body = { - "type": event_type, - "version": "1", - "condition": { - "broadcaster_user_id": str(channel_id) - }, - "transport": { - "method": "webhook", - "callback": CALLBACK_URL, - "secret": SUBSCRIPTION_SECRET - } - } - - # Needs to be as a json: - body_json = json.dumps(body) - async with aiohttp.ClientSession() as session: - async with session.post(url=subscription_url, data=body_json, headers=headers) as response: - return response.status == 202 - - async def get_channel_info(self, channel_name): - """ - Returns the information about the given channel using its name as the lookup parameter. - :param channel_name: The name of the channel. - :return: A dictionary containing the information about a twitch channel or None if there was an error. - """ - - channel_url = TWITCH_HELIX_BASE + "/search/channels" - params = {"query": channel_name} - bearer_info = await self.get_bearer() - headers = {"Client-ID": CLIENT_ID, "Authorization": "Bearer " + bearer_info.get("access_token")} - - async with aiohttp.ClientSession() as session: - async with session.get(url=channel_url, params=params, headers=headers) as response: - if response.status != 200: - self.logger.error("Unable to get Twitch channel info! Response status was %d", response.status) - return None - data = await response.json() - return data.get("data") - - async def get_subscribed_events(self): - """ - Returns a list of information about the current events that are currently subscribed to. - :return: A list of dictionaries. - """ - - events_url = TWITCH_SUB_BASE - bearer_info = await self.get_bearer() - headers = {"Client-ID": CLIENT_ID, "Authorization": "Bearer " + bearer_info.get("access_token")} - async with aiohttp.ClientSession() as session: - async with session.get(url=events_url, headers=headers) as response: - if response.status != 200: - self.logger.error("Unable to get subscribed event list! Response status was %d", response.status) - return None - data = await response.json() - return data.get("data") - - def add_hook(self, hook): - """ - Adds a new hook the dictionary of tracked hooks. - :param hook: The hook to add. - :return: A boolean of weather the hook was added to the dictionary of hooks. - """ - - if hook.id in self.hooks: - return False - self.hooks[hook.id] = {"token": hook.token, "name": hook.name, "guild_id": hook.guild_id} - return True - - def set_hooks(self, hooks): - """ - Set the dictionary of Webhooks for the Twitch noifications to use. - :param hooks: - """ - self.hooks = hooks - - -class TwitchListener(tornado.web.RequestHandler): - """ - This TwitchListener is the webserver that listens for requests. - """ - def __init__(self, application: "TwitchApp", request: httputil.HTTPServerRequest, **kwargs: Any): - super().__init__(application, request, **kwargs) - self.application: TwitchApp = application - self.logger = logging.getLogger(__name__) - - @staticmethod - def verify_twitch(headers, body): - """ - Using the headers and the body of a message, confirm weather or not the incoming request came from Twitch. - :param headers: The request's headers. - :param body: The raw body of the request, not turned into a dict or other kind of data. - :return: True if the signature provided in the header is the same as the calculated signature. - """ - - message_signature = headers.get("Twitch-Eventsub-Message-Signature") - hmac_message = headers.get("Twitch-Eventsub-Message-Id") + headers.get("Twitch-Eventsub-Message-Timestamp") + body - hmac_message_bytes = bytes(hmac_message, "utf-8") - secret_bytes = bytes(SUBSCRIPTION_SECRET, "utf-8") - - calculated_signature = hmac.new(secret_bytes, hmac_message_bytes, hashlib.sha256) - expected_signature = "sha256=" + calculated_signature.hexdigest() - - return expected_signature == message_signature - - async def post(self): - """ - When a POST request is received by this web listener, this method is called to determine what to do with the - incoming request. The general structure to this method can be found in the Twitch documentation: - https://dev.twitch.tv/docs/eventsub#subscriptions. - """ - - self.logger.debug("Received a POST request on /webhook") - current_request = self.request - message_body = current_request.body.decode("utf-8") - body_dict = ast.literal_eval(message_body) - message_headers = current_request.headers - - # Check for messages that have already been received and processed. Twitch will repeat a message if it - # thinks we have not received it. - if message_headers.get("Twitch-Eventsub-Message-Id") in self.application.seen_ids: - self.logger.debug("The message was already received before, ignoring!") - self.set_status(208) - await self.finish() - return - else: - self.application.seen_ids.add(message_headers.get("Twitch-Eventsub-Message-Id")) - - # Verify that the message we have received has come from Twitch. - if not self.verify_twitch(message_headers, message_body): - self.logger.error( - "The message received at %s was not a legitimate message from Twitch, ignoring!", - message_headers.get("Twitch-Eventsub-Message-Timestamp") - ) - self.set_status(403) - await self.finish() - return - - # POST requests from Twitch will either be to confirm that we own the webhook we just created or will be a notification - # for an event we are subscribed to. - if message_headers.get("Twitch-Eventsub-Message-Type") == "webhook_callback_verification": - # Received shortly after creating a new EventSub. - challenge = body_dict.get("challenge") - self.application.subscriptions.append(body_dict.get("subscription")) - self.logger.info("Responding to Webhook Verification Callback with challenge: %s", challenge) - await self.finish(challenge) - elif message_headers.get("Twitch-Eventsub-Message-Type") == "notification": - # Received once a subscribed event occurs. - self.logger.info("Received valid notification from Twitch!") - self.set_status(200) - asyncio.create_task(self.send_webhook(body_dict)) - - async def send_webhook(self, request_body: dict): - """ - Formats a message and send the information of the event to the required discord hooks. - :param request_body: The body of the request that was received. - """ - - event = request_body.get("event") - - channel_name = event.get("broadcaster_user_login") - - channel_info = await self.application.get_channel_info(channel_name) - if not channel_info: - return - channel_info = channel_info[0] - game_name = channel_info.get("game_name") - stream_title = channel_info.get("title") - user_icon = channel_info.get("thumbnail_url") - - async with aiohttp.ClientSession() as session: - hook_adapter = AsyncWebhookAdapter(session) - for hook_id in self.application.tracked_channels.get(channel_info.get("id")): - hook_token = self.application.hooks.get(hook_id).get("token") - webhook = Webhook.partial(id=hook_id, token=hook_token, adapter=hook_adapter) - custom_message = self.application.tracked_channels.get(channel_info.get("id")).get(hook_id) - - description = "​" if custom_message is None else custom_message - - embed = Embed( - title=stream_title, - url=f"{TWITCH_BASE}/{channel_name}", - description=description, - color=TWITCH_EMBED_COLOUR - ) - embed.set_author(name=channel_name, url=f"{TWITCH_BASE}/{channel_name}", icon_url=user_icon) - embed.set_thumbnail(url=user_icon) - embed.add_field(name="**Current Game:**", value=f"**{game_name}**") - - await webhook.send(embed=embed, username=channel_name + " is Live!", avatar_url=TWITCH_ICON) - self.logger.info("Sending Twitch notification to Discord Webhook %s(%s)", webhook.name, hook_id) - - -class TwitchCog(commands.Cog): - """ - The TwitchCog that handles communications from Twitch. - """ - def __init__(self, bot): - self._bot = bot - self.logger = logging.getLogger(__name__) - self._db = DBGatewayActions() - self.user_strings = self._bot.STRINGS["twitch"] - self._http_server, self._twitch_app = self.setup_http_listener() - - @staticmethod - def setup_http_listener(): - """ - Sets up the HTTP server to receive the requests from Twitch. - :return: A tuple containing the instance of the HTTP server and the Application running in the server. - """ - - # Setup the TwitchListener to listen for /webhook requests. - app = TwitchApp([(r"/webhook", TwitchListener)]) - http_server = HTTPServer( - app, - ssl_options={ - "certfile": f"{os.getenv('SSL_CERT_FILE')}", - "keyfile": f"{os.getenv('SSL_KEY_FILE')}" - } - ) - http_server.listen(443) - return http_server, app - - @commands.Cog.listener() - async def on_ready(self): - """ - Is run when the Discord bot gives the signal that it is connected and ready. - """ - - self._twitch_app.load_bearer() - - self._http_server.start() - - self.logger.info("Loading Discord Webhooks for Twitch Cog...") - - tasks = [] - for guild in self._bot.guilds: - self.logger.info("Loading webhooks from guild %s(%s)", guild.name, guild.id) - if guild.me.guild_permissions.manage_webhooks: - tasks.append(guild.webhooks()) - else: - self.logger.error("Missing permission 'manage webhooks' in guild %s(%s)", guild.name, guild.id) - - # Wait for all the tasks to finish. - results = await asyncio.gather(*tasks) - - # Add the hooks to the App. - hooks = load_discord_hooks(WEBHOOK_PREFIX, results, self._bot.user.id) - self._twitch_app.set_hooks(hooks) - self.logger.info( - "Currently using %d Discord Webhooks in %d guilds for Twitch notifications.", - len(self._twitch_app.hooks), - len(self._bot.guilds) - ) - - # Load tracked channels from DB. - db_data = self.load_db_data() - cleaned_data = self.remove_missing_hooks(db_data) - await self._twitch_app.load_tracked_channels(cleaned_data) - if len(cleaned_data) > 0: - self.logger.info("Currently tracking %d Twitch channels(s)", len(cleaned_data)) - else: - self.logger.warning("There are no Twitch channels that are currently tracked!") - - @commands.Cog.listener() - async def on_disconnect(self): - """ - Is executed whenever the client loses a connection to Discord. Could be when no internet or when logged out. - """ - - if self._bot.is_closed: - self._http_server.stop() - - @commands.Cog.listener() - async def on_webhooks_update(self, channel): - """ - When a webhook is changed (deleted/edited) this event fires. When this event occurs, if the webhook changed is a - webhook used by the Twitch Cog, it should be removed from the internal DB of webhooks that the cog uses. - :param channel: The Text Channel that had its webhooks updated. - """ - pass - # TODO: Capture this event to determine when a webhook gets deleted not using the command. - - async def get_channel_id(self, channel): - """ - Gets the Twitch Channel ID for a given channel name. - :param channel: The name of the Twitch channel to find the ID of. - :returns None if there is no channel with that ID, else a string of the ID. - """ - - channel_info = await self._twitch_app.get_channel_info(channel) - - if not channel_info: - return None - - channel_info = channel_info[0] - channel_id = channel_info.get("id") - return str(channel_id) - - def load_db_data(self): - """ - Loads all the currently tracked Twitch channels and which guilds they are tracked in from the database. - :return: A dictionary of Twitch channel ID to a set of guild IDs. - """ - - db_data = self._db.list(TwitchInfo) - guild_info = {} - for item in db_data: - if str(item.channel_id) not in guild_info: - guild_info[str(item.channel_id)] = {item.hook_id: item.custom_message} - else: - guild_info[str(item.channel_id)][item.hook_id] = item.custom_message - - return guild_info - - def remove_missing_hooks(self, db_data): - """ - Removes any hooks for channels where the Discord Webhook has been deleted. - :param db_data: The loaded DB data. - :return: A cleaned version of the param db_data, with missing hooks removed. - """ - cleaned_db = {} - for channel in db_data: - cleaned_db[channel] = {} - hooks = db_data.get(channel) - for hook in hooks: - if hook in self._twitch_app.hooks: - cleaned_db[channel][hook] = hooks.get(hook) - else: - db_item = self._db.get(TwitchInfo, channel_id=channel, hook_id=hook) - if db_item: - self._db.delete(db_item) - if not cleaned_db.get(channel): - # If a channel has no hooks to post to, remove it from the list. - cleaned_db.pop(channel) - - return cleaned_db - - async def remove_hook_from_channel(self, hook_id, channel_id): - """ - Removes a Webhook from a channels list of webhooks to post updates to. - :param hook_id: The ID of the hook to remove. - :param channel_id: The ID of the channel to remove the hook from. - :return: A boolean indicating if the hook ID was removed from the channels list of webhooks. - """ - if channel_id not in self._twitch_app.tracked_channels: - return False - if hook_id not in self._twitch_app.tracked_channels.get(channel_id): - return False - self._twitch_app.tracked_channels.get(channel_id).pop(hook_id) - db_item = self._db.get(TwitchInfo, channel_id=channel_id, hook_id=hook_id) - if db_item: - self._db.delete(db_item) - - if not self._twitch_app.tracked_channels.get(channel_id): - return await self._twitch_app.delete_channel_subscription(channel_id) - - async def get_channel_id_from_command(self, channel): - """ - Gets the ID of the given channel. The given channel can either be the username of the Twitch URL. - :param channel: The channel to find the ID of. - :return: A string of the Twitch user's ID or None if there is no user with the given name. - """ - if TWITCH_BASE in channel: - channel = channel.split("tv/")[-1] - - return await self.get_channel_id(channel) - - def get_webhook_channels_as_embed(self, webhook_id, webhook_name): - """ - Gets the list of channels and their custom messages for a given webhook. - :param webhook_id: The ID of the Webhook to get the channels of. - :param webhook_name: The name of the Webhook. - :return: An embed representing the Twitch channels that post updates to the given Webhook. - """ - db_items = self._db.list(TwitchInfo, hook_id=webhook_id) - embed = Embed( - title="**Currently Tracked Channels:**", - description=f"These are the currently tracked channels for the Webhook: \n`{webhook_name}`", - color=TWITCH_EMBED_COLOUR - ) - embed.set_author(name="Twitch Channels", icon_url=TWITCH_ICON) - if not db_items: - embed.add_field(name="No channels tracked", value="​", inline=False) - return embed - - for item in db_items: - custom_message = item.custom_message if item.custom_message else "" - embed.add_field(name=item.twitch_handle, value=custom_message, inline=False) - return embed - - @commands.group(name="twitch", invoke_without_command=True) - async def twitch(self, ctx): - """ - Empty command, purely used to organise subcommands to be under twitch instead of having to ensure name - uniqueness. - """ - - pass - - @twitch.command(name="createhook", aliases=["newhook", "makehook", "addhook"]) - @commands.has_permissions(administrator=True) - async def create_new_hook(self, context, bound_channel: discord.TextChannel, hook_name: str): - """ - Creates a new Discord Webhook with the given name that is bound to the given channel. - :param context: The context of the command. - :param bound_channel: The channel to bind the Webhook to. - :param hook_name: The name of the Webhook - """ - - hook_id, hook_info = get_webhook_by_name(self._twitch_app.hooks, hook_name, context.guild.id, WEBHOOK_PREFIX) - - if hook_id is not None: - await context.send(self.user_strings["webhook_exists"].format(name=hook_name)) - return - - if WEBHOOK_PREFIX not in hook_name: - hook_name = WEBHOOK_PREFIX + hook_name - hook = await bound_channel.create_webhook(name=hook_name, reason="Created new Twitch Webhook with command!") - self._twitch_app.add_hook(hook) - await context.send( - self.user_strings["webhook_created"].format(name=hook_name, - channel=bound_channel.mention, - hook_id=hook.id) - ) - - @twitch.command(name="deletehook") - @commands.has_permissions(administrator=True) - async def delete_twitch_hook(self, context, hook_name: str): - """ - Deletes a Discord Webhook if a Webhook with the given name exists in the guild. - :param context: The context of the command. - :param hook_name: The name of the Webhook to delete. - """ - hook_id, hook_info = get_webhook_by_name(self._twitch_app.hooks, hook_name, context.guild.id, WEBHOOK_PREFIX) - - if hook_id is None: - await context.send(self.user_strings["webhook_missing"].format(name=hook_name)) - return - - self._twitch_app.hooks.pop(hook_id) - async with aiohttp.ClientSession() as session: - webhook = Webhook.partial(id=hook_id, token=hook_info.get("token"), adapter=AsyncWebhookAdapter(session)) - await webhook.delete(reason=f"Deleted {hook_name} Twitch Webhook with command!") - - # Ensure that channels that were posting to that webhook are no longer trying to: - hook_channels = self._db.list(TwitchInfo, guild_id=context.guild.id, hook_id=hook_id) - if not hook_channels: - await context.send(self.user_strings["webhook_deleted"].format(name=hook_info.get("name"), hook_id=hook_id)) - return - - for channel in hook_channels: - await self.remove_hook_from_channel(hook_id, channel.channel_id) - - await context.send(self.user_strings["webhook_deleted"].format(name=hook_info.get("name"), hook_id=hook_id)) - - @twitch.command(name="add") - @commands.has_permissions(administrator=True) - async def add_twitch_channel(self, context, channel, webhook_name, custom_message=None): - """ - Allows the Live notifications of the given twitch channel to be sent to the Webhook given with the given custom - message. - :param context: The context of the command. - :param channel: The Twitch channel to track. - :param webhook_name: The name of the webhook to send the notifications to. - :param custom_message: The custom message to include in the live notification. - """ - channel_id = await self.get_channel_id_from_command(channel) - webhook_id, webhook_info = get_webhook_by_name(self._twitch_app.hooks, webhook_name, context.guild.id, WEBHOOK_PREFIX) - - if not channel_id: - await context.send(self.user_strings["no_channel_error"].format(channel=channel)) - return - - if not webhook_id: - await context.send(self.user_strings["webhook_missing"].format(name=webhook_name)) - return - - if channel_id in self._twitch_app.tracked_channels: - # The given Twitch Channel is tracked by one or more Webhooks. - if webhook_id in self._twitch_app.tracked_channels.get(channel_id): - # The given Twitch Channel is already tracked in the given Webhook. - await context.send(self.user_strings["channel_already_tracked"].format(name=channel, webhook=webhook_name)) - return - self._twitch_app.tracked_channels[channel_id][webhook_id] = custom_message - db_item = TwitchInfo( - guild_id=context.guild.id, - hook_id=webhook_id, - channel_id=channel_id, - custom_message=custom_message, - twitch_handle=channel - ) - self._db.create(db_item) - return - - if await self._twitch_app.create_subscription("stream.online", channel_name=channel): - # Ensure that the Twitch EventSub was successful before adding the info to the DB. - self._twitch_app.tracked_channels[channel_id] = {webhook_id: custom_message} - db_item = TwitchInfo( - guild_id=context.guild.id, - hook_id=webhook_id, - channel_id=channel_id, - custom_message=custom_message, - twitch_handle=channel - ) - self._db.create(db_item) - await context.send(self.user_strings["channel_added"].format(twitch_channel=channel, discord_channel=webhook_name)) - else: - # Otherwise don't if it failed. - await context.send(self.user_strings["generic_error"].format(channel=channel)) - - @twitch.command(name="remove") - @commands.has_permissions(administrator=True) - async def remove_twitch_channel(self, context, channel, webhook_name): - """ - Stops sending live notifications for the given Twitch channel being sent ot the given Webhook. - :param context: The context of the command. - :param channel: The channel to stop sending updates for. - :param webhook_name: The Webhook to stop sending updates to. - """ - channel_id = await self.get_channel_id_from_command(channel) - webhook_id, webhook_info = get_webhook_by_name(self._twitch_app.hooks, webhook_name, context.guild.id, WEBHOOK_PREFIX) - - if not channel_id: - await context.send(self.user_strings["no_channel_error"].format(channel=channel)) - return - - if not webhook_name: - await context.send(self.user_strings["webhook_missing"].format(name=webhook_name)) - return - - if channel_id in self._twitch_app.tracked_channels: - # The given Twitch Channel is tracked by one or more Webhooks. - if webhook_id not in self._twitch_app.tracked_channels.get(channel_id): - # The given Twitch Channel is not tracked in the given Webhook. - await context.send(self.user_strings["channel_not_tracked"].format(name=channel, webhook=webhook_name)) - return - if await self.remove_hook_from_channel(webhook_id, channel_id): - await context.send( - self.user_strings["channel_removed"].format(twitch_channel=channel, - discord_channel=webhook_name) - ) - return - else: - await context.send(self.user_strings["channel_not_tracked"].format(name=channel, webhook=webhook_name)) - return - - @twitch.command(name="list") - @commands.has_permissions(administrator=True) - async def get_accounts_tracked(self, context, webhook_name=None): - """ - Shows the accounts tracked and their custom message in the given Webhook, or every Webhook if no Webhook is given. - :param context: The context of the command. - :param webhook_name: The name of the webhook to get the accounts of. - """ - if webhook_name: - # Get the accounts for a specific Webhook. - webhook_id, webhook_info = get_webhook_by_name( - self._twitch_app.hooks, - webhook_name, - context.guild.id, WEBHOOK_PREFIX - ) - embed = self.get_webhook_channels_as_embed(webhook_id, webhook_info.get("name")) - await context.send(embed=embed) - return - - # Get the accounts for all the Webhooks. - for hook in self._twitch_app.hooks: - embed = self.get_webhook_channels_as_embed(hook, self._twitch_app.hooks.get(hook).get("name")) - await context.send(embed=embed) - - @twitch.command(name="webhooks") - @commands.has_permissions(administrator=True) - async def get_current_webhooks(self, context): - """ - Gets a list of the current Webhooks for the Twitch Cog in the given guild. - :param context: The context of the command. - """ - guild_hooks = list( - filter(lambda x: self._twitch_app.hooks.get(x).get("guild_id") == context.guild.id, - self._twitch_app.hooks) - ) - if not guild_hooks: - await context.send(self.user_strings["no_webhooks"]) - return - string = ", ".join(self._twitch_app.hooks.get(x).get("name") for x in guild_hooks) - await context.send(self.user_strings["current_webhooks"].format(webhooks=string, prefix=WEBHOOK_PREFIX)) - - @twitch.command(name="setmessage") - @commands.has_permissions(administrator=True) - async def set_channel_message(self, context, channel, webhook_name, custom_message=None): - """ - Sets the custom message for a Twitch channel for the given Webhook. - If the message is left empty, it deletes the custom message. - :param context: The context of the command. - :param channel: The channel to set the custom message of. - :param webhook_name: The name of the Webhook to set the message in. - :param custom_message: The custom message to set. - """ - channel_id = await self.get_channel_id_from_command(channel) - webhook_id, webhook_info = get_webhook_by_name(self._twitch_app.hooks, webhook_name, context.guild.id, WEBHOOK_PREFIX) - - if not channel_id: - await context.send(self.user_strings["channel_missing_error"].format(channel=channel)) - return - - if not webhook_name: - await context.send(self.user_strings["webhook_missing"].format(name=webhook_name)) - return - - if channel_id in self._twitch_app.tracked_channels: - # The given Twitch Channel is tracked by one or more Webhooks. - if webhook_id not in self._twitch_app.tracked_channels.get(channel_id): - # The given Twitch Channel is not tracked in the given Webhook. - await context.send(self.user_strings["channel_not_tracked"].format(name=channel, webhook=webhook_name)) - return - self._twitch_app.tracked_channels[channel_id][webhook_id] = custom_message - db_item = self._db.get(TwitchInfo, guild_id=context.guild.id, channel_id=channel_id, hook_id=webhook_id) - if db_item: - db_item.custom_message = custom_message - self._db.update(db_item) - if not custom_message: - custom_message = "" - await context.send( - self.user_strings["set_custom_message"].format(channel=channel, - message=custom_message, - webhook=webhook_name) - ) - else: - await context.send(self.user_strings["channel_not_tracked"].format(name=channel, webhook=webhook_name)) - return - - @twitch.command(name="getmessage") - async def get_channel_message(self, context, channel, webhook_name=None): - """ - Gets the custom channel message for a Webhook. If no Webhook name is given, get all the custom messages. - :param context: The context of the command. - :param channel: The channel to get the custom messages of. - :param webhook_name: The Webhook to get the custom message of. - """ - channel_id = await self.get_channel_id_from_command(channel) - - if channel_id not in self._twitch_app.tracked_channels: - # The requested channel is not tracked. - await context.send(self.user_strings["no_channel_error"].format(channel=channel)) - return - - if webhook_name: - webhook_id, webhook_info = get_webhook_by_name( - self._twitch_app.hooks, - webhook_name, - context.guild.id, - WEBHOOK_PREFIX - ) - custom_message = self._twitch_app.tracked_channels.get(channel_id).get(webhook_id) - if not custom_message: - custom_message = "" - await context.send( - self.user_strings["get_custom_message"].format(channel=channel, - webhook=webhook_name, - message=custom_message) - ) - return - - string = f"The custom messages for the channel `{channel}` are: \n" - for webhook_id in self._twitch_app.tracked_channels.get(channel_id): - message = self._twitch_app.tracked_channels.get(channel_id).get(webhook_id) - if not message: - message = "" - next_string = f"`{self._twitch_app.hooks.get(webhook_id).get('name')}` : '{message}'" - string += next_string + "\n" - - await context.send(string) - - @twitch.command(name="preview") - async def get_channel_preview(self, context, channel, webhook_name): - """ - Gets a preview embed for a given Twitch channel in a given Webhook. - :param context: The context of the command. - :param channel: The channel to preview. - :param webhook_name: The name of the Webhook to get the preview of. - """ - channel_info = await self._twitch_app.get_channel_info(channel_name=channel) - - if not channel_info: - await context.send(self.user_strings["no_channel_error"].format(channel=channel)) - return - - channel_info = channel_info[0] - channel_id = channel_info.get("id") - webhook_id, webhook_info = get_webhook_by_name(self._twitch_app.hooks, webhook_name, context.guild.id, WEBHOOK_PREFIX) - - if not channel_id: - await context.send(self.user_strings["channel_missing_error"].format(channel=channel)) - return - - if not webhook_name: - await context.send(self.user_strings["webhook_missing"].format(name=webhook_name)) - return - - if channel_id not in self._twitch_app.tracked_channels: - # The given channel is not tracked. - await context.send(self.user_strings["channel_not_tracked"].format(name=channel, webhook=webhook_name)) - return - - if webhook_id not in self._twitch_app.tracked_channels.get(channel_id): - # The given Twitch Channel is not tracked in the given Webhook. - await context.send(self.user_strings["channel_not_tracked"].format(name=channel, webhook=webhook_name)) - return - - custom_message = self._twitch_app.tracked_channels.get(channel_id).get(webhook_id) - if not custom_message: - custom_message = "​" - - embed = Embed( - title=channel_info.get("title"), - url=f"{TWITCH_BASE}/{channel_info.get('broadcaster_login')}", - description=f"**{custom_message}**", - color=TWITCH_EMBED_COLOUR - ) - embed.set_author( - name=channel_info.get("broadcaster_login"), - url=f"{TWITCH_BASE}/{channel_info.get('broadcaster_login')}", - icon_url=channel_info.get("thumbnail_url") - ) - embed.set_thumbnail(url=channel_info.get("thumbnail_url")) - embed.add_field(name="Current Game:", value=f"{channel_info.get('game_name')}") - - await context.send(embed=embed) - - -def setup(bot): - logger = logging.getLogger(__name__) - try: - assert CLIENT_ID != "" and CLIENT_ID is not None, \ - "A CLIENT_ID must be provided in your secrets file. " \ - "If you don't want to use the Twitch integration, set ENABLE_TWITCH to FALSE" - assert CLIENT_SECRET != "" and CLIENT_SECRET is not None, \ - "A CLIENT_SECRET must be provided in your secrets file. " \ - "If you don't want to use the Twitch integration, set ENABLE_TWITCH to FALSE" - assert SUBSCRIPTION_SECRET != "" and SUBSCRIPTION_SECRET is not None, \ - "A SUBSCRIPTION_SECRET must be provided in your secrets file. " \ - "If you don't want to use the Twitch integration, set ENABLE_TWITCH to FALSE" - assert CALLBACK_URL != "/webhook" and CALLBACK_URL is not None, \ - "A CALLBACK_URL must be provided in your secrets file. " \ - "If you don't want to use the Twitch integration, set ENABLE_TWITCH to FALSE" - bot.add_cog(TwitchCog(bot)) - except AssertionError: - logger.error( - "There were one or more environment variables not supplied to the TwitchCog. Disabling the Cog...", - exc_info=True - ) diff --git a/src/esportsbot/cogs/TwitterCog.py b/src/esportsbot/cogs/TwitterCog.py deleted file mode 100644 index 0d8717a5..00000000 --- a/src/esportsbot/cogs/TwitterCog.py +++ /dev/null @@ -1,640 +0,0 @@ -import json -from typing import List, Tuple, Union, Dict - -import discord -import tweepy -from discord.ext import commands -from discord import Webhook, AsyncWebhookAdapter -from discord.errors import Forbidden - -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.lib.stringTyping import str_is_int, str_is_channel_mention -import aiohttp -import asyncio -import logging -from collections import defaultdict - -import os - -from esportsbot.models import TwitterInfo - -bot_hook_prefix = "TwitterHook-" -CONSUMER_KEY = os.getenv("TWITTER_CONSUMER_KEY") -CONSUMER_SECRET = os.getenv("TWITTER_CONSUMER_SECRET") - -ACCESS_TOKEN = os.getenv("TWITTER_ACCESS_TOKEN") -ACCESS_TOKEN_SECRET = os.getenv("TWITTER_ACCESS_TOKEN_SECRET") - - -class TwitterWebhook(tweepy.StreamListener): - """ - Captures events from the Twitter API. - """ - def __init__(self, api, loop=None): - super().__init__(api) - self.loop = loop if loop is not None else asyncio.get_event_loop() - self.api = api - self.me = api.me - self.logger = logging.getLogger(__name__) - self.logger.info("Loaded Twitter Webhook") - self._hooks = {} - self._tracked_accounts = defaultdict(set) - - def on_data(self, data): - """ - This is called whenever something matching the stream filter happens. In this case it will be when a tracked - user Tweets, ReTweets, Quotes, or Replies. - :param data: The new status of the tracked user. - :type data: - :return: None - :rtype: NoneType - """ - - status = json.loads(data) - - if "in_reply_to_status_id" not in status: - # Not a status update. - return - - if not len(self.hooks) > 0: - # There are no hooks to send the new status to. - self.logger.error("Discord webhooks have not been loaded or there are no webhooks!") - return - - self.logger.info("Receive new status for account %s...", status["user"]["screen_name"]) - self.logger.info("Pushing to webhooks...") - - if status.get("retweeted_status") is not None: - self.logger.info("Skipping tweet, it is a retweet") - return - - if status.get("in_reply_to_status_id") is not None: - self.logger.info("Skipping tweet, it is a reply") - return - - self.loop.create_task(self.send_to_webhook(status)) - - def on_error(self, error: int): - """ - Called when the API returns an error. - Common ones are: 420 -> We are being rate limited. - 406 -> Invalid request format. Usually because of incorrect filter values. - :param error: The error code that was returned from the API. - :type error: int - :return: None - :rtype: NoneType - """ - - self.logger.error("There was an error in the Twitter Webhook: %s", error) - - def load_discord_hooks(self, guild_hooks: List[List[Webhook]], bot_user_id: int): - """ - Load the Webhooks used to send the Tweets to discord. - :param bot_user_id: The discord user id of the bot that the cog is running in. - :type bot_user_id: int - :param guild_hooks: The list of guild Webhooks, each index being one guild. - :type guild_hooks: List[List[Webhook]] - :return: None - :rtype: NoneType - """ - - for guild in guild_hooks: - # For each guild in the list... - for g_hook in guild: - # And for each Webhook in the guild... - if bot_hook_prefix in g_hook.name and g_hook.user.id == bot_user_id: - # Only if the Webhook was created for the TwitterCog and by the bot. - self.hooks[g_hook.id] = {"token": g_hook.token, "name": g_hook.name, "guild_id": g_hook.guild_id} - - def add_hook(self, hook: Webhook) -> bool: - """ - Add a new hook to send status' to. - :param hook: The new Webhook to add. - :type hook: discord.Webhook - :return: Whether the Webhook was added to the list of Webhooks. - :rtype: bool - """ - if hook.id in self._hooks: - return False - - self.hooks[hook.id] = {"token": hook.token, "name": hook.name, "guild_id": hook.guild_id} - return True - - def remove_hook(self, hook_id: str) -> bool: - """ - Removes a hook so that status' are no longer sent to that Webhook. - :param hook_id: The ID of the Webhook to remove. - :type hook_id: str - :return: Whether a Webhook with the given ID was removed from the list of Webhooks. - :rtype: bool - """ - if not str_is_int(hook_id): - return False - - hook_id = int(hook_id) - - return self._hooks.pop(hook_id, None) is not None - - def set_tracked_accounts(self, accounts): - """ - Sets the list of tracked Twitter accounts. - :param accounts: The dictionary of accounts to track and which guilds to send their updates to. - :type accounts: dict - :return: None - :rtype: NoneType - """ - self._tracked_accounts = accounts - - def add_tracked_account(self, user_id, guild_id): - """ - Adds a guild to send updates to for a given account. - :param user_id: The Twitter ID of the user. - :type user_id: str - :param guild_id: The ID of the guild to send updates to. - :type guild_id: int - :return: None - :rtype: NoneType - """ - - # This can be done as it is a defaultdict and will just create a new key with an empty set as its value if it - # is a new account. - self._tracked_accounts[user_id].add(guild_id) - - def remove_tracked_account(self, user_id, guild_id) -> bool: - """ - Removes a guild from an accounts list of guilds to send updates to. - :param user_id: The Twitter ID of the user. - :type user_id: str - :param guild_id: The ID of the guild to remove. - :type guild_id: int - :return: None - :rtype: NoneType - """ - - tracked_guilds = self.tracked_accounts.get(user_id) - - if len(tracked_guilds) == 1 and guild_id in tracked_guilds: - # This guild is the only guild the account is tracked in. - self.tracked_accounts.pop(user_id) - self.logger.info( - "%s(guild id) was the only guild id %s(account id) was tracked in," - " popping from tracked accounts.", - guild_id, - user_id - ) - return True - elif guild_id in tracked_guilds: - self.logger.info("%s(guild id) removed from %s(account id) tracked accounts.", guild_id, user_id) - self.tracked_accounts[user_id].remove(guild_id) - return False - else: - # This account is not being tracked in this guild. - self.logger.warning("%s(account id) is not being tracked in %s(guild id)", user_id, guild_id) - return False - - @property - def hooks(self) -> Dict[str, dict]: - """ - Gets the dictionary of current hooks. - :return: A dictionary of the hooks currently being used. - :rtype: Dict[str, dict] - """ - - return self._hooks - - @property - def tracked_accounts(self) -> Dict[str, set]: - """ - Gets the dictionary of the currently tracked accounts and their set of guilds to send updates to. - :return: The dictionary of tracked accounts and their set of guilds. - :rtype: Dict[str, set] - """ - return self._tracked_accounts - - async def send_to_webhook(self, status: dict): - """ - Send the new status received from one of the tracked accounts to the discord Webhooks. - :param status: The new status received. - :type status: dict - :return: None - :rtype: NoneType - """ - - screen_name = status["user"]["screen_name"] - status_id = status["id"] - url = f"https://twitter.com/{screen_name}/status/{status_id}" - self.logger.info("Pushing %s to webhooks...", url) - - # Get the set of guilds to send the updates to for the account that has the new status. - account_guilds = self._tracked_accounts.get(status.get("user").get("id_str")) - - async with aiohttp.ClientSession() as session: - hook_adapter = AsyncWebhookAdapter(session) - for hook_id in self._hooks: - if self._hooks.get(hook_id).get("guild_id") not in account_guilds: - # Ignore hooks for guilds that don't get the updates for this account. - continue - - hook_token = self._hooks.get(hook_id).get("token") - webhook = Webhook.partial(id=hook_id, token=hook_token, adapter=hook_adapter) - self.logger.info("Sending to Webhook %s(%s)", self._hooks.get(hook_id).get("name"), hook_id) - # TODO: Decide how to title the Webhook in discord - await webhook.send( - content=url, - username=screen_name + " Tweeted", - avatar_url=status["user"]["profile_image_url_https"] - ) - - -class TwitterCog(commands.Cog): - """ - Enables forwarding tweets when they are tweeted to a discord channel for specific Twitter accounts. - - This module implements commands that can be used to manage which Twitter accounts are listened to, as well as to manage - the discord webhooks that are used to post the updates to. - """ - def __init__(self, bot, loop=None): - self._bot = bot - self.logger = logging.getLogger(__name__) - self.loop = loop if loop is not None else asyncio.get_event_loop() - self.user_strings = self._bot.STRINGS["twitter"] - self._db = DBGatewayActions() - - auth = tweepy.OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET) - auth.set_access_token(ACCESS_TOKEN, ACCESS_TOKEN_SECRET) - self._api = tweepy.API(auth) - self._api.verify_credentials() - self._stream_listener = TwitterWebhook(self._api) - self._filter = tweepy.Stream(self._api.auth, self._stream_listener) - self.logger.info(f"Finished loading {__name__}... waiting for ready") - - @commands.Cog.listener() - async def on_ready(self): - """ - The bot needs to be ready before the discord Webhooks can be loaded as they can only be fetched once logged in. - """ - await self.load_discord_hooks() - guild_info = self.load_db_data() - - if len(guild_info) > 0: - self._filter.filter(follow=list(guild_info.keys()), is_async=True) - self._stream_listener.set_tracked_accounts(guild_info) - self.logger.info("Currently tracking %d account(s)!", len(guild_info)) - else: - self.logger.warning("There are no accounts that are currently tracked!") - self.logger.info(f"{__name__} is now ready!") - - async def load_discord_hooks(self): - """ - Loads all Webhooks from all guilds whose name starts with the bot prefix and adds them to the Stream Listener. - """ - - self.logger.info("Loading Discord Webhooks...") - tasks = [] - for guild in self._bot.guilds: - self.logger.info("Loading webhooks from %s(%s)", guild.name, guild.id) - if guild.me.guild_permissions.manage_webhooks: - tasks.append(guild.webhooks()) - else: - self.logger.error("Missing permission 'manage webhooks' in guild %s(%s)", guild.name, guild.id) - - # Getting webhooks requires a fetch, hence the use of gather. - results = await asyncio.gather(*tasks) - - self._stream_listener.load_discord_hooks(results, self._bot.user.id) - - self.logger.info("Got %d webhook(s) to post updates to.", len(self._stream_listener.hooks)) - - def load_db_data(self) -> Dict[str, set]: - """ - Loads the Twitter accounts and which guilds they should send updates to. Uses a defaultdict to enable easier - additions of new accounts. - :return: A dictionary with the Twitter accounts as the keys and a set of guild ids as the values. - :rtype: dict - """ - - db_data = self._db.list(TwitterInfo) - guild_info = defaultdict(set) - for item in db_data: - guild_info[str(item.twitter_user_id)].add(item.guild_id) - return guild_info - - @commands.group(name="twitter", invoke_without_command=True) - async def command_group(self, context: commands.Context): - """ - The command group used to group all the commands used in the TwitterCog. - :param context: The context of the command. - """ - pass - - @command_group.command(name="hook", alias=["addtwitterhook", "create-hook"]) - async def twitterhook(self, ctx: commands.Context, channel=None, hook_name=None) -> bool: - """ - Creates a Webhook in a guild. If the channel is specified the Webhook will be bound to that channel - (can be changed in the Integrations panel for a guild's settings), otherwise will be bound to the channel where - the message was sent. If the name is not specified a default name is used. - :param ctx: The context of the command. - :type ctx: discord.ext.commands.Context - :param channel: The channel to bind the Webhook to if not None. - :type channel: str - :param hook_name: The name of the Webhook if not None. - :type hook_name: str - :return: Whether a Webhook was created with the given name and bound to the given channel. - :rtype: bool - """ - - if hook_name is None: - hook_name = "DefaultTwitterHook" - - if channel is not None: - text_channel = await self.channel_from_mention(channel) - else: - text_channel = ctx.channel - - if text_channel is None: - # Unable to find the channel with the given name or mention. - await ctx.send( - self.user_strings["webhook_error"].format(operation="create", - reason="I am unable to find that channel") - ) - return False - - hook_name = bot_hook_prefix + hook_name - existing, _ = self.get_webhook_by_name(hook_name, ctx.guild.id) - if existing is not None: - # A Webhook already exists with that name. - self.logger.warning( - "Attempted to create Webhook with name %s but one already exists with that name in %s(%s)", - hook_name, - ctx.guild.name, - ctx.guild.id - ) - await ctx.send( - self.user_strings["webhook_error"].format( - operation="create", - reason=f"there is already a Webhook " - f"with the name {hook_name}" - ) - ) - return False - - self.logger.info("Creating Webhook for guild %s(%s) with name %s", ctx.guild.name, ctx.guild.id, hook_name) - - hook = await text_channel.create_webhook( - name=hook_name, - reason=f"{ctx.author.name}#{ctx.author.discriminator} created a " - f"webhook for #{text_channel.name} channel using the " - f"createhook command." - ) - - self.logger.info( - "%s#%s created Webhook for guild %s(%s) with name %s in channel %s(%s)", - ctx.author.name, - ctx.author.discriminator, - ctx.guild.name, - ctx.guild.id, - hook_name, - text_channel.name, - text_channel.id - ) - - self.logger.info( - "[%s] id: %s , url: %s , token: %s , channel: %s(%s)", - hook.name, - hook.id, - hook.url, - hook.token, - hook.channel.name, - hook.channel.id - ) - - # Add the hook to the Stream Listener so that it can send the updates to that Webhook. - self._stream_listener.add_hook(hook) - await ctx.send(self.user_strings["webhook_created"].format(name=hook.name, hook_id=hook.id)) - return True - - async def channel_from_mention(self, c_id: str) -> Union[Union[discord.TextChannel, discord.VoiceChannel], None]: - """ - Returns the instance of a channel when the channel has been mentioned. - :param c_id: The mentioned channel. - :type c_id: str - :return: An instance of a channel if there is a channel with that ID, None otherwise. - :rtype: Union[Union[discord.TextChannel, discord.VoiceChannel], None] - """ - - if not str_is_channel_mention(c_id): - # The string was not a mentioned channel. - return None - - # Gets just the ID of the channel. - cleaned_id = c_id[2:-1] - channel = self._bot.get_channel(cleaned_id) - if channel is None: - try: - channel = await self._bot.fetch_channel(cleaned_id) - except Forbidden as e: - self.logger.error("Unable to access channel with id %s due to permission errors: %s", cleaned_id, e.text) - return None - return channel - - def get_webhook_by_name(self, name: str, guild_id: int) -> Union[Tuple[str, dict], Tuple[None, None]]: - """ - Gets the Webhook ID and other details needed to use the Webhook using the name of a Webhook and a guild. - :param name: The name of the Webhook to find. Can include the prefix or not. - :type name: str - :param guild_id: The id of the guild to find the Webhook in. - :type guild_id: int - :return: A tuple containing the Webhook ID and a dictionary containing the token and some other information. - Returns a Tuple of None, None if there is no Webhook with that name. - :rtype: Union[Tuple[int, dict], Tuple[None, None]] - """ - - current_hooks = self._stream_listener.hooks - for hook in current_hooks: - if current_hooks.get(hook).get("name") == name or current_hooks.get(hook).get("name") == (bot_hook_prefix + name): - # Check for the name as well as the name combined with the prefix. - if current_hooks.get(hook).get("guild_id") == guild_id: - return hook, current_hooks.get(hook) - - return None, None - - @command_group.command(name="remove-hook", alias=["deltwitterhook", "delete-hook"]) - async def removetwitterhook(self, ctx: discord.ext.commands.Context, name: str) -> bool: - """ - Deletes a discord Webhook from the calling guild using the name of the Webhook. - :param ctx: The context of the command being called. - :type ctx: discord.ext.commands.Context - :param name: The name of the Webhook to delete. Can include the prefix or not. - :type name: str - :return: Whether a Webhook with the given name was deleted from the calling guild. - :rtype: bool - """ - - self.logger.info("Deleting Webhook with name: %s", name) - h_id, hook_info = self.get_webhook_by_name(name, ctx.guild.id) - if hook_info is None: - # Unable to find a Webhook with the given name in the guild. - await ctx.send( - self.user_strings["webhook_error"].format( - operation="remove", - reason=f"there is no webhook with name {name} " - f"or {bot_hook_prefix + name}" - ) - ) - return False - - async with aiohttp.ClientSession() as session: - webhook = Webhook.partial(id=h_id, token=hook_info.get("token"), adapter=AsyncWebhookAdapter(session)) - await webhook.delete(reason="Deleted with removehook command") - self._stream_listener.remove_hook(h_id) - await ctx.send(self.user_strings["webhook_deleted"].format(name=hook_info.get("name"), hook_id=h_id)) - return True - - @command_group.command(name="add") - async def addtwitter(self, ctx: discord.ext.commands.Context, account: str) -> bool: - """ - Adds a new account to be tracked in the guild from which the command was called. - :param ctx: The context of the command being called. - :type ctx: discord.ext.commands.Context - :param account: The Twitter handle of the account to track, no @ required. - :type account: str - :return: Whether the account was added to the list of tracked accounts. - :rtype: bool - """ - - try: - user = self._api.get_user(account) - # The list of accounts that are currently being tracked across any guild. - if self._filter.body is None: - current_following = [] - else: - current_following = self._filter.body.get("follow").decode("utf-8").split(",") - user_id = user.id_str - tracked_guilds = self._stream_listener.tracked_accounts.get(user_id) - - if tracked_guilds is not None and ctx.guild.id in tracked_guilds: - # The account is already tracked in the current guild. - self.logger.info( - "Not adding %s to %s(%s) as it is already tracked in the guild", - account, - ctx.guild.name, - ctx.guild.id - ) - await ctx.send(self.user_strings["account_exists_error"].format(account=account)) - return False - - if user_id not in current_following: - # The account is not currently tracked in any guild. - self.logger.info("%s is a fresh account, adding to Twitter Webhook filter", account) - current_following.append(user_id) - self._stream_listener.add_tracked_account(user_id, ctx.guild.id) - asyncio.create_task(self.refresh_filter(current_following)) - - if tracked_guilds is None or ctx.guild.id not in tracked_guilds: - db_item = TwitterInfo(guild_id=ctx.guild.id, twitter_user_id=user_id, twitter_handle=account) - self._db.create(db_item) - - self.logger.info("Added %s to accounts tracked", account) - await ctx.send(self.user_strings["account_added"].format(account=account)) - return True - except tweepy.TweepError as e: - self.logger.warning("Unable to add %s as a tracked account due to the following error: %s", account, e) - await ctx.send(self.user_strings["account_missing_error"].format(account=account, operation="add")) - return False - - @command_group.command(name="remove") - async def removetwitter(self, ctx: discord.ext.commands.Context, account: str) -> bool: - """ - Removes an account from the guild from which the command was called. - :param ctx: The context of the command being called. - :type ctx: discord.ext.commands.Context - :param account: The Twitter handle of the account to remove, no @ required. - :type account: str - :return: Whether the account was removed from the list of tracked accounts. - :rtype: bool - """ - - if self._filter.body is None: - # There are no current accounts being tracked. - self.logger.warning("Current filter is empty! Can't remove any tracked accounts.") - await ctx.send(self.user_strings["account_missing_error"].format(operation="remove", account=account)) - return False - - try: - user = self._api.get_user(account) - user_id = user.id_str - tracked_accounts = self._stream_listener.tracked_accounts.get(user_id) - current_filter = self._filter.body.get("follow").decode("utf-8").split(",") - - if tracked_accounts is None or ctx.guild.id not in tracked_accounts: - # Not tracked in this guild. - self.logger.info( - "Cannot remove %s from being tracked as it is not tracked in %s(%s)", - account, - ctx.guild.name, - ctx.guild.id - ) - await ctx.send(self.user_strings["account_missing_error"].format(operation="remove", account=account)) - return False - - if self._stream_listener.remove_tracked_account(user_id, ctx.guild.id): - # The account is no longer tracked in any guild, can be removed from the filter. - current_filter.remove(user_id) - asyncio.create_task(self.refresh_filter(current_filter)) - db_item = self._db.get(TwitterInfo, guild_id=ctx.guild.id, twitter_user_id=user_id) - self._db.delete(db_item) - self.logger.info("Removed %s from being tracked in %s(%s)", account, ctx.guild.name, ctx.guild.id) - await ctx.send(self.user_strings["account_removed"].format(account=account)) - - except tweepy.TweepError as e: - self.logger.warning("Unable to remove %s account due to the following error: %s", account, e) - await ctx.send(self.user_strings["account_missing_error"].format(account=account, operation="remove")) - return False - - @command_group.command(name="list", alias=["accounts", "get-all"]) - async def gettwitters(self, ctx: discord.ext.commands.Context): - """ - Gets the list of Twitter handles that are currently tracked in the guild that called the command. - :param ctx: The context of the command being called. - :type ctx: discord.ext.commands.Context - :return: None - :rtype: NoneType - """ - handles = self._db.list(TwitterInfo, guild_id=ctx.guild.id) - if not handles: - await ctx.send(self.user_strings["accounts_empty"]) - return - - handle_names = [x.twitter_handle for x in handles] - handle_string = ", ".join(handle_names) - await ctx.send(self.user_strings["accounts_list"].format(tracked_accounts=handle_string)) - - async def refresh_filter(self, new_filter: List[str]): - """ - Sets the Twitter stream filter to the new_filter param. - :param new_filter: The list of user ids to track on Twitter. - :type new_filter: List[str] - """ - - self.logger.info("Refreshing filter with new values: %s", new_filter) - # Tweepy is extremely fucky, and disconnect() doesn't do anything but set the running Flag to False. - if self._filter.running: - self._filter.disconnect() - # Hence manually calling del, as the old filter is still running and causes messages to be duplicated. - del self._filter - if len(new_filter) == 0: - self.logger.info("New filter is empty, stopping stream") - return - # Without the delay we get rate limited momentarily by Twitter. - await asyncio.sleep(5) - self._filter = tweepy.Stream(self._api.auth, self._stream_listener, daemon=True) - self.logger.info("Disconnected current stream... Current Status: %s", "Running" if self._filter.running else "Stopped") - self._filter.filter(follow=new_filter, is_async=True) - self.logger.info("Reconnected filter with new parameters") - self.logger.info("Current Stream Status: %s", "Running" if self._filter.running else "Stopped") - - -def setup(bot): - if CONSUMER_KEY is None or CONSUMER_SECRET is None or ACCESS_TOKEN is None or ACCESS_TOKEN_SECRET is None: - raise ValueError("Twitter Env Vars are not set!") - bot.add_cog(TwitterCog(bot)) diff --git a/src/esportsbot/cogs/VoicemasterCog.py b/src/esportsbot/cogs/VoicemasterCog.py deleted file mode 100644 index eef363c1..00000000 --- a/src/esportsbot/cogs/VoicemasterCog.py +++ /dev/null @@ -1,435 +0,0 @@ -import re -from discord.ext import commands -from esportsbot.base_functions import (get_whether_in_vm_parent, get_whether_in_vm_child) -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.models import VoicemasterMaster, VoicemasterSlave - - -class VoicemasterCog(commands.Cog): - """ - Voicemaster is used as a way to have a dynamic number of voice channels. By having a single parent voice channel, users can - easily create their own room/channel by joining, allowing them to easily talk with just the people they want to. - - This module implements commands used to manage the parent and child channels, all commands require the administrator - permission in a server. - """ - def __init__(self, bot): - self.bot = bot - self.STRINGS = bot.STRINGS['voicemaster'] - self.banned_words = [] - with open("esportsbot/banned_words.txt", "r") as f: - for line in f.readlines(): - if not line.startswith("#"): - self.banned_words.append(line.strip()) - - @commands.Cog.listener() - async def on_voice_state_update(self, member, before, after): - """ - When any users voice state changes, such as joining, leaving or moving voice channels, check if the voice channel they - are in is a parent or child, and if so perform the necessary actions. - - - If the user was the last to leave a child voice channel, delete it. - - If the user was the owner of the child voice channel, transfer the ownership to another user in the channel. - - If the user joined the parent voice channel, create a new child channel that they own. - :param member: The member whose voice state has changed. - :param before: The member's voice state before the change. - :param after: The member's voice state after the change. - """ - if not member.guild.me.guild_permissions.move_members: - await self.bot.admin_log( - guild_id=member.guild.id, - actions={ - "Cog": self.__class__.__name__, - "Message": "I need the permission `move members` in this guild to be able to perform Voicemaster" - } - ) - return - - if not before.channel and not after.channel: - return - - if before.channel: - # The user has either disconnected or moved voice channels. - if get_whether_in_vm_child(before.channel.guild.id, before.channel.id): - # If the user was in a VM child. - vm_child = DBGatewayActions().get(VoicemasterSlave, guild_id=member.guild.id, channel_id=before.channel.id) - if not before.channel.members: - # The VM is empty, delete it. - await before.channel.delete() - DBGatewayActions().delete(vm_child) - elif vm_child.owner_id == member.id: - # It was the owner of the channel that left, transfer ownership. - if not vm_child.custom_name: - await before.channel.edit(name=f"{before.channel.members[0].display_name}'s VC") - vm_child.owner_id = before.channel.members[0].id - DBGatewayActions().update(vm_child) - - if after.channel and get_whether_in_vm_parent(after.channel.guild.id, after.channel.id): - child_channel = await member.guild.create_voice_channel( - f"{member.display_name}'s VC", - category=after.channel.category - ) - child_db_entry = VoicemasterSlave( - guild_id=member.guild.id, - channel_id=child_channel.id, - owner_id=member.id, - locked=False, - custom_name=False - ) - DBGatewayActions().create(child_db_entry) - await member.move_to(child_channel) - - @commands.group("voice", aliases=["vm"]) - async def command_group(self, ctx): - """ - The command group used to make all commands sub-commands . - :param ctx: The context of the command . - """ - pass - - @command_group.command(name="setparent", aliases=["setvmparent"]) - @commands.has_permissions(administrator=True) - async def setvmparent(self, ctx, given_channel_id=None): - """ - Set the given voice channel as a parent voice channel. There can be more than one parent voice channel in a server. - :param ctx: The context of the command. - :param given_channel_id: The ID of the voice channel to set as the parent voice channel. - """ - is_a_valid_id = given_channel_id and given_channel_id.isdigit() and len(given_channel_id) == 18 - - if is_a_valid_id: - is_a_parent = DBGatewayActions().get(VoicemasterMaster, guild_id=ctx.author.guild.id, channel_id=given_channel_id) - is_voice_channel = hasattr(self.bot.get_channel(int(given_channel_id)), 'voice_states') - is_a_child = DBGatewayActions().get(VoicemasterSlave, guild_id=ctx.author.guild.id, channel_id=given_channel_id) - - if is_voice_channel and not (is_a_parent or is_a_child): - # Not currently a Parent and is voice channel, add it - DBGatewayActions().create(VoicemasterMaster(guild_id=ctx.author.guild.id, channel_id=given_channel_id)) - await ctx.channel.send("This VC has now been set as a VM parent") - new_vm_parent_channel = self.bot.get_channel(int(given_channel_id)) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": - self.__class__.__name__, - "command": - ctx.message, - "Message": - self.STRINGS["log_vm_parent_added"].format( - author=ctx.author.mention, - channel=new_vm_parent_channel.name, - channel_id=new_vm_parent_channel.id - ) - } - ) - elif is_a_parent: - # This already exists as a parent - await ctx.channel.send(self.STRINGS['error_already_set_parent']) - elif is_a_child: - # This is a child VC - await ctx.channel.send(self.STRINGS['error_already_set_child']) - elif not is_voice_channel: - # This is not a VC ID - await ctx.channel.send(self.STRINGS['error_bad_id']) - - else: - # Invalid input - if not given_channel_id: - await ctx.channel.send(self.STRINGS['error_no_id']) - else: - await ctx.channel.send(self.STRINGS['error_bad_id_format']) - - @command_group.command(name="getparents", aliases=["getvmparents"]) - @commands.has_permissions(administrator=True) - async def getvmparents(self, ctx): - """ - Get a list of the current voice channels set as parent voice channels. - :param ctx: The context of the command. - """ - parent_vm_exists = DBGatewayActions().list(VoicemasterMaster, guild_id=ctx.author.guild.id) - - if parent_vm_exists: - parent_vm_str = str() - for record in parent_vm_exists: - parent_vm_str += f"{self.bot.get_channel(record.channel_id).name} - {record.channel_id}\n" - await ctx.channel.send(self.STRINGS['show_current_vcs'].format(parent_vms=parent_vm_str)) - else: - await ctx.channel.send(self.STRINGS['error_no_vms']) - - @command_group.command(name="removeparent", aliases=["removevmparent"]) - @commands.has_permissions(administrator=True) - async def removevmmaster(self, ctx, given_channel_id=None): - """ - Remove a voice channel from being a parent voice channel. - :param ctx: The context of the command. - :param given_channel_id: The ID of the voice channel to remove from being a parent voice channel. - """ - if given_channel_id: - channel_exists = DBGatewayActions().get( - VoicemasterMaster, - guild_id=ctx.author.guild.id, - channel_id=given_channel_id - ) - if channel_exists: - DBGatewayActions().delete(channel_exists) - await ctx.channel.send(self.STRINGS['success_vm_unset']) - removed_vm_parent = self.bot.get_channel(given_channel_id) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": - self.__class__.__name__, - "command": - ctx.message, - "Message": - self.STRINGS['log_vm_parent_removed'].format( - mention=ctx.author.guild.id, - channel_name=removed_vm_parent.name, - channel_id=removed_vm_parent.id - ) - } - ) - else: - await ctx.channel.send(self.STRINGS['error_not_vm']) - else: - await ctx.channel.send(self.STRINGS['error_no_id']) - - @command_group.command(name="removeallparents") - @commands.has_permissions(administrator=True) - async def removeallparents(self, ctx): - """ - Remove all the current parent voice channels from the current server. - :param ctx: The context of the command. - """ - all_vm_parents = DBGatewayActions().list(VoicemasterMaster, guild_id=ctx.author.guild.id) - for vm_parent in all_vm_parents: - DBGatewayActions().delete(vm_parent) - await ctx.channel.send(self.STRINGS['success_vm_parents_cleared']) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS['log_vm_parents_cleared'].format(mention=ctx.author.mention) - } - ) - - @command_group.command(name="removeallchildren") - @commands.has_permissions(administrator=True) - async def removeallchildren(self, ctx): - """ - Delete all the child voice channels, no matter if there are users in them or not. - :param ctx: THe context of the command. - """ - all_vm_children = DBGatewayActions().list(VoicemasterSlave, guild_id=ctx.author.guild.id) - for vm_child in all_vm_children: - vm_child_channel = self.bot.get_channel(vm_child.channel_id) - if vm_child_channel: - await vm_child_channel.delete() - DBGatewayActions().delete(vm_child) - await ctx.channel.send(self.STRINGS['success_vm_children_cleared']) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS['log_vm_children_cleared'].format(mention=ctx.author.mention) - } - ) - - @command_group.command(name="lock", aliases=["lockvm"]) - async def lockvm(self, ctx): - """ - Locks a child voice channel to the current number of users. This command can only be run by the owner of the child - voice channel. - :param ctx: The context of the command. - """ - if not ctx.author.voice: - await ctx.channel.send(self.STRINGS['error_not_in_vm_child']) - return - in_vm_child = DBGatewayActions().get( - VoicemasterSlave, - guild_id=ctx.author.guild.id, - channel_id=ctx.author.voice.channel.id - ) - - if in_vm_child: - if in_vm_child.owner_id == ctx.author.id: - if not in_vm_child.locked: - in_vm_child.locked = True - DBGatewayActions().update(in_vm_child) - await ctx.author.voice.channel.edit(user_limit=len(ctx.author.voice.channel.members)) - await ctx.channel.send(self.STRINGS['success_child_locked']) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS["log_child_locked"].format(mention=ctx.author.mention) - } - ) - else: - await ctx.channel.send(self.STRINGS['error_already_locked']) - else: - await ctx.channel.send(self.STRINGS['error_not_owned']) - else: - await ctx.channel.send(self.STRINGS['error_not_in_vm_child']) - - @command_group.command(name="unlock", aliases=["unlockvm"]) - async def unlockvm(self, ctx): - """ - Stops the restriction on the number of users allowed in a child voice channel. This command can only be run by the - owner of the child voice channel. - :param ctx: The context of the command. - """ - if not ctx.author.voice: - await ctx.channel.send(self.STRINGS['error_not_in_vm_child']) - return - in_vm_child = DBGatewayActions().get( - VoicemasterSlave, - guild_id=ctx.author.guild.id, - channel_id=ctx.author.voice.channel.id - ) - - if in_vm_child: - if in_vm_child.owner_id == ctx.author.id: - if in_vm_child.locked: - in_vm_child.locked = False - DBGatewayActions().update(in_vm_child) - await ctx.author.voice.channel.edit(user_limit=0) - await ctx.channel.send(self.STRINGS['success_child_unlocked']) - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS["log_child_unlocked"].format(mention=ctx.author.mention) - } - ) - else: - await ctx.channel.send(self.STRINGS['error_already_unlocked']) - else: - await ctx.channel.send(self.STRINGS['error_not_owned']) - else: - await ctx.channel.send(self.STRINGS['error_not_in_vm_child']) - - @command_group.command(name="rename", aliases=["renamevm"]) - async def renamevm(self, ctx): - """ - Sets the name of the voice channel to the string given after the command. If no string is given, the name is set back - to the default name of a voicemaster child channel. - :param ctx: The context of the command. - """ - if not ctx.author.voice: - await ctx.channel.send(self.STRINGS['error_not_in_vm_child']) - return - in_vm_child = DBGatewayActions().get( - VoicemasterSlave, - guild_id=ctx.author.guild.id, - channel_id=ctx.author.voice.channel.id - ) - - command_invoke_string_index = ctx.message.content.index(ctx.invoked_with) + len(ctx.invoked_with) - new_name = ctx.message.content[command_invoke_string_index:].strip() - - if not self.check_vm_name(new_name.lower()): - await ctx.channel.send(self.STRINGS['error_bad_vm_name']) - await ctx.message.delete() - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "Message": f"The user {ctx.author.mention} tried to rename a voice channel using banned words.", - "Attempted Rename": f"Hidden for safety: ||{new_name}||" - } - ) - return - - if in_vm_child: - if in_vm_child.owner_id == ctx.author.id: - if new_name: - await ctx.author.voice.channel.edit(name=new_name) - in_vm_child.custom_name = True - set_name = new_name - else: - await ctx.author.voice.channel.edit(name=f"{ctx.author.display_name}'s VC") - in_vm_child.custom_name = False - set_name = f"{ctx.author.display_name}'s VC" - await self.bot.admin_log( - responsible_user=ctx.author, - guild_id=ctx.guild.id, - actions={ - "Cog": self.__class__.__name__, - "command": ctx.message, - "Message": self.STRINGS["log_child_renamed"].format(mention=ctx.author.mention, - new_name=set_name) - } - ) - DBGatewayActions().update(in_vm_child) - else: - await ctx.channel.send(self.STRINGS['error_not_owned']) - else: - await ctx.channel.send(self.STRINGS['error_not_in_vm_child']) - - def check_vm_name(self, vm_name): - hidden_chars = r"[\s​   ]*" - removed_hidden = re.sub(hidden_chars, "", vm_name) - leet_word = self.simple_leet_translation(removed_hidden) - for bad_word in self.banned_words: - if bad_word in leet_word or bad_word in removed_hidden: - return self.double_check(removed_hidden, bad_word) and self.double_check(leet_word, bad_word) - return True - - @staticmethod - def simple_leet_translation(word): - characters = { - "a": ["4", - "@"], - "b": ["8", - "ß", - "l3"], - "e": ["3"], - "g": ["6"], - "i": ["1", - "!"], - "r": ["2"], - "s": ["5"], - "t": ["7", - "+"], - "": ["_", - "-", - "'", - "|", - "~", - "\""] - } - - translated = word - for character, replaces in characters.items(): - for i in replaces: - translated = translated.replace(i, character) - - return translated - - @staticmethod - def double_check(word, bad_word): - if word == bad_word: - # If the word is the bad word it should not be allowed - return False - - if word.index(bad_word) == 0 or word.index(bad_word) == len(word) - len(bad_word): - # If the bad word is at the end or beginning it is likely to be intentionally bad rather than accidentally caught - return False - - return True - - -def setup(bot): - bot.add_cog(VoicemasterCog(bot)) diff --git a/src/esportsbot/cogs/VotingCog.py b/src/esportsbot/cogs/VotingCog.py deleted file mode 100644 index f3175f4f..00000000 --- a/src/esportsbot/cogs/VotingCog.py +++ /dev/null @@ -1,240 +0,0 @@ -import logging -import os - -from discord.ext import commands - -from esportsbot.DiscordReactableMenus.ExampleMenus import PollReactMenu -from esportsbot.DiscordReactableMenus.reactable_lib import get_all_options -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.models import VotingMenus - -DELETE_ON_CREATE = os.getenv("DELETE_VOTING_CREATION", "FALSE").lower() == "true" - - -class VotingCog(commands.Cog): - """ - Poll reaction menus allow users to create polls with up to 25 different options for other users, and themselves, - to vote on. - - The poll start and end is not time based, but instead controlled by the user that created the poll or administrators. - - This module implements the ability for users to create voting polls, and to then get the results of those polls. - """ - def __init__(self, bot): - self.bot = bot - self.logger = logging.getLogger(__name__) - self.db = DBGatewayActions() - self.voting_menus = {} - self.user_strings = bot.STRINGS["vote_reacts"] - self.logger.info(f"Finished loading {__name__}... waiting for ready") - - @commands.Cog.listener() - async def on_ready(self): - """ - When bot discord client is ready and has logged into the discord API, this function runs and is used to load and - initialise any polls that had started before the bot was shutdown. - """ - self.voting_menus = await self.load_menus() - self.logger.info(f"{__name__} is now ready!") - - async def load_menus(self): - """ - Loads saved role reaction menus from the DB for all guilds . - :return: A dictionary of reaction menu IDs and their reaction menus . - """ - all_menus = self.db.list(VotingMenus) - loaded_menus = {} - for menu in all_menus: - loaded_menus[menu.menu_id] = await PollReactMenu.from_dict(self.bot, menu.menu) - return loaded_menus - - async def validate_menu(self, context, menu_id): - """ - Ensures that the menu being requested is valid and that the user is allowed to edit the menu . - :param context: The context of the command . - :param menu_id: The ID of the menu to fetch . - :return: A tuple of Reaction Menu and if the action is valid . - """ - voting_menu = self.voting_menus.get(menu_id) - - if not voting_menu: - await context.reply(self.user_strings["invalid_id"].format(given_id=menu_id)) - return voting_menu, False - - if voting_menu.author.id != context.author.id and not context.author.guild_permissions.administrator: - owner = f"{voting_menu.author.name}#{voting_menu.author.discriminator}" - await context.reply(self.user_strings["wrong_author"].format(author=owner)) - return voting_menu, False - - return voting_menu, True - - def add_or_update_db(self, menu_id): - """ - Creates a new DB item or updates an existing one for a given menu id . - :param menu_id: The menu id to create or update . - """ - db_item = self.db.get(VotingMenus, menu_id=menu_id) - if db_item: - db_item.menu = self.voting_menus.get(menu_id).to_dict() - self.db.update(db_item) - else: - db_item = VotingMenus(menu_id=menu_id, menu=self.voting_menus.get(menu_id).to_dict()) - self.db.create(db_item) - - async def finalise_poll(self, menu): - """ - Finishes a poll and sends the results of the poll . - :param menu: The menu to finish . - """ - results = await menu.generate_results() - await menu.message.channel.send(embed=results) - self.voting_menus.pop(menu.id) - db_item = self.db.get(VotingMenus, menu_id=menu.id) - self.db.delete(db_item) - await menu.message.delete() - - @commands.group(name="votes") - async def command_group(self, context: commands.Context): - """ - The command group used to make all commands sub-commands . - :param context: The context of the command . - """ - pass - - @command_group.command(name="make-poll") - async def create_poll_menu(self, context: commands.Context): - """ - Creates a new poll with the options provided in the command . - :param context: The context of the command . - """ - message_contents = context.message.content.split("\n") - title = message_contents.pop(0) - title = title[title.index(context.command.name) + len(context.command.name):].strip() - menu_options = get_all_options(message_contents) - description = f"This poll is controlled by {context.author.mention}." - voting_menu = PollReactMenu( - title=title, - description=description, - auto_enable=True, - author=context.author, - show_ids=True, - poll_length=0 - ) - voting_menu.add_many(menu_options) - - await voting_menu.finalise_and_send(self.bot, context.channel) - - self.voting_menus[voting_menu.id] = voting_menu - self.add_or_update_db(voting_menu.id) - if DELETE_ON_CREATE: - await context.message.delete() - - @command_group.command(name="add-option", aliases=["add", "aoption"]) - async def add_poll_option(self, context: commands.Context, menu_id: int, emoji): - """ - Adds another poll option to the given poll . - :param context: The context of the command . - :param menu_id: The ID of the poll to add the option to . - :param emoji: The emoji of the option to add . - """ - voting_menu, valid = await self.validate_menu(context, menu_id) - - if not valid: - return - - # Assume the rest of the message after the emoji param is the description, avoiding the need to use quotes. - description = context.message.content[context.message.content.index(emoji) + len(emoji):].strip() - - voting_menu.add_option(emoji, description) - await voting_menu.update_message() - self.add_or_update_db(voting_menu.id) - - @command_group.command(name="remove-option", aliases=["remove", "roption"]) - async def remove_poll_option(self, context: commands.Context, menu_id: int, emoji): - """ - Remove an option from a poll . - :param context: The context of the command . - :param menu_id: The ID of the poll to remove the option from . - :param emoji: The emoji of the option to remove . - """ - voting_menu, valid = await self.validate_menu(context, menu_id) - - if not valid: - return - - voting_menu.remove_option(emoji) - await voting_menu.update_message() - self.add_or_update_db(voting_menu.id) - - @command_group.command(name="delete-poll", aliases=["delete", "del"]) - async def delete_poll(self, context: commands.Context, menu_id: int): - """ - Delete a poll . - :param context: The context of the command . - :param menu_id: The ID of the poll to delete . - """ - voting_menu, valid = await self.validate_menu(context, menu_id) - - if not valid: - return - - await voting_menu.message.delete() - self.voting_menus.pop(voting_menu.id) - db_item = self.db.get(VotingMenus, menu_id=voting_menu.id) - self.db.delete(db_item) - await context.reply(self.user_strings["delete_menu"].format(menu_id=voting_menu.id)) - - @command_group.command(name="end-poll", aliases=["finish", "complete", "end"]) - async def finish_poll(self, context: commands.Context, menu_id: int): - """ - Finishes a poll to get results and stop new votes from coming in . - :param context: The context of the command . - :param menu_id: The ID of the poll to finish . - """ - voting_menu, valid = await self.validate_menu(context, menu_id) - - if not valid: - return - - await self.finalise_poll(voting_menu) - - @command_group.command(name="reset-poll", aliases=["reset", "clear", "restart"]) - async def reset_poll_votes(self, context: commands.Context, menu_id: int): - """ - Reset the current votes on a poll . - :param context: The context of the command . - :param menu_id: The ID of the poll to reset . - """ - voting_menu, valid = await self.validate_menu(context, menu_id) - - if not valid: - return - - await voting_menu.message.clear_reactions() - if voting_menu.enabled: - voting_menu.enabled = False - await voting_menu.enable_menu(self.bot) - - await context.reply(self.user_strings["reset_menu"].format(menu_id=menu_id)) - - @delete_poll.error - @finish_poll.error - @add_poll_option.error - @reset_poll_votes.error - @remove_poll_option.error - async def integer_parse_error(self, context: commands.Context, error: commands.CommandError): - """ - An error handler for handling any functions that are prone to integer conversion exceptions. - :param context: The context of the command. - :param error: The error that occurred. - :return: - """ - if isinstance(error, commands.BadArgument): - await context.reply(self.user_strings["needs_number"]) - return - - raise error - - -def setup(bot): - bot.add_cog(VotingCog(bot)) diff --git a/src/esportsbot/db_gateway.py b/src/esportsbot/db_gateway.py deleted file mode 100644 index 423fff9d..00000000 --- a/src/esportsbot/db_gateway.py +++ /dev/null @@ -1,107 +0,0 @@ -import os - -from dotenv import load_dotenv -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy_utils import create_database, database_exists - -from esportsbot.models import base - -load_dotenv(dotenv_path=os.path.join("..", "secrets.env")) - -db_string = f"postgresql://{os.getenv('POSTGRES_USER')}:{os.getenv('POSTGRES_PASSWORD')}@{os.getenv('POSTGRES_HOST')}:5432/{os.getenv('POSTGRES_DB')}" - -db = create_engine(db_string) - -if not database_exists(db.url): - create_database(db.url) - -Session = sessionmaker(db) -session = Session() - -base.metadata.create_all(db) - -print("[DATABASE] - Models created") - - -class DBGatewayActions: - """ - Base class for handling database queries - """ - @staticmethod - def list(db_model, **args): - """ - Method to return a list of results that suit the model criteria - - Args: - db_model (database_model): [The model to query in the database] - **args (model_attributes): [The attributes specified for the query] - - Returns: - [list]: [Returns a list of all models that fit the input models criteria] - """ - try: - query = session.query(db_model).filter_by(**args).all() - return query - except Exception as err: - raise Exception(f"Error occurred when using list - {err}") - - @staticmethod - def get(db_model, **args): - """ - Method to return a record that suits the model criteria - - Args: - db_model (database_model): [The model to query in the database] - **args (model_attributes): [The attributes specified for the query] - - Returns: - [list]: [Returns a list of all models that fit the input models criteria] - """ - try: - query = session.query(db_model).filter_by(**args).all() - return query[0] if query != [] else query - except Exception as err: - raise Exception(f"Error occurred when using get - {err}") - - @staticmethod - def update(model): - """ - Method for updating a record in the database - - Args: - model (database_model): [A class that contains the necessary information for an entry] - """ - try: - session.add(model) - session.commit() - except Exception as err: - raise Exception(f"Error occurred when using update - {err}") - - @staticmethod - def delete(model): - """ - Method for deleting a record from the database - - Args: - model (database_model): [A class that contains the necessary information for an entry] - """ - try: - session.delete(model) - session.commit() - except Exception as err: - raise Exception(f"Error occurred when using delete - {err}") - - @staticmethod - def create(model): - """ - Method for adding a record to the database - - Args: - model (database_model): [A class that contains the necessary information for an entry] - """ - try: - session.add(model) - session.commit() - except Exception as err: - raise Exception(f"Error occurred when using create - {err}") diff --git a/src/esportsbot/lib/CustomHelpCommand.py b/src/esportsbot/lib/CustomHelpCommand.py deleted file mode 100644 index ce3327b0..00000000 --- a/src/esportsbot/lib/CustomHelpCommand.py +++ /dev/null @@ -1,252 +0,0 @@ -from discord import Embed, Colour -from discord.ext.commands import HelpCommand, MissingPermissions - -from esportsbot.DiscordReactableMenus.EmojiHandler import MultiEmoji -from esportsbot.DiscordReactableMenus.ReactableMenu import ReactableMenu - - -class CustomHelpCommand(HelpCommand): - """ - A Custom Help Command implementation that uses reactable menus so that each Cog has its own page and improves readability - of the help commands. - """ - def __init__(self, **options): - self.help_strings = options.pop("help_strings") - super().__init__(**options) - - async def send_bot_help(self, mapping): - """ - This function runs when the bare `help` command is run without any groups or commands specified. - :param mapping: The mapping of Cogs -> Union [Command Groups, Commands] - """ - embeds = [] - # Get an embed for each cog that has more than 1 field. Some cogs may have no fields if the user requesting - # does not have permissions to use a given command. Eg: Command needs admin permissions and user is not an admin - for cog, commands in mapping.items(): - embed = await self.get_cog_help(cog, commands) - if len(embed.fields) > 0: - embeds.append(embed) - - help_menu = HelpMenu(embeds=embeds) - await help_menu.finalise_and_send(self.context.bot, self.context.channel) - - async def get_cog_help(self, cog, commands): - """ - Gets the help embed for a given cog and its commands. - :param cog: The cog to get the help embed of. - :param commands: The commands and command groups in the given cog. - :return: An embed for the cog. - """ - embed = Embed( - title=getattr(cog, - "qualified_name", - self.help_strings.get("empty_category")), - description="​", - colour=Colour.random() - ) - for command in commands: - await self.add_command_field(embed, command) - - embed.set_footer(text=self.help_strings.get("embed_footer")) - - return embed - - async def add_command_field(self, embed, command): - """ - Adds the embed field for a given command to an embed. If the command does not pass the checks, it is not added. - Eg: Command needs admin but user is not an admin. - :param embed: THe embed to add the field to. - :param command: The command to add the help field of. - """ - if command.hidden and not self.context.author.guild_permissions.administrator: - return - - checks = command.checks - checks_to_add = [] - for check in checks: - # Remove any custom checks. Checks such as admin will not be removed. - if check.__name__ != "predicate": - command.remove_check(check) - checks_to_add.append(check) - - try: - # For some reason instead of returning False, this will just raise an error if the user is not able to run - # the command. - await command.can_run(self.context) - except MissingPermissions: - return - - for check in checks_to_add: - command.add_check(check) - - fully_qualified_name = command.name - if command.full_parent_name: - fully_qualified_name = f"{command.full_parent_name} {fully_qualified_name}" - - fully_qualified_name = fully_qualified_name.strip() - - # name = - # value = Short help string \n Alias String \n Help command string - - help_dict = self.help_strings.get(fully_qualified_name.replace(" ", "_").replace("-", "_")) - name = self.help_strings["usage_string"].format(prefix=self.clean_prefix, fqn=fully_qualified_name) - if not help_dict: - # If the command is missing help string definition in the user_strings file, try and default to the defined - # help string in the command definition. - value = "" - if command.help: - value += self.help_strings["command_help_short"].format(help_string=command.help) + "\n" - else: - value += self.help_strings["command_help_short"].format( - help_string=self.help_strings["missing_help_string"] - ) + "\n" - else: - value = self.help_strings["command_help_short"].format(help_string=help_dict["help_string"]) + "\n" - - if command.aliases: - alias_string = str(command.aliases).replace("]", "").replace("[", "").replace("'", "") - value += self.help_strings["command_alias"].format(aliases=alias_string) + "\n" - value += self.help_strings["command_help"].format(prefix=self.clean_prefix, fqn=fully_qualified_name) + "\n" - - value += "​" - - embed.add_field(name=f"**{name}**", value=value, inline=False) - - async def send_command_help(self, command): - """ - Runs when the help command is run with a parameter that is a command. This can be a subcommand of a group or a - command that is not in a group. - :param command: The command to get the help information of. - """ - fully_qualified_name = command.name - if command.full_parent_name: - fully_qualified_name = f"{command.full_parent_name} {fully_qualified_name}" - - fully_qualified_name = fully_qualified_name.strip() - - title = self.help_strings["embed_title"].format(prefix=self.clean_prefix, fqn=fully_qualified_name) - usage = self.help_strings["usage_string"].format(prefix=self.clean_prefix, fqn=fully_qualified_name) - - help_dict = self.help_strings.get(fully_qualified_name.replace(" ", "_").replace("-", "_")) - if not help_dict: - short = command.help if command.help else self.help_strings["missing_help_string"] - long_string = command.description if command.description else "" - usage += command.usage if command.usage else "" - else: - short = help_dict.get("help_string", self.help_strings["missing_help_string"]) - long_string = help_dict.get("description", "") - usage += help_dict.get("usage", "") - - description = self.help_strings["command_description"].format(short_string=short, long_string=long_string) - - embed = Embed(title=title, description=description, colour=Colour.random()) - if help_dict and help_dict.get("readme_url"): - embed.__setattr__("url", help_dict.get("readme_url")) - embed.add_field(name="Usage:", value=usage, inline=False) - embed.add_field(name="​", value=self.help_strings["command_footer"]) - embed.set_footer(text=self.help_strings["embed_footer"]) - - await self.context.send(embed=embed) - - async def send_group_help(self, group): - """ - Runs when the help command is run with a parameter that is a command group. - :param group: The command group to send the help information about. - """ - fully_qualified_name = group.name - if group.full_parent_name: - fully_qualified_name = f"{group.full_parent_name} {fully_qualified_name}" - - fully_qualified_name = fully_qualified_name.strip() - - title = self.help_strings["embed_title"].format(prefix=self.clean_prefix, fqn=fully_qualified_name) - help_dict = self.help_strings.get(fully_qualified_name.replace(" ", "_").replace("-", "_")) - if not help_dict: - description = "​" if not group.help else group.help - else: - description = help_dict.get("help_string", "​") - - if help_dict.get('description'): - description += f"\n\n{help_dict.get('description')}\n​" - else: - description += f"\n​" - - embed = Embed(title=title, description=description, colour=Colour.random()) - - if help_dict and help_dict.get("readme_url"): - embed.__setattr__("url", help_dict.get("readme_url")) - - for command in group.commands: - await self.add_command_field(embed, command) - - embed.set_footer(text=self.help_strings["embed_footer"]) - await self.context.send(embed=embed) - - async def send_cog_help(self, cog): - """ - Send the help for a given cog. - :param cog: The cog to get the help about. - """ - await self.context.send(embed=await self.get_cog_help(cog, cog.get_commands())) - - -class HelpMenu(ReactableMenu): - """ - The Reactable Menu used to implement the custom help command. - """ - def __init__(self, **kwargs): - if kwargs.get("add_func") is None: - kwargs["add_func"] = self.react_add_func - super().__init__(**kwargs) - self.embeds = kwargs.get("embeds", None) - if self.embeds is None: - raise ValueError("No embeds supplied to the help menu!") - - self.auto_enable = True - self.show_ids = False - self.current_index = 0 - self.max_index = len(self.embeds) - self.add_option("⬅", "-1") - self.add_option("➡", "+1") - self.add_option("❌", "exit") - - async def react_add_func(self, payload): - """ - The function to run when the help menu is reacted to. - :param payload: The payload information of the reaction event. - """ - emoji_triggered = payload.emoji - channel_id: int = payload.channel_id - message_id: int = payload.message_id - guild = self.message.guild - - if emoji_triggered not in self: - channel = guild.get_channel(channel_id) - message = await channel.fetch_message(message_id) - await message.clear_reaction(emoji_triggered) - return False - - formatted_emoji = MultiEmoji(emoji_triggered) - option = self.options.get(formatted_emoji.emoji_id).get("descriptor") - try: - # Bit scuffed, but if the conversion to an int fails, the "exit" option was chosen and therefore just delete - # the help menu. - self.current_index += int(option) - if self.current_index >= self.max_index: - self.current_index = 0 - if self.current_index < 0: - self.current_index = self.max_index - 1 - - await self.update_message() - channel = guild.get_channel(channel_id) - message = await channel.fetch_message(message_id) - await message.remove_reaction(emoji_triggered, payload.member) - except ValueError: - await self.message.delete() - - def generate_embed(self) -> Embed: - """ - Generate the embed that is sent to the channel based on the current page index. - :return: A discord Embed object. - """ - return self.embeds[self.current_index] diff --git a/src/esportsbot/lib/__init__.py b/src/esportsbot/lib/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/esportsbot/lib/client.py b/src/esportsbot/lib/client.py deleted file mode 100644 index d1947542..00000000 --- a/src/esportsbot/lib/client.py +++ /dev/null @@ -1,112 +0,0 @@ -from types import FrameType -from discord.ext import commands -from discord import Intents, Embed, Colour, Member, User -from esportsbot.DiscordReactableMenus.EmojiHandler import MultiEmoji -from esportsbot.db_gateway import DBGatewayActions -from esportsbot.lib.CustomHelpCommand import CustomHelpCommand -from esportsbot.models import GuildInfo -from typing import Dict, MutableMapping, Union, Any -from datetime import datetime -import os -import signal -import asyncio -import toml - -# Type alias to be used for user facing strings. Allows for multi-level tables. -StringTable = MutableMapping[str, Union[str, "StringTable"]] - - -class EsportsBot(commands.Bot): - """ - A slightly modified version of the basic Bot from discord.commands to include a few extra attributes. - """ - def __init__(self, command_prefix: str, user_strings_file: str, **options): - """ - :param str command_prefix: The prefix to use for bot commands when evoking from discord. - :param str userStringsFile: A path to the `user_strings.toml` configuration file containing *all* user facing strings - """ - super().__init__(command_prefix, **options) - - self.unknown_command_emoji = MultiEmoji(os.getenv("UNKNOWN_COMMAND_EMOJI", "⁉")) - self.STRINGS: StringTable = toml.load(user_strings_file) - - signal.signal(signal.SIGINT, self.interrupt_received) # keyboard interrupt - signal.signal(signal.SIGTERM, self.interrupt_received) # graceful exit request - - def interrupt_received(self, signum: signal.Signals, frame: FrameType): - """Shut down the bot gracefully. - This method is called automatically upon receipt of sigint/sigterm. - - :param signal.Signals signum: Enum representing the type of interrupt received - :param FrameType frame: The current stack frame (https://docs.python.org/3/reference/datamodel.html#frame-objects) - """ - print("[EsportsBot] Interrupt received.") - asyncio.ensure_future(self.shutdown()) - - async def shutdown(self): - """Shut down the bot gracefully. - """ - print("[EsportsBot] Shutting down...") - await self.logout() - - async def admin_log( - self, - guild_id: int, - actions: Dict[str, - Any], - responsible_user: Union[Member, - User] = None, - colour=None - ): - guild_settings = DBGatewayActions().get(GuildInfo, guild_id=guild_id) - if not guild_settings or not guild_settings.log_channel_id: - return - log_channel = await self.fetch_channel(guild_settings.log_channel_id) - - if not responsible_user: - responsible_user = self.user - - if not colour: - colour = Colour.random() - - log_info = [responsible_user.mention] - - if "command" in actions: - # The action to log came from a message - message = actions.pop("command") - log_info.append(message.channel.mention) - log_info.append(f"[message]({message.jump_url})") - else: - log_info.append("Action Performed:") - - log_embed = Embed(description=" | ".join(log_info), colour=colour) - log_embed.set_author(icon_url=self.user.avatar_url_as(size=64), name="Admin Log") - log_embed.set_footer(text=datetime.now().strftime("%m/%d/%Y, %H:%M:%S")) - - for key, value in actions.items(): - log_embed.add_field(name=key, value=value, inline=False) - - await log_channel.send(embed=log_embed) - - -# Singular class instance of EsportsBot -_instance: EsportsBot = None - - -def instance() -> EsportsBot: - """Get the singular instance of the discord client. - EsportsBot is singular to allow for global client instance references outside of cogs, e.g emoji validation in lib - """ - global _instance - if _instance is None: - intents = Intents.default() - intents.members = True - _instance = EsportsBot( - os.getenv("COMMAND_PREFIX", - "!"), - "esportsbot/user_strings.toml", - intents=intents, - help_command=None - ) - _instance.help_command = CustomHelpCommand(help_strings=_instance.STRINGS["help"]) - return _instance diff --git a/src/esportsbot/lib/discordUtil.py b/src/esportsbot/lib/discordUtil.py deleted file mode 100644 index d723c919..00000000 --- a/src/esportsbot/lib/discordUtil.py +++ /dev/null @@ -1,64 +0,0 @@ -import shlex - -from discord import TextChannel -from typing import List - - -async def send_timed_message(channel: TextChannel, *args, timer: int = 15, **kwargs): - """Sends a message to a specific channel that gets deleted after a given amount of seconds. - - :param TextChannel channel: The channel to send the message to. - :param int timer: The number of seconds to wait until deleting the message (Default 15) - """ - timed_message = await channel.send(*args, **kwargs) - await timed_message.delete(delay=timer) - - -def load_discord_hooks(prefix_to_filter, guild_hooks, bot_user_id: int): - """ - Loads the list of Discord Webhooks which are where the Event Notifications are sent to. - :param prefix_to_filter: The Prefix to use to filter Webhooks to just the specific cog. - :param guild_hooks: The list of lists of Webhooks, where each index is for a different Guild. - :param bot_user_id: The Discord user ID of the bot that is running. - """ - - hooks = {} - - for guild in guild_hooks: - # For each guild in the list... - for g_hook in guild: - # And for each Webhook in the guild... - if prefix_to_filter in g_hook.name and g_hook.user.id == bot_user_id: - # Only if the Webhook was created for the TwitterCog and by the bot. - hooks[g_hook.id] = {"token": g_hook.token, "name": g_hook.name, "guild_id": g_hook.guild_id} - - return hooks - - -def get_webhook_by_name(current_hooks, name, guild_id, prefix_to_filter): - """ - Gets the information about a Discord Webhook given its name. - :param current_hooks: The current known webhooks to search through. - :param name: The name of the Webhook. - :param guild_id: The ID of the guild where the Webhook is in. - :param prefix_to_filter: The prefix used to ensure that the webhook belongs to the cog. - :return: A Tuple of hook ID and hook information. - """ - - # current_hooks = self._twitch_app.hooks - if prefix_to_filter not in name: - # Only find webhooks created for this cog. - name = prefix_to_filter + name - for hook in current_hooks: - if current_hooks.get(hook).get("name") == name: - if current_hooks.get(hook).get("guild_id") == guild_id: - return hook, current_hooks.get(hook) - - return None, None - - -def get_attempted_arg(message: str, arg_index: int) -> [str, List]: - command_args = shlex.split(message) - command_args.pop(0) - attempted_arg = command_args[arg_index] - return attempted_arg, command_args diff --git a/src/esportsbot/lib/exceptions.py b/src/esportsbot/lib/exceptions.py deleted file mode 100644 index ccbbbc3c..00000000 --- a/src/esportsbot/lib/exceptions.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -The lib package was partially copied over from the BASED template project: https://github.com/Trimatix/BASED -It is modified and not actively synced with BASED, so will very likely be out of date. - -.. codeauthor:: Trimatix -""" - -import traceback - - -def print_exception_trace(e: Exception): - """Prints the trace for an exception into stdout. - Great for debugging errors that are swallowed by the event loop. - - :param Exception e: The exception whose stack trace to print - """ - traceback.print_exception(type(e), e, e.__traceback__) diff --git a/src/esportsbot/lib/stringTyping.py b/src/esportsbot/lib/stringTyping.py deleted file mode 100644 index d8572c4e..00000000 --- a/src/esportsbot/lib/stringTyping.py +++ /dev/null @@ -1,60 +0,0 @@ -import re - -MENTION_REGEX = re.compile(r"^<@!?[0-9]+>$") -ROLE_REGEX = re.compile(r"^<@&[0-9]+>$") -CHANNEL_REGEX = re.compile(r"^<#[0-9]+>$") -""" -The lib package was partially copied over from the BASED template project: https://github.com/Trimatix/BASED -It is modified and not actively synced with BASED, so will very likely be out of date. - -.. codeauthor:: Trimatix -""" - - -def str_is_int(x) -> bool: - """Decide whether or not something is either an integer, or is castable to integer. - - :param x: The object to type-check - :return: True if x is an integer or if x can be casted to integer. False otherwise - :rtype: bool - """ - - try: - int(x) - except TypeError: - return False - except ValueError: - return False - return True - - -def str_is_role_mention(mention: str) -> bool: - """Decide whether the given string is a discord role mention, being <@&ROLEID> where ROLEID is an integer discord role id. - - :param str mention: The string to check - :return: True if mention matches the formatting of a discord role mention, False otherwise - :rtype: bool - """ - return ROLE_REGEX.match(mention) is not None - - -def str_is_user_mention(mention: str) -> bool: - """Decide whether the given string is a discord user mention, being <@USERID> where USERID is an integer discord user id. - - :param str mention: The string to check - :return: True if mention matches the formatting of a discord user mention, False otherwise - :rtype: bool - """ - return MENTION_REGEX.match(mention) is not None - - -def str_is_channel_mention(mention: str) -> bool: - """ - Decide whether the given string is a discord channel mention, being <@CHANNELID> where CHANNELID is an integer discord - channel id. - - :param str mention: The string to check - :return: True if mention matches the formatting of a discord channel mention, False otherwise - :rtype: bool - """ - return CHANNEL_REGEX.match(mention) is not None diff --git a/src/esportsbot/models.py b/src/esportsbot/models.py deleted file mode 100644 index 5275a4c0..00000000 --- a/src/esportsbot/models.py +++ /dev/null @@ -1,126 +0,0 @@ -from sqlalchemy import Column, String, BigInteger, Boolean -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.ext.declarative import declarative_base - -base = declarative_base() - -__all__ = [ - "GuildInfo", - "DefaultRoles", - "PingablePolls", - "PingableRoles", - "PingableSettings", - "EventCategories", - "RoleMenus", - "VotingMenus", - "VoicemasterMaster", - "VoicemasterSlave", - "TwitchInfo", - "TwitterInfo", - "MusicChannels", - "base" -] - - -class GuildInfo(base): - __tablename__ = 'guild_info' - guild_id = Column(BigInteger, primary_key=True, nullable=False) - log_channel_id = Column(BigInteger, nullable=True) - - -class DefaultRoles(base): - __tablename__ = 'default_roles' - default_roles_id = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) - guild_id = Column(BigInteger, nullable=False) - role_id = Column(BigInteger, nullable=False) - - -class PingablePolls(base): - __tablename__ = 'pingable_polls' - guild_id = Column(BigInteger, primary_key=True, nullable=False) - pingable_name = Column(String, primary_key=True, nullable=False) - poll_id = Column(BigInteger, nullable=False) - poll = Column(JSONB, nullable=False) - - -class PingableRoles(base): - __tablename__ = 'pingable_roles' - guild_id = Column(BigInteger, primary_key=True, nullable=False) - role_id = Column(BigInteger, primary_key=True, nullable=False) - menu_id = Column(BigInteger, nullable=False) - menu = Column(JSONB, nullable=False) - total_pings = Column(BigInteger, nullable=False) - monthly_pings = Column(BigInteger, nullable=False) - - -class PingableSettings(base): - __tablename__ = 'pingable_settings' - guild_id = Column(BigInteger, primary_key=True, nullable=False) - default_cooldown_length = Column(BigInteger, nullable=False) - default_poll_length = Column(BigInteger, nullable=False) - default_poll_threshold = Column(BigInteger, nullable=False) - default_poll_emoji = Column(JSONB, nullable=False) - default_role_emoji = Column(JSONB, nullable=False) - - -class EventCategories(base): - __tablename__ = 'event_categories' - guild_id = Column(BigInteger, primary_key=True, nullable=False) - event_id = Column(BigInteger, primary_key=True, nullable=False) - event_name = Column(String, nullable=False) - event_menu = Column(JSONB, nullable=False) - - -class RoleMenus(base): - __tablename__ = 'role_menus' - menu_id = Column(BigInteger, primary_key=True, nullable=False) - menu = Column(JSONB, nullable=False) - - -class VotingMenus(base): - __tablename__ = 'voting_menus' - menu_id = Column(BigInteger, primary_key=True, nullable=False) - menu = Column(JSONB, nullable=False) - - -class VoicemasterMaster(base): - __tablename__ = 'voicemaster_master' - master_id = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) - guild_id = Column(BigInteger, nullable=False) - channel_id = Column(BigInteger, nullable=False) - - -class VoicemasterSlave(base): - __tablename__ = 'voicemaster_slave' - vc_id = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) - guild_id = Column(BigInteger, nullable=False) - channel_id = Column(BigInteger, nullable=False) - owner_id = Column(BigInteger, nullable=False) - locked = Column(Boolean, nullable=False) - custom_name = Column(Boolean, nullable=False) - - -class TwitchInfo(base): - __tablename__ = 'twitch_info' - guild_id = Column(BigInteger, nullable=False) - channel_id = Column(BigInteger, primary_key=True, nullable=False) - hook_id = Column(BigInteger, primary_key=True, nullable=False) - twitch_handle = Column(String, nullable=False) - custom_message = Column(String, nullable=True) - - -class TwitterInfo(base): - __tablename__ = 'twitter_info' - id = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) - guild_id = Column(BigInteger, nullable=False) - twitter_user_id = Column(String, nullable=False) - twitter_handle = Column(String, nullable=False) - - -class MusicChannels(base): - __tablename__ = 'music_channels' - id = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) - guild_id = Column(BigInteger, nullable=False) - channel_id = Column(BigInteger, nullable=False) - queue_message_id = Column(BigInteger, nullable=False) - preview_message_id = Column(BigInteger, nullable=False) diff --git a/src/esportsbot/user_strings.toml b/src/esportsbot/user_strings.toml deleted file mode 100644 index f437b3b6..00000000 --- a/src/esportsbot/user_strings.toml +++ /dev/null @@ -1,715 +0,0 @@ -command_error_generic = "There was an internal error while performing your command! Please contact a developer!" -command_error_required_arguments = "Arguments are required for this command! See `{command_prefix}help {command_used}` for more information." -guild_leave = "Left the guild: {guild_name}" - -[help] -missing_help_string = ":warning: Missing help string :warning:" -empty_category = "No Category" -embed_title = "Showing help for — {prefix}{fqn}" -embed_footer = "For more help go to https://github.com/FragSoc/esports-bot" -usage_string = "{prefix}{fqn} " -command_help_short = "• {help_string}" -command_description = "{short_string} \n\n {long_string}" -command_help = "• Help Command: {prefix}help {fqn}" -command_alias = "• Aliases: {aliases}" -command_footer = "• Parameters with `<>` around them are required parameters.\n• Parameters with `[]` are optional parameters.\n• The brackets are not required when executing the command." - -[help.music] -help_string = "This is a command group for controlling music playback. All subcommands of this one can only be run in the music channel. Just executing this command will not do anything, use the help command for this command to see the list of all music commands." -readme_url = "https://github.com/FragSoc/esports-bot#music-bot" - -[help.music_queue] -help_string = "Gets the current list of songs in the queue." -description = "Unlike the active queue in the music channel, this command will show the entire queue. This command does not take any parameters." -readme_url = "https://github.com/FragSoc/esports-bot#music-queue" - -[help.music_join] -help_string = "Make the bot join the channel." -description = "This command makes the bot join a channel. If the bot is already in another channel it won't join. If an admin executes this command, they can force it to join by using `-f` or `force` at the end of the command." -usage = "[optional: -f | force]" -readme_url = "https://github.com/FragSoc/esports-bot#music-join-optional--f--force" - -[help.music_kick] -help_string = "Kicks the bot from the voice channel." -description = "This command makes the bot leave the current voice channel. If you are not in the channel with the bot, it won't leave. If an admin executes this command, they can force it to leave any channel by using `-f` or `force` at the end of the command." -usage = "[optional: -f | force]" -readme_url = "https://github.com/FragSoc/esports-bot#music-kick-optional--f--force" - -[help.music_skip] -help_string = "Skips the currently playing song." -description = "This command can skip 1 or many songs. If you give the command a number, it will skip to that song number in the queue. For example, if you give it the number `4`, it will start playing song number `4` in the queue, and remove all songs before that song. If you are not in the voice channel with the bot, this command will have no effect." -usage = "[optional: skip to song number]" -readme_url = "https://github.com/FragSoc/esports-bot#music-skip-optional-skip-to-position" - -[help.music_volume] -help_string = "Sets the volume of the bot." -description = "This sets the volume for all users in the channel, and gets reset if the bot leaves. Note that this will not change the bots independent volume slider that exists for all users. If you are not in the voice channel with the bot, this command will have no effect." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#music-volume-volume-level" - -[help.music_clear] -help_string = "Clears the entire queue." -description = "This will clear the entire queue, but will not remove the currently playing song. If you are not in the voice channel with the bot, this command will have no effect." -readme_url = "https://github.com/FragSoc/esports-bot#music-clear" - -[help.music_resume] -help_string = "Resumes playback of the current song or starts playback of the given song." -description = "If there is a song that is currently playing, it will continue playing. If there are no songs currently paused or in queue and a song is given to the command, it will start playing the given song. If there are songs in the queue, or there is a paused song, the song will be added to the queue. If you are not in the voice channel with the bot, this command will have no effect." -usage = "[optional: song request]" -readme_url = "https://github.com/FragSoc/esports-bot#music-play-optional-song-request" - -[help.music_pause] -help_string = "Pauses the current song." -readme_url = "https://github.com/FragSoc/esports-bot#music-pause" - -[help.music_remove] -help_string = "Removes a song from the queue at a specific position." -description = "If there is a song at the position given, it will remove that song from the queue. If no song position is given, it will remove the first song in the queue. If you are not in the voice channel with the bot, this command will have no effect." -usage = "[optional: song number]" -readme_url = "https://github.com/FragSoc/esports-bot#music-remove-song-position" - -[help.music_move] -help_string = "Moves a song from one position in the queue to another." -description = "This will move a song at the first position to the second position. It will not swap the song at the second position to the first position." -usage = " " -readme_url = "https://github.com/FragSoc/esports-bot#music-move-from-position-to-position" - -[help.musicadmin] -help_string = "This is a command group for managing the music channel, all of these commands require the `administrator` permission in this server. Just executing this command will not do anything, use the help command for this command to see the list of all admin commands." -readme_url = "https://github.com/FragSoc/esports-bot#music-bot" - -[help.musicadmin_fix] -help_string = "Performs a hard reset on the music channels and the current state of the music bot in this server." -description = "This should only be needed if there is an issue where the bot thinks it is still in a channel and then won't join a new one. Before using this command you should try the `join` or `kick` commands with `-f` or `force` first." -readme_url = "https://github.com/FragSoc/esports-bot#musicadmin-fix" - -[help.musicadmin_set] -help_string = "Sets the music channel for this server." -description = "By default this command will just send the song queue and current song preview messages. You can also use the `-c` optional arg to clear the channel first." -usage = " [optional: [args]" -readme_url = "https://github.com/FragSoc/esports-bot#musicadmin-set-channel-mention-optional-args" - -[help.musicadmin_get] -help_string = "Gets the channel that is currently set as the music channel in this server." -readme_url = "https://github.com/FragSoc/esports-bot#musicadmin-get" - -[help.musicadmin_reset] -help_string = "Clears the music channel and performs the setup again." -readme_url = "https://github.com/FragSoc/esports-bot#musicadmin-reset" - -[help.musicadmin_remove] -help_string = "Unlinks the currently set music channel from being the music channel." -description = "This will just stop the channel from processing song requests, the channel will not be deleted with this command and will just be a regular text channel." -readme_url = "https://github.com/FragSoc/esports-bot#musicadmin-remove" - -[help.pingme] -help_string = "This is a command group used to create custom roles that have ping cooldown timers. Use the help command to see a list of all `pingme` subcommands." -readme_url = "https://github.com/FragSoc/esports-bot#pingable-roles" - -[help.pingme_settings] -help_string = "This is a command group used to manage the default settings for pingable roles in this server. These commands require the `administrator` permission in this server. Use the help command to see a list of all subcommands." -readme_url = "https://github.com/FragSoc/esports-bot#pingable-roles" - -[help.pingme_settings_get_settings] -help_string = "Shows the current settings for this server." -description = "The settings shown are the default settings applied to new roles when they are created. Polls and Roles can have some of their settings overridden with other pingme settings commands." -readme_url = "https://github.com/FragSoc/esports-bot#pingme-settings-get-settings" - -[help.pingme_settings_default_settings] -help_string = "Resets all settings for this server to the bot-defined defaults." -readme_url = "https://github.com/FragSoc/esports-bot#pingme-settings-default-settings" - -[help.pingme_settings_poll_length] -help_string = "Sets the default poll length to the given time in seconds." -description = "This is the default length used in a poll if when no poll length is given when creating a poll." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-settings-poll-length-poll-length-in-seconds" - -[help.pingme_settings_poll_threshold] -help_string = "Sets the number of votes required for a poll to pass." -description = "If a poll passes or equals the value set here, when finished a pingable role will be created with the default settings and all users who voted will be granted the new role." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-settings-poll-threshold-number-of-votes-threshold" - -[help.pingme_settings_ping_cooldown] -help_string = "Sets the default ping cooldown for new pingable roles." -description = "This cooldown is the time in which the role cannot be mentioned again. This ping cooldown will only apply to new roles created, and will not affect roles previously created with the default ping cooldown." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-settings-ping-cooldown-cooldown-in-seconds" - -[help.pingme_settings_poll_emoji] -help_string = "Sets the emoji used in voting polls." -description = "This sets the emoji that users use to vote on if they want a new pingable role. This will not affect any already running polls." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-settings-poll-emoji-emoji" - -[help.pingme_settings_role_emoji] -help_string = "Sets the emoji used in the role reaction menu." -description = "Once a role is created it will also create a role reaction menu so that users who didn't vote but want the role can get the role. This command sets the emoji for the reaction used to get or remove the role from a user. This will not affect any already existing role reaction menus." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-settings-role-emoji-emoji" - -[help.pingme_create_role] -help_string = "Creates a new poll to create a new role." -description = "Creates a new poll for users to vote on. If the number of votes surpasses the servers defined vote threshold, a new role is created with the name given in this command. The role will be given to all users who voted and a reaction menu will be created so users who have the role can remove it and users who want the role can receive it." -usage=" [Optional: poll length in seconds]" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-create-role-role-name-optional-poll-length-in-seconds" - -[help.pingme_delete_role] -help_string = "Deletes the roles mentioned." -description = "Completely deletes all roles mentioned, as long as the roles mentioned are pingable roles. This command cannot delete roles that are not pingable roles." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-delete-role-one-or-many-role-mentions" - -[help.pingme_convert_role] -help_string = "Converts a mentioned role into a pingable one." -description = "This creates the reaction menu for the role and makes it a role that has a ping cooldown, which by default is the server default ping cooldown timer." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-convert-role-one-or-many-role-mentions" - -[help.pingme_convert_pingable] -help_string = "Converts a mentioned role from a pingable role to a normal one." -description = "This will make it so that the role is no longer bound by a cooldown as to how many often it can be mentioned. It will also mean that there will no longer be a reaction menu for users to receive a role." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-convert-pingable-one-or-many-role-mentions" - -[help.pingme_role_cooldown] -help_string = "Sets the ping cooldown for the given role." -description = "This will set the number of seconds users must wait before the role can be mentioned again." -usage = " " -readme_url = "https://github.com/FragSoc/esports-bot#pingme-role-cooldown-role-mention--role-id-cooldown-in-seconds" - -[help.pingme_role_emoji] -help_string = "Sets the emoji for the given role." -description = "If the mentioned role is a pingable role, this will set the emoji used in the role's reaction menu." -usage = " " -readme_url = "https://github.com/FragSoc/esports-bot#pingme-role-emoji-role-mention--role-id-emoji" - -[help.pingme_disable_role] -help_string = "Disables a pingable role." -description = "If the role mentioned is a pingable role, this will stop it from being mentioned and will also disable the reaction menu for the role." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-disable-role-one-or-many-role-mentions" - -[help.pingme_enable_role] -help_string = "Enables a pingable role." -description = "If the role mentioned is a pingable role, it will allow the role to be mentioned with it's mention cooldown in place and will enable the reaction menu." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#pingme-enable-role-one-or-many-role-mentions" - -[help.voice] -help_string = "This is the command group to manage the Voice Master functions of this bot. Use the help command for this command to see a list of all the Voice Master related commands." -readme_url = "https://github.com/FragSoc/esports-bot#voicemaster" - -[help.voice_setparent] -help_string = "Sets a channel to be a parent channel." -description = "This will set the voice channel given to act as a parent channel, so when users join they will be moved into their own child-channel. There can be more than 1 parent voice channel." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#voice-setparent-channel_id" - -[help.voice_getparents] -help_string = "Get a list of all Voicemaster parent channels." -readme_url = "https://github.com/FragSoc/esports-bot#voice-getparents" - -[help.voice_removeparent] -help_string = "Stops the given voice channel from acting as a Voicemaster parent." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#voice-removeparent-channel_id" - -[help.voice_removeallparents] -help_string = "Stops all voice channels from acting as Voicemaster parents." -readme_url = "https://github.com/FragSoc/esports-bot#voice-removeallparents" - -[help.voice_removeallchildren] -help_string = "Deletes all Voicemaster child voice channels." -readme_url = "https://github.com/FragSoc/esports-bot#voice-removeallchildren" - -[help.voice_lock] -help_string = "Locks your current Voicemaster voice channel." -description = "If you are in a Voicemaster child channel and if you own the channel, it will lock the number of users allowed in the channel to the current number of users in the channel." -readme_url = "https://github.com/FragSoc/esports-bot#voice-lock" - -[help.voice_unlock] -help_string = "Unlocks your current Voicemaster voice channel." -description = "If you are in a Voicemaster child channel and if you own the channel, it will remove the user-count limit." -readme_url = "https://github.com/FragSoc/esports-bot#voice-unlock" - -[help.voice_rename] -help_string = "Renames your current Voicemaster voice channel." -description = "If you are in a Voicemaster child channel and if you own the channel, it will change the name to the name given. If no name is given, it gets reset to the default name." -readme_url = "https://github.com/FragSoc/esports-bot#voice-rename" - -[help.admin] -help_string = "This command group contains a few admin-related commands." -readme_url = "https://github.com/FragSoc/esports-bot#administrator-tools" - -[help.dev] -help_string = "This command group contains commands used by this bot's developers." -readme_url = "https://github.com/FragSoc/esports-bot#administrator-tools" - -[help.admin_clear] -help_string = "Deletes the given number of messages. Defaults to 5 messages." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#admin-clear" - -[help.admin_version] -help_string = "Get the current bot version." -readme_url = "https://github.com/FragSoc/esports-bot#admin-version" - -[help.admin_members] -help_string = "Get the number of members in the server." -readme_url = "https://github.com/FragSoc/esports-bot#admin-members" - -[help.dev_remove_cog] -help_string = "Unloads the given cog." -description = "This removes a cog from being loaded and removes all its commands. This can only be run by a member that is defined in the dev_ids section of the bots environment. If a cog makes use of the `on_ready` function, it will not work properly if loaded through the command, and will require a full restart to re-enable." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#dev-remove-cog-cog-name" - -[help.dev_add_cog] -help_string = "Loads the given cog." -description = "Enables a Cog and adds its commands. This can only be run by a member that is defined in the dev_ids section of the bots environment. If the given cog uses the `on_ready` function, it will not work as expected and will require a full restart to properly enable." -usage ="" -readme_url = "https://github.com/FragSoc/esports-bot#dev-add-cog-cog-name" - -[help.dev_reload_cog] -help_string = "Reloads the given cog." -description = "Unloads and then reloads the given cog. Refer to the 'load cog' command to see potential side-effects. This can only be run by a member that is defined in the dev_ids section of the bots environment." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#dev-reload-cog-cog-name" - -[help.admin_set_rep] -help_string = "Sets the permissions for a game rep." -description = "By mentioning a user, and then giving a set of category or channel IDs separated by a space, this command will give the required permissions for a Game Rep for the given channels." -usage = " " -readme_url = "https://github.com/FragSoc/esports-bot#admin-set-rep-user-mention-channel-or-category-ids" - -[help.admin_user_info] -help_string = "Get basic information about a users profile." -description = "Shows an embed with the basic information about a user's profile. Useful for seeing join date, creation date and other information." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#admin-user-info-user-mention" - -[help.setlogchannel] -help_string = "Sets the given channel to the logging channel." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#setlogchannel-channel_mention--channel_id" - -[help.getlogchannel] -help_string = "Get the current logging channel." -readme_url = "https://github.com/FragSoc/esports-bot#getlogchannel" - -[help.removelogchannel] -help_string = "Stops the current logging channel from being active." -description = "The current logging channel will become a regular channel and logging events will no longer be posted to the channel." -readme_url = "https://github.com/FragSoc/esports-bot#removelogchannel" - -[help.twitter] -help_string = "This is the command group used to manage posting Twitter updates to discord channels. Use the help command for this command to see a list of all the Twitter related commands." -readme_url = "https://github.com/FragSoc/esports-bot#twitter-integration" - -[help.twitter_hook] -help_string = "Create a new Discord Webhook for Twitter." -description = "This will create a new Discord Webhook that can then be used to post Twitter updates to the channel that the webhook is bound to. If you want a specific name you must include the channel parameter." -usage = "[optional: channel mention] [optional: hook name]" -readme_url = "https://github.com/FragSoc/esports-bot#twitter-hook-optional-channel-mention-optional-hook-name" - -[help.twitter_remove_hook] -help_string = "Deletes an existing Discord Webhook for Twitter." -description = "This will delete the given Discord Webhook. You do not need to include the Twitter Webhook prefix, but the Discord Webhook you are wanting to delete must be one that has the Twitter prefix in it. Once delete, Twitter updates will no longer be posted to that channel." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#twitter-remove-hook-hook-name" - -[help.twitter_add] -help_string = "Adds a Twitter account to be tracked." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#twitter-add-twitter-handle" - -[help.twitter_remove] -help_string = "Stops tracking a Twitter account." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#twitter-remove-twitter-handle" - -[help.twitter_list] -help_string = "Lists all the Twitter accounts currently tracked." -readme_url = "https://github.com/FragSoc/esports-bot#twitter-list" - -[help.setdefaultroles] -help_string = "Sets the list of roles to be given to users when they join the server." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#setdefaultroles-role_mention--role_id" - -[help.getdefaultroles] -help_string = "Get the list of current default roles." -readme_url = "https://github.com/FragSoc/esports-bot#getdefaultroles" - -[help.removedefaultroles] -help_string = "Removes all default roles." -description = "This will stop applying the current list of default roles to new members. Does not affect existing members." -readme_url = "https://github.com/FragSoc/esports-bot#removedefaultroles" - -[help.events] -help_string = "This is a command group for controlling the creation and management of events and their text and voice channels. Use the help command for this command to see the subcommands available." -description = "Useful terminology for this command group are: \n — Shared Role: The role that every user already has. \n — Event Role: The role that is used in the sign-in menu and is created with the event." -readme_url = "https://github.com/FragSoc/esports-bot#event-category-management" - -[help.events_create_event] -help_string = "Creates a new event with a given name." -description = "This command creates an event category, event role and the sign-in menu for the event. If the `shared role` parameter is left empty and the `DefaultRolesCog` is enabled, the bot will attempt to use one of the defined default roles. Once an event is created, it will by default not be viewable by users until the event is opened." -usage = " [optional: shared role]" -readme_url = "https://github.com/FragSoc/esports-bot#events-create-event-event-name-role-mention--role-id" - -[help.events_open_event] -help_string = "Reveal the sign-in menu to non-administrators." -description = "Allows members with the shared role to see the sign-in channel, and then also react to the sign-in menu to receive the event role." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#events-open-event-event-name" - -[help.events_close_event] -help_string = "Stop members from seeing all event channels." -description = "Stops all channels in the event category from being viewed by non-administrators. This does not remove the event role from users." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#events-close-event-event-name" - -[help.events_delete_event] -help_string = "Delete all event channels and the event role." -description = "This will create a reaction menu to confirm your choice. If confirmed, all channels and the event role will be deleted." -usage = "" -readme_url = "https://github.com/FragSoc/esports-bot#events-delete-event-event-name" - -[help.roles] -help_string = "This is a command group for controlling the creation of role reaction menus. Use the help command for this command to see the subcommands available." -readme_url = "https://github.com/FragSoc/esports-bot#role-reaction-menus" - -[help.roles_make_menu] -help_string = "Create a new role menu." -description = "This will create a new role reaction menu with the given title and description, so long as each are surrounded with double quotes. Each option in the menu is defined with a role and an emoji as its identifier. Each emoji must be unique, but roles do not." -usage = " <description> [none or many: <role mention> <emoji>]" -readme_url = "https://github.com/FragSoc/esports-bot#roles-make-menu-title-description-mentioned-role-emoji" - -[help.roles_add_option] -help_string = "Add another option to an existing role menu." -description = "Adds another option to an existing role menu. The emoji must not already be another option in the role menu, but the role can be any existing role." -usage = "<role menu ID> [none or many: <role mention> <emoji>]" -readme_url = "https://github.com/FragSoc/esports-bot#roles-add-option-optional-menu-id-mentioned-role-emoji" - -[help.roles_remove_option] -help_string = "Remove an option from an existing role menu." -description = "Using the emoji to identify which option you want to remove from the role menu, remove the option from that given menu." -usage = "<emoji option to remove> <role menu ID>" -readme_url = "https://github.com/FragSoc/esports-bot#roles-remove-option-emoji-optional-menu-id" - -[help.roles_disable_menu] -help_string = "Disables a role menu." -description = "This stops users from being able to react to a role reaction menu to receive the roles defined in the menu." -usage = "<role menu ID>" -readme_url = "https://github.com/FragSoc/esports-bot#roles-disable-menu-optional-menu-id" - -[help.roles_enable_menu] -help_string = "Enables a role menu." -description = "Allows users to receive roles from a role reaction menu when they react with the defined emojis in the menu." -usage = "<role menu ID>" -readme_url = "https://github.com/FragSoc/esports-bot#roles-enable-menu-optional-menu-id" - -[help.roles_delete_menu] -help_string = "Deletes a role menu." -description = "Deletes a menu entirely. Does not delete the roles or emojis associated with the menu." -usage = "<role menu ID>" -readme_url = "https://github.com/FragSoc/esports-bot#roles-delete-menu-menu-id" - -[help.roles_toggle_ids] -help_string = "Toggle showing IDs at the bottom of role menus." -description = "This will hide or show the menu ID of all role reaction menus in their footer." -readme_url = "https://github.com/FragSoc/esports-bot#roles-toggle-ids" - -[help.votes] -help_string = "This is a command group used to create and manage reaction based polls." -readme_url = "https://github.com/FragSoc/esports-bot#poll-reaction-menus" - -[help.votes_make_poll] -help_string = "Creates a new poll with a given name." -description = "Creates a new poll with the given name, if the name is more than 1 word long it must be surrounded by double quotes. Each option in the poll must be on a new line and in the format of: <emoji> <description> where if the description is more than 1 word long it must also be surrounded by double quotes. There can be up to 25 options" -usage = "<title> \n [<emoji> <description>]" -readme_url = "https://github.com/FragSoc/esports-bot#votes-make-poll-title-emoji-description" - -[help.votes_add_option] -help_string = "Add another poll option to an existing poll." -description = "The option added must be in the same for as when creating the menu, where if the description is longer than 1 word it must be surrounded by double quotes. You must be the creator of the poll to add an option." -usage = "<poll menu ID> <emoji> <description>" -readme_url = "https://github.com/FragSoc/esports-bot#votes-add-option-menu-id-emoji-description" - -[help.votes_remove_option] -help_string = "Remove an existing poll option from a poll." -description = "Using the emoji to identify the option, remove the option from the poll. You must be the creator of the poll to remove an option." -usage = "<poll menu ID> <emoji to remove>" -readme_url = "https://github.com/FragSoc/esports-bot#votes-remove-option-menu-id-emoji" - -[help.votes_delete_poll] -help_string = "Delete a poll." -description = "This will not get the poll results, but will just remove the message. You must be the creator of the poll to delete it." -usage = "<poll menu ID>" -readme_url = "https://github.com/FragSoc/esports-bot#votes-delete-poll-menu-id" - -[help.votes_end_poll] -help_string = "End the voting on a poll." -description = "This will generate the results of the poll and stop people from voting on the poll. You must be the creator of the poll to end it." -usage = "<poll menu ID>" -readme_url = "https://github.com/FragSoc/esports-bot#votes-end-poll-menu-id" - -[help.votes_reset_poll] -help_string = "Remove all user votes on a poll." -description = "Resets a poll to have no votes on it. You must be the creator of the poll to reset it." -usage = "<poll menu ID>" -readme_url = "https://github.com/FragSoc/esports-bot#votes-reset-poll-menu-id" - -[help.twitch] -help_string = "This is a command group used to control the managing of posting Twitch live notifications. Use the help command of this command to see available subcommands." -readme_url = "https://github.com/FragSoc/esports-bot#twitch-integration" - -[help.twitch_createhook] -help_string = "Create a new Discord Webhook for Twitch notifications." -description = "Creates a Webhook with the given name that can be used to post live notifications of specific Twitch channels to the specific Discord text channel the Webhook is bound to." -usage = "<channel mention> <hook name>" -readme_url = "https://github.com/FragSoc/esports-bot#twitch-createhook-channel-mention-hook-name" - -[help.twitch_deletehook] -help_string = "Delete an existing Discord Webhook for Twitch notifications." -description = "Deletes a Webhook used for Twitch notifications. The name provided does not need to include the Twitch Webhook prefix, but it does have to be a Webhook used for Twitch notifications. Any channel that was bound to the given Webhook will no longer have it's live notifications posted to that Discord channel." -usage = "<hook name>" -readme_url = "https://github.com/FragSoc/esports-bot#twitch-deletehook-hook-name" - -[help.twitch_add] -help_string = "Adds a Twitch channel to be tracked." -description = "This will track the given Twitch channel and post its updates to the given discord Webhook. A channel can be bound to many Discord Webhooks. If a custom message is given, it will be used in the live notification and must be surrounded by double quotes." -usage = "<channel name | channel url> <hook name> [optional: custom message]" -readme_url = "https://github.com/FragSoc/esports-bot#twitch-add-channel-name--channel-url-hook-name-optional-custom-message" - -[help.twitch_remove] -help_string = "Stops tracking a Twitch channel." -description = "This will stop posting updates for the given Twitch channel in the given Webhook's text channel. If the Twitch channel is tracked in other channels, the notifications will still be posted there." -usage = "<channel name | channel url> <hook name>" -readme_url = "https://github.com/FragSoc/esports-bot#twitch-remove-twitch-handle-hook-name" - -[help.twitch_list] -help_string = "Shows a list of the current Discord Webhooks and their tracked channels." -description = "To only see one Webhooks channels, specify the Webhook name in the command." -usage = "[optional: hook name]" -readme_url = "https://github.com/FragSoc/esports-bot#twitch-list-optional-hook-name" - -[help.twitch_webhooks] -help_string = "Get a list of the current Discord Webhooks for Twitch." -readme_url = "https://github.com/FragSoc/esports-bot#twitch-webhooks" - -[help.twitch_setmessage] -help_string = "Sets the custom message for a Twitch channel." -description = "This will set the custom live message for a Twitch channel for a specific Webhook. If the custom message is left empty, the custom message is removed from the live notification." -usage = "<channel name> <hook name> [optional: custom message]" -readme_url = "https://github.com/FragSoc/esports-bot#twitch-setmessage-twitch-handle-hook-name-optional-custom-message" - -[help.twitch_getmessage] -help_string = "Get the custom message(s) of a Twitch channel." -description = "If no hook name is specified, it will return a list of all the custom messages for all the Webhooks the channel is tracked in. Otherwise it will return just the message for the given Webhook." -usage = "<channel name> [optional: hook name]" -readme_url = "https://github.com/FragSoc/esports-bot#twitch-getmessage-twitch-handle-optional-hook-name" - -[help.twitch_preview] -help_string = "Preview the live notification for a Twitch channel." -description = "See what a notification will look like in a given Discord Webhook for the given Twitch channel." -usage = "<channel name> <hook name>" -readme_url = "https://github.com/FragSoc/esports-bot#twitch-preview-twitch-handle-hook-name" - -[logging] -channel_set = "Logging channel has been set to <#{channel_id!s}>" -channel_set_already = "Logging channel already set to this channel" -channel_set_notify_in_channel = "{author_mention} has set this channel as the logging channel" - -channel_get = "Logging channel is set to <#{channel_id!s}>" -channel_get_notfound = "Logging channel has not been set" - -channel_removed = "Log channel has been removed" -channel_removed_log = "{author_mention} has set removed the logging channel" - -[admin] -channel_cleared = "{author_mention} has cleared {message_amount} messages from {author_mention}" -members = "This server has {member_count} members including me" -no_version = "No version recorded" - -[default_role] -default_role_join = "{member_name} has joined the server and received: {role_ids}" -default_role_join_no_role = "{member_name} has joined the server" -default_role_missing = "Default role(s) have not been set" - -default_roles_set = "Default role(s) are now set to {roles}" -default_roles_set_empty = "No roles were passed, please review your usage" -default_roles_set_error = "Error occurred during this operation, please check that you have formatted these inputs correctly" -default_roles_set_log = "{author_mention} has set the default role(s) to: {roles}" - -default_role_get = "Default role(s) are set to:" - -default_role_removed = "Default role(s) are removed" -default_role_removed_log = "{author_mention} has removed the default role" - -[music] -music_channel_set = "The Music Channel has been set to {channel}" -music_channel_set_log = "{author} has bound the Music Channel to {channel}" -music_channel_set_missing_channel = "You need to either use a # to mention the channel or paste the ID of the channel" -music_channel_set_invalid_channel = """The channel given was not valid, check the ID pasted or try using a # to mention - the channel""" -music_channel_set_not_text_channel = "You must provide a Text Channel to bind as the Music Channel" -music_channel_set_not_empty = """The channel given is not empty, if you want to clear the channel - use {prefix}setmusicchannel -c <channel>""" - -music_channel_get = "The Music Channel is currently set to {channel}" -music_channel_missing = "The Music Channel has not been set" - -music_channel_reset = "The Music Channel ({channel}) has been reset" - -music_channel_removed = "The Music Channel has been unbound from {channel}" -music_channel_removed_log = "{author} has unbound the Music Channel from {channel}" - -bot_inactive = "I am not currently active. Start playing some songs first by joining a channel and requesting one!" -song_process_failed = "The following songs had issues while processing: \n{songs}" - -music_channel_wrong_channel = "The command `{command}` must be sent in the Music Channel" - -no_connect_perms = "I need the permission `connect` to be able to join your Voice Channel" -unable_to_join = "I am unable to join your Voice Channel as either you are not in one or I am already in another one" -not_admin = "You cannot do that as you are not an administrator in this server" - -volume_set_invalid_value = "The volume level must be between 0 and 100" -volume_set_success = "The volume has been set to {volume_level}%" - -song_remove_invalid_value = "The song number must be a value in the current queue" -song_remove_valid_options = "Valid options are from `1` to `{end_index}`" -song_remove_success = "The song **{song_title}** has been removed from position **{song_position}** in the queue" - -song_moved_success = "The song **{title}** has been moved from position `{from_pos}` to position `{to_pos}`" - -song_pause_success = "Song Paused!" - -song_resume_success = "Song Resumed!" - -song_skipped_success = "Song Skipped!" - -kick_bot_success = "I have left the Voice Channel and emptied the queue" - -clear_queue_success = "Queue Cleared!" - -shuffle_queue_success = "Queue Shuffled!" - -[event_categories] -success_event = """✅ New event category '{event_name}' created successfully! -The event role is {event_role_mention}, and the sign-in menu is ID `{sign_in_menu_id}`, in {sign_in_channel_mention}. - -The event is currently **closed**, and invisible to the `{shared_role_name}` role. Open the event when you're ready with `{command_prefix}open-event {event_name}`! -Feel free to create more Text Channels and Voice Channels below the ones created by the command! -""" -success_channel = "✅ <#{channel_id}> is now visible to **{role_name}**!" -event_exists = ":x: An event category with the name '{event_name}' already exists!" -no_events = ":x: This server doesn't have any event categories registered!" -success_event_closed = "✅ All event channels are longer visible to anyone" -success_event_deleted = "✅ {event_name} event and role successfully deleted." -delete_cancelled = "✅ {event_name} event and role will not be deleted" -unrecognised_event = ":x: Unrecognised event. The following events exist in this server: {events}" -invalid_role = ":x: Invalid role! Please give your Role as either a mention or an ID." -user_missing_perms = ":x: I am unable to perform that action as you are missing the `{permission}` permission in this server!" -bot_missing_perms = ":x: I am unable to perform that action as I may be missing one of the following permissions: `{permissions}`" -missing_arguments = ":x: There were key arguments missing in the supplied command. Try using `{prefix}help {command}` to find how to use this command" - -[pingable_roles] -already_exists = ":warning: There is already a pingable role with the name `{role}` in this server" -create_success = "✅ Successfully created a poll for your pingable role" -set_poll_length = "✅ The default poll length is now set to `{poll_length} seconds`" -set_poll_threshold = "✅ The default number of votes required to create a role is now set to `{vote_threshold} votes`" -set_role_cooldown = "✅ The cooldown for pingable roles is now set to `{cooldown} seconds`" -set_poll_emoji = "✅ The emoji used to role creation polls is now set to {emoji}" -set_role_emoji = "✅ The emoji used in pingable role reaction menus is now set to {emoji}" -no_roles_given = ":x: You must mention one or more roles to use this command" -not_pingable_role = ":warning: `{role}` is not a pingable role, only pingable roles can be used with this command." -role_delete_success = "✅ The following role(s) were deleted: `{deleted_roles}`" -role_convert_success = "✅ The following role(s) were converted to pingable roles: `{converted_roles}`" -pingable_convert_success= "✅ The following role(s) were converted to normal roles: `{converted_roles}`" -invalid_role = ":x: The role or role ID is not a valid role or pingable role" -role_cooldown_updated = "✅ The ping cooldown for the role `{role}` has been set to `{seconds} seconds`" -role_emoji_updated = "✅ The emoji for the role `{role}` has been set to {emoji}" -no_pingable_roles = ":warning: There are currently no pingable roles in this server! Use the `create-role` command to invoke a poll to create one, or convert an existing role to a pingable role with the `convert-role` command" -roles_disabled = "✅ The following role(s) were disabled: `{disabled_roles}`" -roles_enabled = "✅ The following role(s) were enabled: `{enabled_roles}`" -needs_initialising = ":x: This server has not had its default settings setup! Use the `{prefix}{command}` command to setup the settings" -reserve_emoji = ":x: Sorry, the emoji `{emoji}` cannot be used as it is reserved for internal use" -default_settings_set = "✅ Default pingable settings have been applied" - -[voicemaster] -success_child_locked = "Your VM child has been locked 🔒" -success_child_unlocked = "Your VM child has been unlocked 🔓" -success_vm_parents_cleared = "Cleared all VM parents from this server" -success_vm_set = "This VC has now been set as a VM parent" -success_vm_children_cleared = "Cleared all VM children from this server" -success_vm_unset = "This VC is no longer a VM parent" - -show_current_vcs = "Current VM parent VCs in this server:\n{parent_vms}" - -error_already_locked = "Your VM child is already locked" -error_already_set_parent = "This VC is already set as a VM parent" -error_already_set_child = "This VC is already set as a VM child" -error_already_unlocked = "Your VM child is already unlocked" -error_bad_id = "The ID entered is not a VC" -error_bad_id_format = "The ID argument is not a valid discord ID format" -error_no_id = "You need to provide a VC ID" -error_no_vms = "No VCs in this server currently set as VM parents" -error_not_in_vm_child = "You are not currently in a VM child" -error_not_owned = "You are not the owner of this VM child" -error_not_vm = "This VC is not currently a VM parent" -error_bad_vm_name = "Sorry, but that is not a valid name for a Voice Channel" - -log_vm_parent_added = "{author} has made {channel} - {channel_id} a VM parent VC" -log_vm_parent_removed = "{mention} has removed {channel_name} - {channel_id} from VM parent VC" -log_vm_parents_cleared = "{mention} has removed all VM parents" -log_vm_children_cleared = "{mention} has removed all VM children" -log_child_locked = "{mention} has locked their VM child" -log_child_unlocked = "{mention} has unlocked their VM child" -log_child_renamed = "{mention} has renamed their VM child to `{new_name}`" - -[twitch] -generic_error = "There was an error while trying to add `{channel}` as a tracked channel" -invalid_name = "Unable to create a webhook with the name `{name}` as it is either in use already or invalid" -webhook_created = "Successfully created a new Webhook! Name: `{name}`, Channel: {channel}, Webhook ID: `{hook_id}`" -webhook_deleted = "Successfully deleted `{name}` Webhook (ID: `{hook_id}`)" -webhook_exists = "A Discord Webhook already exists with the name `{name}` in this server" -webhook_missing = "There is no Discord Webhook with the name `{name}` in this server" -no_webhooks = "There are currently no Discord Webhooks for the Twitch Cog in this server" -current_webhooks = "There are the following Discord Webhooks for the Twitch Cog in this server: `{webhooks}`. \n If you want to add a Twitch channel or get current Twitch channels of a Webhook, you can reference the name of the Webhook without the prefix `{prefix}`" -channel_added = "Live notifications for `{twitch_channel}` will now be sent to {discord_channel}" -channel_removed = "`{twitch_channel}` will no longer have live notifications sent to {discord_channel}" -channel_not_tracked = "The Twitch channel `{name}` is not currently tracked in the Webhook `{webhook}`" -channel_already_tracked = "The Twitch channel `{name}` is already tracked in the Webhook `{webhook}`" -set_custom_message = "Set the custom live message for `{channel}` to `{message}` for the webhook `{webhook}`" -get_custom_message = "The custom message for `{channel}` in the webhook `{webhook}` is `{message}`" -no_channel_error = "There is no Twitch channel with the name `{channel}`" - -[twitter] -webhook_created = "Created a Webhook -> Name: {name} , ID: {hook_id}" -webhook_deleted = "Deleted a Webhook with Name: {name} and ID: {hook_id}" -webhook_error = "Unable to {operation} Webhook because {reason}" -account_added = "Successfully added {account} to tracked accounts" -account_removed = "Successfully removed {account} from tracked accounts" -accounts_empty = "There are no accounts currently tracked in this server" -accounts_list = "Currently tracked accounts are: {tracked_accounts}" -account_missing_error = "Unable to {operation} {account} because there is no account with that name" -account_exists_error = "Unable to add {account} to tracked accounts because it is already tracked" - -[role_reacts] -duplicate_emoji = "Cannot add the emoji {emoji} as there is already a role tied to that emoji in this reaction menu" -missing_quotes = "Unable to get title and description from message. You may be missing quotes around your title and description individually" -invalid_id = "There is no role reaction menu with the id `{given_id}`" -invalid_emoji = "The provided emoji was not a valid emoji" -disable_menu = "✅ The menu with ID `{menu_id}` has been disabled" -enable_menu = "✅ The menu with ID `{menu_id}` has been enabled" -delete_menu = "✅ The menu with ID `{menu_id}` has been deleted" - -[vote_reacts] -wrong_author = ":x: You cannot perform this action, only `{author}` can do this" -needs_number = ":x: The argument required for this is a menu ID as a number. You can copy the menu ID from the footer of the menu or by copying the message ID of the menu" -invalid_id = ":warning: There is no poll with the id `{given_id}`" -disable_menu = "✅ The poll with ID `{menu_id}` has been disabled" -reset_menu = "✅ The poll with ID `{menu_id}` has had its reactions cleared" diff --git a/src/esportsbot/cogs/__init__.py b/src/extensions/__init__.py similarity index 100% rename from src/esportsbot/cogs/__init__.py rename to src/extensions/__init__.py diff --git a/src/main.py b/src/main.py index 85288171..90aa1405 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,20 @@ -from esportsbot import bot import coloredlogs import logging +import os +import sys +from bot import start_bot -coloredlogs.install(level=logging.INFO) -bot.launch() +if __name__ == "__main__": + coloredlogs.install(level=logging.INFO) + logger = logging.getLogger(__name__) + if os.getenv("DISCORD_TOKEN") is None: + logger.warning("Missing Discord Token environment variable, attempting manual load...") + from dotenv import load_dotenv + env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "secrets.env")) + if not load_dotenv(dotenv_path=env_path): + raise RuntimeError(f"Unable to load .env file: {env_path}") + if sys.platform not in ('win32', 'cygwin', 'cli'): + logger.info("Deteced UNIX platform, using uvloop for asyncio operations!") + import uvloop + uvloop.install() + start_bot() From 47998754ff7c26e5a9511ac80e9d1d7f1d60ec12 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 03:46:00 +0100 Subject: [PATCH 002/404] Added skeleton file for VoiceAdmin cog --- requirements.txt | 1 + src/common/__init__.py | 0 src/common/io.py | 16 ++++++++++++++++ src/extensions/VoiceAdmin.py | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+) create mode 100644 src/common/__init__.py create mode 100644 src/common/io.py create mode 100644 src/extensions/VoiceAdmin.py diff --git a/requirements.txt b/requirements.txt index d36d631f..a5aa0cda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ PyNaCl aiohttp[speedups] coloredlogs uvloop +toml diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/common/io.py b/src/common/io.py new file mode 100644 index 00000000..0af3dd67 --- /dev/null +++ b/src/common/io.py @@ -0,0 +1,16 @@ +import os +import toml +import logging +from typing import Dict + +logger = logging.getLogger(__name__) + + +def load_cog_toml(cog_path: str) -> Dict: + cog_name = os.path.splitext(cog_path)[-1][1:] + path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "locale", f"{cog_name}.toml")) + try: + return toml.load(path) + except FileNotFoundError: + logger.warning(f"Unable to load TOML file for {cog_path}") + return {} diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py new file mode 100644 index 00000000..489cf1c9 --- /dev/null +++ b/src/extensions/VoiceAdmin.py @@ -0,0 +1,18 @@ +from discord.ext.commands import Bot, Cog + +import logging +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) + + +class VoiceAdmin(Cog): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + +async def setup(bot: Bot): + await bot.add_cog(VoiceAdmin(bot)) From 31104a9ee9c7bc47cea5ddc214305fa47ab37a80 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 03:47:02 +0100 Subject: [PATCH 003/404] Added environment variable for VoiceAdmin cog --- secrets.template | 2 ++ src/client.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/secrets.template b/secrets.template index 7839bbc2..ced470c0 100644 --- a/secrets.template +++ b/secrets.template @@ -19,3 +19,5 @@ PGADMIN_DEFAULT_PASSWORD= # This variable can be used to force the bot to use a specific DB. Must be a valid DB url, including protocol. DB_OVERRIDE= ################### + +ENABLE_VOICEADMIN=FALSE \ No newline at end of file diff --git a/src/client.py b/src/client.py index f20f18b7..a7b2104e 100644 --- a/src/client.py +++ b/src/client.py @@ -17,7 +17,7 @@ async def setup_hook(self) -> None: # List of extensions to load. Initialised with default extensions. enabled_extensions: List[str] = [] # Dictionary of Environment variables -> extension name - MODULE_ENV_VARS: Dict[str, Union[str, None]] = {} + MODULE_ENV_VARS: Dict[str, Union[str, None]] = {"VOICEADMIN": "VoiceAdmin"} # For each of the enabled Environment variables, add it's respective extension to the list. for var in MODULE_ENV_VARS: From c33347bb3fa37c50bfca0fd144c7fa7b4ce3e0d4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 03:49:23 +0100 Subject: [PATCH 004/404] Added AdminTools cog skeleton --- src/extensions/AdminTools.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/extensions/AdminTools.py diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py new file mode 100644 index 00000000..90420ec4 --- /dev/null +++ b/src/extensions/AdminTools.py @@ -0,0 +1,18 @@ +from discord.ext.commands import Bot, Cog + +import logging +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) + + +class AdminTools(Cog): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + +async def setup(bot: Bot): + await bot.add_cog(AdminTools(bot)) From 5821ea8632eba1484d85b00facb43ad130329807 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 03:49:40 +0100 Subject: [PATCH 005/404] Added AdminTools cog to default extensions --- src/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.py b/src/client.py index a7b2104e..803aa9c6 100644 --- a/src/client.py +++ b/src/client.py @@ -15,7 +15,7 @@ def __init__(self, command_prefix: str, *args, **kwargs): async def setup_hook(self) -> None: # List of extensions to load. Initialised with default extensions. - enabled_extensions: List[str] = [] + enabled_extensions: List[str] = ["AdminTools"] # Dictionary of Environment variables -> extension name MODULE_ENV_VARS: Dict[str, Union[str, None]] = {"VOICEADMIN": "VoiceAdmin"} From 00571276599c7129a795660e902ef628ca552052 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 03:54:09 +0100 Subject: [PATCH 006/404] Added remaining required cog skeleton files --- src/extensions/AutoRoles.py | 18 ++++++++++++++++++ src/extensions/EventTools.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/extensions/AutoRoles.py create mode 100644 src/extensions/EventTools.py diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py new file mode 100644 index 00000000..8466cb20 --- /dev/null +++ b/src/extensions/AutoRoles.py @@ -0,0 +1,18 @@ +from discord.ext.commands import Bot, Cog + +import logging +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) + + +class AutoRoles(Cog): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + +async def setup(bot: Bot): + await bot.add_cog(AutoRoles(bot)) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py new file mode 100644 index 00000000..e1cb7f77 --- /dev/null +++ b/src/extensions/EventTools.py @@ -0,0 +1,18 @@ +from discord.ext.commands import Bot, Cog + +import logging +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) + + +class EventTools(Cog): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + +async def setup(bot: Bot): + await bot.add_cog(EventTools(bot)) From 10da1c9852c125fe49bad1f5cc7b4f5e8b7f2a94 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 03:54:39 +0100 Subject: [PATCH 007/404] Registered environment variables for new skeleton cogs --- secrets.template | 4 +++- src/client.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/secrets.template b/secrets.template index ced470c0..785490a9 100644 --- a/secrets.template +++ b/secrets.template @@ -20,4 +20,6 @@ PGADMIN_DEFAULT_PASSWORD= DB_OVERRIDE= ################### -ENABLE_VOICEADMIN=FALSE \ No newline at end of file +ENABLE_VOICEADMIN=FALSE +ENABLE_EVENTTOOLS=FALSE +ENABLE_AUTOROLES=FALSE \ No newline at end of file diff --git a/src/client.py b/src/client.py index 803aa9c6..5e51d8ac 100644 --- a/src/client.py +++ b/src/client.py @@ -17,7 +17,13 @@ async def setup_hook(self) -> None: # List of extensions to load. Initialised with default extensions. enabled_extensions: List[str] = ["AdminTools"] # Dictionary of Environment variables -> extension name - MODULE_ENV_VARS: Dict[str, Union[str, None]] = {"VOICEADMIN": "VoiceAdmin"} + MODULE_ENV_VARS: Dict[str, + Union[str, + None]] = { + "VOICEADMIN": "VoiceAdmin", + "EVENTTOOLS": "EventTools", + "AUTOROLES": "AutoRoles" + } # For each of the enabled Environment variables, add it's respective extension to the list. for var in MODULE_ENV_VARS: From b1745162ad5b1a9d522196eb93d7732bf47f063e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 03:58:01 +0100 Subject: [PATCH 008/404] Removed return type from setup_hook --- src/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.py b/src/client.py index 5e51d8ac..bec0b102 100644 --- a/src/client.py +++ b/src/client.py @@ -13,7 +13,7 @@ def __init__(self, command_prefix: str, *args, **kwargs): super().__init__(command_prefix, *args, **kwargs) self.logger = logging.getLogger(__name__) - async def setup_hook(self) -> None: + async def setup_hook(self): # List of extensions to load. Initialised with default extensions. enabled_extensions: List[str] = ["AdminTools"] # Dictionary of Environment variables -> extension name From 29ea4562b5592435417037da99b150a6633beb54 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 03:59:31 +0100 Subject: [PATCH 009/404] Added on_voice_state_update listener --- src/extensions/VoiceAdmin.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 489cf1c9..5763a2b5 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -1,3 +1,4 @@ +from discord import Member, VoiceState from discord.ext.commands import Bot, Cog import logging @@ -13,6 +14,21 @@ def __init__(self, bot: Bot): self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") + @Cog.listener() + async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): + if not member.guild.me.guild_permissions.move_members: + self.logger.error(f"Missing perimssion `move_members` in guild {member.guild.name} (guildid - {member.guild.id})!") + return + + if not before.channel and not after.channel: + return + + if before.channel: + pass + + if after.channel: + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From fcf7f9a774ed85a2dd57d39f155ad67f69b345c2 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:04:08 +0100 Subject: [PATCH 010/404] Added set_parent_channel command signature --- src/extensions/VoiceAdmin.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 5763a2b5..53f9407e 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -1,5 +1,6 @@ -from discord import Member, VoiceState +from discord import Interaction, Member, VoiceChannel, VoiceState from discord.ext.commands import Bot, Cog +from discord.app_commands import command, describe, rename, default_permissions, checks, guild_only import logging from common.io import load_cog_toml @@ -29,6 +30,15 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: if after.channel: pass + @command(name=COG_STRINGS["vc_set_parent_name"], description=COG_STRINGS["vc_set_parent_description"]) + @describe(channel=COG_STRINGS["vc_set_parent_param_describe"]) + @rename(channel=COG_STRINGS["vc_set_parent_param_rename"]) + @default_permissions(administrator=True) + @checks.has_permssions(administrator=True) + @guild_only() + async def set_parent_channel(self, interaction: Interaction, channel: VoiceChannel): + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From d897c070fa451dd563b01a2475981202fb5bfb81 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:05:49 +0100 Subject: [PATCH 011/404] Added remove_parent_channel command signature --- src/extensions/VoiceAdmin.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 53f9407e..0de76351 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -39,6 +39,15 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: async def set_parent_channel(self, interaction: Interaction, channel: VoiceChannel): pass + @command(name=COG_STRINGS["vc_remove_parent_name"], description=COG_STRINGS["vc_remove_parent_description"]) + @describe(channel=COG_STRINGS["vc_remove_parent_param_describe"]) + @rename(channel=COG_STRINGS["vc_remove_parent_param_rename"]) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def remove_parent_channel(self, interaction: Interaction, channel: VoiceChannel): + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From a4c18fab2a047787294fddddc9cf4687f1ac6f8a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:07:09 +0100 Subject: [PATCH 012/404] Added get_parent_channels command signature --- src/extensions/VoiceAdmin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 0de76351..a945b77c 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -48,6 +48,11 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann async def remove_parent_channel(self, interaction: Interaction, channel: VoiceChannel): pass + @command(name=COG_STRINGS["vc_get_parents_name"], description=COG_STRINGS["vc_get_parents_description"]) + @guild_only() + async def get_parent_channels(self, interaction: Interaction): + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From 1c7cb7eff16f9d9c452249cf6fddd16b238c15b5 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:08:44 +0100 Subject: [PATCH 013/404] Added rename_channel command signature --- src/extensions/VoiceAdmin.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index a945b77c..493332ad 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -53,6 +53,16 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh async def get_parent_channels(self, interaction: Interaction): pass + @command( + name=COG_STRINGS["vc_rename_name"], + description=f"{COG_STRINGS['vc_rename_description']} {COG_STRINGS['vc_must_be_owner']}" + ) + @describe(new_name=COG_STRINGS["vc_rename_param_describe"]) + @rename(new_name=COG_STRINGS["vc_rename_param_rename"]) + @guild_only() + async def rename_channel(self, interaction: Interaction, new_name: str = ""): + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From 57f8c9bc5075d215c9f91ae348170a6649eb2f2e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:09:39 +0100 Subject: [PATCH 014/404] Added lock_channel command signature --- src/extensions/VoiceAdmin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 493332ad..842b3581 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -63,6 +63,14 @@ async def get_parent_channels(self, interaction: Interaction): async def rename_channel(self, interaction: Interaction, new_name: str = ""): pass + @command( + name=COG_STRINGS["vc_lock_name"], + description=f"{COG_STRINGS['vc_lock_description']} {COG_STRINGS['vc_must_be_owner']}" + ) + @guild_only() + async def lock_channel(self, interaction: Interaction): + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From 11882dab2ac165d7ab5384cacc7ae150e7e67056 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:10:32 +0100 Subject: [PATCH 015/404] Added unlock_channel command signature --- src/extensions/VoiceAdmin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 842b3581..8cefb2ec 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -71,6 +71,14 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): async def lock_channel(self, interaction: Interaction): pass + @command( + name=COG_STRINGS["vc_unlock_name"], + description=f"{COG_STRINGS['vc_unlock_description']} {COG_STRINGS['vc_must_be_owner']}" + ) + @guild_only() + async def unlock_channel(self, interaction: Interaction): + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From e29e7ff9ed4e4fcd3c41a158d94992eb3a2306ee Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:11:27 +0100 Subject: [PATCH 016/404] Added limit_channel command signature --- src/extensions/VoiceAdmin.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 8cefb2ec..d4070ff6 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -79,6 +79,16 @@ async def lock_channel(self, interaction: Interaction): async def unlock_channel(self, interaction: Interaction): pass + @command( + name=COG_STRINGS["vc_limit_name"], + description=f"{COG_STRINGS['vc_limit_description']} {COG_STRINGS['vc_must_be_owner']}" + ) + @describe(user_limit=COG_STRINGS["vc_limit_param_describe"]) + @rename(user_limit=COG_STRINGS["vc_limit_param_rename"]) + @guild_only() + async def limit_channel(self, interaction: Interaction, user_limit: int = 0): + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From 9c80d82ac66f3b7669ec62f91da387be74aa56b5 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:13:34 +0100 Subject: [PATCH 017/404] Added unlimit_channel command signature --- src/extensions/VoiceAdmin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index d4070ff6..197049a5 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -89,6 +89,14 @@ async def unlock_channel(self, interaction: Interaction): async def limit_channel(self, interaction: Interaction, user_limit: int = 0): pass + @command( + name=COG_STRINGS["vc_unlimit_name"], + description=f"{COG_STRINGS['vc_unlimit_description']} {COG_STRINGS['vc_must_be_owner']}" + ) + @guild_only() + async def unlimit_channel(self, interaction: Interaction): + pass + async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) From 3cf1f605e153884bc281b103240d321ab59ba295 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:14:05 +0100 Subject: [PATCH 018/404] Added VoiceAdmin cog string file --- src/locale/VoiceAdmin.toml | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/locale/VoiceAdmin.toml diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml new file mode 100644 index 00000000..219059ac --- /dev/null +++ b/src/locale/VoiceAdmin.toml @@ -0,0 +1,60 @@ +default_vc_name = "{name}'s VC" +vc_limited_icon_with_delimited = "​📌​" +vc_locked_icon_with_delimiter = "​🔒​" +vc_must_be_owner = "You must be the VC owner to do this." + +vc_set_parent_name = "vc-set-parent" +vc_set_parent_description = "Set a Voice Channel to be a parent Voice Channel." +vc_set_parent_param_describe = "The Voice Channel to add to the parent Voice Channel list." +vc_set_parent_param_rename = "voice-channel" +vc_set_parent_success = "`{channel}` is now a parent Voice Channel ✅" +vc_set_parent_warn_already_parent = "The Voice Channel selected is already a parent Voice Channel ⚠️" +vc_set_parent_warn_already_child = "The Voice Channel selected cannot be a child Voice Channel ⚠️" + +vc_remove_parent_name = "vc-remove-parent" +vc_remove_parent_description = "Remove a Voice Channel from being a parent Voice Channel." +vc_remove_parent_param_describe = "The Voice Channel to remove from the parent Voice Channel list." +vc_remove_parent_param_rename = "voice-channel" +vc_remove_parent_success = "`{channel}` is no longer a parent Voice Channel ✅" +vc_remove_parent_warn_not_parent = "The Voice Channel selected is not currently a parent Voice Channel ⚠️" +vc_remove_parent_warn_already_child = "The Voice Channel selected cannot be a child Voice Channel ⚠️" + +vc_get_parents_name = "vc-get-parents" +vc_get_parents_description = "Get the list of current parent Voice Channels." +vc_get_parents_format = "Current parent Voice Channels in this server: \n {channels}" + +vc_rename_name = "vc-rename" +vc_rename_description = "Rename your current Voice Channel." +vc_rename_param_describe = "The new name of the VC. Leaving this empty will reset the name to default." +vc_rename_param_rename = "new-name" +vc_rename_success = "You have renamed your Voice Channel to `{name}` ✅" +vc_rename_warn_no_voice = "You cannot rename a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" +vc_rename_warn_not_owner = "You cannot rename your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" + +vc_lock_name = "vc-lock" +vc_lock_description = "Only allow current members to (re)join your VC." +vc_lock_success = "Your Voice Channel is now locked ✅" +vc_lock_warn_no_voice = "You cannot lock a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" +vc_lock_warn_not_owner = "You canont lock your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" + +vc_unlock_name = "vc-unlock" +vc_unlock_description = "Allow anyone to join your VC again." +vc_unlock_success = "Your Voice Channel is now unlocked ✅" +vc_unlock_warn_no_voice = "You cannot unlock a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" +vc_unlock_warn_not_owner = "You canont unlock your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" +vc_unlock_warn_not_locked = "You cannot unlock your Voice Channel as it is not locked ⚠️" + +vc_limit_name = "vc-limit" +vc_limit_description = "Set the member count limit of your VC." +vc_limit_param_describe = "Number of members (1-99) to limit the voice channel to. If ommitted, uses the current member count." +vc_limit_param_rename = "member-limit" +vc_limit_success = "Your Voice Channel is now limited to `{count}` member(s) ✅" +vc_limit_warn_no_voice = "You cannot limit a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" +vc_limit_warn_not_owner = "You canont limit your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" + +vc_unlimit_name = "vc-unlimit" +vc_unlimit_description = "Remove the member count limit of your VC." +vc_unlimit_succss = "The member count limit has been removed from your Voice Channel ✅" +vc_unlimit_warn_no_voice = "You cannot remove the limit from a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" +vc_unlimit_warn_not_owner = "You canont remove limit the limit from your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" +vc_unlimit_warn_not_limited = "You cannot remove the user limit of your Voice Channel as it is not currently limited ⚠️" \ No newline at end of file From 9892998ec10a22b8cdb065a0ee05044cca764c69 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:19:50 +0100 Subject: [PATCH 019/404] Added docstrings to functions --- src/bot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bot.py b/src/bot.py index fd349394..45033df7 100644 --- a/src/bot.py +++ b/src/bot.py @@ -9,6 +9,11 @@ def start_bot(): + """Performs final checks before running the bot and if successful, starts the bot. + + Raises: + RuntimeError: If DISCORD_TOKEN environment variable is missing. + """ logger.info("Loading bot...") if not os.getenv("DISCORD_TOKEN"): From 51bd0c8846ada4dba36c80fa5fb3228906c693d7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:22:37 +0100 Subject: [PATCH 020/404] Added docstrings to functions and classes --- src/client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/client.py b/src/client.py index bec0b102..d6788b81 100644 --- a/src/client.py +++ b/src/client.py @@ -10,10 +10,17 @@ class __EsportsBot(Bot): def __init__(self, command_prefix: str, *args, **kwargs): + """Creates a new instance of the the private EsportsBot class. + + Args: + command_prefix (str): The character(s) to use as the legacy command prefix. + """ super().__init__(command_prefix, *args, **kwargs) self.logger = logging.getLogger(__name__) async def setup_hook(self): + """The setup function that is called prior to the bot connecting to the Discord Gateway. + """ # List of extensions to load. Initialised with default extensions. enabled_extensions: List[str] = ["AdminTools"] # Dictionary of Environment variables -> extension name From efc093dc75cffe72230a200b1f1b36290336a5cd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:42:50 +0100 Subject: [PATCH 021/404] Added docstrings to all commands --- src/extensions/VoiceAdmin.py | 85 ++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 197049a5..2f18a1c8 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -11,12 +11,33 @@ class VoiceAdmin(Cog): def __init__(self, bot: Bot): + """VoiceAdmin cog is used to dynamically create and manage Voice Channels, + by assigning specific channels to act as parent channels. + + When users join parent Voice Channels, a new chil Voice Channel is created, + and the user moved to it. The user has control over the child Voice Channel name, + and can limit how many/who can join. + + Args: + bot (Bot): The instance of the bot to attach the cog to. + """ self.bot = bot self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") @Cog.listener() async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): + """The listener used to track when users join/leave Voice Channels that the Bot has access to. + + Is used to create child Voice Channels when users join parent Voice Channels. It is also used + to transfer ownership of a child Voice Channel when it's owner leaves, or delete a child Voice Channel + if the last member in the Voice Channel leaves. + + Args: + member (Member): The member who's Voice State was updated. + before (VoiceState): The Voice State prior to the update. + after (VoiceState): The new Voice State after the update. + """ if not member.guild.me.guild_permissions.move_members: self.logger.error(f"Missing perimssion `move_members` in guild {member.guild.name} (guildid - {member.guild.id})!") return @@ -37,6 +58,14 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: @checks.has_permssions(administrator=True) @guild_only() async def set_parent_channel(self, interaction: Interaction, channel: VoiceChannel): + """The command used to set a given Voice Channel to be a parent Voice Channel. + + This means that when users join the given Voice Channel, the Bot will create child Voice Channels. + + Args: + interaction (Interaction): The interaction that triggered the command. + channel (VoiceChannel): The Voice Channel to set as a parent Voice Channel. + """ pass @command(name=COG_STRINGS["vc_remove_parent_name"], description=COG_STRINGS["vc_remove_parent_description"]) @@ -46,11 +75,24 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann @checks.has_permissions(administrator=True) @guild_only() async def remove_parent_channel(self, interaction: Interaction, channel: VoiceChannel): + """The command used to stop a channel from being a parent Voice Channel. + + This means that when users join the given Voice Channel, child Voice Channels will no longer be created. + + Args: + interaction (Interaction): The interaction that triggered the command. + channel (VoiceChannel): The Voice Channel to stop behaving as a parent Voice Channel. + """ pass @command(name=COG_STRINGS["vc_get_parents_name"], description=COG_STRINGS["vc_get_parents_description"]) @guild_only() async def get_parent_channels(self, interaction: Interaction): + """The command used to get a list of the currently set parent Voice Channels in the current guild/server. + + Args: + interaction (Interaction): The interaction that triggered the command. + """ pass @command( @@ -61,6 +103,16 @@ async def get_parent_channels(self, interaction: Interaction): @rename(new_name=COG_STRINGS["vc_rename_param_rename"]) @guild_only() async def rename_channel(self, interaction: Interaction, new_name: str = ""): + """The command users can use to rename their child Voice Channels. + + Only the owner of the child Voice Channel is allowed to use this command to rename a child Voice Channel. If + no new name is provided, the Voice Channel's name is reset to the child Voice Channel default name. + + Args: + interaction (Interaction): The interaction that triggered the command. + new_name (str, optional): The new name to set the Voice Channel to. + Defaults to the default child Voice Channel string. + """ pass @command( @@ -69,6 +121,15 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): ) @guild_only() async def lock_channel(self, interaction: Interaction): + """The command that allows users to lock who can join their child Voice Channels. + It will set the members who are allowed to join the child Voice Channel to those who are + currently in the child Voice Channel. + + Only the owner of the child Voice Channel is allowed to lock who can join. + + Args: + interaction (Interaction): The interaction that triggered the command. + """ pass @command( @@ -77,6 +138,13 @@ async def lock_channel(self, interaction: Interaction): ) @guild_only() async def unlock_channel(self, interaction: Interaction): + """The command users can use to re-allow anyone to join their child Voice Channels. + + Only the owner of the child Voice Channel is allowed to remove the lock. + + Args: + interaction (Interaction): The interaction that triggered the command. + """ pass @command( @@ -87,6 +155,16 @@ async def unlock_channel(self, interaction: Interaction): @rename(user_limit=COG_STRINGS["vc_limit_param_rename"]) @guild_only() async def limit_channel(self, interaction: Interaction, user_limit: int = 0): + """The command that allows users to set a member count limit on their child Voice Channels. + If no user limit is provided, the current number of members in the channel is set as the limit. + + Only the owner of the child voice Channel can limit the number of members allowed in the child Voice Channel. + + Args: + interaction (Interaction): The interaction that triggered the command. + user_limit (int, optional): The number of members to limit the child Voice Channel to. + Defaults to the number of members in the child Voice Channel. + """ pass @command( @@ -95,6 +173,13 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): ) @guild_only() async def unlimit_channel(self, interaction: Interaction): + """The command that allows users to remove the member count limit on their child Voice Channels. + + Only the owner of the chid Voice Channel can remove the member limit on the child Voice Channel. + + Args: + interaction (Interaction): The interaction that triggered the command. + """ pass From b2526323ef385418a116a90b351a325da336a9eb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:50:05 +0100 Subject: [PATCH 022/404] Added bot version string loading --- src/common/io.py | 9 +++++++++ src/extensions/AdminTools.py | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/common/io.py b/src/common/io.py index 0af3dd67..e5cdc502 100644 --- a/src/common/io.py +++ b/src/common/io.py @@ -14,3 +14,12 @@ def load_cog_toml(cog_path: str) -> Dict: except FileNotFoundError: logger.warning(f"Unable to load TOML file for {cog_path}") return {} + + +def load_bot_version(): + file_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "version.txt")) + try: + with open(file_path, "rt") as file: + return file.readline() + except FileNotFoundError: + return None diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index 90420ec4..a213efda 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -1,7 +1,7 @@ from discord.ext.commands import Bot, Cog import logging -from common.io import load_cog_toml +from common.io import load_bot_version, load_cog_toml COG_STRINGS = load_cog_toml(__name__) @@ -11,6 +11,12 @@ class AdminTools(Cog): def __init__(self, bot: Bot): self.bot = bot self.logger = logging.getLogger(__name__) + version = load_bot_version() + if version is None: + self.version_string = COG_STRINGS["admin_version_missing"] + else: + self.version_string = COG_STRINGS["admin_version_format"].format(version=version) + self.logger.info(f"{__name__} has been added as a Cog") From 951941641f9ddc3ab2bb18771df0a28a9fe581ab Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:52:06 +0100 Subject: [PATCH 023/404] Added get_member_count command signature --- src/extensions/AdminTools.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index a213efda..6527a29c 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -1,4 +1,6 @@ +from discord import Interaction from discord.ext.commands import Bot, Cog +from discord.app_commands import command, default_permissions, checks, guild_only import logging from common.io import load_bot_version, load_cog_toml @@ -19,6 +21,13 @@ def __init__(self, bot: Bot): self.logger.info(f"{__name__} has been added as a Cog") + @command(name=COG_STRINGS["admin_members_name"], description=COG_STRINGS["admin_members_description"]) + @default_permissions(administrator=True) + @checks.has_permssions(administrator=True) + @guild_only() + async def get_member_count(self, interaction: Interaction): + pass + async def setup(bot: Bot): await bot.add_cog(AdminTools(bot)) From 54243c76ca0f6478c9168db48aba20a7dc477c4d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:53:09 +0100 Subject: [PATCH 024/404] Added get_bot_version command signature --- src/extensions/AdminTools.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index 6527a29c..6b5a93a1 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -28,6 +28,13 @@ def __init__(self, bot: Bot): async def get_member_count(self, interaction: Interaction): pass + @command(name=COG_STRINGS["admin_version_name"], description=COG_STRINGS["admin_version_description"]) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only + async def get_bot_version(self, interaction: Interaction): + pass + async def setup(bot: Bot): await bot.add_cog(AdminTools(bot)) From 4464279371f879f48256aff8aac240208eaccf20 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:54:27 +0100 Subject: [PATCH 025/404] Added clear_messages command signature --- src/extensions/AdminTools.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index 6b5a93a1..dbebf3e0 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -1,6 +1,6 @@ from discord import Interaction from discord.ext.commands import Bot, Cog -from discord.app_commands import command, default_permissions, checks, guild_only +from discord.app_commands import command, describe, rename, default_permissions, checks, guild_only import logging from common.io import load_bot_version, load_cog_toml @@ -35,6 +35,15 @@ async def get_member_count(self, interaction: Interaction): async def get_bot_version(self, interaction: Interaction): pass + @command(name=COG_STRINGS["admin_clear_name"], description=COG_STRINGS["admin_clear_description"]) + @describe(count=COG_STRINGS["admin_clear_param_describe"]) + @rename(count=COG_STRINGS["admin_clear_param_rename"]) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only + async def clear_messages(self, interaction: Interaction, count: int = 5): + pass + async def setup(bot: Bot): await bot.add_cog(AdminTools(bot)) From 67d69f968a69752f7f707e9fa403d7edb8a4450d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:55:26 +0100 Subject: [PATCH 026/404] Added AdminTools cog string file --- src/locale/AdminTools.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/locale/AdminTools.toml diff --git a/src/locale/AdminTools.toml b/src/locale/AdminTools.toml new file mode 100644 index 00000000..4653e4a4 --- /dev/null +++ b/src/locale/AdminTools.toml @@ -0,0 +1,19 @@ +admin_members_name = "admin-member-count" +admin_members_description = "Get the current member count of the server." +admin_members_format = "Current member count — `{count}` ." + +admin_user_info_name = "admin-user-info" +admin_user_info_title = "{user} — User Info" +admin_user_info_description = "Showing the user info for {mention} \n" + +admin_clear_name = "admin-clear-messages" +admin_clear_description = "Delete a specific number of messages in the given channel. " +admin_clear_param_describe = "The number of messages to delete. Defaults to 5 messages." +admin_clear_param_rename = "message-count" +admin_clear_warn_too_many = "The maximum number of messages that can be deleted in one go is 100 ⚠️" +admin_clear_success = "Successfully deleted `{count}` message(s) ✅" + +admin_version_name = "admin-get-version" +admin_version_description = "Get the current version of the Bot." +admin_version_format = "Current version — `{version}`" +admin_version_missing = "No current bot version set ⚠️" \ No newline at end of file From 0ae8f697137fa8c106ef718cad08704a6d7b8749 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 04:59:36 +0100 Subject: [PATCH 027/404] Added docstrings to all commands --- src/extensions/AdminTools.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index dbebf3e0..4152e690 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -11,6 +11,13 @@ class AdminTools(Cog): def __init__(self, bot: Bot): + """AdminTools cog is used to manage basic Administrator/Moderation tools. + All commands in this cog require the user to have the administrator permission + in a given guild/server. + + Args: + bot (Bot): The instance of the bot to attach the cog to. + """ self.bot = bot self.logger = logging.getLogger(__name__) version = load_bot_version() @@ -26,6 +33,11 @@ def __init__(self, bot: Bot): @checks.has_permssions(administrator=True) @guild_only() async def get_member_count(self, interaction: Interaction): + """The command used to get the current member count in the current guild/server. + + Args: + interaction (Interaction): The interaction that triggered the command. + """ pass @command(name=COG_STRINGS["admin_version_name"], description=COG_STRINGS["admin_version_description"]) @@ -33,6 +45,11 @@ async def get_member_count(self, interaction: Interaction): @checks.has_permissions(administrator=True) @guild_only async def get_bot_version(self, interaction: Interaction): + """The command used to get the global current version of the Bot. + + Args: + interaction (Interaction): The interaction that triggered the command. + """ pass @command(name=COG_STRINGS["admin_clear_name"], description=COG_STRINGS["admin_clear_description"]) @@ -42,6 +59,13 @@ async def get_bot_version(self, interaction: Interaction): @checks.has_permissions(administrator=True) @guild_only async def clear_messages(self, interaction: Interaction, count: int = 5): + """The command used to bulk delete messages in the current channel. + Defaults to 5 messages if no value is given, and has a maximum value of 100. + + Args: + interaction (Interaction): The interaction that triggered the command. + count (int, optional): The number of messages to delete. Defaults to 5. Maximum 100. + """ pass From 4be2f7620207e2404c4083930141ff92e76bfa62 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 05:05:33 +0100 Subject: [PATCH 028/404] Fixed incorrect check name --- src/extensions/AdminTools.py | 2 +- src/extensions/VoiceAdmin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index 4152e690..b63b76d6 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -30,7 +30,7 @@ def __init__(self, bot: Bot): @command(name=COG_STRINGS["admin_members_name"], description=COG_STRINGS["admin_members_description"]) @default_permissions(administrator=True) - @checks.has_permssions(administrator=True) + @checks.has_permissions(administrator=True) @guild_only() async def get_member_count(self, interaction: Interaction): """The command used to get the current member count in the current guild/server. diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 2f18a1c8..0bd1e72b 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -55,7 +55,7 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: @describe(channel=COG_STRINGS["vc_set_parent_param_describe"]) @rename(channel=COG_STRINGS["vc_set_parent_param_rename"]) @default_permissions(administrator=True) - @checks.has_permssions(administrator=True) + @checks.has_permissions(administrator=True) @guild_only() async def set_parent_channel(self, interaction: Interaction, channel: VoiceChannel): """The command used to set a given Voice Channel to be a parent Voice Channel. From b7453bf7cb52b43b3b287ac70064f72f82580a6a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 05:25:58 +0100 Subject: [PATCH 029/404] Updated README to reflect status --- README.md | 631 ++++++------------------------------------------------ 1 file changed, 68 insertions(+), 563 deletions(-) diff --git a/README.md b/README.md index f537d9d1..4190afbf 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ </div> Dependency Versions: + <div align=left> <img src="https://img.shields.io/badge/min%20python%20version-3.8.0-green?style=flat-square" /> <img src="https://img.shields.io/badge/min%20postgres%20version-11-lightgrey?style=flat-square" /> @@ -16,28 +17,6 @@ Dependency Versions: This Discord bot was written to merge all the functions of different bots used in the Fragsoc Discord server into one bot that is maintained by Fragsoc members. -## How to set up an instance of this bot with Docker - -1. Clone this repository: -```console -$ git clone https://github.com/FragSoc/Esports-Bot-Rewrite.git -``` - -2. Change into the repo directory: -```console -$ cd Esports-Bot-Rewrite -``` - -3. Rename the `secrets.template` to `secrets.env` and set all the variables. Be sure to read the `Current Functions` section below for the Cog you want to enable in case of any special setup instructions: -```console -$ nano secrets.env -``` - -4. Run docker-compose: -```console -$ docker-compose up -``` - ## How to set up an instance of this bot without Docker Requirements needed to run: @@ -47,615 +26,141 @@ Requirements needed to run: - [A postgres 11 database](https://www.postgresql.org/docs/current/admin.html) 1. Clone this repository: -```console -$ git clone https://github.com/FragSoc/Esports-Bot-Rewrite.git +```console +$ git clone https://github.com/FragSoc/esports-bot.git ``` 2. Change into the repo directory: + ```console -$ cd Esports-Bot-Rewrite +$ cd esports-bot ``` -3. Rename the `secrets.template` to `secrets.env` and set all the variables. Be sure to read the `Current Functions` section below for the Cog you want to enable in case of any special setup instructions: +3. Rename the `secrets.template` to `secrets.env` and set all the variables. + ```console $ nano secrets.env $ source secrets.env ``` -4. Change into the bot directory: -```bash -$ cd src -``` -5. Install all the requirements for python: +4. Install all the requirements for python: + ```bash pip install -r requirements.txt ``` -6. Run the bot: + +5. Run the bot: + ```bash -python3 main.py +python3 src/main.py ``` ## Current Functions -The list below describes the different "Cogs" of the bot, their associated commands, and any additional information required to set them up. - -<details> -<summary>Voicemaster</summary> - -### Voicemaster - #### !voice setparent <channel_id> -* Aliases: `setvmparent` -* Make the given ID a Voicemaster parent voice channel. - -#### !voice getparents -* Aliases: `setvmparent` -* Get all the Voicemaster parent voice channels in the server. - -#### !voice removeparent <channel_id> -* Aliases: `removevmparent` -* Remove the given ID as a Voicemaster parent voice channel. - -#### !voice removeallparents -* Remove all Voicemaster parents from the server. - -#### !voice removeallchildren -* Delete all the Voicemaster child channels in the server. - -#### !voice lock -* Aliases: `lockvm` -* Locks the Voicemaster child you're currently in to the number of current members. - -#### !voice unlock -* Aliases: `unlockvm` -* Unlocks the Voicemaster child you're currently in. - -#### !voice rename -* Aliases: `renamevm` -* Renames your current Voicemaster voice channel. -</details> - -<details> -<summary>Default Role</summary> - -### Default role - #### !setdefaultroles <role_mention | role_id> -* Sets the roles that the server gives to members when they join the server. - -#### !getdefaultroles -* Gets the current default roles set for the server. - -#### !removedefaultroles -* Removes the current default roles for the server. -</details> - -<details> -<summary>Log Channel</summary> - -### Log Channel - #### !setlogchannel <channel_mention | channel_id> -* Set the log channel to the #'ed channel or given role ID. - -#### !getlogchannel -* Gets the current log channel value. - -#### !removelogchannel -* Removes the current log channel value. -</details> - -<details> -<summary>Administrator Tools</summary> - -### Administrator Tools - Adds a few commands useful for admin operations. -#### !admin clear -* Aliases: `cls, purge, delete` -* Clear the specified number of messages from the current text channel. - -#### !admin version -* Get the current version of the bot. - -#### !admin members -* List the current number of members in the server. - -#### !dev remove-cog \<cog name> -* Unloads the given cog. -* *This command requires your user ID to be defined in the env file under `DEV_IDS`* - -#### !dev add-cog \<cog name> -* Loads the given cog. -* *This command requires your user ID to be defined in the env file under `DEV_IDS`* - -#### !dev reload-cog \<cog name> -* Reloads the given cog. -* *This command requires your user ID to be defined in the env file under `DEV_IDS`* - -#### !admin set-rep \<user mention> \<channel or category IDs> -* Sets the permissions for a user in the channels/categories given. -* *Requires `administrator` permission in Discord* - -#### !admin user-info \<user mention> -* Get basic information about a users profile. -* *Requires `administrator` permission in Discord* - -</details> - -<details> -<summary>Twitter Integration</summary> - -### Twitter Integration - -Enables forwarding tweets when they are tweeted to a discord channel for specific Twitter accounts. - -Requires the `ENABLE_TWITTER` variable to be set to `TRUE` in order to function. - -#### !twitter add \<twitter handle> - -* Add a Twitter handle to notify when they tweet or quote retweet. - -#### !twitter remove \<twitter handle> - -* Remove the given Twitter handle from notifications. - -#### !twitter hook [optional: channel mention] [optional: hook name] - -* Aliases: `addtwitterhook, create-hook` -* Creates a Discord Webhook bound to the channel the command was executed in, unless a channel is given, and with a default name unless a name is given. - -#### !twitter remove-hook \<hook name> - -* Aliases: `deltwitterhook, delete-hook` -* Deletes the Discord Webhook so that updates are no longer sent to that channel - -#### !twitter list - -* Aliases: `accounts, get-all`. -* Returns a list of the currently tracked Twitter accounts for the server. - -</details> - -<details> -<summary>Event Channel Management</summary> - -### Event Category Management - -Each server can have any number of named event categories, where each category creates a sign-in channel, a general chat, a voice chat and a role for the event. All commands in this cog required the `administrator` permission in Discord. - -#### !events create-event \<event name> \<role mention | role ID> - -* Creates the text channels, and voice channel for the event. The role given is used to later expose the sign-in channel to members. Upon creation the event is set to `closed`. -* See the `open-event` and `close-event` for more information regarding which members can see which channels. -* The role created for this event will have the same as the event name, it is not the role given in the command. - -#### !events open-event \<event name> - -* Allows the role given in the `create-event` command to see the sign-in channel, and add reactions to the sign-in message. -* The sign-in message grants the role created by the bot for the event. - -#### !events close-event \<event name> - -* Stops any member who is not an administrator from being able to see any of the event channels. - -#### !events delete-event \<event name> - -* Deletes all the channels in the category for the event and deletes the role created by the bot for the event. - -</details> +The list below describes the different "Cogs" of the bot, their associated commands, and any additional information required to set them up. <details> -<summary>Twitch Integration</summary> - -### Twitch Integration - -Enables sending notifications to a Discord channel whenever a tracked channel goes live. - -Requires the `ENABLE_TWITCH` variable to be set to `TRUE` in order to function. - -Set the `TEMP_BEARER_FILE` to anything you like, this will be the file where your bearer token is stored for reuse. - -### Creating your self-signed SSL keys: - -1. Create the Certificate Authority (CA) private key: -```console -$ openssl genrsa -des3 -out servercakey.pem -``` - -2. Create the CA public certificate: -```console -$ openssl req -new -x509 -key servercakey.pem -out root.crt -``` +<summary>AdminTools</summary> -3. Create the server's private key file: -```console -$ openssl genrsa -out server.key -``` - -4. Create the server's certificate request: -```console -$ openssl req -new -out reqout.txt -key server.key -``` - -5. Use the CA private key file to sign the server's certificate: -``` -$ openssl x509 -req -in reqout.txt -days 3650 -sha1 -CAcreateserial -CA root.crt -CAkey servercakey.pem -out server.crt -``` - -6. Move your `server.crt` and `server.key` files into the `src` folder. - -7. Set the environment variable `SSL_CERT_FILE` to the name of your `server.crt` file and the variable `SSL_KEY_FILE` to the name of your `server.key` file. - -### Getting your Twitch Credentials: - -1. Go to the [Twitch Developers](https://dev.twitch.tv/) site. -1. Once logged in, in the top left, go to `Your Console` or [this](https://dev.twitch.tv/console) site. -1. Register a new application using any name and the OAuth Redirect URL of `http://localhost`. -1. Once created, click `manage`. Copy the string that is in `Client ID` and then click the `New Secret` button to generate a new `Client Secret` and then copy the string it generates. - -In your `.env` file the `TWITCH_SUB_SECRET` should be a string that is 10-100 characters long and should not be shared anywhere. This is used to authenticate if a message has come from Twitch or if it has been altered along the way. - -The `TWITCH_CALLBACK` is the URL to your HTTPS server. For testing you can use `ngrok`: - -- Run `ngrok http 443` and copy the `https` URL **not** the `htttp` URL and use that as your `TWITCH_CALLBACK` variable. +## AdminTools -#### !twitch createhook \<channel mention> \<hook name> +AdminTools cog is used to manage basic Administrator/Moderation tools. +All commands in this cog require the user to have the administrator permission in a given guild/server. -* Creates a Discord Webhook bound to the channel given and with the name given, but prefixed with the Twitch Webhook prefix. +### Current Commands: -#### !twitch deletehook \<hook name> +#### /admin-member-count -* Deletes the given Discord Webhook. +- Get the current member count of the server. +- _Not yet implmented!_ -#### !twitch add \<channel name | channel url> \<hook name> [optional: custom message] +#### /admin-clear-messages [optional: message-count] -* Adds a Twitch channel to be tracked in the given Webhook. -* *__If a custom message is given, it must be surrounded by double quotes__*: `!twitch add <twitch_handle> "custom_message"` +- Delete a specific number of messages in the given channel. + Defaults to 5 messages, with a maximum of 100 messages. +- _Not yet implmented!_ -#### !twitch remove \<twitch handle> \<hook name> +#### /admin-get-version -* Removes a Twitch channel from being tracked in the current Discord server. - -#### !twitch list [optional: hook name] - -* Shows a list of all the currently tracked Twitch accounts and their custom messages. -* If a hook name is given, only shows the information for the given hook. - -#### !twitch webhooks -* Get a list of the current Discord Webhooks for Twitch notifications. - -#### !twitch setmessage \<twitch handle> \<hook name> [optional: custom message] - -* Sets the custom message of a Twitch channel. Can be left empty if the custom message is to be removed. -* *__If a custom message is given, it must be surrounded by double quotes__*: `!twitch setmessage <twitch_handle> "custom_message"` - -#### !twitch getmessage \<twitch handle> [optional: hook name] - -* Gets the currently set custom message for a Twitch channel. -* If a hook name is given, gets the currently set custom message for the Twitch channel in that Webhook. - -#### !twitch preview \<twitch handle> \<hook name> -* Get a preview of the live notification for the given Twitch channel in the given Webhook. +- Get the current version of the Bot. +- _Not yet implmented!_ </details> <details> -<summary>Role Reaction Menus</summary> - -### Role Reaction Menus. - -Role reaction menus allow admins to create reactable menus that when reacted to grant defined roles to the user. +<summary>VoiceAdmin</summary> -For devs: +## VoiceAdmin -* To enable this function in the bot use the `ENABLE_ROLEREACTIONS` env var and set it to `TRUE`. -* Making new types of reaction menus is easy - simply extend `DiscordReactableMenus.ReactableMenu` or one of the example menus in `DiscordReactableMenus.ExampleMenus`. +### Environment Variable: `ENABLE_VOICEADMIN` -#### !roles make-menu \<title> \<description> [\<mentioned role> \<emoji>] +VoiceAdmin cog is used to dynamically create and manage Voice Channels, by assigning specific channels to act as parent channels. +When users join parent Voice Channels, a new chil Voice Channel is created, and the user moved to it. +The user has control over the child Voice Channel name, and can limit how many/who can join. -* Creates a new role reaction menu with the given roles and their emojis. -* Each option must be a mentioned role followed by the emoji to use as its reaction. There can be up to 25 roles in a single reaction menu. -* The `title` is displayed at the top of the menu, and the `description` just below. To have either blank leave the quotes empty. -* If the `DELETE_ROLE_CREATION` env var is set to `TRUE` the command message will be deleted. -* *Requires `administrator` permission in Discord* -* An example usage of this command is as such: `!roles make-menu "{title}" "{description}" {@option1 role} {option1 emoji} ... ...` +### Current Commands: -#### !roles add-option [optional: menu id] [\<mentioned role> \<emoji>] +#### /vc-set-parent \<voice-channel\> -* Adds more role reaction options to the given menu. If there is no menu id given, the latest role reaction menu will be used. -* There can be one or many options added at the same time with this command. -* Each option must be a mentioned role followed by the emoji to use as its reaction. There can be up to 25 roles in a single reaction menu. -* *Requires `administrator` permission in Discord* -* An example usage of this command is as such: `!roles add-option {menu id} {@option role} {option emoji} ... ...` +- Set a Voice Channel to be a parent Voice Channel. +- _Not yet implmented!_ -#### !roles remove-option \<emoji> [optional: menu id] +#### /vc-remove-parent \<voice-channel\> -* Removes the role associated with the emoji from the given menu. If there is no menu id given, the latest role reaction menu will be used. -* *Requires `administrator` permission in Discord* +- Remove a Voice Channel from being a parent Voice Channel. +- _Not yet implmented!_ -#### !roles disable-menu [optional: menu id] +#### /vc-get-parents -* Disables a reaction menu. This means that roles will not be given to users when they react to the message. If there is no menu id given, the latest role reaction menu will be used. -* *Requires `administrator` permission in Discord* +- Get the list of current parent Voice Channels. +- _Not yet implmented!_ -#### !roles enable-menu [optional: menu id] +#### /vc-rename \<new-name\> -* Enables a reaction menu. This means that users will be able to receive roles from the reaction menu when they react. If there is no menu id given, the latest role reaction menu will be used. -* *Requires `administrator` permission in Discord* +- Rename your current Voice Channel. +- _Not yet implmented!_ -#### !roles delete-menu \<menu id> +#### /vc-lock -* Deletes the given role reaction menu. __Does not__ delete any of the roles in the menu, just the message. -* *Requires `administrator` permission in Discord* +- Only allow current members to (re)join your Voice Channel. +- _Not yet implmented!_ -#### !roles toggle-ids +#### /vc-unlock -* Shows or Hides all role reaction menu footers, which contain the ID of the role reaction menu for ease of identification. -* *Requires `administrator` permission in Discord* +- Allow anyone to join your Voice Channel again. +- _Not yet implmented!_ -</details> - -<details> -<summary>Poll Reaction Menus</summary> +#### /vc-limit -### Poll Reaction menus. +- Set the member count limit of your Voice Channel. +- _Not yet implmented!_ -Poll reaction menus allow users to create polls with up to 25 different options for other users, and themselves, to vote on. +#### /vc-unlimit -The poll start and end is not time based, but instead controlled by the user that created the poll or administrators. - -For devs: - -* To enable this function in the bot use the `ENABLE_VOTINGMENUS` env var and set it to `TRUE`. -* Making new types of reaction menus is easy - simply extend `DiscordReactableMenus.ReactableMenu` or one of the example menus in `DiscordReactableMenus.ExampleMenus`. - -#### !votes make-poll \<title> [\<emoji> \<description>] - -* Creates a new poll with each emoji having a description. -* Each option must be an emoji and a description, with each one on a new line. There can be up to 25 roles in a single reaction menu. -* If the `DELETE_VOTING_CREATION` env var is set to `TRUE` the command message will be deleted. -* An example usage of this command is as such: - ``` - !votes make-poll {title} - {option1 emoji} {option1 description} - {option2 emoji} {option2 description} - ... ... - [up to option 25] - ``` - -#### !votes add-option \<menu id> \<emoji> \<description> - -* Aliases: `add, aoption` -* Adds another option to the poll with the menu id given. -* Only one option can be added at a time with this command. -* Each option must be an emoji and a description, with each one on a new line. There can be up to 25 roles in a single reaction menu. -* *You must be the owner of the poll or be an administrator* -* An example usage of this command is as such: `!votes add-option {menu id} {option emoji} {option description}` - -#### !votes remove-option \<menu id> \<emoji> - -* Aliases: `remove, roption` -* Removes the option from the poll with the menu id given. -* *You must be the owner of the poll or be an administrator* - -#### !votes delete-poll \<menu id> - -* Aliases: `delete, del` -* Deletes the poll with the menu id given. -* *You must be the owner of the poll or be an administrator* - -#### !votes end-poll \<menu id> - -* Aliases: `finish, complete, end` -* Deletes the actual poll message and sends a new message with the results of the poll. -* *You must be the owner of the poll or be an administrator* - -#### !votes reset-poll \<menu id> - -* Aliases: `reset, clear, restart` -* Removes all the current user-added reactions from the poll with the menu id given. -* *You must be the owner of the poll or be an administrator* +- Remove the member count limit of your Voice Channel. +- _Not yet implmented!_ </details> <details> -<summary>Music Bot</summary> - -### Music Bot - -A basic music bot that functions similarly to the popular 'Hydra Bot'. - -Commands that use the prefix of `!music` are commands that must be sent in the defined music channel for the server. -The rest of the commands in this cog can be sent anywhere. -Most `!music` commands require you to be in the same voice channel as the bot, or if it is not in a channel, for you to be in a voice channel. -Some `!music` commands can have this requirement ignored if the user performing the command is an administrator and uses the `force` or `-f` flag in the command. - -To add new songs to the queue, just put the name, YouTube link, or a YouTube playlist into the music channel once set. -Also requires you to be in the voice channel with the bot, or if the bot is inactive, in any voice channel. - -To enable this cog, use the `ENABLE_MUSIC` env var in your `secrets.env` file, and set it to `TRUE`. -For this cog to work, the `GOOGLE_API` env var must also be set, and instructions on how to get an API credential is below: - -### To create your Google API credentials: - -1. Go to the [Google Cloud API]("https://console.cloud.google.com/apis/") site. -1. Create a new project and name it whatever you want. -1. In the [dashboard](https://console.cloud.google.com/apis/dashboard), click the `Enable APIs and Services` and search for `YouTube Data API v3`. -1. Click `Enable` to enable the use of the YouTube API. -1. Keep going back until at your [dashboard](https://console.cloud.google.com/apis/dashboard), and go to the [credentials](https://console.cloud.google.com/apis/credentials) section on the left. -1. Click on `Create Credentials` and then `API key`. -1. Copy the key given. For security, it is recommended that you "restrict key" and only enable `YouTube Data API v3`. - -#### !musicadmin set \<channel mention> [optional: [args]] - -* This sets the channel mentioned to be used as the music channel. All messages into this channel will be considered music requests, and any music commands must be sent in this channel. -* Optional args: - * Using `-c` will clear the entire channel before setting it up as the music channel. -* *Requires `administrator` permission in Discord* - -#### !musicadmin get -* Sends the currently set music channel for the server. -* *Requires `administrator` permission in Discord* - -#### !musicadmin reset -* This clears the current music channel and resets the preview and queue messages. -* *Requires `administrator` permission in Discord* - -#### !musicadmin remove +<summary>AutoRoles</summary> -* Unlinks the currently linked music channel from being the music channel. This will not delete the channel or its contents. -* *Requires `administrator` permission in Discord* +## AutoRoles -#### !musicadmin fix -* If the bot has broken and thinks it is still in a Voice Channel, use this command to force it to reset. -* *Requires `administrator` permission in Discord* +### Environment Variable: `ENABLE_AUTOROLES` -#### !music queue - -* Aliases: `songqueue, songs, songlist, songslist` -* Gets the current list of songs in the queue. - -#### !music join [optional: -f | force] - -* Aliases: `connect` -* Make the bot join the channel. -* If you are an admin you can force it join your voice channel using the `-f` or `force` option. - -#### !music kick [optional: -f | force] - -* Aliases: `leave` -* Kicks the bot from the channel. -* If you are an admin you can force it to leave a voice channel with the `-f` or `force` option. - -#### !music play [optional: song request] - -* Aliases: `resume` -* Resumes playback of the current song. -* If a song is requested and there is no current song, it is played, otherwise it is added to the queue. - -#### !music pause - -* Pauses the current song. - -#### !music shuffle - -* Shuffles the current queue of songs. - -#### !music volume \<volume level> - -* Sets the volume of the bot for everyone to the level given. - -#### !music clear - -* Clears the queue entirely, does not stop the current song from playing. - -#### !music skip [optional: skip to position] - -* Skips the current song. -* If a number is given it will also skip to the song at the position given. -* For example, if 'songs to skip' is 4, the next song to play would be song 4 in the queue. - -#### !music remove \<song position> - -* Removes the song at the given position from the queue. - -#### !music move \<from position> \<to position> - -* Moves the song at position `from position` to position `to position` in the queue. +### _Not yet implemented!_ </details> <details> -<summary>Pingable Roles</summary> - -### Pingable Roles - -Pingable roles are roles that can be voted in to be created by any user, and that once created have a cooldown tied to how often that role can be pinged. - -A user can create a poll where if there are enough votes by the time the poll ends, a role will be created. The length of the poll and the number of votes required are customisable by server admins. - -After the poll finishes, a reaction menu gets created, allowing *any* user to react and receive the role. Initially the role will have the default cooldown of the server, but can be overridden. - -#### !pingme settings get-settings - -* Returns an embed of the current default settings for the server. -* *Requires `administrator` permission in Discord* - -#### !pingme settings default-settings - -* Resets all settings for this guild to the bot-defined defaults defined in the `.env` file. -* *Requires `administrator` permission in Discord* - -#### !pingme settings poll-length \<poll length in seconds> - -* Sets the default poll length to the given time in seconds. -* Polls can have a custom length by specifying it when using the [`!pingme create-role`](#pingme-create-role-role-name-optional-poll-length-in-seconds) command. -* *Requires `administrator` permission in Discord* - -#### !pingme settings poll-threshold \<number of votes threshold> - -* Sets the number of votes required in a poll for the role to be created. -* *Requires `administrator` permission in Discord* - -#### !pingme settings ping-cooldown \<cooldown in seconds> - -* Sets the default ping cooldown for any pingable role created with this cog. -* Roles can have their cooldown altered individually with the [`!pingme role-cooldown`](#pingme-role-cooldown-role-mention--role-id-cooldown-in-seconds) command. -* *Requires `administrator` permission in Discord* - -#### !pingme settings poll-emoji \<emoji> - -* Sets the emoji to be used when creating a poll to vote in. -* *Requires `administrator` permission in Discord* - -#### !pingme settings role-emoji \<emoji> - -* Sets the default emoji to be used in the role reaction menu for the pingable role once it has been created. -* Roles can have their reactable emoji altered individually with the [`!pingme role-emoji`](#pingme-role-emoji-role-mention--role-id-emoji) command. -* *Requires `administrator` permission in Discord* - -#### !pingme disable-role \<one or many role mentions> - -* Disables the roles mentioned from being mentioned by non-administrators and disables their reaction menus. -* The roles provided __must__ be pingable roles created with this cog. -* *Requires `administrator` permission in Discord* - -#### !pingme enable-role \<one or many role mentions> - -* Enabled the roles mentioned to be mentioned by non-administrators and allows their reaction menus to be reacted to. -* The roles provided __must__ be pingable roles created with this cog. -* *Requires `administrator` permission in Discord* - -#### !pingme create-role \<role name> [optional: poll length in seconds] - -* Creates a new poll to create a role if the number of votes has surpassed the server's threshold after the poll length has passed. - -#### !pingme delete-role \<one or many role mentions> - -* Deletes the mentioned roles from the server. -* The roles provided __must__ be pingable roles created with this cog. -* *Requires `administrator` permission in Discord* - -#### !pingme convert-role \<one or many role mentions> - -* Converts the mentioned roles into pingable roles and creates their reaction menus. -* The roles provided __cannot__ be roles that are already pingable roles. -* *Requires `administrator` permission in Discord* - -#### !pingme convert-pingable \<one or many role mentions> - -* Converts the mentioned roles from pingable roles into normal roles and deletes their reaction menus. -* The roles provided __must__ be pingable roles created with this cog. -* *Requires `administrator` permission in Discord* - -#### !pingme role-cooldown \<role mention | role ID> \<cooldown in seconds> +<summary>EventTools</summary> -* Sets the ping cooldown for a specific role which overrides the server default for that role. -* The role provided __must__ be a pingable role created with this cog. -* *Requires `administrator` permission in Discord* +## EventTools -#### !pingme role-emoji \<role mention | role ID> \<emoji> +### Environment Variable: `ENABLE_EVENTTOOLS` -* Sets the emoji to use in the reaction menu for the given role. -* The role provided __must__ be a pingable role created with this cog. -* *Requires `administrator` permission in Discord* +### _Not yet implemented!_ </details> From c3e68b336cd9d660c206f0d84c686e6367a52a6f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 05:30:25 +0100 Subject: [PATCH 030/404] Added TODO and reorganised README --- README.md | 88 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 4190afbf..e407ce58 100644 --- a/README.md +++ b/README.md @@ -17,46 +17,7 @@ Dependency Versions: This Discord bot was written to merge all the functions of different bots used in the Fragsoc Discord server into one bot that is maintained by Fragsoc members. -## How to set up an instance of this bot without Docker - -Requirements needed to run: - -- Python 3.8 -- Pip -- [A postgres 11 database](https://www.postgresql.org/docs/current/admin.html) - -1. Clone this repository: - -```console -$ git clone https://github.com/FragSoc/esports-bot.git -``` - -2. Change into the repo directory: - -```console -$ cd esports-bot -``` - -3. Rename the `secrets.template` to `secrets.env` and set all the variables. - -```console -$ nano secrets.env -$ source secrets.env -``` - -4. Install all the requirements for python: - -```bash -pip install -r requirements.txt -``` - -5. Run the bot: - -```bash -python3 src/main.py -``` - -## Current Functions +# Current Functions The list below describes the different "Cogs" of the bot, their associated commands, and any additional information required to set them up. @@ -164,3 +125,50 @@ The user has control over the child Voice Channel name, and can limit how many/w ### _Not yet implemented!_ </details> + +# TODO: + +- Implement unimplemented commands in VoiceAdmin and AdminTools cogs. +- Implement EventTools cog. +- Implement AutoRoles cog. +- Add back functionality of previous bot (eg. Music, PingableRoles, etc.) +- Add game deal tracker (DealTracker(?) cog) + +# Quick Setup Guide + +Requirements needed to run: + +- Python 3.8 +- Pip +- [A postgres 11 database](https://www.postgresql.org/docs/current/admin.html) + +1. Clone this repository: + +```console +$ git clone https://github.com/FragSoc/esports-bot.git +``` + +2. Change into the repo directory: + +```console +$ cd esports-bot +``` + +3. Rename the `secrets.template` to `secrets.env` and set all the variables. + +```console +$ nano secrets.env +$ source secrets.env +``` + +4. Install all the requirements for python: + +```bash +pip install -r requirements.txt +``` + +5. Run the bot: + +```bash +python3 src/main.py +``` From 78d5f5aeb37acadd8cf380380a6dce680187dd10 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 16:30:12 +0000 Subject: [PATCH 031/404] implemented admin-member-count command --- README.md | 1 - src/extensions/AdminTools.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e407ce58..ab8276bf 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,6 @@ All commands in this cog require the user to have the administrator permission i #### /admin-member-count - Get the current member count of the server. -- _Not yet implmented!_ #### /admin-clear-messages [optional: message-count] diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index b63b76d6..415a8ec0 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -38,7 +38,9 @@ async def get_member_count(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - pass + member_count = interaction.guild.member_count + await interaction.response.send_message(COG_STRINGS["admin_members_format"].format(count=member_count)) + return True @command(name=COG_STRINGS["admin_version_name"], description=COG_STRINGS["admin_version_description"]) @default_permissions(administrator=True) From 6adbade071f10b5f0a125cdb6d9075ab3e8ad726 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 16:32:11 +0000 Subject: [PATCH 032/404] implemented admin-get-version command --- README.md | 1 - src/extensions/AdminTools.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ab8276bf..d9fe7e47 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ All commands in this cog require the user to have the administrator permission i #### /admin-get-version - Get the current version of the Bot. -- _Not yet implmented!_ </details> diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index 415a8ec0..d46d2632 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -52,7 +52,8 @@ async def get_bot_version(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - pass + await interaction.response.send_message(self.version_string) + return True @command(name=COG_STRINGS["admin_clear_name"], description=COG_STRINGS["admin_clear_description"]) @describe(count=COG_STRINGS["admin_clear_param_describe"]) From 8a216cc7e9ec733487ad7b01f5fa550b2e04e98b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 16:34:36 +0000 Subject: [PATCH 033/404] implemented admin-clear-messages command --- README.md | 1 - src/extensions/AdminTools.py | 8 +++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d9fe7e47..bf91246a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ All commands in this cog require the user to have the administrator permission i - Delete a specific number of messages in the given channel. Defaults to 5 messages, with a maximum of 100 messages. -- _Not yet implmented!_ #### /admin-get-version diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index d46d2632..97478db3 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -69,7 +69,13 @@ async def clear_messages(self, interaction: Interaction, count: int = 5): interaction (Interaction): The interaction that triggered the command. count (int, optional): The number of messages to delete. Defaults to 5. Maximum 100. """ - pass + if count > 100: + await interaction.response.send_message(COG_STRINGS["admin_clear_warn_too_many"]) + return False + + await interaction.response.defer() + await interaction.channel.purge(limit=count) + return True async def setup(bot: Bot): From 6eb3d084393b81f58e880ef6f24b6a43a343d107 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 16:48:02 +0000 Subject: [PATCH 034/404] Partial voice_state_update implementation --- src/extensions/VoiceAdmin.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 0bd1e72b..5de9ce0a 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -8,6 +8,18 @@ COG_STRINGS = load_cog_toml(__name__) +def channel_is_child(channel: VoiceChannel): + return False + + +def channel_is_parent(channel: VoiceChannel): + return False + + +def member_is_owner(member: Member, channel: VoiceChannel, db_entry=None): + return False + + class VoiceAdmin(Cog): def __init__(self, bot: Bot): @@ -46,10 +58,26 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: return if before.channel: - pass + if not channel_is_child(before.channel): + return + + if not before.channel.members: + await before.channel.delete() + if not channel_is_parent(after.channel): + return + + if member_is_owner(member, before.channel): + new_owner = before.channel.members[0] + await before.channel.edit(name=f"{new_owner.display_name}'s VC") if after.channel: - pass + if not channel_is_parent(after.channel): + return + + new_child_channel: VoiceChannel = await after.channel.category.create_voice_channel( + name=f"{member.display_name}'s VC" + ) + await member.move_to(new_child_channel) @command(name=COG_STRINGS["vc_set_parent_name"], description=COG_STRINGS["vc_set_parent_description"]) @describe(channel=COG_STRINGS["vc_set_parent_param_describe"]) From e90f52c307f39e79adbb5016ef806845b9e6a965 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 16:53:58 +0000 Subject: [PATCH 035/404] Added VoiceAdmin Parent and Child models --- src/database/models.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/database/models.py b/src/database/models.py index 4efe964d..87ede262 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -4,4 +4,21 @@ base = declarative_base() -__all__ = ["base"] +__all__ = ["base", "VoiceAdminParent", "VoiceAdminChild"] + + +class VoiceAdminParent(base): + __tablename__ = "voiceadmin_parents" + primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + guild_id = Column(BigInteger, nullable=False) + channel_id = Column(BigInteger, nullable=False) + + +class VoiceAdminChild(base): + __tablename__ = "voiceadmin_children" + primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + guild_id = Column(BigInteger, nullable=False) + channel_id = Column(BigInteger, nullable=False) + is_locked = Column(Boolean, nullable=False) + is_limited = Column(Boolean, nullable=False) + has_custom_name = Column(Boolean, nullable=False) From 413a1de4bfe7dba614772161acc90943141a5fa3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 16:59:04 +0000 Subject: [PATCH 036/404] Added owner_id field to VoiceAdminChild table --- src/database/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/database/models.py b/src/database/models.py index 87ede262..c89a3ae2 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -19,6 +19,7 @@ class VoiceAdminChild(base): primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) channel_id = Column(BigInteger, nullable=False) + owner_id = Column(BigInteger, nullable=False) is_locked = Column(Boolean, nullable=False) is_limited = Column(Boolean, nullable=False) has_custom_name = Column(Boolean, nullable=False) From f7ccfb1cebec384245dbbceae42365e0ff7cd993 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 21:51:16 +0000 Subject: [PATCH 037/404] Implemented DB storage to voice_state_update --- src/extensions/VoiceAdmin.py | 41 +++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 5de9ce0a..1e350701 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -4,20 +4,32 @@ import logging from common.io import load_cog_toml +from database.models import VoiceAdminParent, VoiceAdminChild +from database.gateway import DBSession COG_STRINGS = load_cog_toml(__name__) def channel_is_child(channel: VoiceChannel): - return False + db_result = DBSession.get(VoiceAdminChild, guild_id=channel.guild.id, channel_id=channel.id) + return not not db_result def channel_is_parent(channel: VoiceChannel): - return False + db_result = DBSession.get(VoiceAdminParent, guild_id=channel.guild.id, channel_id=channel.id) + return not not db_result -def member_is_owner(member: Member, channel: VoiceChannel, db_entry=None): - return False +def member_is_owner(member: Member, channel: VoiceChannel, db_entry: VoiceAdminChild = None): + if db_entry is None: + db_entry: VoiceAdminChild = DBSession.get(VoiceAdminChild, guild_id=channel.guild.id, channel_id=channel.id) + if db_entry is None: + return False + return db_entry.owner_id == member.id + + +def primary_key_from_channel(channel: VoiceChannel): + return int(f"{channel.guild.id % 100}{channel.id % 100}") class VoiceAdmin(Cog): @@ -61,13 +73,22 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: if not channel_is_child(before.channel): return + db_entry: VoiceAdminChild = DBSession.get( + VoiceAdminChild, + guild_id=before.channel.guild.id, + channel_id=before.channel.id + ) + if not before.channel.members: await before.channel.delete() + DBSession.delete(db_entry) if not channel_is_parent(after.channel): return - if member_is_owner(member, before.channel): + if member_is_owner(member, before.channel, db_entry): new_owner = before.channel.members[0] + db_entry.owner_id = new_owner.id + DBSession.update(db_entry) await before.channel.edit(name=f"{new_owner.display_name}'s VC") if after.channel: @@ -77,6 +98,16 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: new_child_channel: VoiceChannel = await after.channel.category.create_voice_channel( name=f"{member.display_name}'s VC" ) + db_entry: VoiceAdminChild = VoiceAdminChild( + primary_key=primary_key_from_channel(new_child_channel), + guild_id=new_child_channel.guild.id, + channel_id=new_child_channel.id, + owner_id=member.id, + is_locked=False, + is_limited=False, + has_custom_name=False + ) + DBSession.create(db_entry) await member.move_to(new_child_channel) @command(name=COG_STRINGS["vc_set_parent_name"], description=COG_STRINGS["vc_set_parent_description"]) From 8c281bdfdbbe0712199957912343fa7236d39e35 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 22 Dec 2022 21:58:28 +0000 Subject: [PATCH 038/404] Added checks for manage_channels permission --- src/extensions/VoiceAdmin.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 1e350701..70f58d34 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -73,6 +73,14 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: if not channel_is_child(before.channel): return + if not before.channel.category.permissions_for(before.channel.guild.me).manage_channels: + self.logger.error( + f"Missing permission `manage_channels` for category {before.channel.category.name} " + f"(channelid - {before.channel.category.id}) in guild {before.channel.guild.name} " + f"(guildid - {before.channel.guild.id})" + ) + return + db_entry: VoiceAdminChild = DBSession.get( VoiceAdminChild, guild_id=before.channel.guild.id, @@ -95,6 +103,14 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: if not channel_is_parent(after.channel): return + if not after.channel.category.permissions_for(after.channel.guild.me).manage_channels: + self.logger.error( + f"Missing permission `manage_channels` for category {after.channel.category.name} " + f"(channelid - {after.channel.category.id}) in guild {after.channel.guild.name} " + f"(guildid - {after.channel.guild.id})" + ) + return + new_child_channel: VoiceChannel = await after.channel.category.create_voice_channel( name=f"{member.display_name}'s VC" ) From 3b766edd300aa41404efef9b2d415308806e0ecf Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 23 Dec 2022 13:54:46 +0000 Subject: [PATCH 039/404] Implemented vc-set-parent command --- README.md | 1 - src/extensions/VoiceAdmin.py | 21 ++++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bf91246a..34a4e25a 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,6 @@ The user has control over the child Voice Channel name, and can limit how many/w #### /vc-set-parent \<voice-channel\> - Set a Voice Channel to be a parent Voice Channel. -- _Not yet implmented!_ #### /vc-remove-parent \<voice-channel\> diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 70f58d34..dc9a07e8 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -141,7 +141,26 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann interaction (Interaction): The interaction that triggered the command. channel (VoiceChannel): The Voice Channel to set as a parent Voice Channel. """ - pass + if channel_is_parent(channel): + await interaction.response.send_message(COG_STRINGS["vc_set_parent_warn_already_parent"]) + return False + + if channel_is_child(channel): + await interaction.response.send_message(COG_STRINGS["vc_set_parent_warn_already_child"]) + return False + + db_entry: VoiceAdminParent = VoiceAdminParent( + primary_key=primary_key_from_channel(channel), + guild_id=interaction.guild.id, + channel_id=channel.id + ) + DBSession.create(db_entry) + self.logger.info( + f"Successfully added {channel.name} (guildid - {channel.guild.id} | channelid - {channel.id}) " + f"to Parent Voice Channel DB Table!" + ) + await interaction.response.send_message(COG_STRINGS["vc_set_parent_success"].format(channel=channel)) + return True @command(name=COG_STRINGS["vc_remove_parent_name"], description=COG_STRINGS["vc_remove_parent_description"]) @describe(channel=COG_STRINGS["vc_remove_parent_param_describe"]) From db364a7d950bfad1743c44f1ed2a68d39dd1b466 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 23 Dec 2022 13:59:25 +0000 Subject: [PATCH 040/404] Added log events for child channel creation/deletion --- src/extensions/VoiceAdmin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index dc9a07e8..e7ede1ad 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -97,6 +97,10 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: new_owner = before.channel.members[0] db_entry.owner_id = new_owner.id DBSession.update(db_entry) + self.logger.info( + f"Deleted child Voice Channel - " + f"{before.channel.name} (guildid - {before.channel.guild.id} | channelid - {before.channel.id}" + ) await before.channel.edit(name=f"{new_owner.display_name}'s VC") if after.channel: @@ -124,6 +128,10 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: has_custom_name=False ) DBSession.create(db_entry) + self.logger.info( + f"Created new child Voice Channel - " + f"{new_child_channel.name} (guildid - {new_child_channel.guild.id} | channelid - {new_child_channel.id})" + ) await member.move_to(new_child_channel) @command(name=COG_STRINGS["vc_set_parent_name"], description=COG_STRINGS["vc_set_parent_description"]) From 137f6af75a471faff624ed4eae01e25f38ceabbc Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 23 Dec 2022 14:04:03 +0000 Subject: [PATCH 041/404] Implemented vc-remove-parent command --- README.md | 1 - src/extensions/VoiceAdmin.py | 9 ++++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 34a4e25a..072272c1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ The user has control over the child Voice Channel name, and can limit how many/w #### /vc-remove-parent \<voice-channel\> - Remove a Voice Channel from being a parent Voice Channel. -- _Not yet implmented!_ #### /vc-get-parents diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index e7ede1ad..75635e23 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -185,7 +185,14 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh interaction (Interaction): The interaction that triggered the command. channel (VoiceChannel): The Voice Channel to stop behaving as a parent Voice Channel. """ - pass + if not channel_is_parent(channel): + await interaction.response.send_message(COG_STRINGS["vc_remove_parent_warn_not_parent"]) + return False + + db_entry = DBSession.get(VoiceAdminParent, guild_id=channel.guild.id, channel_id=channel.id) + DBSession.delete(db_entry) + await interaction.response.send_message(COG_STRINGS["vc_remove_parent_success"].format(channel=channel.name)) + return True @command(name=COG_STRINGS["vc_get_parents_name"], description=COG_STRINGS["vc_get_parents_description"]) @guild_only() From e2e424c31bf922b4b25761ab8ab6d03c2d41797d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 23 Dec 2022 14:18:49 +0000 Subject: [PATCH 042/404] Added checks for NoneType of channel --- src/extensions/VoiceAdmin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 75635e23..19168d7e 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -11,16 +11,23 @@ def channel_is_child(channel: VoiceChannel): + if not channel: + return False db_result = DBSession.get(VoiceAdminChild, guild_id=channel.guild.id, channel_id=channel.id) return not not db_result def channel_is_parent(channel: VoiceChannel): + if not channel: + return False db_result = DBSession.get(VoiceAdminParent, guild_id=channel.guild.id, channel_id=channel.id) return not not db_result def member_is_owner(member: Member, channel: VoiceChannel, db_entry: VoiceAdminChild = None): + if not channel: + return False + if db_entry is None: db_entry: VoiceAdminChild = DBSession.get(VoiceAdminChild, guild_id=channel.guild.id, channel_id=channel.id) if db_entry is None: From a16d71a0b6d88b6e9bd08c577b296abc33fb3396 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 23 Dec 2022 17:58:57 +0000 Subject: [PATCH 043/404] Implemented vc-get-parents command --- src/extensions/VoiceAdmin.py | 13 ++++++++++++- src/locale/VoiceAdmin.toml | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 19168d7e..d6c57057 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -209,7 +209,18 @@ async def get_parent_channels(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - pass + db_items = DBSession.list(VoiceAdminParent) + + fetched_channels = [await interaction.guild.fetch_channel(x.channel_id) for x in db_items] + + if len(fetched_channels) == 0: + await interaction.response.send_message(COG_STRINGS["vc_get_parents_empty"]) + return False + + response_string = "\n".join([f"- {x.name}" for x in fetched_channels]) + + await interaction.response.send_message(COG_STRINGS["vc_get_parents_format"].format(channels=response_string)) + return True @command( name=COG_STRINGS["vc_rename_name"], diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml index 219059ac..0a8ac62f 100644 --- a/src/locale/VoiceAdmin.toml +++ b/src/locale/VoiceAdmin.toml @@ -21,6 +21,7 @@ vc_remove_parent_warn_already_child = "The Voice Channel selected cannot be a ch vc_get_parents_name = "vc-get-parents" vc_get_parents_description = "Get the list of current parent Voice Channels." +vc_get_parents_empty = "There are currently no parent Voice channels in this server ⚠️" vc_get_parents_format = "Current parent Voice Channels in this server: \n {channels}" vc_rename_name = "vc-rename" From 6e034b1ff807da7deb266d403b81e0cad4ad00e8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 23 Dec 2022 18:16:55 +0000 Subject: [PATCH 044/404] Implemented vc-rename command --- src/extensions/VoiceAdmin.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index d6c57057..9163e11b 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -240,7 +240,38 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): new_name (str, optional): The new name to set the Voice Channel to. Defaults to the default child Voice Channel string. """ - pass + voice_state = interaction.user.voice + + if voice_state is None: + await interaction.response.send_message(COG_STRINGS["vc_rename_warn_no_voice"]) + return False + + voice_channel = voice_state.channel + db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) + + if not member_is_owner(interaction.user, voice_channel, db_entry): + await interaction.response.send_message(COG_STRINGS["vc_rename_warn_not_owner"]) + return False + + name_set = new_name if new_name else f"{interaction.user.display_name}'s VC" + + if not new_name: + if db_entry.has_custom_name: + await voice_channel.edit(name=f"{interaction.user.display_name}'s VC") + db_entry.has_custom_name = False + DBSession.update(db_entry) + else: + await voice_channel.edit(name=new_name) + if not db_entry.has_custom_name: + db_entry.has_custom_name = True + DBSession.update(db_entry) + + self.logger.info( + f"Updated child Voice Channel of {interaction.user.display_name} " + f"(guildid - {interaction.guild.id} | channelid - {voice_channel.id}) to {name_set}" + ) + await interaction.response.send_message(COG_STRINGS["vc_rename_success"].format(name=name_set)) + return True @command( name=COG_STRINGS["vc_lock_name"], From e7b831cd371d4337cea2c623bdf856547a77c6b7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 21:28:21 +0000 Subject: [PATCH 045/404] Updated README to reflect implemented commands --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 072272c1..804e7cde 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,10 @@ The user has control over the child Voice Channel name, and can limit how many/w #### /vc-get-parents - Get the list of current parent Voice Channels. -- _Not yet implmented!_ #### /vc-rename \<new-name\> -- Rename your current Voice Channel. -- _Not yet implmented!_ +- Rename your current Voice Channel #### /vc-lock From e42f8dde1627df7c6b8a52c97d31ac6066ce8658 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 21:28:46 +0000 Subject: [PATCH 046/404] Implementeded vc-lock and vc-unlock commands --- README.md | 2 - src/extensions/VoiceAdmin.py | 86 +++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 804e7cde..9a83942e 100644 --- a/README.md +++ b/README.md @@ -78,12 +78,10 @@ The user has control over the child Voice Channel name, and can limit how many/w #### /vc-lock - Only allow current members to (re)join your Voice Channel. -- _Not yet implmented!_ #### /vc-unlock - Allow anyone to join your Voice Channel again. -- _Not yet implmented!_ #### /vc-limit diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 9163e11b..a59fb1e5 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -1,3 +1,4 @@ +from discord.errors import Forbidden from discord import Interaction, Member, VoiceChannel, VoiceState from discord.ext.commands import Bot, Cog from discord.app_commands import command, describe, rename, default_permissions, checks, guild_only @@ -288,7 +289,64 @@ async def lock_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - pass + voice_state = interaction.user.voice + + if voice_state is None: + await interaction.response.send_message(COG_STRINGS["vc_lock_warn_no_voice"]) + return False + + voice_channel = voice_state.channel + db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) + + if not member_is_owner(interaction.user, voice_channel, db_entry): + await interaction.response.send_message(COG_STRINGS["vc_lock_warn_not_owner"]) + return False + + current_perms = voice_channel.overwrites + + try: + await voice_channel.set_permissions( + voice_channel.guild.me.top_role, + connect=True, + view_channel=True, + manage_channels=True, + manage_permissions=True + ) + except Forbidden: + self.logger.error( + f"Unable to change permissions for {voice_channel.guild.me.top_role.name} Role for child Voice channel " + f"(guildid - {voice_channel.guild.id} | channelid - {voice_channel.id}, " + f"as it is the bot's top role and it is not an admin in {voice_channel.guild.name} guild" + ) + + for group, permission in current_perms.items(): + permission.connect = False + permission.speak = False + try: + await voice_channel.set_permissions(group, overwrite=permission) + except Forbidden: + self.logger.error( + f"Unable to change permissions for {voice_channel.guild.me.top_role.name} Role for child Voice channel " + f"(guildid - {voice_channel.guild.id} | channelid - {voice_channel.id}" + ) + + members = voice_channel.members + for member in members: + try: + await voice_channel.set_permissions(member, connect=True, speak=True, view_channel=True) + except Forbidden: + self.logger.error( + f"Unable to change permissions for {member.display_name} member for child Voice channel " + f"(guildid - {voice_channel.guild.id} | channelid - {voice_channel.id}" + ) + + if not db_entry.is_locked: + db_entry.is_locked = True + DBSession.update(db_entry) + + await interaction.response.send_message(COG_STRINGS["vc_lock_success"]) + + return True @command( name=COG_STRINGS["vc_unlock_name"], @@ -303,7 +361,31 @@ async def unlock_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - pass + voice_state = interaction.user.voice + + if voice_state is None: + await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_no_voice"]) + return False + + voice_channel = voice_state.channel + db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) + + if not member_is_owner(interaction.user, voice_channel, db_entry): + await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_not_owner"]) + return False + + if not db_entry.is_locked: + if not voice_channel.permissions_synced: + await voice_channel.edit(sync_permissions=True) + await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_not_locked"]) + return False + + db_entry.is_locked = False + DBSession.update(db_entry) + await voice_channel.edit(sync_permissions=True) + + await interaction.response.send_message(COG_STRINGS["vc_unlock_success"]) + return True @command( name=COG_STRINGS["vc_limit_name"], From 1c33744445d8a6ba42a77e3f4e1aff81ca4b0ff2 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 21:42:55 +0000 Subject: [PATCH 047/404] Fixed incorrect key for vc-unlimit string --- src/locale/VoiceAdmin.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml index 0a8ac62f..0f1eae7a 100644 --- a/src/locale/VoiceAdmin.toml +++ b/src/locale/VoiceAdmin.toml @@ -52,10 +52,11 @@ vc_limit_param_rename = "member-limit" vc_limit_success = "Your Voice Channel is now limited to `{count}` member(s) ✅" vc_limit_warn_no_voice = "You cannot limit a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" vc_limit_warn_not_owner = "You canont limit your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" +vc_limit_warn_too_many = "A Voice Channel has a maximum limit of 99 members, please provide a value less-than or equal to 99 ⚠️" vc_unlimit_name = "vc-unlimit" vc_unlimit_description = "Remove the member count limit of your VC." -vc_unlimit_succss = "The member count limit has been removed from your Voice Channel ✅" +vc_unlimit_success = "The member count limit has been removed from your Voice Channel ✅" vc_unlimit_warn_no_voice = "You cannot remove the limit from a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" vc_unlimit_warn_not_owner = "You canont remove limit the limit from your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" vc_unlimit_warn_not_limited = "You cannot remove the user limit of your Voice Channel as it is not currently limited ⚠️" \ No newline at end of file From ebcf12a3f295ea4330533e5498d520e3f47acdfc Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 21:50:00 +0000 Subject: [PATCH 048/404] Implemented vc-limit and vc-unlimt commands --- README.md | 2 -- src/extensions/VoiceAdmin.py | 51 ++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9a83942e..8b89645e 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,10 @@ The user has control over the child Voice Channel name, and can limit how many/w #### /vc-limit - Set the member count limit of your Voice Channel. -- _Not yet implmented!_ #### /vc-unlimit - Remove the member count limit of your Voice Channel. -- _Not yet implmented!_ </details> diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index a59fb1e5..c17efeee 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -405,7 +405,32 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): user_limit (int, optional): The number of members to limit the child Voice Channel to. Defaults to the number of members in the child Voice Channel. """ - pass + voice_state = interaction.user.voice + + if not voice_state: + await interaction.response.send_message(COG_STRINGS["vc_limit_warn_no_voice"]) + return False + + voice_channel = voice_state.channel + db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) + + if not member_is_owner(interaction.user, voice_channel, db_entry): + await interaction.response.send_message(COG_STRINGS["vc_limit_warn_not_owner"]) + return False + + if user_limit <= 0: + user_limit = len(voice_channel.members) + elif user_limit > 99: + await interaction.response.send_message(COG_STRINGS["vc_limit_warn_too_many"]) + return False + + await voice_channel.edit(user_limit=user_limit) + if not db_entry.is_limited: + db_entry.is_limited = True + DBSession.update(db_entry) + + await interaction.response.send_message(COG_STRINGS["vc_limit_success"].format(count=user_limit)) + return True @command( name=COG_STRINGS["vc_unlimit_name"], @@ -420,7 +445,29 @@ async def unlimit_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - pass + voice_state = interaction.user.voice + + if not voice_state: + await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_no_voice"]) + return False + + voice_channel = voice_state.channel + db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) + + if not member_is_owner(interaction.user, voice_channel, db_entry): + await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_not_owner"]) + return False + + if not db_entry.is_limited: + await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_not_limited"]) + return False + + db_entry.is_limited = False + DBSession.update(db_entry) + await voice_channel.edit(user_limit=None) + + await interaction.response.send_message(COG_STRINGS["vc_unlimit_success"]) + return True async def setup(bot: Bot): From 1458438fa5e050c4a4451d643e8d116aa9a2b60f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 22:03:56 +0000 Subject: [PATCH 049/404] Made all warning messages ephermeral --- src/extensions/AdminTools.py | 2 +- src/extensions/VoiceAdmin.py | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index 97478db3..facf0ad0 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -70,7 +70,7 @@ async def clear_messages(self, interaction: Interaction, count: int = 5): count (int, optional): The number of messages to delete. Defaults to 5. Maximum 100. """ if count > 100: - await interaction.response.send_message(COG_STRINGS["admin_clear_warn_too_many"]) + await interaction.response.send_message(COG_STRINGS["admin_clear_warn_too_many"], ephemeral=True) return False await interaction.response.defer() diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index c17efeee..8a448dc8 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -158,11 +158,11 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann channel (VoiceChannel): The Voice Channel to set as a parent Voice Channel. """ if channel_is_parent(channel): - await interaction.response.send_message(COG_STRINGS["vc_set_parent_warn_already_parent"]) + await interaction.response.send_message(COG_STRINGS["vc_set_parent_warn_already_parent"], ephemeral=True) return False if channel_is_child(channel): - await interaction.response.send_message(COG_STRINGS["vc_set_parent_warn_already_child"]) + await interaction.response.send_message(COG_STRINGS["vc_set_parent_warn_already_child"], ephemeral=True) return False db_entry: VoiceAdminParent = VoiceAdminParent( @@ -194,7 +194,7 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh channel (VoiceChannel): The Voice Channel to stop behaving as a parent Voice Channel. """ if not channel_is_parent(channel): - await interaction.response.send_message(COG_STRINGS["vc_remove_parent_warn_not_parent"]) + await interaction.response.send_message(COG_STRINGS["vc_remove_parent_warn_not_parent"], ephemeral=True) return False db_entry = DBSession.get(VoiceAdminParent, guild_id=channel.guild.id, channel_id=channel.id) @@ -244,14 +244,14 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): voice_state = interaction.user.voice if voice_state is None: - await interaction.response.send_message(COG_STRINGS["vc_rename_warn_no_voice"]) + await interaction.response.send_message(COG_STRINGS["vc_rename_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_rename_warn_not_owner"]) + await interaction.response.send_message(COG_STRINGS["vc_rename_warn_not_owner"], ephemeral=True) return False name_set = new_name if new_name else f"{interaction.user.display_name}'s VC" @@ -292,14 +292,14 @@ async def lock_channel(self, interaction: Interaction): voice_state = interaction.user.voice if voice_state is None: - await interaction.response.send_message(COG_STRINGS["vc_lock_warn_no_voice"]) + await interaction.response.send_message(COG_STRINGS["vc_lock_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_lock_warn_not_owner"]) + await interaction.response.send_message(COG_STRINGS["vc_lock_warn_not_owner"], ephemeral=True) return False current_perms = voice_channel.overwrites @@ -364,20 +364,20 @@ async def unlock_channel(self, interaction: Interaction): voice_state = interaction.user.voice if voice_state is None: - await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_no_voice"]) + await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_not_owner"]) + await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_not_owner"], ephemeral=True) return False if not db_entry.is_locked: if not voice_channel.permissions_synced: await voice_channel.edit(sync_permissions=True) - await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_not_locked"]) + await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_not_locked"], ephemeral=True) return False db_entry.is_locked = False @@ -408,20 +408,20 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): voice_state = interaction.user.voice if not voice_state: - await interaction.response.send_message(COG_STRINGS["vc_limit_warn_no_voice"]) + await interaction.response.send_message(COG_STRINGS["vc_limit_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_limit_warn_not_owner"]) + await interaction.response.send_message(COG_STRINGS["vc_limit_warn_not_owner"], ephemeral=True) return False if user_limit <= 0: user_limit = len(voice_channel.members) elif user_limit > 99: - await interaction.response.send_message(COG_STRINGS["vc_limit_warn_too_many"]) + await interaction.response.send_message(COG_STRINGS["vc_limit_warn_too_many"], ephemeral=True) return False await voice_channel.edit(user_limit=user_limit) @@ -448,18 +448,18 @@ async def unlimit_channel(self, interaction: Interaction): voice_state = interaction.user.voice if not voice_state: - await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_no_voice"]) + await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_not_owner"]) + await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_not_owner"], ephemeral=True) return False if not db_entry.is_limited: - await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_not_limited"]) + await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_not_limited"], ephemeral=True) return False db_entry.is_limited = False From 056484184e2d758975fdd9cf928863d5d974f73c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 22:09:18 +0000 Subject: [PATCH 050/404] Added `ALL_MESSAGES_EPHEMERAL` environment variable --- secrets.template | 1 + src/client.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/secrets.template b/secrets.template index 785490a9..f2b83200 100644 --- a/secrets.template +++ b/secrets.template @@ -5,6 +5,7 @@ COMMAND_PREFIX=! UNKNOWN_COMMAND_EMOJI=⁉ DEV_USER_IDS= DEV_GUILD_ID= +ALL_MESSAGES_EPHEMERAL=FALSE ################## ## Database Vars ## diff --git a/src/client.py b/src/client.py index d6788b81..d4070e97 100644 --- a/src/client.py +++ b/src/client.py @@ -9,7 +9,7 @@ class __EsportsBot(Bot): - def __init__(self, command_prefix: str, *args, **kwargs): + def __init__(self, command_prefix: str, all_messages_ephemeral: bool, *args, **kwargs): """Creates a new instance of the the private EsportsBot class. Args: @@ -17,6 +17,7 @@ def __init__(self, command_prefix: str, *args, **kwargs): """ super().__init__(command_prefix, *args, **kwargs) self.logger = logging.getLogger(__name__) + self.only_ephemeral = all_messages_ephemeral async def setup_hook(self): """The setup function that is called prior to the bot connecting to the Discord Gateway. @@ -49,4 +50,8 @@ async def setup_hook(self): await self.tree.sync(guild=DEV_GUILD) -EsportsBot = __EsportsBot(command_prefix=os.getenv("COMMAND_PREFIX"), intents=Intents.all()) +EsportsBot = __EsportsBot( + command_prefix=os.getenv("COMMAND_PREFIX"), + all_messages_ephemeral=os.getenv("ALL_MESSAGES_EPHEMERAL").upper() == "TRUE", + intents=Intents.all() +) From ccce2e69cd53027ef5d223cb1ff6fe3186522ce8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 22:33:47 +0000 Subject: [PATCH 051/404] Moved environment variable to be loaded later --- src/client.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client.py b/src/client.py index d4070e97..824e5637 100644 --- a/src/client.py +++ b/src/client.py @@ -22,6 +22,9 @@ def __init__(self, command_prefix: str, all_messages_ephemeral: bool, *args, **k async def setup_hook(self): """The setup function that is called prior to the bot connecting to the Discord Gateway. """ + if not self.only_ephemeral: + self.only_ephemeral = os.getenv("ALL_MESSAGES_EPHEMERAL", "FALSE").upper() == "TRUE" + # List of extensions to load. Initialised with default extensions. enabled_extensions: List[str] = ["AdminTools"] # Dictionary of Environment variables -> extension name @@ -50,8 +53,4 @@ async def setup_hook(self): await self.tree.sync(guild=DEV_GUILD) -EsportsBot = __EsportsBot( - command_prefix=os.getenv("COMMAND_PREFIX"), - all_messages_ephemeral=os.getenv("ALL_MESSAGES_EPHEMERAL").upper() == "TRUE", - intents=Intents.all() -) +EsportsBot = __EsportsBot(command_prefix=os.getenv("COMMAND_PREFIX"), all_messages_ephemeral=False, intents=Intents.all()) From 80e8339f5fdb7e71b3cd11e36b31ccc84d4aeeac Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 22:34:41 +0000 Subject: [PATCH 052/404] Implemented new environment variable into response messages --- src/extensions/AdminTools.py | 10 +++++++--- src/extensions/VoiceAdmin.py | 36 ++++++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index facf0ad0..c7acb077 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -4,13 +4,14 @@ import logging from common.io import load_bot_version, load_cog_toml +from client import EsportsBot COG_STRINGS = load_cog_toml(__name__) class AdminTools(Cog): - def __init__(self, bot: Bot): + def __init__(self, bot: EsportsBot): """AdminTools cog is used to manage basic Administrator/Moderation tools. All commands in this cog require the user to have the administrator permission in a given guild/server. @@ -39,7 +40,10 @@ async def get_member_count(self, interaction: Interaction): interaction (Interaction): The interaction that triggered the command. """ member_count = interaction.guild.member_count - await interaction.response.send_message(COG_STRINGS["admin_members_format"].format(count=member_count)) + await interaction.response.send_message( + COG_STRINGS["admin_members_format"].format(count=member_count), + ephemeral=self.bot.only_ephemeral + ) return True @command(name=COG_STRINGS["admin_version_name"], description=COG_STRINGS["admin_version_description"]) @@ -52,7 +56,7 @@ async def get_bot_version(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - await interaction.response.send_message(self.version_string) + await interaction.response.send_message(self.version_string, ephemeral=self.bot.only_ephemeral) return True @command(name=COG_STRINGS["admin_clear_name"], description=COG_STRINGS["admin_clear_description"]) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 8a448dc8..022bec98 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -7,6 +7,7 @@ from common.io import load_cog_toml from database.models import VoiceAdminParent, VoiceAdminChild from database.gateway import DBSession +from client import EsportsBot COG_STRINGS = load_cog_toml(__name__) @@ -42,7 +43,7 @@ def primary_key_from_channel(channel: VoiceChannel): class VoiceAdmin(Cog): - def __init__(self, bot: Bot): + def __init__(self, bot: EsportsBot): """VoiceAdmin cog is used to dynamically create and manage Voice Channels, by assigning specific channels to act as parent channels. @@ -175,7 +176,10 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann f"Successfully added {channel.name} (guildid - {channel.guild.id} | channelid - {channel.id}) " f"to Parent Voice Channel DB Table!" ) - await interaction.response.send_message(COG_STRINGS["vc_set_parent_success"].format(channel=channel)) + await interaction.response.send_message( + COG_STRINGS["vc_set_parent_success"].format(channel=channel), + ephemeral=self.bot.only_ephemeral + ) return True @command(name=COG_STRINGS["vc_remove_parent_name"], description=COG_STRINGS["vc_remove_parent_description"]) @@ -199,7 +203,10 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh db_entry = DBSession.get(VoiceAdminParent, guild_id=channel.guild.id, channel_id=channel.id) DBSession.delete(db_entry) - await interaction.response.send_message(COG_STRINGS["vc_remove_parent_success"].format(channel=channel.name)) + await interaction.response.send_message( + COG_STRINGS["vc_remove_parent_success"].format(channel=channel.name), + ephemeral=self.bot.only_ephemeral + ) return True @command(name=COG_STRINGS["vc_get_parents_name"], description=COG_STRINGS["vc_get_parents_description"]) @@ -215,12 +222,15 @@ async def get_parent_channels(self, interaction: Interaction): fetched_channels = [await interaction.guild.fetch_channel(x.channel_id) for x in db_items] if len(fetched_channels) == 0: - await interaction.response.send_message(COG_STRINGS["vc_get_parents_empty"]) + await interaction.response.send_message(COG_STRINGS["vc_get_parents_empty"], ephemeral=self.bot.only_ephemeral) return False response_string = "\n".join([f"- {x.name}" for x in fetched_channels]) - await interaction.response.send_message(COG_STRINGS["vc_get_parents_format"].format(channels=response_string)) + await interaction.response.send_message( + COG_STRINGS["vc_get_parents_format"].format(channels=response_string), + ephemeral=self.bot.only_ephemeral + ) return True @command( @@ -271,7 +281,10 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): f"Updated child Voice Channel of {interaction.user.display_name} " f"(guildid - {interaction.guild.id} | channelid - {voice_channel.id}) to {name_set}" ) - await interaction.response.send_message(COG_STRINGS["vc_rename_success"].format(name=name_set)) + await interaction.response.send_message( + COG_STRINGS["vc_rename_success"].format(name=name_set), + ephemeral=self.bot.only_ephemeral + ) return True @command( @@ -344,7 +357,7 @@ async def lock_channel(self, interaction: Interaction): db_entry.is_locked = True DBSession.update(db_entry) - await interaction.response.send_message(COG_STRINGS["vc_lock_success"]) + await interaction.response.send_message(COG_STRINGS["vc_lock_success"], ephemeral=self.bot.only_ephemeral) return True @@ -384,7 +397,7 @@ async def unlock_channel(self, interaction: Interaction): DBSession.update(db_entry) await voice_channel.edit(sync_permissions=True) - await interaction.response.send_message(COG_STRINGS["vc_unlock_success"]) + await interaction.response.send_message(COG_STRINGS["vc_unlock_success"], ephemeral=self.bot.only_ephemeral) return True @command( @@ -429,7 +442,10 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): db_entry.is_limited = True DBSession.update(db_entry) - await interaction.response.send_message(COG_STRINGS["vc_limit_success"].format(count=user_limit)) + await interaction.response.send_message( + COG_STRINGS["vc_limit_success"].format(count=user_limit), + ephemeral=self.bot.only_ephemeral + ) return True @command( @@ -466,7 +482,7 @@ async def unlimit_channel(self, interaction: Interaction): DBSession.update(db_entry) await voice_channel.edit(user_limit=None) - await interaction.response.send_message(COG_STRINGS["vc_unlimit_success"]) + await interaction.response.send_message(COG_STRINGS["vc_unlimit_success"], ephemeral=self.bot.only_ephemeral) return True From 8684630705902dc7f0c75c345c93b2f76199aabe Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 22:55:33 +0000 Subject: [PATCH 053/404] Defer all responses to account for potentially slow connections --- src/extensions/AdminTools.py | 4 ++- src/extensions/VoiceAdmin.py | 66 ++++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index c7acb077..917a2f63 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -39,8 +39,10 @@ async def get_member_count(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ + await interaction.response.defer() + member_count = interaction.guild.member_count - await interaction.response.send_message( + await interaction.followup.send( COG_STRINGS["admin_members_format"].format(count=member_count), ephemeral=self.bot.only_ephemeral ) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 022bec98..6fa9f4fc 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -158,12 +158,14 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann interaction (Interaction): The interaction that triggered the command. channel (VoiceChannel): The Voice Channel to set as a parent Voice Channel. """ + await interaction.response.defer() + if channel_is_parent(channel): - await interaction.response.send_message(COG_STRINGS["vc_set_parent_warn_already_parent"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_set_parent_warn_already_parent"], ephemeral=True) return False if channel_is_child(channel): - await interaction.response.send_message(COG_STRINGS["vc_set_parent_warn_already_child"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_set_parent_warn_already_child"], ephemeral=True) return False db_entry: VoiceAdminParent = VoiceAdminParent( @@ -176,7 +178,7 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann f"Successfully added {channel.name} (guildid - {channel.guild.id} | channelid - {channel.id}) " f"to Parent Voice Channel DB Table!" ) - await interaction.response.send_message( + await interaction.followup.send( COG_STRINGS["vc_set_parent_success"].format(channel=channel), ephemeral=self.bot.only_ephemeral ) @@ -197,13 +199,15 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh interaction (Interaction): The interaction that triggered the command. channel (VoiceChannel): The Voice Channel to stop behaving as a parent Voice Channel. """ + await interaction.response.defer() + if not channel_is_parent(channel): - await interaction.response.send_message(COG_STRINGS["vc_remove_parent_warn_not_parent"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_remove_parent_warn_not_parent"], ephemeral=True) return False db_entry = DBSession.get(VoiceAdminParent, guild_id=channel.guild.id, channel_id=channel.id) DBSession.delete(db_entry) - await interaction.response.send_message( + await interaction.followup.send( COG_STRINGS["vc_remove_parent_success"].format(channel=channel.name), ephemeral=self.bot.only_ephemeral ) @@ -217,17 +221,19 @@ async def get_parent_channels(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ + await interaction.response.defer() + db_items = DBSession.list(VoiceAdminParent) fetched_channels = [await interaction.guild.fetch_channel(x.channel_id) for x in db_items] if len(fetched_channels) == 0: - await interaction.response.send_message(COG_STRINGS["vc_get_parents_empty"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["vc_get_parents_empty"], ephemeral=self.bot.only_ephemeral) return False response_string = "\n".join([f"- {x.name}" for x in fetched_channels]) - await interaction.response.send_message( + await interaction.followup.send( COG_STRINGS["vc_get_parents_format"].format(channels=response_string), ephemeral=self.bot.only_ephemeral ) @@ -251,17 +257,19 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): new_name (str, optional): The new name to set the Voice Channel to. Defaults to the default child Voice Channel string. """ + await interaction.response.defer() + voice_state = interaction.user.voice if voice_state is None: - await interaction.response.send_message(COG_STRINGS["vc_rename_warn_no_voice"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_rename_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_rename_warn_not_owner"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_rename_warn_not_owner"], ephemeral=True) return False name_set = new_name if new_name else f"{interaction.user.display_name}'s VC" @@ -281,7 +289,7 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): f"Updated child Voice Channel of {interaction.user.display_name} " f"(guildid - {interaction.guild.id} | channelid - {voice_channel.id}) to {name_set}" ) - await interaction.response.send_message( + await interaction.followup.send( COG_STRINGS["vc_rename_success"].format(name=name_set), ephemeral=self.bot.only_ephemeral ) @@ -302,17 +310,19 @@ async def lock_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ + await interaction.response.defer() + voice_state = interaction.user.voice if voice_state is None: - await interaction.response.send_message(COG_STRINGS["vc_lock_warn_no_voice"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_lock_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_lock_warn_not_owner"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_lock_warn_not_owner"], ephemeral=True) return False current_perms = voice_channel.overwrites @@ -357,7 +367,7 @@ async def lock_channel(self, interaction: Interaction): db_entry.is_locked = True DBSession.update(db_entry) - await interaction.response.send_message(COG_STRINGS["vc_lock_success"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["vc_lock_success"], ephemeral=self.bot.only_ephemeral) return True @@ -374,30 +384,32 @@ async def unlock_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ + await interaction.response.defer() + voice_state = interaction.user.voice if voice_state is None: - await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_no_voice"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_unlock_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_not_owner"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_unlock_warn_not_owner"], ephemeral=True) return False if not db_entry.is_locked: if not voice_channel.permissions_synced: await voice_channel.edit(sync_permissions=True) - await interaction.response.send_message(COG_STRINGS["vc_unlock_warn_not_locked"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_unlock_warn_not_locked"], ephemeral=True) return False db_entry.is_locked = False DBSession.update(db_entry) await voice_channel.edit(sync_permissions=True) - await interaction.response.send_message(COG_STRINGS["vc_unlock_success"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["vc_unlock_success"], ephemeral=self.bot.only_ephemeral) return True @command( @@ -418,23 +430,25 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): user_limit (int, optional): The number of members to limit the child Voice Channel to. Defaults to the number of members in the child Voice Channel. """ + await interaction.response.defer() + voice_state = interaction.user.voice if not voice_state: - await interaction.response.send_message(COG_STRINGS["vc_limit_warn_no_voice"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_limit_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_limit_warn_not_owner"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_limit_warn_not_owner"], ephemeral=True) return False if user_limit <= 0: user_limit = len(voice_channel.members) elif user_limit > 99: - await interaction.response.send_message(COG_STRINGS["vc_limit_warn_too_many"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_limit_warn_too_many"], ephemeral=True) return False await voice_channel.edit(user_limit=user_limit) @@ -442,7 +456,7 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): db_entry.is_limited = True DBSession.update(db_entry) - await interaction.response.send_message( + await interaction.followup.send( COG_STRINGS["vc_limit_success"].format(count=user_limit), ephemeral=self.bot.only_ephemeral ) @@ -461,28 +475,30 @@ async def unlimit_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ + await interaction.response.defer() + voice_state = interaction.user.voice if not voice_state: - await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_no_voice"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_unlimit_warn_no_voice"], ephemeral=True) return False voice_channel = voice_state.channel db_entry = DBSession.get(VoiceAdminChild, guild_id=voice_channel.guild.id, channel_id=voice_channel.id) if not member_is_owner(interaction.user, voice_channel, db_entry): - await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_not_owner"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_unlimit_warn_not_owner"], ephemeral=True) return False if not db_entry.is_limited: - await interaction.response.send_message(COG_STRINGS["vc_unlimit_warn_not_limited"], ephemeral=True) + await interaction.followup.send(COG_STRINGS["vc_unlimit_warn_not_limited"], ephemeral=True) return False db_entry.is_limited = False DBSession.update(db_entry) await voice_channel.edit(user_limit=None) - await interaction.response.send_message(COG_STRINGS["vc_unlimit_success"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["vc_unlimit_success"], ephemeral=self.bot.only_ephemeral) return True From d7154aa204a90bc008c20d227f201564bdea3ea7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 22:56:27 +0000 Subject: [PATCH 054/404] Added response for admin-clear-messages command --- src/extensions/AdminTools.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index 917a2f63..e321fd03 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -80,7 +80,11 @@ async def clear_messages(self, interaction: Interaction, count: int = 5): return False await interaction.response.defer() - await interaction.channel.purge(limit=count) + messages = await interaction.channel.purge(limit=count, before=interaction.created_at) + await interaction.followup.send( + COG_STRINGS["admin_clear_success"].format(count=len(messages)), + ephemeral=self.bot.only_ephemeral + ) return True From 99e2586942893648a219decf09236fb335d19981 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 22:56:49 +0000 Subject: [PATCH 055/404] Updated TODO list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b89645e..c6ebff5b 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ The user has control over the child Voice Channel name, and can limit how many/w # TODO: -- Implement unimplemented commands in VoiceAdmin and AdminTools cogs. +- ~~Implement unimplemented commands in VoiceAdmin and AdminTools cogs.~~ - Implement EventTools cog. - Implement AutoRoles cog. - Add back functionality of previous bot (eg. Music, PingableRoles, etc.) From 5f000ea3e5f4e4a9f4738f7c116b9f17336d46a3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 23:04:25 +0000 Subject: [PATCH 056/404] Updated README DB information --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c6ebff5b..72e8aa99 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Requirements needed to run: - Python 3.8 - Pip - [A postgres 11 database](https://www.postgresql.org/docs/current/admin.html) + - If using the `DB_OVERRIDE` environment variable, any valid DB schema for SQLAlchemy can be used by providing the correct schema URI. These can be [found here](https://docs.sqlalchemy.org/en/14/dialects/). 1. Clone this repository: From 99e31c7b2dad20ed1d7574a451e274eb5ddf1d15 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 23:09:03 +0000 Subject: [PATCH 057/404] Updated README TODO --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 72e8aa99..944b76da 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,11 @@ The user has control over the child Voice Channel name, and can limit how many/w # TODO: - ~~Implement unimplemented commands in VoiceAdmin and AdminTools cogs.~~ -- Implement EventTools cog. -- Implement AutoRoles cog. +- Implement EventTools cog +- Implement AutoRoles cog - Add back functionality of previous bot (eg. Music, PingableRoles, etc.) - Add game deal tracker (DealTracker(?) cog) +- Add proper support for SQLite auto increment primary keys # Quick Setup Guide From 4b7e28d7c703345421ee1913ce559d15acf4a584 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 23:26:16 +0000 Subject: [PATCH 058/404] Added listeners to required events --- src/extensions/AutoRoles.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 8466cb20..d97dca22 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -1,18 +1,32 @@ from discord.ext.commands import Bot, Cog +from discord import Member import logging from common.io import load_cog_toml +from client import EsportsBot COG_STRINGS = load_cog_toml(__name__) class AutoRoles(Cog): - def __init__(self, bot: Bot): + def __init__(self, bot: EsportsBot): self.bot = bot self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") + @Cog.listener + async def on_member_join(self, member: Member): + pass + + @Cog.listener + async def on_member_remove(self, member: Member): + pass + + @Cog.listener + async def on_member_update(self, before: Member, after: Member): + pass + async def setup(bot: Bot): await bot.add_cog(AutoRoles(bot)) From b1c07e39d6c87bfbc4a5fe879f19ed45b6f9340d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 23:38:09 +0000 Subject: [PATCH 059/404] Implemented event handlers --- src/extensions/AutoRoles.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index d97dca22..3601767f 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -17,14 +17,15 @@ def __init__(self, bot: EsportsBot): @Cog.listener async def on_member_join(self, member: Member): - pass - - @Cog.listener - async def on_member_remove(self, member: Member): - pass + if not member.pending: + self.assign_roles(member) @Cog.listener async def on_member_update(self, before: Member, after: Member): + if before.pending and not after.pending: + self.assign_roles(after) + + async def assign_roles(self, member: Member): pass From 8f51de28651fdb47a4c86c05e66a702eb55fbc6a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 23:45:40 +0000 Subject: [PATCH 060/404] Added DB model for AutoRoles --- src/database/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/database/models.py b/src/database/models.py index c89a3ae2..a826cc63 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -23,3 +23,10 @@ class VoiceAdminChild(base): is_locked = Column(Boolean, nullable=False) is_limited = Column(Boolean, nullable=False) has_custom_name = Column(Boolean, nullable=False) + + +class AutoRolesConfig(base): + __tablename__ = "autoroles_config" + primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + guild_id = Column(BigInteger, nullable=False) + role_id = Column(BigInteger, nullable=False) From 81c1e023e7e34037af948002631679c742463c93 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 25 Dec 2022 23:46:04 +0000 Subject: [PATCH 061/404] Implemented assign_role function --- src/extensions/AutoRoles.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 3601767f..569744d5 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -4,6 +4,8 @@ import logging from common.io import load_cog_toml from client import EsportsBot +from database.gateway import DBSession +from database.models import AutoRolesConfig COG_STRINGS = load_cog_toml(__name__) @@ -26,7 +28,11 @@ async def on_member_update(self, before: Member, after: Member): self.assign_roles(after) async def assign_roles(self, member: Member): - pass + guild_roles = DBSession.list(AutoRolesConfig, guild_id=member.guild.id) + + if guild_roles: + roles = [member.guild.get_role(x.role_id) for x in guild_roles] + await member.add_roles(roles) async def setup(bot: Bot): From 94d43d5b718d2b6591124aebf95bec201d786fc4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 26 Dec 2022 23:58:51 +0100 Subject: [PATCH 062/404] Fixed listener decorators missing brackets --- src/extensions/AutoRoles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 569744d5..8d64e04b 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -17,12 +17,12 @@ def __init__(self, bot: EsportsBot): self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") - @Cog.listener + @Cog.listener() async def on_member_join(self, member: Member): if not member.pending: self.assign_roles(member) - @Cog.listener + @Cog.listener() async def on_member_update(self, before: Member, after: Member): if before.pending and not after.pending: self.assign_roles(after) From e0151e15e33cf21dc13ccadbe4f9eea3ff255752 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 00:02:53 +0100 Subject: [PATCH 063/404] Added discord functions utility file --- src/common/discord.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/common/discord.py diff --git a/src/common/discord.py b/src/common/discord.py new file mode 100644 index 00000000..e9846eb4 --- /dev/null +++ b/src/common/discord.py @@ -0,0 +1,35 @@ +from discord.app_commands import Transformer +from discord import Role, Interaction, Guild +from typing import List + + +def raw_role_string_to_id(role_str: str): + if not role_str.startswith("<@&") or not role_str.endswith(">"): + raise ValueError(f"The given string of `{role_str}` is not a valid raw role string!") + + role_id: str = role_str[role_str.index("<@&") + 3:role_str.index(">")] + + if not role_id.isdigit(): + raise ValueError(f"Unable to find a valid Role ID in raw role string `{role_str}`") + + return int(role_id) + + +async def get_role(guild: Guild, role_id: int): + role = guild.get_role(role_id) + if role is None: + roles = await guild.fetch_roles() + return list(filter(lambda x: x.id == role_id, roles))[0] + + return role + + +class RoleListTransformer(Transformer): + + async def transform(self, interaction: Interaction, roles: str) -> List[Role]: + raw_roles = roles.split(" ") + parsed_roles = [raw_role_string_to_id(x.strip()) for x in raw_roles] + + fetched_roles = [await get_role(interaction.guild, x) for x in parsed_roles] + + return fetched_roles From 69c1173d038dedc277f6008dd05059776e9237aa Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 00:29:02 +0100 Subject: [PATCH 064/404] Refactored RoleListTransformer to use regex --- src/common/discord.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index e9846eb4..4f768277 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -1,6 +1,9 @@ from discord.app_commands import Transformer from discord import Role, Interaction, Guild from typing import List +import re + +ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") def raw_role_string_to_id(role_str: str): @@ -27,9 +30,11 @@ async def get_role(guild: Guild, role_id: int): class RoleListTransformer(Transformer): async def transform(self, interaction: Interaction, roles: str) -> List[Role]: - raw_roles = roles.split(" ") - parsed_roles = [raw_role_string_to_id(x.strip()) for x in raw_roles] - - fetched_roles = [await get_role(interaction.guild, x) for x in parsed_roles] - - return fetched_roles + roles_found = re.finditer(ROLE_REGEX, roles) + parsed_roles = [] + for _, role_match in enumerate(roles_found): + role_id = role_match.group() + role = await get_role(interaction.guild, role_id) + parsed_roles.append(role) + + return parsed_roles From 68c500081ae5c7aebb844971dcd644a5f39b1e7e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 00:32:51 +0100 Subject: [PATCH 065/404] Added explicit type conversion and try/catch to role_id --- src/common/discord.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/common/discord.py b/src/common/discord.py index 4f768277..df77359c 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -34,7 +34,10 @@ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: parsed_roles = [] for _, role_match in enumerate(roles_found): role_id = role_match.group() - role = await get_role(interaction.guild, role_id) + try: + role = await get_role(interaction.guild, int(role_id)) parsed_roles.append(role) + except ValueError: + continue return parsed_roles From e23067d4937675dcc15e1e2c67e06ebcb24501d0 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 00:33:14 +0100 Subject: [PATCH 066/404] Refactored `raw_role_string_to_id` to use regex --- src/common/discord.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index df77359c..254cf2b0 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -7,15 +7,14 @@ def raw_role_string_to_id(role_str: str): - if not role_str.startswith("<@&") or not role_str.endswith(">"): - raise ValueError(f"The given string of `{role_str}` is not a valid raw role string!") + role_found = re.search(ROLE_REGEX, role_str) + if not role_found: + return 0 - role_id: str = role_str[role_str.index("<@&") + 3:role_str.index(">")] - - if not role_id.isdigit(): - raise ValueError(f"Unable to find a valid Role ID in raw role string `{role_str}`") - - return int(role_id) + try: + return int(role_found.group()) + except ValueError: + return 0 async def get_role(guild: Guild, role_id: int): @@ -36,7 +35,7 @@ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: role_id = role_match.group() try: role = await get_role(interaction.guild, int(role_id)) - parsed_roles.append(role) + parsed_roles.append(role) except ValueError: continue From 9198ccc50711deb4592412c75b1a7b87ce3ee9b2 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 00:47:28 +0100 Subject: [PATCH 067/404] Added generic primarykey generator function --- src/common/discord.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/common/discord.py b/src/common/discord.py index 254cf2b0..7da3b08b 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -1,6 +1,7 @@ from discord.app_commands import Transformer from discord import Role, Interaction, Guild -from typing import List +from discord.abc import GuildChannel +from typing import List, Union import re ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") @@ -26,6 +27,10 @@ async def get_role(guild: Guild, role_id: int): return role +def primary_key_from_object(object: Union[Role, GuildChannel]): + return int(f"{object.guild.id % 1000}{object.id % 1000}") + + class RoleListTransformer(Transformer): async def transform(self, interaction: Interaction, roles: str) -> List[Role]: From 64be52c87d4bd5a10691b0a25dc71795f3a6393d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 00:48:14 +0100 Subject: [PATCH 068/404] Replaced primarykey function with new generic function --- src/extensions/VoiceAdmin.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index 6fa9f4fc..e3bd7398 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -4,6 +4,7 @@ from discord.app_commands import command, describe, rename, default_permissions, checks, guild_only import logging +from common.discord import primary_key_from_object from common.io import load_cog_toml from database.models import VoiceAdminParent, VoiceAdminChild from database.gateway import DBSession @@ -37,10 +38,6 @@ def member_is_owner(member: Member, channel: VoiceChannel, db_entry: VoiceAdminC return db_entry.owner_id == member.id -def primary_key_from_channel(channel: VoiceChannel): - return int(f"{channel.guild.id % 100}{channel.id % 100}") - - class VoiceAdmin(Cog): def __init__(self, bot: EsportsBot): @@ -128,7 +125,7 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: name=f"{member.display_name}'s VC" ) db_entry: VoiceAdminChild = VoiceAdminChild( - primary_key=primary_key_from_channel(new_child_channel), + primary_key=primary_key_from_object(new_child_channel), guild_id=new_child_channel.guild.id, channel_id=new_child_channel.id, owner_id=member.id, @@ -169,7 +166,7 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann return False db_entry: VoiceAdminParent = VoiceAdminParent( - primary_key=primary_key_from_channel(channel), + primary_key=primary_key_from_object(channel), guild_id=interaction.guild.id, channel_id=channel.id ) From b135f5986ce857ea77191f870da70134e5f2d4b6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:01:32 +0100 Subject: [PATCH 069/404] Created AutoRoles.toml --- src/locale/AutoRoles.toml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/locale/AutoRoles.toml diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml new file mode 100644 index 00000000..3d03b1a5 --- /dev/null +++ b/src/locale/AutoRoles.toml @@ -0,0 +1,7 @@ +roles_set_list_name = "roles-set-list" +roles_set_list_description = "Set the list of automatically applied roles when a user joins." +roles_set_list_param_describe = "The list of roles to give." +roles_set_list_param_rename = "roles" +roles_set_warn_empty = "No roles were configured to be applied when members join. Any roles previously configured are still present ⚠️" +roles_set_success_title = "Finished Configuring AutoRoles!" +roles_set_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" \ No newline at end of file From 7ea8de3ef113509fd4e55e54f1df61f0b54fe15b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:05:29 +0100 Subject: [PATCH 070/404] Implemented roles-set-list command --- src/extensions/AutoRoles.py | 55 ++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 8d64e04b..06727d3d 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -1,8 +1,11 @@ from discord.ext.commands import Bot, Cog -from discord import Member +from discord.app_commands import command, describe, rename, default_permissions, checks, guild_only, Transform +from discord import Member, Interaction, Role, Embed, Color import logging +from typing import List from common.io import load_cog_toml +from common.discord import RoleListTransformer, primary_key_from_object from client import EsportsBot from database.gateway import DBSession from database.models import AutoRolesConfig @@ -34,6 +37,56 @@ async def assign_roles(self, member: Member): roles = [member.guild.get_role(x.role_id) for x in guild_roles] await member.add_roles(roles) + @command(name=COG_STRINGS["roles_set_list_name"], description=COG_STRINGS["roles_set_list_description"]) + @describe(roles=COG_STRINGS["roles_set_list_param_describe"]) + @rename(roles=COG_STRINGS["roles_set_list_param_rename"]) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[Role], RoleListTransformer]): + """The command used to set the list of roles to give to members when the join the guild/server. + + If there are one or more valid roles given in the `roles` parameter, + any previously configured roles to be applied will be overridden. + + Args: + interaction (Interaction): The interaction that triggered the command. + roles (Transform[List[Role], RoleListTransformer]): One or many roles mentioned. + Do not need to be separated with a delimiter. + """ + await interaction.response.defer() + + initial_entries = DBSession.list(AutoRolesConfig, guild_id=interaction.guild.id) + + successful_roles = [] + for role in roles: + if role.is_assignable: + db_entry = AutoRolesConfig( + primary_key=primary_key_from_object(role), + guild_id=interaction.guild.id, + role_id=role.id + ) + DBSession.create(db_entry) + successful_roles.append(role) + + if len(successful_roles) == 0: + await interaction.followup.send(COG_STRINGS["roles_set_warn_empty"], ephemeral=self.bot.only_ephemeral) + return False + else: + for entry in initial_entries: + DBSession.delete(entry) + + formatted_string = "\n".join([f"• {x.mention}" for x in successful_roles]) + + response_embed = Embed( + title=COG_STRINGS["roles_set_success_title"], + description=COG_STRINGS["roles_set_success_description"].format(roles=formatted_string), + color=Color.random() + ) + + await interaction.followup.send(embed=response_embed, ephemeral=self.bot.only_ephemeral) + return True + async def setup(bot: Bot): await bot.add_cog(AutoRoles(bot)) From 83be7bcbcc8279bff66966f4fca26b4afc4c3994 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:07:57 +0100 Subject: [PATCH 071/404] Added roles-set-list command README docs --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 944b76da..cbbe0de4 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,12 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Environment Variable: `ENABLE_AUTOROLES` -### _Not yet implemented!_ +### _Only partial implementation_ + +#### /roles-set-list \<One or many roles mentioned\> + +- Sets the roles to be given to new users when they join the guild/server. + - If one or more the of the roles are valid, any roles previously configured will be removed. </details> From 7ba45ac4999bb43a7695cd7523cb6fd0043d7f9a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:16:29 +0100 Subject: [PATCH 072/404] Implemented roles-add-role command --- src/extensions/AutoRoles.py | 32 ++++++++++++++++++++++++++++++++ src/locale/AutoRoles.toml | 9 ++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 06727d3d..1bc8f20d 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -87,6 +87,38 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ await interaction.followup.send(embed=response_embed, ephemeral=self.bot.only_ephemeral) return True + @command(name=COG_STRINGS["roles_add_role_name"], description=COG_STRINGS["roles_add_role_description"]) + @describe(role=COG_STRINGS["roles_add_role_param_describe"]) + @rename(role=COG_STRINGS["roles_add_role_param_rename"]) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def add_guild_role(self, interaction: Interaction, role: Role): + """The command that adds a role to the list of roles, without overriding the currently configured roles. + + Args: + interaction (Interaction): The interaction that triggered the command. + role (Role): The role to add. + """ + await interaction.response.defer() + + db_entry = DBSession.get(AutoRolesConfig, guild_id=role.guild.id, role_id=role.id) + + if db_entry: + await interaction.followup.send( + COG_STRINGS["roles_add_role_warn_already_added"], + ephemeral=self.bot.only_ephemeral + ) + return False + + db_entry = AutoRolesConfig(primary_key=primary_key_from_object(role), guild_id=role.guild.id, role_id=role.id) + DBSession.create(db_entry) + await interaction.followup.send( + COG_STRINGS["roles_add_role_success"].format(role=role.mention), + ephemeral=self.bot.only_ephemeral + ) + return True + async def setup(bot: Bot): await bot.add_cog(AutoRoles(bot)) diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml index 3d03b1a5..770e5016 100644 --- a/src/locale/AutoRoles.toml +++ b/src/locale/AutoRoles.toml @@ -4,4 +4,11 @@ roles_set_list_param_describe = "The list of roles to give." roles_set_list_param_rename = "roles" roles_set_warn_empty = "No roles were configured to be applied when members join. Any roles previously configured are still present ⚠️" roles_set_success_title = "Finished Configuring AutoRoles!" -roles_set_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" \ No newline at end of file +roles_set_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" + +roles_add_role_name = "roles-add-role" +roles_add_role_description = "Add a role to the list of automatically applied roles." +roles_add_role_param_describe = "The role to add." +roles_add_role_param_rename = "role" +roles_add_role_success = "Succesfully added {role} to the AutoRoles config ✅" +roles_add_role_warn_already_added = "The role given is already in the list of roles to apply ⚠️" \ No newline at end of file From a70dc79033fb3fea919fdbebd82965704633486a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:17:39 +0100 Subject: [PATCH 073/404] Added roles-add-role command README docs --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cbbe0de4..dea047b3 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,10 @@ The user has control over the child Voice Channel name, and can limit how many/w - Sets the roles to be given to new users when they join the guild/server. - If one or more the of the roles are valid, any roles previously configured will be removed. +#### /roles-add-role \<role\> + +- Adds a role to the list of roles without overriding the currently configured roles. + </details> <details> From 13629cee6074edb5e647e438f9d3baf8abfd6994 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:22:19 +0100 Subject: [PATCH 074/404] Added check to prevent unintentionally removing an AutoRole from the DB --- src/extensions/AutoRoles.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 1bc8f20d..59878c79 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -66,6 +66,9 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ guild_id=interaction.guild.id, role_id=role.id ) + if db_entry in initial_entries: + initial_entries.remove(db_entry) + else: DBSession.create(db_entry) successful_roles.append(role) From 653dda376e58c8463cd67499bc697152b4e44445 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:24:15 +0100 Subject: [PATCH 075/404] Changed warning messages to always be ephemeral --- src/extensions/AutoRoles.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 59878c79..a41ed108 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -73,7 +73,7 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ successful_roles.append(role) if len(successful_roles) == 0: - await interaction.followup.send(COG_STRINGS["roles_set_warn_empty"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["roles_set_warn_empty"], ephemeral=True) return False else: for entry in initial_entries: @@ -108,10 +108,7 @@ async def add_guild_role(self, interaction: Interaction, role: Role): db_entry = DBSession.get(AutoRolesConfig, guild_id=role.guild.id, role_id=role.id) if db_entry: - await interaction.followup.send( - COG_STRINGS["roles_add_role_warn_already_added"], - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["roles_add_role_warn_already_added"], ephemeral=True) return False db_entry = AutoRolesConfig(primary_key=primary_key_from_object(role), guild_id=role.guild.id, role_id=role.id) From d91e33dffa958a8b484b051cd4ad93e2f9471413 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:26:27 +0100 Subject: [PATCH 076/404] Fixed incorrect indentation --- src/extensions/AutoRoles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index a41ed108..b1019c7f 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -69,7 +69,7 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ if db_entry in initial_entries: initial_entries.remove(db_entry) else: - DBSession.create(db_entry) + DBSession.create(db_entry) successful_roles.append(role) if len(successful_roles) == 0: From f96cc5528e369e96ee9ddb358076fbc363ad9cf2 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:29:46 +0100 Subject: [PATCH 077/404] Implemented roles-remove-role command --- src/extensions/AutoRoles.py | 19 +++++++++++++++++++ src/locale/AutoRoles.toml | 9 ++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index b1019c7f..e8f1b56b 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -119,6 +119,25 @@ async def add_guild_role(self, interaction: Interaction, role: Role): ) return True + @command(name=COG_STRINGS["roles_remove_role_name"], description=COG_STRINGS["roles_remove_role_description"]) + @describe(role=COG_STRINGS["roles_remove_role_param_describe"]) + @rename(role=COG_STRINGS["roles_remove_role_param_rename"]) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def remove_guild_role(self, interaction: Interaction, role: Role): + await interaction.response.defer() + + db_entry = DBSession.get(AutoRolesConfig, guild_id=role.guild.id, role_id=role.id) + + if not db_entry: + await interaction.followup.send(COG_STRINGS["roles_remove_role_warn_not_added"], ephemeral=True) + return False + + DBSession.delete(db_entry) + await interaction.followup.send(COG_STRINGS["roles_remove_role_success"].format(role=role.mention)) + return True + async def setup(bot: Bot): await bot.add_cog(AutoRoles(bot)) diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml index 770e5016..11e2c12c 100644 --- a/src/locale/AutoRoles.toml +++ b/src/locale/AutoRoles.toml @@ -11,4 +11,11 @@ roles_add_role_description = "Add a role to the list of automatically applied ro roles_add_role_param_describe = "The role to add." roles_add_role_param_rename = "role" roles_add_role_success = "Succesfully added {role} to the AutoRoles config ✅" -roles_add_role_warn_already_added = "The role given is already in the list of roles to apply ⚠️" \ No newline at end of file +roles_add_role_warn_already_added = "The role given is already in the list of roles to apply ⚠️" + +roles_remove_role_name = "roles-remove-role" +roles_remove_role_description = "Remove a role from the list of automatically applied roles." +roles_remove_role_param_describe = "The role to remove." +roles_remove_role_param_rename = "role" +roles_remove_role_success = "Succesfully removed {role} from the AutoRoles config ✅" +roles_remove_role_warn_not_added = "The role given is not in the list of roles to apply ⚠️" \ No newline at end of file From ab584334eba600a68f0b3ea5e2ed254410aa0b52 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:30:52 +0100 Subject: [PATCH 078/404] Added roles-remove-role command README docs --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index dea047b3..2e747d9a 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,10 @@ The user has control over the child Voice Channel name, and can limit how many/w - Adds a role to the list of roles without overriding the currently configured roles. +#### /roles-remove-role \<role\> + +- Removes a role from the list of currently configured roles. + </details> <details> From 7052392d6919d143b2272f141a3d8ae8099ee2a4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:33:33 +0100 Subject: [PATCH 079/404] Added roles-remove-role command docstring --- src/extensions/AutoRoles.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index e8f1b56b..e41eb695 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -126,6 +126,12 @@ async def add_guild_role(self, interaction: Interaction, role: Role): @checks.has_permissions(administrator=True) @guild_only() async def remove_guild_role(self, interaction: Interaction, role: Role): + """The command used to remove a role from the list of currently configured roles in a given guild/server. + + Args: + interaction (Interaction): The interaction that triggered the command. + role (Role): The role to remove. + """ await interaction.response.defer() db_entry = DBSession.get(AutoRolesConfig, guild_id=role.guild.id, role_id=role.id) From 8c6699fa7dbbc66c21f2b017fa8130b3546e5f50 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:41:28 +0100 Subject: [PATCH 080/404] Implemented roles-get-list command --- src/extensions/AutoRoles.py | 31 ++++++++++++++++++++++++++++++- src/locale/AutoRoles.toml | 8 +++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index e41eb695..71e417e4 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -5,7 +5,7 @@ import logging from typing import List from common.io import load_cog_toml -from common.discord import RoleListTransformer, primary_key_from_object +from common.discord import RoleListTransformer, get_role, primary_key_from_object from client import EsportsBot from database.gateway import DBSession from database.models import AutoRolesConfig @@ -144,6 +144,35 @@ async def remove_guild_role(self, interaction: Interaction, role: Role): await interaction.followup.send(COG_STRINGS["roles_remove_role_success"].format(role=role.mention)) return True + @command(name=COG_STRINGS["roles_get_list_name"], description=COG_STRINGS["roles_get_list_description"]) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def list_guild_roles(self, interaction: Interaction): + """The command to get the current list of roles that are configured for a given guild/server. + + Args: + interaction (Interaction): The interaction that triggered the command. + """ + await interaction.response.defer() + + db_items = DBSession.list(AutoRolesConfig, guild_id=interaction.guild.id) + + if not db_items: + await interaction.followup.send() + return False + + fetched_roles = [await get_role(interaction.guild, x.role_id) for x in db_items] + + formatted_string = "\n".join([f"• {x.mention}" for x in fetched_roles]) + response_embed = Embed( + title=COG_STRINGS["roles_get_list_success_title"], + description=COG_STRINGS["roles_get_list_success_description"].format(roles=formatted_string), + color=Color.random() + ) + await interaction.followup.send(embed=response_embed) + return True + async def setup(bot: Bot): await bot.add_cog(AutoRoles(bot)) diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml index 11e2c12c..15eeeee0 100644 --- a/src/locale/AutoRoles.toml +++ b/src/locale/AutoRoles.toml @@ -18,4 +18,10 @@ roles_remove_role_description = "Remove a role from the list of automatically ap roles_remove_role_param_describe = "The role to remove." roles_remove_role_param_rename = "role" roles_remove_role_success = "Succesfully removed {role} from the AutoRoles config ✅" -roles_remove_role_warn_not_added = "The role given is not in the list of roles to apply ⚠️" \ No newline at end of file +roles_remove_role_warn_not_added = "The role given is not in the list of roles to apply ⚠️" + +roles_get_list_name = "roles-get-list" +roles_get_list_description = "Get the list of currently configured AutoRoles." +roles_get_list_warn_no_roles = "No roles are currently configured to be applied when a member joins ⚠️" +roles_get_list_success_title = "Current AutoRoles Config" +roles_get_list_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" \ No newline at end of file From c7282c33f62452de5eef040f9f4003f52f6cdf64 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:42:11 +0100 Subject: [PATCH 081/404] Added roles-get-list command README docs --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2e747d9a..9b09ec57 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ The user has control over the child Voice Channel name, and can limit how many/w - Removes a role from the list of currently configured roles. +#### /roles-get-list + +- Gets the list of currently configured AutoRoles. + </details> <details> From 148ce001c43f6090a7cf7275591fe5d9d10332a8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:46:31 +0100 Subject: [PATCH 082/404] Added missing warning response for roles-get-list --- src/extensions/AutoRoles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 71e417e4..43f59078 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -159,7 +159,7 @@ async def list_guild_roles(self, interaction: Interaction): db_items = DBSession.list(AutoRolesConfig, guild_id=interaction.guild.id) if not db_items: - await interaction.followup.send() + await interaction.followup.send(COG_STRINGS["roles_get_list_warn_no_roles"]) return False fetched_roles = [await get_role(interaction.guild, x.role_id) for x in db_items] From 0ab25710f2669b2f205c673562b407632f69fca0 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:47:36 +0100 Subject: [PATCH 083/404] Ensured AutoRole messages were correctly ephemeral --- src/extensions/AutoRoles.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 43f59078..1186b337 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -141,7 +141,10 @@ async def remove_guild_role(self, interaction: Interaction, role: Role): return False DBSession.delete(db_entry) - await interaction.followup.send(COG_STRINGS["roles_remove_role_success"].format(role=role.mention)) + await interaction.followup.send( + COG_STRINGS["roles_remove_role_success"].format(role=role.mention), + ephemeral=self.bot.only_ephemeral + ) return True @command(name=COG_STRINGS["roles_get_list_name"], description=COG_STRINGS["roles_get_list_description"]) @@ -159,7 +162,7 @@ async def list_guild_roles(self, interaction: Interaction): db_items = DBSession.list(AutoRolesConfig, guild_id=interaction.guild.id) if not db_items: - await interaction.followup.send(COG_STRINGS["roles_get_list_warn_no_roles"]) + await interaction.followup.send(COG_STRINGS["roles_get_list_warn_no_roles"], ephemeral=True) return False fetched_roles = [await get_role(interaction.guild, x.role_id) for x in db_items] @@ -170,7 +173,7 @@ async def list_guild_roles(self, interaction: Interaction): description=COG_STRINGS["roles_get_list_success_description"].format(roles=formatted_string), color=Color.random() ) - await interaction.followup.send(embed=response_embed) + await interaction.followup.send(embed=response_embed, ephemeral=self.bot.only_ephemeral) return True From 418834af15ca7bc374449254dba3fcfcda749c9d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:48:43 +0100 Subject: [PATCH 084/404] Implemented roles-clear-list command --- src/extensions/AutoRoles.py | 18 ++++++++++++++++++ src/locale/AutoRoles.toml | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 1186b337..3be38b53 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -176,6 +176,24 @@ async def list_guild_roles(self, interaction: Interaction): await interaction.followup.send(embed=response_embed, ephemeral=self.bot.only_ephemeral) return True + @command(name=COG_STRINGS["roles_clear_list_name"], description=COG_STRINGS["roles_clear_list_description"]) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def clear_guild_roles(self, interaction: Interaction): + """The command used to entirely clear the list of Roles for a given guild/server. + + Args: + interaction (Interaction): The interaction that triggered the command. + """ + db_items = DBSession.list(AutoRolesConfig, interaction.guild.id) + + for item in db_items: + DBSession.delete(item) + + await interaction.followup.send(COG_STRINGS["roles_clear_list_success"], ephemeral=self.bot.only_ephemeral) + return True + async def setup(bot: Bot): await bot.add_cog(AutoRoles(bot)) diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml index 15eeeee0..a7222f1e 100644 --- a/src/locale/AutoRoles.toml +++ b/src/locale/AutoRoles.toml @@ -24,4 +24,8 @@ roles_get_list_name = "roles-get-list" roles_get_list_description = "Get the list of currently configured AutoRoles." roles_get_list_warn_no_roles = "No roles are currently configured to be applied when a member joins ⚠️" roles_get_list_success_title = "Current AutoRoles Config" -roles_get_list_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" \ No newline at end of file +roles_get_list_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" + +roles_clear_list_name = "roles-clear-list" +roles_clear_list_name = "Clears the list of roles to apply." +roles_clear_list_success = "The list of roles has been cleared ✅" \ No newline at end of file From 6e141d9a33cf888ae522b7f37172093e2198d2c5 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:49:37 +0100 Subject: [PATCH 085/404] Added roles-clear-list command README docs --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9b09ec57..1bdfbee1 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,10 @@ The user has control over the child Voice Channel name, and can limit how many/w - Gets the list of currently configured AutoRoles. +#### /roles-clear-list + +- Clears all roles from the list of configured AutoRoles. + </details> <details> From 53809a79b75eb5d6ef9d92997b5749d692fd46df Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:51:33 +0100 Subject: [PATCH 086/404] Fixed duplicate key --- src/locale/AutoRoles.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml index a7222f1e..53f391b5 100644 --- a/src/locale/AutoRoles.toml +++ b/src/locale/AutoRoles.toml @@ -27,5 +27,5 @@ roles_get_list_success_title = "Current AutoRoles Config" roles_get_list_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" roles_clear_list_name = "roles-clear-list" -roles_clear_list_name = "Clears the list of roles to apply." +roles_clear_list_description = "Clears the list of roles to apply." roles_clear_list_success = "The list of roles has been cleared ✅" \ No newline at end of file From 222c886f406e53b3718fd3cc0e5e751f799fe49c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:58:44 +0100 Subject: [PATCH 087/404] Fixed roles not being assigned --- src/extensions/AutoRoles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 3be38b53..5de734c4 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -23,7 +23,7 @@ def __init__(self, bot: EsportsBot): @Cog.listener() async def on_member_join(self, member: Member): if not member.pending: - self.assign_roles(member) + await self.assign_roles(member) @Cog.listener() async def on_member_update(self, before: Member, after: Member): @@ -35,7 +35,7 @@ async def assign_roles(self, member: Member): if guild_roles: roles = [member.guild.get_role(x.role_id) for x in guild_roles] - await member.add_roles(roles) + await member.add_roles(*roles) @command(name=COG_STRINGS["roles_set_list_name"], description=COG_STRINGS["roles_set_list_description"]) @describe(roles=COG_STRINGS["roles_set_list_param_describe"]) From 69691cf3423763b9db04f165df997713956cb474 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 01:59:41 +0100 Subject: [PATCH 088/404] Updated README to reflect project status --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1bdfbee1..40676467 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,6 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Environment Variable: `ENABLE_AUTOROLES` -### _Only partial implementation_ - #### /roles-set-list \<One or many roles mentioned\> - Sets the roles to be given to new users when they join the guild/server. @@ -140,10 +138,11 @@ The user has control over the child Voice Channel name, and can limit how many/w - ~~Implement unimplemented commands in VoiceAdmin and AdminTools cogs.~~ - Implement EventTools cog -- Implement AutoRoles cog +- ~~Implement AutoRoles cog~~ - Add back functionality of previous bot (eg. Music, PingableRoles, etc.) - Add game deal tracker (DealTracker(?) cog) - Add proper support for SQLite auto increment primary keys +- Add proper use of command groups # Quick Setup Guide From 3347b0b333378b62e250e6c13dbfafb75f41e850 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 16:13:29 +0100 Subject: [PATCH 089/404] Updated bot parameter type --- src/extensions/EventTools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index e1cb7f77..12063d2d 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -2,13 +2,14 @@ import logging from common.io import load_cog_toml +from client import EsportsBot COG_STRINGS = load_cog_toml(__name__) class EventTools(Cog): - def __init__(self, bot: Bot): + def __init__(self, bot: EsportsBot): self.bot = bot self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") From e39919d0d427573554a5b561b0bf7a605e047955 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 21:41:06 +0100 Subject: [PATCH 090/404] Added String to Datetime transformer --- src/common/discord.py | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/common/discord.py b/src/common/discord.py index 7da3b08b..2100d502 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -3,6 +3,7 @@ from discord.abc import GuildChannel from typing import List, Union import re +from datetime import datetime ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") @@ -45,3 +46,54 @@ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: continue return parsed_roles + + +class TimezoneTransformer(Transformer): + DATE_REGEX = re.compile(r"(?P<Day>\d{2})\/(?P<Month>\d{2})\/(?P<Year>\d{4}|\d{2})") + TIME_REGEX = re.compile( + r"(?P<Hour>\d{2}):" + r"(?P<Minute>\d{2})" + r"(:(?P<Second>\d{2}))?" + r"((\s(?P<Zone0>\w{3}))|((?P<AMPMGap>\s)?(?P<AMPM>\w{2})\s(?P<Zone1>\w{3})))?" + ) + + async def transform(self, interaction: Interaction, date_string: str) -> datetime: + date_matches = re.search(self.DATE_REGEX, date_string) + + if date_matches is None or not all(date_matches.groupdict().values()): + raise ValueError("The given string did not contain a valid date component.") + + date_values = date_matches.groupdict() + day_format = "%-d" if len(date_values.get("Day")) == 1 else "%d" + month_format = "%-m" if len(date_values.get("Month")) == 1 else "%m" + year_format = "%y" if len(date_values.get("Year")) == 2 else "%Y" + + date_format = f"{day_format}/{month_format}/{year_format}" + + time_matches = re.search(self.TIME_REGEX, date_string) + + if time_matches is None or not any(date_matches.groupdict().values()): + raise ValueError("The given string did not contain a valid time component.") + + time_values = time_matches.groupdict() + + is_24_hr = time_values.get("AMPM") is None + + if is_24_hr: + hour_format = "%-H" if len(time_values.get("Hour")) == 1 else "%H" + zone_format = " %Z" + else: + hour_format = "%-I" if len(time_values.get("Hour")) == 1 else "%I" + zone_format = f"{' ' if time_values.get('AMPMGap') else ''}%p %Z" + + minute_format = "%-M" if len(time_values.get("Minute")) == 1 else "%M" + if time_values.get("Second"): + second_format = "%-S" if len(time_values.get("Second")) == 1 else "%S" + else: + second_format = "" + + time_format = f"{hour_format}:{minute_format}{':'+second_format if second_format else ''}{zone_format}" + + full_format = f"{date_format} {time_format}" + + return datetime.strptime(date_string, full_format) From d953c9aa8ecf6d18309e3a3a67642dd59c31bb85 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 27 Dec 2022 22:47:06 +0100 Subject: [PATCH 091/404] Added .astimezone() to TimezoneTransformer return statement --- src/common/discord.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/common/discord.py b/src/common/discord.py index 2100d502..d0350706 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -49,6 +49,7 @@ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: class TimezoneTransformer(Transformer): + # TODO: Update regex to accept shorter Timezone strings DATE_REGEX = re.compile(r"(?P<Day>\d{2})\/(?P<Month>\d{2})\/(?P<Year>\d{4}|\d{2})") TIME_REGEX = re.compile( r"(?P<Hour>\d{2}):" @@ -96,4 +97,4 @@ async def transform(self, interaction: Interaction, date_string: str) -> datetim full_format = f"{date_format} {time_format}" - return datetime.strptime(date_string, full_format) + return datetime.strptime(date_string, full_format).astimezone() From 6db3b93ffa773c957ff221126900e977597d6240 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 28 Dec 2022 00:41:30 +0100 Subject: [PATCH 092/404] Removed timezone from TimezoneTransformer --- src/common/discord.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index d0350706..bdaf66df 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -51,12 +51,10 @@ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: class TimezoneTransformer(Transformer): # TODO: Update regex to accept shorter Timezone strings DATE_REGEX = re.compile(r"(?P<Day>\d{2})\/(?P<Month>\d{2})\/(?P<Year>\d{4}|\d{2})") - TIME_REGEX = re.compile( - r"(?P<Hour>\d{2}):" - r"(?P<Minute>\d{2})" - r"(:(?P<Second>\d{2}))?" - r"((\s(?P<Zone0>\w{3}))|((?P<AMPMGap>\s)?(?P<AMPM>\w{2})\s(?P<Zone1>\w{3})))?" - ) + TIME_REGEX = re.compile(r"(?P<Hour>\d{2}):" + r"(?P<Minute>\d{2})" + r"(:(?P<Second>\d{2}))?" + r"(?P<AMPM>\w{2})?") async def transform(self, interaction: Interaction, date_string: str) -> datetime: date_matches = re.search(self.DATE_REGEX, date_string) @@ -82,10 +80,8 @@ async def transform(self, interaction: Interaction, date_string: str) -> datetim if is_24_hr: hour_format = "%-H" if len(time_values.get("Hour")) == 1 else "%H" - zone_format = " %Z" else: hour_format = "%-I" if len(time_values.get("Hour")) == 1 else "%I" - zone_format = f"{' ' if time_values.get('AMPMGap') else ''}%p %Z" minute_format = "%-M" if len(time_values.get("Minute")) == 1 else "%M" if time_values.get("Second"): @@ -93,8 +89,8 @@ async def transform(self, interaction: Interaction, date_string: str) -> datetim else: second_format = "" - time_format = f"{hour_format}:{minute_format}{':'+second_format if second_format else ''}{zone_format}" + time_format = f"{hour_format}:{minute_format}{':'+second_format if second_format else ''} {'' if is_24_hr else '%p'}" full_format = f"{date_format} {time_format}" - return datetime.strptime(date_string, full_format).astimezone() + return datetime.strptime(date_string, full_format) From 3779357315a8b7d40fad2a45d61d55c2fc6b9a16 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 28 Dec 2022 02:21:46 +0100 Subject: [PATCH 093/404] Created function to load timezone choices from file --- src/common/io.py | 12 +++++++++++ timezone.json | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 timezone.json diff --git a/src/common/io.py b/src/common/io.py index e5cdc502..eb6cba69 100644 --- a/src/common/io.py +++ b/src/common/io.py @@ -1,6 +1,7 @@ import os import toml import logging +import json from typing import Dict logger = logging.getLogger(__name__) @@ -23,3 +24,14 @@ def load_bot_version(): return file.readline() except FileNotFoundError: return None + + +def load_timezones(): + file_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "timezone.json")) + try: + with open(file_path, "r") as file: + data = json.load(file) + zones = data.get("timezones") + return zones + except FileNotFoundError: + return {} diff --git a/timezone.json b/timezone.json new file mode 100644 index 00000000..1482a30a --- /dev/null +++ b/timezone.json @@ -0,0 +1,52 @@ +{ + "timezones": { + "atvie": { + "_description": "Vienna, Austria", + "_alias": "Europe/Vienna" + }, + "deber": { + "_description": "Berlin, Germany", + "_alias": "Europe/Berlin" + }, + "esmad": { + "_description": "Madrid, Spain", + "_alias": "Europe/Madrid" + }, + "frpar": { + "_description": "Paris, France", + "_alias": "Europe/Paris" + }, + "gblon": { + "_description": "London, United Kingdom", + "_alias": "Europe/London Europe/Belfast GB GB-Eire" + }, + "hkhkg": { + "_description": "Hong Kong SAR China", + "_alias": "Asia/Hong_Kong Hongkong" + }, + "jptyo": { + "_description": "Tokyo, Japan", + "_alias": "Asia/Tokyo Japan" + }, + "krsel": { + "_description": "Seoul, South Korea", + "_alias": "Asia/Seoul ROK" + }, + "sgsin": { + "_description": "Singapore", + "_alias": "Asia/Singapore Singapore" + }, + "uslax": { + "_description": "Los Angeles, United States", + "_alias": "America/Los_Angeles US/Pacific US/Pacific-New" + }, + "usnyc": { + "_description": "New York, United States", + "_alias": "America/New_York US/Eastern" + }, + "usden": { + "_description": "Denver, United States", + "_alias": "America/Denver America/Shiprock Navajo US/Mountain" + } + } +} \ No newline at end of file From 00c3ec124b2994fcd8cb5a2ef914fe76966c0fa2 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 28 Dec 2022 13:38:51 +0100 Subject: [PATCH 094/404] Removed timezone component of TIME_REGEX --- src/common/discord.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index bdaf66df..0d130403 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -49,12 +49,13 @@ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: class TimezoneTransformer(Transformer): - # TODO: Update regex to accept shorter Timezone strings DATE_REGEX = re.compile(r"(?P<Day>\d{2})\/(?P<Month>\d{2})\/(?P<Year>\d{4}|\d{2})") - TIME_REGEX = re.compile(r"(?P<Hour>\d{2}):" - r"(?P<Minute>\d{2})" - r"(:(?P<Second>\d{2}))?" - r"(?P<AMPM>\w{2})?") + TIME_REGEX = re.compile( + r"(?P<Hour>\d{2}):" + r"(?P<Minute>\d{2})" + r"(:(?P<Second>\d{2}))?" + r"(?P<AMPMGap>\s)?(?P<AMPM>\w{2})?" + ) async def transform(self, interaction: Interaction, date_string: str) -> datetime: date_matches = re.search(self.DATE_REGEX, date_string) @@ -89,7 +90,14 @@ async def transform(self, interaction: Interaction, date_string: str) -> datetim else: second_format = "" - time_format = f"{hour_format}:{minute_format}{':'+second_format if second_format else ''} {'' if is_24_hr else '%p'}" + gap = " " if time_values.get("AMPMGap") else "" + + time_format = ( + f"{hour_format}:" # Hours + f"{minute_format}" # Minutes + f"{':'+second_format if second_format else ''}" # Seconds + f"{gap}{''if is_24_hr else '%p'}" # 12/24hr clock + ) full_format = f"{date_format} {time_format}" From 810807e14c0389d2d8ced9481330a792bb32a691 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 28 Dec 2022 13:39:31 +0100 Subject: [PATCH 095/404] Refactored TimezoneTransformer to DatetimeTransformer --- src/common/discord.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/discord.py b/src/common/discord.py index 0d130403..3d42b80e 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -48,7 +48,7 @@ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: return parsed_roles -class TimezoneTransformer(Transformer): +class DatetimeTransformer(Transformer): DATE_REGEX = re.compile(r"(?P<Day>\d{2})\/(?P<Month>\d{2})\/(?P<Year>\d{4}|\d{2})") TIME_REGEX = re.compile( r"(?P<Hour>\d{2}):" From 0a109f3dbf9ce4b7262139d1c8d2f4713c0d08cb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 28 Dec 2022 13:41:19 +0100 Subject: [PATCH 096/404] Created EventTools DB model with basic attributes --- src/database/models.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/database/models.py b/src/database/models.py index a826cc63..82517273 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -30,3 +30,13 @@ class AutoRolesConfig(base): primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) role_id = Column(BigInteger, nullable=False) + + +class EventToolsEvents(base): + __tablename__ = "eventtools_events" + primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + guild_id = Column(BigInteger, nullable=False) + channel_id = Column(BigInteger, nullable=False) + role_id = Column(BigInteger, nullable=False) + event_id = Column(BigInteger, nullable=False) + event_name = Column(String, nullable=False) From 3beeac2a9681528fde62d6f4ecbbd08807e12657 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 13:11:29 +0100 Subject: [PATCH 097/404] Applied isort import formatting --- src/client.py | 7 ++++--- src/common/discord.py | 9 +++++---- src/common/io.py | 7 ++++--- src/database/gateway.py | 4 ++-- src/database/models.py | 2 +- src/extensions/AdminTools.py | 7 ++++--- src/extensions/AutoRoles.py | 13 +++++++------ src/extensions/VoiceAdmin.py | 11 ++++++----- src/main.py | 4 +++- 9 files changed, 36 insertions(+), 28 deletions(-) diff --git a/src/client.py b/src/client.py index 824e5637..5f428215 100644 --- a/src/client.py +++ b/src/client.py @@ -1,8 +1,9 @@ -import os import logging -from typing import List, Dict, Union -from discord.ext.commands import Bot +import os +from typing import Dict, List, Union + from discord import Intents, Object +from discord.ext.commands import Bot __all__ = ["EsportsBot"] diff --git a/src/common/discord.py b/src/common/discord.py index 3d42b80e..c812050f 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -1,9 +1,10 @@ -from discord.app_commands import Transformer -from discord import Role, Interaction, Guild -from discord.abc import GuildChannel -from typing import List, Union import re from datetime import datetime +from typing import List, Union + +from discord import Guild, Interaction, Role +from discord.abc import GuildChannel +from discord.app_commands import Transformer ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") diff --git a/src/common/io.py b/src/common/io.py index eb6cba69..0851c952 100644 --- a/src/common/io.py +++ b/src/common/io.py @@ -1,9 +1,10 @@ -import os -import toml -import logging import json +import logging +import os from typing import Dict +import toml + logger = logging.getLogger(__name__) diff --git a/src/database/gateway.py b/src/database/gateway.py index ebc21e4c..fbc9105a 100644 --- a/src/database/gateway.py +++ b/src/database/gateway.py @@ -1,8 +1,8 @@ -import os import logging +import os from typing import Any -from sqlalchemy import create_engine, Table +from sqlalchemy import Table, create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy_utils import create_database, database_exists diff --git a/src/database/models.py b/src/database/models.py index 82517273..b6352bd6 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, BigInteger, Boolean +from sqlalchemy import BigInteger, Boolean, Column, String from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.declarative import declarative_base diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index e321fd03..fb2ab1de 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -1,10 +1,11 @@ +import logging + from discord import Interaction +from discord.app_commands import (checks, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, Cog -from discord.app_commands import command, describe, rename, default_permissions, checks, guild_only -import logging -from common.io import load_bot_version, load_cog_toml from client import EsportsBot +from common.io import load_bot_version, load_cog_toml COG_STRINGS = load_cog_toml(__name__) diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 5de734c4..673324f3 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -1,12 +1,13 @@ -from discord.ext.commands import Bot, Cog -from discord.app_commands import command, describe, rename, default_permissions, checks, guild_only, Transform -from discord import Member, Interaction, Role, Embed, Color - import logging from typing import List -from common.io import load_cog_toml -from common.discord import RoleListTransformer, get_role, primary_key_from_object + +from discord import Color, Embed, Interaction, Member, Role +from discord.app_commands import (Transform, checks, command, default_permissions, describe, guild_only, rename) +from discord.ext.commands import Bot, Cog + from client import EsportsBot +from common.discord import (RoleListTransformer, get_role, primary_key_from_object) +from common.io import load_cog_toml from database.gateway import DBSession from database.models import AutoRolesConfig diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index e3bd7398..b012a239 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -1,14 +1,15 @@ -from discord.errors import Forbidden +import logging + from discord import Interaction, Member, VoiceChannel, VoiceState +from discord.app_commands import (checks, command, default_permissions, describe, guild_only, rename) +from discord.errors import Forbidden from discord.ext.commands import Bot, Cog -from discord.app_commands import command, describe, rename, default_permissions, checks, guild_only -import logging +from client import EsportsBot from common.discord import primary_key_from_object from common.io import load_cog_toml -from database.models import VoiceAdminParent, VoiceAdminChild from database.gateway import DBSession -from client import EsportsBot +from database.models import VoiceAdminChild, VoiceAdminParent COG_STRINGS = load_cog_toml(__name__) diff --git a/src/main.py b/src/main.py index 90aa1405..3ba85822 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,9 @@ -import coloredlogs import logging import os import sys + +import coloredlogs + from bot import start_bot if __name__ == "__main__": From 6ca8a321b5a0ccf65856d7e5b7334f647d1ca777 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 14:58:03 +0100 Subject: [PATCH 098/404] Removed alternative aliases --- timezone.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/timezone.json b/timezone.json index 1482a30a..215d69b6 100644 --- a/timezone.json +++ b/timezone.json @@ -18,35 +18,35 @@ }, "gblon": { "_description": "London, United Kingdom", - "_alias": "Europe/London Europe/Belfast GB GB-Eire" + "_alias": "Europe/London" }, "hkhkg": { "_description": "Hong Kong SAR China", - "_alias": "Asia/Hong_Kong Hongkong" + "_alias": "Asia/Hong_Kong" }, "jptyo": { "_description": "Tokyo, Japan", - "_alias": "Asia/Tokyo Japan" + "_alias": "Asia/Tokyo" }, "krsel": { "_description": "Seoul, South Korea", - "_alias": "Asia/Seoul ROK" + "_alias": "Asia/Seoul" }, "sgsin": { "_description": "Singapore", - "_alias": "Asia/Singapore Singapore" + "_alias": "Asia/Singapore" }, "uslax": { "_description": "Los Angeles, United States", - "_alias": "America/Los_Angeles US/Pacific US/Pacific-New" + "_alias": "US/Pacific" }, "usnyc": { "_description": "New York, United States", - "_alias": "America/New_York US/Eastern" + "_alias": "US/Eastern" }, "usden": { "_description": "Denver, United States", - "_alias": "America/Denver America/Shiprock Navajo US/Mountain" + "_alias": "US/Mountain" } } } \ No newline at end of file From d838ed998ef28d1fd68833fa94e3f0be34959123 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 14:59:24 +0100 Subject: [PATCH 099/404] Implemented basic event creation command --- src/extensions/EventTools.py | 235 ++++++++++++++++++++++++++++++++++- src/locale/EventTools.toml | 14 +++ 2 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/locale/EventTools.toml diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 12063d2d..a2e464bc 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -1,19 +1,250 @@ +import logging +from dataclasses import dataclass, field +from datetime import datetime +from enum import IntEnum +from zoneinfo import ZoneInfo + +from discord import ( + EntityType, + EventStatus, + Interaction, + PermissionOverwrite, + PrivacyLevel, + Role, + ScheduledEvent, + SelectOption +) +from discord.app_commands import ( + Choice, + Transform, + checks, + choices, + command, + default_permissions, + describe, + guild_only, + rename +) from discord.ext.commands import Bot, Cog +from discord.ui import Select, View -import logging -from common.io import load_cog_toml from client import EsportsBot +from common.discord import DatetimeTransformer +from common.io import load_cog_toml, load_timezones COG_STRINGS = load_cog_toml(__name__) +EVENT_INTERACTION_PREFIX = f"{__name__}.interaction" +TIMEZONES = load_timezones() + +SIGN_IN_CHANNEL_SUFFIX = "sign-in" + +denied_perms = PermissionOverwrite(read_messages=False, send_messages=False, connect=False, view_channel=False) +read_only_perms = PermissionOverwrite(read_messages=True, send_messages=False, connect=False, view_channel=True) +writable_perms = PermissionOverwrite(read_messages=True, send_messages=True, connect=True, view_channel=True) + + +class RoleTypeEnum(IntEnum): + DEFAULT = 0 # Guild default role + COMMON = 1 # Common role amongst members + EVENT = 2 # Event specific role + BOTTOP = 3 # The Bot's top role + + +@dataclass(slots=True, unsafe_hash=True) +class Event: + name: str + start_time: datetime + end_time: datetime + guild_id: int = field(compare=True) + channel_id: int = field(compare=True) + event_id: int = field(hash=True) + role_id: int = None + + +def get_event_custom_id(guild_id: int, channel_id: int, suffix: str): + return f"{EVENT_INTERACTION_PREFIX}-{guild_id}-{channel_id}-{suffix}" + + +def get_category_permissions(role_type: RoleTypeEnum, is_signin: bool = False, is_open: bool = False): + # pass + match role_type: + case RoleTypeEnum.DEFAULT: + return denied_perms + case RoleTypeEnum.COMMON: + if not is_open: + return denied_perms + elif is_signin: + return read_only_perms + else: + return denied_perms + case RoleTypeEnum.EVENT: + if not is_open: + return denied_perms + elif is_signin: + return read_only_perms + else: + return writable_perms + case RoleTypeEnum.BOTTOP: + return writable_perms + case _: + return denied_perms class EventTools(Cog): def __init__(self, bot: EsportsBot): self.bot = bot + self.events = {} self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") + @Cog.listener() + async def on_scheduled_event_update(self, before: ScheduledEvent, after: ScheduledEvent): + if not self.events.get(before.id) or not self.events.get(after.id): + return False + + if after.status == EventStatus.scheduled: + pass + + if after.status == EventStatus.active: + pass + + if after.status == EventStatus.cancelled: + pass + + @Cog.listener() + async def on_interaction(self, interaction: Interaction): + if not interaction.data or not interaction.data.get("custom_id"): + return False + + if interaction.data.get("custom_id").startswith(EVENT_INTERACTION_PREFIX): + await interaction.response.send_message(interaction.data.get("custom_id"), ephemeral=True) + + @command(name=COG_STRINGS["events_create_event_name"], description=COG_STRINGS["events_create_event_description"]) + @describe( + event_name=COG_STRINGS["events_create_event_title_describe"], + event_location=COG_STRINGS["events_create_event_location_describe"], + event_start=COG_STRINGS["events_create_event_start_desribe"], + event_end=COG_STRINGS["events_create_event_end_describe"], + timezone=COG_STRINGS["events_create_event_timezone_describe"], + role=COG_STRINGS["events_create_event_role_describe"] + ) + @rename( + event_name=COG_STRINGS["events_create_event_title_rename"], + event_location=COG_STRINGS["events_create_event_location_rename"], + event_start=COG_STRINGS["events_create_event_start_rename"], + event_end=COG_STRINGS["events_create_event_end_rename"], + timezone=COG_STRINGS["events_create_event_timezone_rename"], + role=COG_STRINGS["events_create_event_role_rename"] + ) + @choices( + timezone=[Choice(name=TIMEZONES.get(x).get("_description"), + value=TIMEZONES.get(x).get("_alias")) for x in TIMEZONES] + ) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def create_event( + self, + interaction: Interaction, + event_name: str, + event_location: str, + event_start: Transform[datetime, + DatetimeTransformer], + event_end: Transform[datetime, + DatetimeTransformer], + timezone: Choice[str], + role: Role + ): + await interaction.response.defer() + + event_start_aware = event_start.replace(tzinfo=ZoneInfo(timezone.value)) + event_end_aware = event_end.replace(tzinfo=ZoneInfo(timezone.value)) + + event_role = await interaction.guild.create_role(name=f"{event_name} (Event)") + + category_permissions = { + interaction.guild.me: get_category_permissions(RoleTypeEnum.BOTTOP), + event_role: get_category_permissions(RoleTypeEnum.EVENT), + role: get_category_permissions(RoleTypeEnum.COMMON), + interaction.guild.default_role: get_category_permissions(RoleTypeEnum.DEFAULT) + } + + signin_permissions = { + interaction.guild.me: get_category_permissions(RoleTypeEnum.BOTTOP, + is_signin=True), + event_role: get_category_permissions(RoleTypeEnum.EVENT, + is_signin=True), + role: get_category_permissions(RoleTypeEnum.COMMON, + is_signin=True), + interaction.guild.default_role: get_category_permissions(RoleTypeEnum.DEFAULT, + is_signin=True) + } + + category = await interaction.guild.create_category(name=event_name, overwrites=category_permissions) + signin_channel = await interaction.guild.create_text_channel( + name=f"{event_name} {SIGN_IN_CHANNEL_SUFFIX}", + category=category, + overwrites=signin_permissions + ) + + event = await interaction.guild.create_scheduled_event( + name=event_name, + start_time=event_start_aware.astimezone(), + end_time=event_end_aware.astimezone(), + description=f"Once the event has started, use {signin_channel.mention} to sign in!", + location=f"Channel: {signin_channel.mention} | Building: {event_location}", + entity_type=EntityType.external, + privacy_level=PrivacyLevel.guild_only + ) + + signin_menu = View(timeout=None) + + options = [ + { + "label": "Not Signed In", + "description": f"Select this option to sign out of {event_name}", + "value": 0, + "emoji": "❎", + "default": True + }, + { + "label": "Signed In", + "description": f"Select this option to sign in to {event_name}", + "value": 1, + "emoji": "✅", + "default": False + } + ] + + sign_in_status = Select( + placeholder="Your Sign-in Status", + min_values=1, + max_values=1, + options=[SelectOption(**x) for x in options], + custom_id=get_event_custom_id(interaction.guild.id, + signin_channel.id, + "sign_in_status") + ) + + signin_menu.add_item(sign_in_status) + + await signin_channel.send(content=f"Use the menu below to sign in or out of {event_name}!", view=signin_menu) + + event_store = Event( + name=event_name, + start_time=event_start_aware, + end_time=event_end_aware, + guild_id=interaction.guild.id, + channel_id=signin_channel.id, + role_id=event_role.id, + event_id=event.id + ) + + self.events[event_store] = event_store + + await interaction.followup.send("Created event!", ephemeral=True) + async def setup(bot: Bot): await bot.add_cog(EventTools(bot)) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml new file mode 100644 index 00000000..2a3f60da --- /dev/null +++ b/src/locale/EventTools.toml @@ -0,0 +1,14 @@ +events_create_event_name = "events-create-event" +events_create_event_description = "Create a new event." +events_create_event_title_describe = "The name of the event." +events_create_event_title_rename = "event-name" +events_create_event_location_describe = "The physical location of the event." +events_create_event_location_rename = "event-location" +events_create_event_start_desribe = "Event start in format: dd/mm/yy(yy?) hh:mm(:ss?) (AM/PM?)" +events_create_event_start_rename = "start-time" +events_create_event_end_describe = "Event end in format: dd/mm/yy(yy?) hh:mm(:ss?) (AM/PM?)" +events_create_event_end_rename = "end-time" +events_create_event_role_describe = "A role that all users have." +events_create_event_timezone_describe = "The timezone of the given times." +events_create_event_timezone_rename = "timezone" +events_create_event_role_rename = "common-role" \ No newline at end of file From 7c7dd38822e937d5911b3b21132137e5abcb66c1 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 17:38:26 +0100 Subject: [PATCH 100/404] Implemented Colour transformer --- src/common/discord.py | 62 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index c812050f..cff279d5 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -2,13 +2,45 @@ from datetime import datetime from typing import List, Union -from discord import Guild, Interaction, Role +from discord import Colour, Guild, Interaction, Role from discord.abc import GuildChannel -from discord.app_commands import Transformer +from discord.app_commands import Choice, Transformer ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") +def make_colour_list(): + all_vars = dir(Colour) + colour_vars = dir(Colour) + + def valid_key(string: str): + starts_with = ["_", "from_", "to_"] + ends_with = ["_gray"] + start_end_with = [{"start": "__", "end": "__"}] + + for req in start_end_with: + if string.startswith(req["start"]) and string.endswith(req["end"]): + return False + + for req in starts_with: + if string.startswith(req): + return False + + for req in ends_with: + if string.endswith(req): + return False + + return True + + for key in all_vars: + if not valid_key(key) or key in ["value", "r", "g", "b"]: + colour_vars.remove(key) + return colour_vars + + +VALID_COLOUR_NAMES = make_colour_list() + + def raw_role_string_to_id(role_str: str): role_found = re.search(ROLE_REGEX, role_str) if not role_found: @@ -103,3 +135,29 @@ async def transform(self, interaction: Interaction, date_string: str) -> datetim full_format = f"{date_format} {time_format}" return datetime.strptime(date_string, full_format) + + +class ColourTransformer(Transformer): + + async def autocomplete(self, interaction: Interaction, current_str: str) -> List[Choice[str]]: + return [ + Choice(name=colour.replace("_", + " ").capitalize(), + value=colour) for colour in VALID_COLOUR_NAMES if current_str.lower() in colour.lower() + ][:25] + + async def transform(self, interaction: Interaction, input_string: str) -> Colour: + if input_string.startswith("#"): + try: + return Colour.from_str(input_string) + except ValueError: + return Colour.default() + elif input_string in VALID_COLOUR_NAMES: + return getattr(Colour, input_string)() + else: + try: + manual_name = input_string.replace(" ", "_").strip().lower() + colour = getattr(Colour, manual_name) + return colour() + except AttributeError: + return Colour.default() From c4d422c2c90dac1c04275b128f26df1c4695a079 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 17:42:18 +0100 Subject: [PATCH 101/404] Added `event-colour` option to events-create-event command --- src/extensions/EventTools.py | 18 ++++++++++++------ src/locale/EventTools.toml | 4 +++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index a2e464bc..b9a19710 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo from discord import ( + Colour, EntityType, EventStatus, Interaction, @@ -17,6 +18,7 @@ from discord.app_commands import ( Choice, Transform, + autocomplete, checks, choices, command, @@ -29,7 +31,7 @@ from discord.ui import Select, View from client import EsportsBot -from common.discord import DatetimeTransformer +from common.discord import ColourTransformer, DatetimeTransformer from common.io import load_cog_toml, load_timezones COG_STRINGS = load_cog_toml(__name__) @@ -127,7 +129,8 @@ async def on_interaction(self, interaction: Interaction): event_start=COG_STRINGS["events_create_event_start_desribe"], event_end=COG_STRINGS["events_create_event_end_describe"], timezone=COG_STRINGS["events_create_event_timezone_describe"], - role=COG_STRINGS["events_create_event_role_describe"] + role=COG_STRINGS["events_create_event_role_describe"], + event_colour=COG_STRINGS["events_create_event_colour_describe"] ) @rename( event_name=COG_STRINGS["events_create_event_title_rename"], @@ -135,12 +138,14 @@ async def on_interaction(self, interaction: Interaction): event_start=COG_STRINGS["events_create_event_start_rename"], event_end=COG_STRINGS["events_create_event_end_rename"], timezone=COG_STRINGS["events_create_event_timezone_rename"], - role=COG_STRINGS["events_create_event_role_rename"] + role=COG_STRINGS["events_create_event_role_rename"], + event_colour=COG_STRINGS["events_create_event_colour_rename"] ) @choices( timezone=[Choice(name=TIMEZONES.get(x).get("_description"), value=TIMEZONES.get(x).get("_alias")) for x in TIMEZONES] ) + @autocomplete(event_colour=ColourTransformer.autocomplete) @default_permissions(administrator=True) @checks.has_permissions(administrator=True) @guild_only() @@ -154,14 +159,15 @@ async def create_event( event_end: Transform[datetime, DatetimeTransformer], timezone: Choice[str], - role: Role + role: Role, + event_colour: Transform[Colour, + ColourTransformer] ): await interaction.response.defer() - event_start_aware = event_start.replace(tzinfo=ZoneInfo(timezone.value)) event_end_aware = event_end.replace(tzinfo=ZoneInfo(timezone.value)) - event_role = await interaction.guild.create_role(name=f"{event_name} (Event)") + event_role = await interaction.guild.create_role(name=f"{event_name} (Event)", color=event_colour) category_permissions = { interaction.guild.me: get_category_permissions(RoleTypeEnum.BOTTOP), diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index 2a3f60da..a686cbfe 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -11,4 +11,6 @@ events_create_event_end_rename = "end-time" events_create_event_role_describe = "A role that all users have." events_create_event_timezone_describe = "The timezone of the given times." events_create_event_timezone_rename = "timezone" -events_create_event_role_rename = "common-role" \ No newline at end of file +events_create_event_role_rename = "common-role" +events_create_event_colour_describe = "The colour to use for the event role. Choose from the list or give a hex value starting with #" +events_create_event_colour_rename = "event-role-colour" \ No newline at end of file From 7fcbfedae3a56e69cea38e0748c31f4dae8d8310 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 18:35:56 +0100 Subject: [PATCH 102/404] Updated sign-in message --- src/extensions/EventTools.py | 24 ++++++++++++++++++++---- src/locale/EventTools.toml | 6 +++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index b9a19710..fd9b42cb 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -6,6 +6,7 @@ from discord import ( Colour, + Embed, EntityType, EventStatus, Interaction, @@ -208,15 +209,15 @@ async def create_event( options = [ { - "label": "Not Signed In", + "label": COG_STRINGS["events_create_event_sign_out"], "description": f"Select this option to sign out of {event_name}", "value": 0, "emoji": "❎", "default": True }, { - "label": "Signed In", - "description": f"Select this option to sign in to {event_name}", + "label": COG_STRINGS["events_create_event_sign_in"], + "description": f"Select this option to sign into {event_name}", "value": 1, "emoji": "✅", "default": False @@ -235,7 +236,22 @@ async def create_event( signin_menu.add_item(sign_in_status) - await signin_channel.send(content=f"Use the menu below to sign in or out of {event_name}!", view=signin_menu) + signin_embed = Embed( + title=COG_STRINGS["events_create_event_embed_title"].format(name=event_name), + description=COG_STRINGS["events_create_event_embed_description"].format( + name=event_name, + location=event_location, + role=event_role.mention, + start=int(event.start_time.timestamp()), + end=int(event.end_time.timestamp()), + sign_in=COG_STRINGS["events_create_event_sign_in"], + sign_out=COG_STRINGS["events_create_event_sign_out"] + ), + color=event_colour, + url=event.url + ) + + await signin_channel.send(embed=signin_embed, view=signin_menu) event_store = Event( name=event_name, diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index a686cbfe..16847032 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -13,4 +13,8 @@ events_create_event_timezone_describe = "The timezone of the given times." events_create_event_timezone_rename = "timezone" events_create_event_role_rename = "common-role" events_create_event_colour_describe = "The colour to use for the event role. Choose from the list or give a hex value starting with #" -events_create_event_colour_rename = "event-role-colour" \ No newline at end of file +events_create_event_colour_rename = "event-role-colour" +events_create_event_sign_in = "Signed In" +events_create_event_sign_out = "Not Signed In" +events_create_event_embed_title = "About {name}" +events_create_event_embed_description = "Welcome to the {name} sign-in channel!\n\n{name} is being held in the `{location}` and will run from <t:{start}:d><t:{start}:t> till <t:{end}:d><t:{end}:t>.\n\nUse the menu below and select `{sign_in}` to sign into {name} and receive the {role} role. If you wish to remove the role, simply choose the `{sign_out}` option from the menu below." \ No newline at end of file From 64c7904a5d14d8b140a735bce4bf9075076d98fa Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 19:05:38 +0100 Subject: [PATCH 103/404] Refactored `role` to `common role` and moved permissions to function --- src/extensions/EventTools.py | 55 +++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index fd9b42cb..5c77435f 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -9,6 +9,7 @@ Embed, EntityType, EventStatus, + Guild, Interaction, PermissionOverwrite, PrivacyLevel, @@ -93,6 +94,36 @@ def get_category_permissions(role_type: RoleTypeEnum, is_signin: bool = False, i return denied_perms +def get_event_permissions(guild: Guild, event_role: Role, common_role: Role, is_open: bool): + category_permissions = { + guild.me: get_category_permissions(RoleTypeEnum.BOTTOP, + is_open=is_open), + event_role: get_category_permissions(RoleTypeEnum.EVENT, + is_open=is_open), + common_role: get_category_permissions(RoleTypeEnum.COMMON, + is_open=is_open), + guild.default_role: get_category_permissions(RoleTypeEnum.DEFAULT, + is_open=is_open) + } + + signin_permissions = { + guild.me: get_category_permissions(RoleTypeEnum.BOTTOP, + is_signin=True, + is_open=is_open), + event_role: get_category_permissions(RoleTypeEnum.EVENT, + is_signin=True, + is_open=is_open), + common_role: get_category_permissions(RoleTypeEnum.COMMON, + is_signin=True, + is_open=is_open), + guild.default_role: get_category_permissions(RoleTypeEnum.DEFAULT, + is_signin=True, + is_open=is_open) + } + + return (category_permissions, signin_permissions) + + class EventTools(Cog): def __init__(self, bot: EsportsBot): @@ -130,7 +161,7 @@ async def on_interaction(self, interaction: Interaction): event_start=COG_STRINGS["events_create_event_start_desribe"], event_end=COG_STRINGS["events_create_event_end_describe"], timezone=COG_STRINGS["events_create_event_timezone_describe"], - role=COG_STRINGS["events_create_event_role_describe"], + common_role=COG_STRINGS["events_create_event_role_describe"], event_colour=COG_STRINGS["events_create_event_colour_describe"] ) @rename( @@ -139,7 +170,7 @@ async def on_interaction(self, interaction: Interaction): event_start=COG_STRINGS["events_create_event_start_rename"], event_end=COG_STRINGS["events_create_event_end_rename"], timezone=COG_STRINGS["events_create_event_timezone_rename"], - role=COG_STRINGS["events_create_event_role_rename"], + common_role=COG_STRINGS["events_create_event_role_rename"], event_colour=COG_STRINGS["events_create_event_colour_rename"] ) @choices( @@ -160,7 +191,7 @@ async def create_event( event_end: Transform[datetime, DatetimeTransformer], timezone: Choice[str], - role: Role, + common_role: Role, event_colour: Transform[Colour, ColourTransformer] ): @@ -170,23 +201,7 @@ async def create_event( event_role = await interaction.guild.create_role(name=f"{event_name} (Event)", color=event_colour) - category_permissions = { - interaction.guild.me: get_category_permissions(RoleTypeEnum.BOTTOP), - event_role: get_category_permissions(RoleTypeEnum.EVENT), - role: get_category_permissions(RoleTypeEnum.COMMON), - interaction.guild.default_role: get_category_permissions(RoleTypeEnum.DEFAULT) - } - - signin_permissions = { - interaction.guild.me: get_category_permissions(RoleTypeEnum.BOTTOP, - is_signin=True), - event_role: get_category_permissions(RoleTypeEnum.EVENT, - is_signin=True), - role: get_category_permissions(RoleTypeEnum.COMMON, - is_signin=True), - interaction.guild.default_role: get_category_permissions(RoleTypeEnum.DEFAULT, - is_signin=True) - } + category_permissions, signin_permissions = get_event_permissions(interaction.guild, event_role, common_role, False) category = await interaction.guild.create_category(name=event_name, overwrites=category_permissions) signin_channel = await interaction.guild.create_text_channel( From 54fe159871a563da9153a9d4acdf23f0e1d29025 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 19:10:34 +0100 Subject: [PATCH 104/404] Added `ScheduledEvent` as valid type for primary_key_from_object --- src/common/discord.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index cff279d5..c88e20cf 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import List, Union -from discord import Colour, Guild, Interaction, Role +from discord import Colour, Guild, Interaction, Role, ScheduledEvent from discord.abc import GuildChannel from discord.app_commands import Choice, Transformer @@ -61,7 +61,7 @@ async def get_role(guild: Guild, role_id: int): return role -def primary_key_from_object(object: Union[Role, GuildChannel]): +def primary_key_from_object(object: Union[Role, GuildChannel, ScheduledEvent]): return int(f"{object.guild.id % 1000}{object.id % 1000}") From c6f9f0fbac8b8092f18ba0e5ffb042faf2b708e3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 19:11:06 +0100 Subject: [PATCH 105/404] Refactored role_id to event_role_id --- src/database/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/models.py b/src/database/models.py index b6352bd6..a57a4acc 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -37,6 +37,6 @@ class EventToolsEvents(base): primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) channel_id = Column(BigInteger, nullable=False) - role_id = Column(BigInteger, nullable=False) + event_role_id = Column(BigInteger, nullable=False) event_id = Column(BigInteger, nullable=False) event_name = Column(String, nullable=False) From ea68df1cfeeddd325225412d3bb345e9013df2bf Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 19:11:14 +0100 Subject: [PATCH 106/404] Added common_role_id --- src/database/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/database/models.py b/src/database/models.py index a57a4acc..dbb7e76f 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -38,5 +38,6 @@ class EventToolsEvents(base): guild_id = Column(BigInteger, nullable=False) channel_id = Column(BigInteger, nullable=False) event_role_id = Column(BigInteger, nullable=False) + common_role_id = Column(BigInteger, nullable=False) event_id = Column(BigInteger, nullable=False) event_name = Column(String, nullable=False) From 511ccaa8733595ebd8d3db56582f40bcc93faa91 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 19:13:02 +0100 Subject: [PATCH 107/404] Updated Event class to reflect DB model changes --- src/extensions/EventTools.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 5c77435f..c540fe78 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -54,15 +54,19 @@ class RoleTypeEnum(IntEnum): BOTTOP = 3 # The Bot's top role -@dataclass(slots=True, unsafe_hash=True) +@dataclass(slots=True) class Event: name: str start_time: datetime end_time: datetime guild_id: int = field(compare=True) channel_id: int = field(compare=True) - event_id: int = field(hash=True) - role_id: int = None + event_id: int + event_role_id: int = None + common_role_id: int = None + + def __hash__(self) -> int: + return self.event_id def get_event_custom_id(guild_id: int, channel_id: int, suffix: str): @@ -274,7 +278,8 @@ async def create_event( end_time=event_end_aware, guild_id=interaction.guild.id, channel_id=signin_channel.id, - role_id=event_role.id, + event_role_id=event_role.id, + common_role_id=common_role.id, event_id=event.id ) From ab5a5b61b27a83675fba109c3c70085aeb2363f5 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 19:15:36 +0100 Subject: [PATCH 108/404] Implemented saving events to DB --- src/extensions/EventTools.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index c540fe78..a15cf7e9 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -33,8 +33,10 @@ from discord.ui import Select, View from client import EsportsBot -from common.discord import ColourTransformer, DatetimeTransformer +from common.discord import (ColourTransformer, DatetimeTransformer, primary_key_from_object) from common.io import load_cog_toml, load_timezones +from database.gateway import DBSession +from database.models import EventToolsEvents COG_STRINGS = load_cog_toml(__name__) EVENT_INTERACTION_PREFIX = f"{__name__}.interaction" @@ -283,6 +285,16 @@ async def create_event( event_id=event.id ) + db_entry = EventToolsEvents( + primary_key=primary_key_from_object(event), + guild_id=interaction.guild.id, + channel_id=signin_channel.id, + event_role_id=event_role.id, + common_role_id=common_role.id, + event_id=event.id, + event_name=event_name + ) + DBSession.create(db_entry) self.events[event_store] = event_store await interaction.followup.send("Created event!", ephemeral=True) From b3fa077661b822381ec20fc3ebef1b620f559322 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 19:53:36 +0100 Subject: [PATCH 109/404] Removed storing of event start/end times --- src/extensions/EventTools.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index a15cf7e9..38933312 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -59,8 +59,6 @@ class RoleTypeEnum(IntEnum): @dataclass(slots=True) class Event: name: str - start_time: datetime - end_time: datetime guild_id: int = field(compare=True) channel_id: int = field(compare=True) event_id: int @@ -276,8 +274,6 @@ async def create_event( event_store = Event( name=event_name, - start_time=event_start_aware, - end_time=event_end_aware, guild_id=interaction.guild.id, channel_id=signin_channel.id, event_role_id=event_role.id, From 2115ce63767f67ae3895cca7495b126b740170db Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 20:00:19 +0100 Subject: [PATCH 110/404] Implement loading events from DB --- src/extensions/EventTools.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 38933312..9f9da1ac 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -132,10 +132,31 @@ class EventTools(Cog): def __init__(self, bot: EsportsBot): self.bot = bot - self.events = {} + self.events = self.load_events() self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") + def load_events(self): + db_entries = DBSession.list(EventToolsEvents) + all_events = {} + for entry in db_entries: + event = Event( + name=entry.event_name, + guild_id=entry.guild_id, + channel_id=entry.channel_id, + event_id=entry.event_id, + event_role_id=entry.event_role_id, + common_role_id=entry.common_role_id + ) + if all_events.get(event): + self.logger.warning( + f"Duplicate event found - {entry.event_name} " + f"(guildid - {entry.guild_id} | eventid - {entry.event_id}). Skipping adding this event..." + ) + continue + all_events[event] = event + return all_events + @Cog.listener() async def on_scheduled_event_update(self, before: ScheduledEvent, after: ScheduledEvent): if not self.events.get(before.id) or not self.events.get(after.id): From a98228fbba5b2475ddbf7bb2b0cbc36eca889e07 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 20:30:34 +0100 Subject: [PATCH 111/404] Change events dict to use event ID as key explicitly --- src/extensions/EventTools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 9f9da1ac..bbde3f3e 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -154,7 +154,7 @@ def load_events(self): f"(guildid - {entry.guild_id} | eventid - {entry.event_id}). Skipping adding this event..." ) continue - all_events[event] = event + all_events[entry.event_id] = event return all_events @Cog.listener() @@ -312,7 +312,7 @@ async def create_event( event_name=event_name ) DBSession.create(db_entry) - self.events[event_store] = event_store + self.events[event.id] = event_store await interaction.followup.send("Created event!", ephemeral=True) From 1dba4b06b6a033eb22db56282bd19cb86490943f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 20:34:17 +0100 Subject: [PATCH 112/404] Removed guild param and changed channel to event for event_custom_id --- src/extensions/EventTools.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index bbde3f3e..a2b43a79 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -69,8 +69,8 @@ def __hash__(self) -> int: return self.event_id -def get_event_custom_id(guild_id: int, channel_id: int, suffix: str): - return f"{EVENT_INTERACTION_PREFIX}-{guild_id}-{channel_id}-{suffix}" +def get_event_custom_id(event_id: int, suffix: str): + return f"{EVENT_INTERACTION_PREFIX}-{event_id}-{suffix}" def get_category_permissions(role_type: RoleTypeEnum, is_signin: bool = False, is_open: bool = False): @@ -101,7 +101,7 @@ def get_category_permissions(role_type: RoleTypeEnum, is_signin: bool = False, i def get_event_permissions(guild: Guild, event_role: Role, common_role: Role, is_open: bool): category_permissions = { guild.me: get_category_permissions(RoleTypeEnum.BOTTOP, - is_open=is_open), + is_open=is_open), event_role: get_category_permissions(RoleTypeEnum.EVENT, is_open=is_open), common_role: get_category_permissions(RoleTypeEnum.COMMON, @@ -269,8 +269,7 @@ async def create_event( min_values=1, max_values=1, options=[SelectOption(**x) for x in options], - custom_id=get_event_custom_id(interaction.guild.id, - signin_channel.id, + custom_id=get_event_custom_id(event.id, "sign_in_status") ) From c3666ff6f7a59ffebfff873ba6cb3aba03084218 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 20:36:46 +0100 Subject: [PATCH 113/404] Added log event for loading of events --- src/extensions/EventTools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index a2b43a79..70b42eae 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -134,6 +134,7 @@ def __init__(self, bot: EsportsBot): self.bot = bot self.events = self.load_events() self.logger = logging.getLogger(__name__) + self.logger.info(f"Loaded {len(self.events)} event(s) from DB") self.logger.info(f"{__name__} has been added as a Cog") def load_events(self): From de7fb5c106777258a7027a6f60603ae58d281b94 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 29 Dec 2022 20:47:31 +0100 Subject: [PATCH 114/404] Implemented basic opening/closing of events --- src/extensions/EventTools.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 70b42eae..eb7eac5b 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -158,19 +158,42 @@ def load_events(self): all_events[entry.event_id] = event return all_events + async def update_event_channel_permissions(self, event_id: int, guild: Guild, is_open: bool): + event = self.events.get(event_id) + event_role = guild.get_role(event.event_role_id) + common_role = guild.get_role(event.common_role_id) + category_permissions, signin_permissions = get_event_permissions(guild, event_role, common_role, is_open) + signin_channel = guild.get_channel(event.channel_id) + category = signin_channel.category + await category.edit(overwrites=category_permissions) + await signin_channel.edit(overwrites=signin_permissions) + @Cog.listener() async def on_scheduled_event_update(self, before: ScheduledEvent, after: ScheduledEvent): + # Not an EventTool event if not self.events.get(before.id) or not self.events.get(after.id): return False - if after.status == EventStatus.scheduled: - pass - - if after.status == EventStatus.active: - pass + # Open the sign-in channel when the event starts + if before.status == EventStatus.scheduled and after.status == EventStatus.active: + await self.update_event_channel_permissions(after.id, after.guild, is_open=True) + # Delete the channels and role upon cancellation if after.status == EventStatus.cancelled: - pass + event = self.events.pop(after.id) + event_role = after.guild.get_role(event.event_role_id) + signin_channel = after.guild.get_channel(event.channel_id) + category = signin_channel.category + for channel in category.channels: + await channel.delete() + await category.delete() + await event_role.delete() + db_entry = DBSession.get(EventToolsEvents, guild_id=event.guild_id, event_id=event.event_id) + DBSession.delete(db_entry) + + # Hide the channels again when the event ends + if after.status == EventStatus.ended: + await self.update_event_channel_permissions(after.id, after.guild, is_open=False) @Cog.listener() async def on_interaction(self, interaction: Interaction): From 16a5df4ea9d232c418c0815d45c3dd9cc7f4e78d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 31 Dec 2022 00:26:33 +0100 Subject: [PATCH 115/404] Reduced indentation of main path --- src/extensions/EventTools.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index eb7eac5b..f8c8d157 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -200,8 +200,10 @@ async def on_interaction(self, interaction: Interaction): if not interaction.data or not interaction.data.get("custom_id"): return False - if interaction.data.get("custom_id").startswith(EVENT_INTERACTION_PREFIX): - await interaction.response.send_message(interaction.data.get("custom_id"), ephemeral=True) + if not interaction.data.get("custom_id").startswith(EVENT_INTERACTION_PREFIX): + return False + + await interaction.response.send_message(interaction.data.get("custom_id"), ephemeral=True) @command(name=COG_STRINGS["events_create_event_name"], description=COG_STRINGS["events_create_event_description"]) @describe( From 786fcccfc5e25bfb7b3e51ca0fd34cb00b0b371f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 31 Dec 2022 00:56:31 +0100 Subject: [PATCH 116/404] Implemented sign in/out interaction --- src/extensions/EventTools.py | 45 ++++++++++++++++++++++++++++++++++-- src/locale/EventTools.toml | 4 +++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index f8c8d157..b8d3e9b8 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -43,6 +43,7 @@ TIMEZONES = load_timezones() SIGN_IN_CHANNEL_SUFFIX = "sign-in" +SIGN_IN_INTERACTION_SUFFIX = "sign_in_status" denied_perms = PermissionOverwrite(read_messages=False, send_messages=False, connect=False, view_channel=False) read_only_perms = PermissionOverwrite(read_messages=True, send_messages=False, connect=False, view_channel=True) @@ -73,6 +74,11 @@ def get_event_custom_id(event_id: int, suffix: str): return f"{EVENT_INTERACTION_PREFIX}-{event_id}-{suffix}" +def parse_custom_id(custom_id: str): + parts = custom_id.split("-") + return {"event_id": parts[1], "suffix": parts[2]} + + def get_category_permissions(role_type: RoleTypeEnum, is_signin: bool = False, is_open: bool = False): # pass match role_type: @@ -128,6 +134,21 @@ def get_event_permissions(guild: Guild, event_role: Role, common_role: Role, is_ return (category_permissions, signin_permissions) +async def handle_sign_in_menu(interaction: Interaction, event: Event): + is_signed_in = int(interaction.data.get("values")[0]) + + event_role = interaction.guild.get_role(event.event_role_id) + if not event_role: + return False, is_signed_in + + if is_signed_in: + await interaction.user.add_roles(event_role) + return True, is_signed_in + else: + await interaction.user.remove_roles(event_role) + return True, is_signed_in + + class EventTools(Cog): def __init__(self, bot: EsportsBot): @@ -203,7 +224,27 @@ async def on_interaction(self, interaction: Interaction): if not interaction.data.get("custom_id").startswith(EVENT_INTERACTION_PREFIX): return False - await interaction.response.send_message(interaction.data.get("custom_id"), ephemeral=True) + id_data = parse_custom_id(interaction.data.get("custom_id")) + + if not id_data.get("event_id").isdigit(): + self.logger.warning(f"Received malformed custom-id: {interaction.data.get('custom_id')}") + return False + + event = self.events.get(int(id_data.get("event_id"))) + if not event: + return False + + success, status = await handle_sign_in_menu(interaction, event) + + current_status = "Signed In" if status else "Not Signed In" + + if success: + await interaction.response.send_message( + COG_STRINGS["events_signin_status_success"].format(status=current_status, name=event.name), + ephemeral=True + ) + else: + await interaction.response.send_message(COG_STRINGS["events_signin_status_failed"], ephemeral=True) @command(name=COG_STRINGS["events_create_event_name"], description=COG_STRINGS["events_create_event_description"]) @describe( @@ -296,7 +337,7 @@ async def create_event( max_values=1, options=[SelectOption(**x) for x in options], custom_id=get_event_custom_id(event.id, - "sign_in_status") + SIGN_IN_INTERACTION_SUFFIX) ) signin_menu.add_item(sign_in_status) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index 16847032..c62f9e5b 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -17,4 +17,6 @@ events_create_event_colour_rename = "event-role-colour" events_create_event_sign_in = "Signed In" events_create_event_sign_out = "Not Signed In" events_create_event_embed_title = "About {name}" -events_create_event_embed_description = "Welcome to the {name} sign-in channel!\n\n{name} is being held in the `{location}` and will run from <t:{start}:d><t:{start}:t> till <t:{end}:d><t:{end}:t>.\n\nUse the menu below and select `{sign_in}` to sign into {name} and receive the {role} role. If you wish to remove the role, simply choose the `{sign_out}` option from the menu below." \ No newline at end of file +events_create_event_embed_description = "Welcome to the {name} sign-in channel!\n\n{name} is being held in the `{location}` and will run from <t:{start}:d><t:{start}:t> till <t:{end}:d><t:{end}:t>.\n\nUse the menu below and select `{sign_in}` to sign into {name} and receive the {role} role. If you wish to remove the role, simply choose the `{sign_out}` option from the menu below." +events_signin_status_success = "You are now `{status}` to {name} ✅" +events_signin_status_failed = "There was an issue while trying to update your sign-in status. Please try again later, or contact an admin ⚠️" \ No newline at end of file From 9153caa432bde3d2c8784f9707c60931963704cd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 7 Feb 2023 09:08:20 +0000 Subject: [PATCH 117/404] Added docstrings for existing functions --- src/extensions/EventTools.py | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index b8d3e9b8..7f559a36 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -159,6 +159,11 @@ def __init__(self, bot: EsportsBot): self.logger.info(f"{__name__} has been added as a Cog") def load_events(self): + """Load saved events from DB to be tracked by the bot. + + Returns: + dict: A dictionary mapping event ID to an Event object containing the event data. + """ db_entries = DBSession.list(EventToolsEvents) all_events = {} for entry in db_entries: @@ -180,6 +185,14 @@ def load_events(self): return all_events async def update_event_channel_permissions(self, event_id: int, guild: Guild, is_open: bool): + """Used to update the eventcategory and sign-in channel permissions, + based on if the event is currently open or not. + + Args: + event_id (int): The ID of the event to update. + guild (Guild): The guild in which the event exists. + is_open (bool): Whether or not the event is currently open or not. + """ event = self.events.get(event_id) event_role = guild.get_role(event.event_role_id) common_role = guild.get_role(event.common_role_id) @@ -191,6 +204,15 @@ async def update_event_channel_permissions(self, event_id: int, guild: Guild, is @Cog.listener() async def on_scheduled_event_update(self, before: ScheduledEvent, after: ScheduledEvent): + """The event listener for when a Discord Event has an update. + + Args: + before (ScheduledEvent): The state of the event before the change. + after (ScheduledEvent): The state of the event after the change. + + Returns: + bool: If the change was meaningfully handled. + """ # Not an EventTool event if not self.events.get(before.id) or not self.events.get(after.id): return False @@ -218,6 +240,16 @@ async def on_scheduled_event_update(self, before: ScheduledEvent, after: Schedul @Cog.listener() async def on_interaction(self, interaction: Interaction): + f"""The event listener for when a user performs an interaction. + + This event listener only listens for events that have a custom ID with the prefix of {EVENT_INTERACTION_PREFIX} + + Args: + interaction (Interaction): The interaction object holding the interaction data. + + Returns: + bool: If the interaction was meaningfully handled. + """ if not interaction.data or not interaction.data.get("custom_id"): return False @@ -287,6 +319,18 @@ async def create_event( event_colour: Transform[Colour, ColourTransformer] ): + """The command used to create a new event. + + Args: + interaction (Interaction): The interaction that triggered the command. + event_name (str): The name of the new event. + event_location (str): The physical location of the event in the real world. + event_start (Transform[datetime, DatetimeTransformer]): The start date and time of the event. + event_end (Transform[datetime, DatetimeTransformer]): The end date and time of the event. + timezone (Choice[str]): The timezone in which the event is happening. + common_role (Role): The role that all users have. Used to restrict the channel to actual guild members. + event_colour (Transform[Colour, ColourTransformer]): The colour to use for the event role. + """ await interaction.response.defer() event_start_aware = event_start.replace(tzinfo=ZoneInfo(timezone.value)) event_end_aware = event_end.replace(tzinfo=ZoneInfo(timezone.value)) From 50601ba02285329a4ada6e9cb053cac82aa3f6f6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 7 Feb 2023 12:05:29 +0000 Subject: [PATCH 118/404] Added arg transformer to autocomplete events --- src/common/discord.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/common/discord.py b/src/common/discord.py index c88e20cf..6c2dabab 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -161,3 +161,18 @@ async def transform(self, interaction: Interaction, input_string: str) -> Colour return colour() except AttributeError: return Colour.default() + + +class EventTransformer(Transformer): + + async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: + filtered_events = [] + all_events = self.events + guild_events = [all_events.get(x) for x in all_events if all_events.get(x).guild_id == interaction.guild_id] + if value.isdigit(): + filtered_events = [x for x in guild_events if value in str(x.event_id)] + else: + filtered_events = [x for x in guild_events if value.lower() in x.name.lower()] + + choices = [Choice(name=f"{x.name} ({x.event_id})", value=str(x.event_id)) for x in filtered_events][:25] + return choices From 751a9190607b7c606425855576d65bb026c93658 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 7 Feb 2023 12:08:41 +0000 Subject: [PATCH 119/404] Added checks for start/end date of event creation --- src/extensions/EventTools.py | 8 ++++++++ src/locale/EventTools.toml | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 7f559a36..9dcbfc7d 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -335,6 +335,14 @@ async def create_event( event_start_aware = event_start.replace(tzinfo=ZoneInfo(timezone.value)) event_end_aware = event_end.replace(tzinfo=ZoneInfo(timezone.value)) + if event_end_aware <= event_start_aware: + await interaction.followup.send(content=COG_STRINGS["events_create_event_warn_invalid_dates"], ephemeral=True) + return False + + if event_start_aware <= datetime.now(tz=ZoneInfo(timezone.value)): + await interaction.followup.send(content=COG_STRINGS["events_create_event_warn_invalid_start"], ephemeral=True) + return False + event_role = await interaction.guild.create_role(name=f"{event_name} (Event)", color=event_colour) category_permissions, signin_permissions = get_event_permissions(interaction.guild, event_role, common_role, False) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index c62f9e5b..c2d1cff0 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -14,6 +14,8 @@ events_create_event_timezone_rename = "timezone" events_create_event_role_rename = "common-role" events_create_event_colour_describe = "The colour to use for the event role. Choose from the list or give a hex value starting with #" events_create_event_colour_rename = "event-role-colour" +events_create_event_warn_invalid_dates = "The end time cannot be less-than or equal to the start time ⚠️" +events_create_event_warn_invalid_start = "The start date of the event cannot be in the past ⚠️" events_create_event_sign_in = "Signed In" events_create_event_sign_out = "Not Signed In" events_create_event_embed_title = "About {name}" From 07447033da730054af2393294066ba411ddba85b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 7 Feb 2023 12:09:01 +0000 Subject: [PATCH 120/404] Added open/close event commands --- src/extensions/EventTools.py | 136 ++++++++++++++++++++++++++++++++++- src/locale/EventTools.toml | 23 +++++- 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 9dcbfc7d..a756a018 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -33,7 +33,7 @@ from discord.ui import Select, View from client import EsportsBot -from common.discord import (ColourTransformer, DatetimeTransformer, primary_key_from_object) +from common.discord import (ColourTransformer, DatetimeTransformer, primary_key_from_object, EventTransformer) from common.io import load_cog_toml, load_timezones from database.gateway import DBSession from database.models import EventToolsEvents @@ -272,7 +272,8 @@ async def on_interaction(self, interaction: Interaction): if success: await interaction.response.send_message( - COG_STRINGS["events_signin_status_success"].format(status=current_status, name=event.name), + COG_STRINGS["events_signin_status_success"].format(status=current_status, + name=event.name), ephemeral=True ) else: @@ -434,6 +435,137 @@ async def create_event( await interaction.followup.send("Created event!", ephemeral=True) + @command(name=COG_STRINGS["events_open_event_name"], description=COG_STRINGS["events_open_event_description"]) + @describe( + event_id=COG_STRINGS["events_open_event_event_id_describe"], + ) + @rename( + event_id=COG_STRINGS["events_open_event_event_id_rename"], + ) + @autocomplete(event_id=EventTransformer.autocomplete) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def open_event(self, interaction: Interaction, event_id: str): + await interaction.response.defer() + if not event_id.isdigit(): + await interaction.followup.send( + content=COG_STRINGS["events_open_event_warn_invalid_id"].format(event=event_id), + ephemeral=True + ) + return False + + event_id_int = int(event_id) + event = self.events.get(event_id_int) + if event is None: + await interaction.followup.send( + content=COG_STRINGS["events_open_event_warn_invalid_id"].format(event=event_id), + ephemeral=True + ) + return False + + guild_events = interaction.guild.scheduled_events + discord_event = None + for guild_event in guild_events: + if guild_event.id == event.event_id: + discord_event = guild_event + break + + if not discord_event: + await interaction.followup.send(content=COG_STRINGS["events_open_event_error_missing_event"], ephemeral=True) + return False + + await discord_event.start() + await interaction.followup.send( + content=COG_STRINGS["events_open_event_success"].format(event_name=event.name), + ephemeral=self.bot.only_ephemeral + ) + return True + + @command(name=COG_STRINGS["events_close_event_name"], description=COG_STRINGS["events_close_event_description"]) + @describe( + event_id=COG_STRINGS["events_close_event_event_id_describe"], + archive=COG_STRINGS["events_close_event_archive_describe"], + clear_messages=COG_STRINGS["events_close_events_clear_messages_describe"], + ) + @rename( + event_id=COG_STRINGS["events_close_event_event_id_rename"], + archive=COG_STRINGS["events_close_event_archive_rename"], + clear_messages=COG_STRINGS["events_close_events_clear_messages_rename"], + ) + @autocomplete(event_id=EventTransformer.autocomplete) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def close_event(self, interaction: Interaction, event_id: str, archive: bool = True, clear_messages: bool = False): + await interaction.response.defer() + if not event_id.isdigit(): + await interaction.followup.send( + content=COG_STRINGS["events_close_event_warn_invalid_id"].format(event=event_id), + ephemeral=True + ) + return False + + event_id_int = int(event_id) + event = self.events.pop(event_id_int, None) + if event is None: + await interaction.followup.send( + content=COG_STRINGS["events_close_event_warn_invalid_id"].format(event=event_id), + ephemeral=True + ) + return False + + event_store = DBSession.get(EventToolsEvents, guild_id=interaction.guild.id, event_id=event.event_id) + sign_in_channel_id = event.channel_id + sign_in_channel = interaction.guild.get_channel(sign_in_channel_id) + if not sign_in_channel: + sign_in_channel = await interaction.guild.fetch_channel(sign_in_channel_id) + + category_channel = sign_in_channel.category + event_role = interaction.guild.get_role(event.event_role_id) + + guild_events = interaction.guild.scheduled_events + discord_event = None + for guild_event in guild_events: + if guild_event.id == event.event_id: + discord_event = guild_event + break + + if not discord_event: + await interaction.followup.send(content=COG_STRINGS["events_close_event_warn_missing_event"], ephemeral=True) + return False + + await discord_event.end() + + if not archive: + DBSession.delete(event_store) + category_channels = category_channel.channels + for channel in category_channels: + await channel.delete() + await category_channel.delete() + await event_role.delete() + await interaction.followup.send( + content=COG_STRINGS["events_close_event_success_no_archive"].format(event_name=event.name), + ephemeral=self.bot.only_ephemeral + ) + return True + elif clear_messages: + category_channels = category_channel.text_channels + for channel in category_channels: + if channel.id != sign_in_channel_id: + await channel.purge() + + await self.update_event_channel_permissions(event.event_id, interaction.guild, is_open=False) + + await interaction.followup.send( + content=COG_STRINGS["events_close_event_success"].format( + event_name=event.name, + result="cleared" if clear_messages else "not changed" + ), + ephemeral=self.bot.only_ephemeral + ) + return True + async def setup(bot: Bot): await bot.add_cog(EventTools(bot)) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index c2d1cff0..cde7fc4b 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -21,4 +21,25 @@ events_create_event_sign_out = "Not Signed In" events_create_event_embed_title = "About {name}" events_create_event_embed_description = "Welcome to the {name} sign-in channel!\n\n{name} is being held in the `{location}` and will run from <t:{start}:d><t:{start}:t> till <t:{end}:d><t:{end}:t>.\n\nUse the menu below and select `{sign_in}` to sign into {name} and receive the {role} role. If you wish to remove the role, simply choose the `{sign_out}` option from the menu below." events_signin_status_success = "You are now `{status}` to {name} ✅" -events_signin_status_failed = "There was an issue while trying to update your sign-in status. Please try again later, or contact an admin ⚠️" \ No newline at end of file +events_signin_status_failed = "There was an issue while trying to update your sign-in status. Please try again later, or contact an admin ⚠️" + +events_open_event_name = "events-open-event" +events_open_event_description = "Opens a given event." +events_open_event_event_id_describe = "The name or ID of the vent to open." +events_open_event_event_id_rename = "event" +events_open_event_success = "Successfully opened `{event_name}` ✅" +events_open_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" +events_open_event_error_missing_event = "There was an error finding the discord event to start, please contact an admin! ❌" + +events_close_event_name = "events-close-event" +events_close_event_description = "Closes a given event." +events_close_event_event_id_describe = "The name or ID of the event to close." +events_close_event_archive_describe = "Set to 'True' if you want to retain the channels, otherwise 'False' will delete the channels." +events_close_events_clear_messages_describe = "Set to 'True' if you want to clear the messages in the channels, otherwise 'False' will keep them." +events_close_event_event_id_rename = "event" +events_close_event_archive_rename = "keep-event" +events_close_events_clear_messages_rename = "clear-messages" +events_close_event_success = "Successfully closed `{event_name}`. The contents of the channels was {result} ✅" +events_close_event_success_no_archive = "Successfully closed `{event_name}` and deleted it's related data ✅" +events_close_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" +events_close_event_warn_missing_event = "There was an error finding the discord event to end ⚠️" \ No newline at end of file From 3ae18057ca9bf4f83571e044e402b88e87a0930f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 7 Feb 2023 13:36:16 +0000 Subject: [PATCH 121/404] Added archived flag for Event DB items --- src/database/models.py | 1 + src/extensions/EventTools.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/database/models.py b/src/database/models.py index dbb7e76f..b99f3d3a 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -41,3 +41,4 @@ class EventToolsEvents(base): common_role_id = Column(BigInteger, nullable=False) event_id = Column(BigInteger, nullable=False) event_name = Column(String, nullable=False) + is_archived = Column(Boolean, nullable=True, default=False) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index a756a018..7d697250 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -65,6 +65,7 @@ class Event: event_id: int event_role_id: int = None common_role_id: int = None + is_archived: bool = False def __hash__(self) -> int: return self.event_id From 19c93ebbd4dd883e28abb011b2bb6f0518b1005d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 7 Feb 2023 13:41:23 +0000 Subject: [PATCH 122/404] Implemented event archiving --- src/extensions/EventTools.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 7d697250..8c29b763 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -65,7 +65,6 @@ class Event: event_id: int event_role_id: int = None common_role_id: int = None - is_archived: bool = False def __hash__(self) -> int: return self.event_id @@ -154,19 +153,20 @@ class EventTools(Cog): def __init__(self, bot: EsportsBot): self.bot = bot - self.events = self.load_events() + self.events, self.archived_events = self.load_events() self.logger = logging.getLogger(__name__) self.logger.info(f"Loaded {len(self.events)} event(s) from DB") self.logger.info(f"{__name__} has been added as a Cog") def load_events(self): - """Load saved events from DB to be tracked by the bot. + """Load saved events from DB to be tracked by the bot, but split into active events and archived events. Returns: - dict: A dictionary mapping event ID to an Event object containing the event data. + tuple[dict, dict]: Each dictionary in the tuples mapps event ID to an Event object containing the event data. """ db_entries = DBSession.list(EventToolsEvents) - all_events = {} + active_events = {} + archived_events = {} for entry in db_entries: event = Event( name=entry.event_name, @@ -176,14 +176,17 @@ def load_events(self): event_role_id=entry.event_role_id, common_role_id=entry.common_role_id ) - if all_events.get(event): + if active_events.get(event): self.logger.warning( f"Duplicate event found - {entry.event_name} " f"(guildid - {entry.guild_id} | eventid - {entry.event_id}). Skipping adding this event..." ) continue - all_events[entry.event_id] = event - return all_events + if entry.is_archived: + archived_events[entry.event_id] = event + else: + active_events[entry.event_id] = event + return active_events, archived_events async def update_event_channel_permissions(self, event_id: int, guild: Guild, is_open: bool): """Used to update the eventcategory and sign-in channel permissions, @@ -557,7 +560,9 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo await channel.purge() await self.update_event_channel_permissions(event.event_id, interaction.guild, is_open=False) - + self.archived_events[event.event_id] = event + event_store.is_archived = True + DBSession.update(event_store) await interaction.followup.send( content=COG_STRINGS["events_close_event_success"].format( event_name=event.name, From ed471d28fe90ae4b843c58f7eece6a269b24e8de Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 7 Feb 2023 14:02:34 +0000 Subject: [PATCH 123/404] Added event_reschedule command outline --- src/extensions/EventTools.py | 35 +++++++++++++++++++++++++++++++++++ src/locale/EventTools.toml | 15 ++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 8c29b763..fcdcaf8f 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -572,6 +572,41 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo ) return True + @command(name=COG_STRINGS["events_reschedule_event_name"], description=COG_STRINGS["events_reschedule_event_description"]) + @describe( + event_id=COG_STRINGS["events_reschedule_event_event_id_describe"], + event_location=COG_STRINGS["events_reschedule_event_event_location_describe"], + event_start=COG_STRINGS["events_reschedule_event_event_start_describe"], + event_end=COG_STRINGS["events_reschedule_event_event_end_describe"], + timezone=COG_STRINGS["events_reschedule_event_timezone_describe"], + ) + @rename( + event_id=COG_STRINGS["events_reschedule_event_event_id_rename"], + event_location=COG_STRINGS["events_reschedule_event_event_location_rename"], + event_start=COG_STRINGS["events_reschedule_event_event_start_rename"], + event_end=COG_STRINGS["events_reschedule_event_event_end_rename"], + timezone=COG_STRINGS["events_reschedule_event_timezone_rename"], + ) + @choices( + timezone=[Choice(name=TIMEZONES.get(x).get("_description"), + value=TIMEZONES.get(x).get("_alias")) for x in TIMEZONES] + ) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def reschedule_event( + self, + interaction: Interaction, + event_id: str, + event_location: str, + event_start: Transform[datetime, + DatetimeTransformer], + event_end: Transform[datetime, + DatetimeTransformer], + timezone: Choice[str] + ): + pass + async def setup(bot: Bot): await bot.add_cog(EventTools(bot)) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index cde7fc4b..c25a672f 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -42,4 +42,17 @@ events_close_events_clear_messages_rename = "clear-messages" events_close_event_success = "Successfully closed `{event_name}`. The contents of the channels was {result} ✅" events_close_event_success_no_archive = "Successfully closed `{event_name}` and deleted it's related data ✅" events_close_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" -events_close_event_warn_missing_event = "There was an error finding the discord event to end ⚠️" \ No newline at end of file +events_close_event_warn_missing_event = "There was an error finding the discord event to end ⚠️" + +events_reschedule_event_name = "" +events_reschedule_event_description = "" +events_reschedule_event_event_id_describe = "" +events_reschedule_event_event_location_describe = "" +events_reschedule_event_event_start_describe = "" +events_reschedule_event_event_end_describe = "" +events_reschedule_event_timezone_describe = "" +events_reschedule_event_event_id_rename = "" +events_reschedule_event_event_location_rename = "" +events_reschedule_event_event_start_rename = "" +events_reschedule_event_event_end_rename = "" +events_reschedule_event_timezone_rename = "" \ No newline at end of file From f8545da5ebf55a4f1b60ce85850681a8f6564450 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 7 Feb 2023 14:06:41 +0000 Subject: [PATCH 124/404] Added event-reschedule-event command strings --- src/locale/EventTools.toml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index c25a672f..b11b0074 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -44,15 +44,15 @@ events_close_event_success_no_archive = "Successfully closed `{event_name}` and events_close_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" events_close_event_warn_missing_event = "There was an error finding the discord event to end ⚠️" -events_reschedule_event_name = "" -events_reschedule_event_description = "" -events_reschedule_event_event_id_describe = "" -events_reschedule_event_event_location_describe = "" -events_reschedule_event_event_start_describe = "" -events_reschedule_event_event_end_describe = "" -events_reschedule_event_timezone_describe = "" -events_reschedule_event_event_id_rename = "" -events_reschedule_event_event_location_rename = "" -events_reschedule_event_event_start_rename = "" -events_reschedule_event_event_end_rename = "" -events_reschedule_event_timezone_rename = "" \ No newline at end of file +events_reschedule_event_name = "events-reschedule-event" +events_reschedule_event_description = "Reuse an archived channel and schedule it again." +events_reschedule_event_event_id_describe = "The name or ID of the vent to open." +events_reschedule_event_event_location_describe = "The physical location of the event." +events_reschedule_event_event_start_describe = "Event start in format: dd/mm/yy(yy?) hh:mm(:ss?) (AM/PM?)" +events_reschedule_event_event_end_describe = "Event end in format: dd/mm/yy(yy?) hh:mm(:ss?) (AM/PM?)" +events_reschedule_event_timezone_describe = "The timezone of the given times." +events_reschedule_event_event_id_rename = "event" +events_reschedule_event_event_location_rename = "event-location" +events_reschedule_event_event_start_rename = "start-time" +events_reschedule_event_event_end_rename = "end-time" +events_reschedule_event_timezone_rename = "timezone" \ No newline at end of file From b8296025023d45475e5921f0f1294e06e8544446 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 10:42:30 +0000 Subject: [PATCH 125/404] Refactored EventTransformer and added ArchivedEventTransformer --- src/common/discord.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index 6c2dabab..b8ecb8e4 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -163,16 +163,25 @@ async def transform(self, interaction: Interaction, input_string: str) -> Colour return Colour.default() -class EventTransformer(Transformer): +def get_events(guild: Guild, event_dict: dict, value: str) -> List[Choice[str]]: + filtered_events = [] + guild_events = [event_dict.get(x) for x in event_dict if event_dict.get(x).guild_id == guild.id] + if value.isdigit(): + filtered_events = [x for x in guild_events if value in str(x.event_id)] + else: + filtered_events = [x for x in guild_events if value.lower() in x.name.lower()] + + choices = [Choice(name=f"{x.name} ({x.event_id})", value=str(x.event_id)) for x in filtered_events][:25] + return choices + + +class ActiveEventTransformer(Transformer): async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: - filtered_events = [] - all_events = self.events - guild_events = [all_events.get(x) for x in all_events if all_events.get(x).guild_id == interaction.guild_id] - if value.isdigit(): - filtered_events = [x for x in guild_events if value in str(x.event_id)] - else: - filtered_events = [x for x in guild_events if value.lower() in x.name.lower()] + return get_events(interaction.guild, self.events, value) - choices = [Choice(name=f"{x.name} ({x.event_id})", value=str(x.event_id)) for x in filtered_events][:25] - return choices + +class ArchivedEventTransformer(Transformer): + + async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: + return get_events(interaction.guild, self.archived_events, value) From 7f66145d54aa144890f5f94c0ca564d4dbedc97f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 10:42:49 +0000 Subject: [PATCH 126/404] Implemented events-reschedule-event command --- src/extensions/EventTools.py | 239 ++++++++++++++++++++++++----------- src/locale/EventTools.toml | 6 +- 2 files changed, 171 insertions(+), 74 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index fcdcaf8f..270e7c7d 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -15,7 +15,8 @@ PrivacyLevel, Role, ScheduledEvent, - SelectOption + SelectOption, + TextChannel ) from discord.app_commands import ( Choice, @@ -33,7 +34,13 @@ from discord.ui import Select, View from client import EsportsBot -from common.discord import (ColourTransformer, DatetimeTransformer, primary_key_from_object, EventTransformer) +from common.discord import ( + ColourTransformer, + DatetimeTransformer, + primary_key_from_object, + ActiveEventTransformer, + ArchivedEventTransformer +) from common.io import load_cog_toml, load_timezones from database.gateway import DBSession from database.models import EventToolsEvents @@ -149,6 +156,27 @@ async def handle_sign_in_menu(interaction: Interaction, event: Event): return True, is_signed_in +async def schedule_event( + guild: Guild, + name: str, + start_time: datetime, + end_time: datetime, + location: str, + signin_channel: TextChannel +): + discord_event = await guild.create_scheduled_event( + name=name, + start_time=start_time.astimezone(), + end_time=end_time.astimezone(), + description=f"Once the event has started, use {signin_channel.mention} to sign in!", + location=f"Channel: {signin_channel.mention} | Building {location}", + entity_type=EntityType.external, + privacy_level=PrivacyLevel.guild_only + ) + + return discord_event + + class EventTools(Cog): def __init__(self, bot: EsportsBot): @@ -206,6 +234,52 @@ async def update_event_channel_permissions(self, event_id: int, guild: Guild, is await category.edit(overwrites=category_permissions) await signin_channel.edit(overwrites=signin_permissions) + async def delete_event(self, guild: Guild, event_id: int = None, event: Event = None): + if event is None: + event = self.events.pop(event_id, None) + + if event is None: + return False + + event_store = DBSession.get(EventToolsEvents, guild_id=guild.id, event_id=event.event_id) + sign_in_channel_id = event.channel_id + sign_in_channel = guild.get_channel(sign_in_channel_id) + if not sign_in_channel: + sign_in_channel = await guild.fetch_channel(sign_in_channel_id) + + category_channel = sign_in_channel.category + event_role = guild.get_role(event.event_role_id) + DBSession.delete(event_store) + category_channels = category_channel.channels + for channel in category_channels: + await channel.delete() + await category_channel.delete() + await event_role.delete() + return True + + async def archive_event(self, guild: Guild, event_id: int = None, event: Event = None, clear_messages: bool = False): + if event is None: + event = self.events.pop(event_id, None) + + if event is None: + return False + + event_store = DBSession.get(EventToolsEvents, guild_id=guild.id, event_id=event.event_id) + + signin_channel = guild.get_channel(event.channel_id) + category = signin_channel.category + + if clear_messages: + for channel in category.text_channels: + if channel.id != signin_channel.id: + await channel.purge() + + await self.update_event_channel_permissions(event.event_id, guild, is_open=False) + self.archived_events[event.event_id] = event + event_store.is_archived = True + DBSession.update(event_store) + return True + @Cog.listener() async def on_scheduled_event_update(self, before: ScheduledEvent, after: ScheduledEvent): """The event listener for when a Discord Event has an update. @@ -227,20 +301,11 @@ async def on_scheduled_event_update(self, before: ScheduledEvent, after: Schedul # Delete the channels and role upon cancellation if after.status == EventStatus.cancelled: - event = self.events.pop(after.id) - event_role = after.guild.get_role(event.event_role_id) - signin_channel = after.guild.get_channel(event.channel_id) - category = signin_channel.category - for channel in category.channels: - await channel.delete() - await category.delete() - await event_role.delete() - db_entry = DBSession.get(EventToolsEvents, guild_id=event.guild_id, event_id=event.event_id) - DBSession.delete(db_entry) + await self.delete_event(after.guild, event_id=after.id) # Hide the channels again when the event ends if after.status == EventStatus.ended: - await self.update_event_channel_permissions(after.id, after.guild, is_open=False) + await self.archive_event(after.guild, event_id=after.id) @Cog.listener() async def on_interaction(self, interaction: Interaction): @@ -359,14 +424,13 @@ async def create_event( overwrites=signin_permissions ) - event = await interaction.guild.create_scheduled_event( - name=event_name, - start_time=event_start_aware.astimezone(), - end_time=event_end_aware.astimezone(), - description=f"Once the event has started, use {signin_channel.mention} to sign in!", - location=f"Channel: {signin_channel.mention} | Building: {event_location}", - entity_type=EntityType.external, - privacy_level=PrivacyLevel.guild_only + event = await schedule_event( + interaction.guild, + event_name, + event_start_aware, + event_end_aware, + event_location, + signin_channel ) signin_menu = View(timeout=None) @@ -446,7 +510,7 @@ async def create_event( @rename( event_id=COG_STRINGS["events_open_event_event_id_rename"], ) - @autocomplete(event_id=EventTransformer.autocomplete) + @autocomplete(event_id=ActiveEventTransformer.autocomplete) @default_permissions(administrator=True) @checks.has_permissions(administrator=True) @guild_only() @@ -497,7 +561,7 @@ async def open_event(self, interaction: Interaction, event_id: str): archive=COG_STRINGS["events_close_event_archive_rename"], clear_messages=COG_STRINGS["events_close_events_clear_messages_rename"], ) - @autocomplete(event_id=EventTransformer.autocomplete) + @autocomplete(event_id=ActiveEventTransformer.autocomplete) @default_permissions(administrator=True) @checks.has_permissions(administrator=True) @guild_only() @@ -519,57 +583,29 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo ) return False - event_store = DBSession.get(EventToolsEvents, guild_id=interaction.guild.id, event_id=event.event_id) - sign_in_channel_id = event.channel_id - sign_in_channel = interaction.guild.get_channel(sign_in_channel_id) - if not sign_in_channel: - sign_in_channel = await interaction.guild.fetch_channel(sign_in_channel_id) - - category_channel = sign_in_channel.category - event_role = interaction.guild.get_role(event.event_role_id) - - guild_events = interaction.guild.scheduled_events - discord_event = None - for guild_event in guild_events: - if guild_event.id == event.event_id: - discord_event = guild_event - break - - if not discord_event: - await interaction.followup.send(content=COG_STRINGS["events_close_event_warn_missing_event"], ephemeral=True) - return False - - await discord_event.end() - if not archive: - DBSession.delete(event_store) - category_channels = category_channel.channels - for channel in category_channels: - await channel.delete() - await category_channel.delete() - await event_role.delete() - await interaction.followup.send( - content=COG_STRINGS["events_close_event_success_no_archive"].format(event_name=event.name), - ephemeral=self.bot.only_ephemeral - ) - return True - elif clear_messages: - category_channels = category_channel.text_channels - for channel in category_channels: - if channel.id != sign_in_channel_id: - await channel.purge() + if await self.delete_event(interaction.guild, event=event): + await interaction.followup.send( + content=COG_STRINGS["events_close_event_success_no_archive"].format(event_name=event.name), + ephemeral=self.bot.only_ephemeral + ) + else: + await interaction.followup.send(content=COG_STRINGS[""], ephemeral=True) + return False + else: + if await self.archive_event(interaction.guild, event_id=event_id_int, event=event): + await interaction.followup.send( + content=COG_STRINGS["events_close_event_success"].format( + event_name=event.name, + result="cleared" if clear_messages else "not changed" + ), + ephemeral=self.bot.only_ephemeral + ) + + discord_event = interaction.guild.get_scheduled_event(event.event_id) + if discord_event is not None: + await discord_event.end() - await self.update_event_channel_permissions(event.event_id, interaction.guild, is_open=False) - self.archived_events[event.event_id] = event - event_store.is_archived = True - DBSession.update(event_store) - await interaction.followup.send( - content=COG_STRINGS["events_close_event_success"].format( - event_name=event.name, - result="cleared" if clear_messages else "not changed" - ), - ephemeral=self.bot.only_ephemeral - ) return True @command(name=COG_STRINGS["events_reschedule_event_name"], description=COG_STRINGS["events_reschedule_event_description"]) @@ -591,6 +627,7 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo timezone=[Choice(name=TIMEZONES.get(x).get("_description"), value=TIMEZONES.get(x).get("_alias")) for x in TIMEZONES] ) + @autocomplete(event_id=ArchivedEventTransformer.autocomplete) @default_permissions(administrator=True) @checks.has_permissions(administrator=True) @guild_only() @@ -605,7 +642,63 @@ async def reschedule_event( DatetimeTransformer], timezone: Choice[str] ): - pass + await interaction.response.defer() + + if not event_id.isdigit(): + await interaction.followup.send( + content=COG_STRINGS["events_reschedule_event_warn_invalid_id"].format(event=event_id), + ephemeral=True + ) + return False + + event_id_int = int(event_id) + event = self.archived_events.pop(event_id_int, None) + if event is None: + await interaction.followup.send( + content=COG_STRINGS["events_reschedule_event_warn_invalid_id"].format(event=event_id), + ephemeral=True + ) + return False + + event_store = DBSession.get( + EventToolsEvents, + guild_id=interaction.guild.id, + event_id=event.event_id, + ) + if event_store.is_archived: + event_store.is_archived = False + + event_start_aware = event_start.replace(tzinfo=ZoneInfo(timezone.value)) + event_end_aware = event_end.replace(tzinfo=ZoneInfo(timezone.value)) + + if event_end_aware <= event_start_aware: + await interaction.followup.send(content=COG_STRINGS["events_reschedule_event_warn_invalid_dates"], ephemeral=True) + return False + + if event_start_aware <= datetime.now(tz=ZoneInfo(timezone.value)): + await interaction.followup.send(content=COG_STRINGS["events_reschedule_event_warn_invalid_start"], ephemeral=True) + return False + + signin_channel = interaction.guild.get_channel(event.channel_id) + discord_event = await schedule_event( + interaction.guild, + event.name, + event_start_aware, + event_end_aware, + event_location, + signin_channel + ) + event.event_id = discord_event.id + event_store.event_id = discord_event.id + DBSession.update(event_store) + self.events[event.event_id] = event + + await interaction.followup.send( + content=COG_STRINGS["events_reschedule_event_success"].format(name=event.name, + event_id=event.event_id), + ephemeral=self.bot.only_ephemeral + ) + return True async def setup(bot: Bot): diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index b11b0074..8dde8324 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -55,4 +55,8 @@ events_reschedule_event_event_id_rename = "event" events_reschedule_event_event_location_rename = "event-location" events_reschedule_event_event_start_rename = "start-time" events_reschedule_event_event_end_rename = "end-time" -events_reschedule_event_timezone_rename = "timezone" \ No newline at end of file +events_reschedule_event_timezone_rename = "timezone" +events_reschedule_event_success = "The event `{name} (ID: {event_id})` has succcessfully been rescheduled ✅" +events_reschedule_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" +events_reschedule_event_warn_invalid_dates = "The end time cannot be less-than or equal to the start time ⚠️" +events_reschedule_event_warn_invalid_start "The start date of the event cannot be in the past ⚠️" \ No newline at end of file From 1c63a7f4bd8f27e7fdf342f52ab8a94d77c02f8d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 11:17:24 +0000 Subject: [PATCH 127/404] Fixed typos in open and reschedule commands --- src/locale/EventTools.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index 8dde8324..053be852 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -25,7 +25,7 @@ events_signin_status_failed = "There was an issue while trying to update your si events_open_event_name = "events-open-event" events_open_event_description = "Opens a given event." -events_open_event_event_id_describe = "The name or ID of the vent to open." +events_open_event_event_id_describe = "The name or ID of the event to open." events_open_event_event_id_rename = "event" events_open_event_success = "Successfully opened `{event_name}` ✅" events_open_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" @@ -46,7 +46,7 @@ events_close_event_warn_missing_event = "There was an error finding the discord events_reschedule_event_name = "events-reschedule-event" events_reschedule_event_description = "Reuse an archived channel and schedule it again." -events_reschedule_event_event_id_describe = "The name or ID of the vent to open." +events_reschedule_event_event_id_describe = "The name or ID of the event to reschedule." events_reschedule_event_event_location_describe = "The physical location of the event." events_reschedule_event_event_start_describe = "Event start in format: dd/mm/yy(yy?) hh:mm(:ss?) (AM/PM?)" events_reschedule_event_event_end_describe = "Event end in format: dd/mm/yy(yy?) hh:mm(:ss?) (AM/PM?)" From 27df20ac1aa753898a15e7edcd8d4c6563fae21f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 11:18:21 +0000 Subject: [PATCH 128/404] Added EventTransformer to search all events --- src/common/discord.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/common/discord.py b/src/common/discord.py index b8ecb8e4..11b5c85f 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -175,6 +175,12 @@ def get_events(guild: Guild, event_dict: dict, value: str) -> List[Choice[str]]: return choices +class EventTransformer(Transformer): + + async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: + return get_events(interaction.guild, self.events | self.archived_events, value) + + class ActiveEventTransformer(Transformer): async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: From ddb3b7139f947421a3d34a8cd37dd51d8f35cdaa Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 11:27:25 +0000 Subject: [PATCH 129/404] Implemented events-remove-event command --- src/extensions/EventTools.py | 45 ++++++++++++++++++++++++++++++++++++ src/locale/EventTools.toml | 9 +++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 270e7c7d..386bd5e3 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -38,6 +38,7 @@ ColourTransformer, DatetimeTransformer, primary_key_from_object, + EventTransformer, ActiveEventTransformer, ArchivedEventTransformer ) @@ -700,6 +701,50 @@ async def reschedule_event( ) return True + @command(name=COG_STRINGS["events_remove_event_name"], description=COG_STRINGS["events_remove_event_description"]) + @describe(event_id=COG_STRINGS["events_remove_event_event_id_describe"]) + @rename( + event_id=COG_STRINGS["events_remove_event_event_id_rename"], + ) + @autocomplete(event_id=EventTransformer.autocomplete) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) + @guild_only() + async def remove_event(self, interaction: Interaction, event_id: str): + await interaction.response.defer() + + if not event_id.isdigit(): + await interaction.followup.send( + content=COG_STRINGS["events_remove_event_warn_invalid_id"].format(event=event_id), + ephemeral=True + ) + return False + + event_id_int = int(event_id) + event = self.events.pop(event_id_int, None) + if event is None: + event = self.archived_events.pop(event_id_int, None) + + if event is None: + await interaction.followup.send( + content=COG_STRINGS["events_remove_event_warn_invalid_id"].format(event=event_id), + ephemeral=True + ) + return False + + event_store = DBSession.get( + EventToolsEvents, + guild_id=interaction.guild.id, + event_id=event_id_int, + ) + + await self.delete_event(interaction.guild, event=event) + + if event_store is not None: + DBSession.delete(event_store) + + await interaction.followup.send(content=COG_STRINGS[""].format(name=event.name)) + async def setup(bot: Bot): await bot.add_cog(EventTools(bot)) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index 053be852..7e94d4eb 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -59,4 +59,11 @@ events_reschedule_event_timezone_rename = "timezone" events_reschedule_event_success = "The event `{name} (ID: {event_id})` has succcessfully been rescheduled ✅" events_reschedule_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" events_reschedule_event_warn_invalid_dates = "The end time cannot be less-than or equal to the start time ⚠️" -events_reschedule_event_warn_invalid_start "The start date of the event cannot be in the past ⚠️" \ No newline at end of file +events_reschedule_event_warn_invalid_start "The start date of the event cannot be in the past ⚠️" + +events_remove_event_name = "events-remove-event" +events_remove_event_description = "Entirely deletes either an active or archived event." +events_remove_event_event_id_describe = "The name or ID of the event to delete." +events_remove_event_event_id_rename = "event" +events_remove_event_success = "Successfully deleted the `{name}` event ✅" +events_remove_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" \ No newline at end of file From 96048a6c1bfbb5d8d54a03287075b7ce637101bf Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 11:33:07 +0000 Subject: [PATCH 130/404] Fixed missing `=` sign between key and value --- src/locale/EventTools.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index 7e94d4eb..d0973d63 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -59,7 +59,7 @@ events_reschedule_event_timezone_rename = "timezone" events_reschedule_event_success = "The event `{name} (ID: {event_id})` has succcessfully been rescheduled ✅" events_reschedule_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" events_reschedule_event_warn_invalid_dates = "The end time cannot be less-than or equal to the start time ⚠️" -events_reschedule_event_warn_invalid_start "The start date of the event cannot be in the past ⚠️" +events_reschedule_event_warn_invalid_start = "The start date of the event cannot be in the past ⚠️" events_remove_event_name = "events-remove-event" events_remove_event_description = "Entirely deletes either an active or archived event." From 4bf56ef1cea60a32899680dab87d9743f4af002d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 11:38:36 +0000 Subject: [PATCH 131/404] Added missing COG_STRINGS key --- src/extensions/EventTools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 386bd5e3..54f6e114 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -743,7 +743,7 @@ async def remove_event(self, interaction: Interaction, event_id: str): if event_store is not None: DBSession.delete(event_store) - await interaction.followup.send(content=COG_STRINGS[""].format(name=event.name)) + await interaction.followup.send(content=COG_STRINGS["events_remove_event_success"].format(name=event.name), ephemeral=self.bot.only_ephemeral) async def setup(bot: Bot): From 418edfc4c7904e26825968d858f23bda463a5e35 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 11:50:25 +0000 Subject: [PATCH 132/404] Made response deferals ephemeral --- src/extensions/EventTools.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 54f6e114..ac5bcc13 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -402,7 +402,7 @@ async def create_event( common_role (Role): The role that all users have. Used to restrict the channel to actual guild members. event_colour (Transform[Colour, ColourTransformer]): The colour to use for the event role. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) event_start_aware = event_start.replace(tzinfo=ZoneInfo(timezone.value)) event_end_aware = event_end.replace(tzinfo=ZoneInfo(timezone.value)) @@ -516,7 +516,7 @@ async def create_event( @checks.has_permissions(administrator=True) @guild_only() async def open_event(self, interaction: Interaction, event_id: str): - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) if not event_id.isdigit(): await interaction.followup.send( content=COG_STRINGS["events_open_event_warn_invalid_id"].format(event=event_id), @@ -567,7 +567,7 @@ async def open_event(self, interaction: Interaction, event_id: str): @checks.has_permissions(administrator=True) @guild_only() async def close_event(self, interaction: Interaction, event_id: str, archive: bool = True, clear_messages: bool = False): - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) if not event_id.isdigit(): await interaction.followup.send( content=COG_STRINGS["events_close_event_warn_invalid_id"].format(event=event_id), @@ -643,7 +643,7 @@ async def reschedule_event( DatetimeTransformer], timezone: Choice[str] ): - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) if not event_id.isdigit(): await interaction.followup.send( @@ -711,7 +711,7 @@ async def reschedule_event( @checks.has_permissions(administrator=True) @guild_only() async def remove_event(self, interaction: Interaction, event_id: str): - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) if not event_id.isdigit(): await interaction.followup.send( From 4368f9ca8eca30b2ed35ed6ef8c4781adc2370a8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 11:56:22 +0000 Subject: [PATCH 133/404] Applied formatter --- src/extensions/EventTools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index ac5bcc13..39928600 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -743,7 +743,10 @@ async def remove_event(self, interaction: Interaction, event_id: str): if event_store is not None: DBSession.delete(event_store) - await interaction.followup.send(content=COG_STRINGS["events_remove_event_success"].format(name=event.name), ephemeral=self.bot.only_ephemeral) + await interaction.followup.send( + content=COG_STRINGS["events_remove_event_success"].format(name=event.name), + ephemeral=self.bot.only_ephemeral + ) async def setup(bot: Bot): From fd5c86a8c748b9952927c544b237cda2909eb7cb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:32:36 +0000 Subject: [PATCH 134/404] Moved import statement to ensure .env is loaded before --- src/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 3ba85822..d9567471 100644 --- a/src/main.py +++ b/src/main.py @@ -4,8 +4,6 @@ import coloredlogs -from bot import start_bot - if __name__ == "__main__": coloredlogs.install(level=logging.INFO) logger = logging.getLogger(__name__) @@ -19,4 +17,5 @@ logger.info("Deteced UNIX platform, using uvloop for asyncio operations!") import uvloop uvloop.install() + from bot import start_bot start_bot() From 3e62fa6c1de28a8a73d20681c496e2c0d370914c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:35:42 +0000 Subject: [PATCH 135/404] Altered dict pop/get usage to ensure value access --- src/extensions/EventTools.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 39928600..0e80aada 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -237,7 +237,7 @@ async def update_event_channel_permissions(self, event_id: int, guild: Guild, is async def delete_event(self, guild: Guild, event_id: int = None, event: Event = None): if event is None: - event = self.events.pop(event_id, None) + event = self.events.get(event_id, None) if event is None: return False @@ -250,6 +250,11 @@ async def delete_event(self, guild: Guild, event_id: int = None, event: Event = category_channel = sign_in_channel.category event_role = guild.get_role(event.event_role_id) + if event_store.is_archived: + self.archived_events.pop(event_id, None) + else: + self.events.pop(event_id, None) + DBSession.delete(event_store) category_channels = category_channel.channels for channel in category_channels: @@ -260,7 +265,7 @@ async def delete_event(self, guild: Guild, event_id: int = None, event: Event = async def archive_event(self, guild: Guild, event_id: int = None, event: Event = None, clear_messages: bool = False): if event is None: - event = self.events.pop(event_id, None) + event = self.events.get(event_id, None) if event is None: return False @@ -276,6 +281,7 @@ async def archive_event(self, guild: Guild, event_id: int = None, event: Event = await channel.purge() await self.update_event_channel_permissions(event.event_id, guild, is_open=False) + self.events.pop(event.event_id) self.archived_events[event.event_id] = event event_store.is_archived = True DBSession.update(event_store) @@ -576,7 +582,7 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo return False event_id_int = int(event_id) - event = self.events.pop(event_id_int, None) + event = self.events.get(event_id_int, None) if event is None: await interaction.followup.send( content=COG_STRINGS["events_close_event_warn_invalid_id"].format(event=event_id), @@ -721,9 +727,9 @@ async def remove_event(self, interaction: Interaction, event_id: str): return False event_id_int = int(event_id) - event = self.events.pop(event_id_int, None) + event = self.events.get(event_id_int, None) if event is None: - event = self.archived_events.pop(event_id_int, None) + event = self.archived_events.get(event_id_int, None) if event is None: await interaction.followup.send( From 5159eace82ea977ad976bc3add4163b5e970aff3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:37:02 +0000 Subject: [PATCH 136/404] Enforce permission syncing for event channels --- src/extensions/EventTools.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 0e80aada..8eadb7d4 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -52,6 +52,7 @@ SIGN_IN_CHANNEL_SUFFIX = "sign-in" SIGN_IN_INTERACTION_SUFFIX = "sign_in_status" +CATEGORY_ARCHIVED_SUFFIX = "(closed)" denied_perms = PermissionOverwrite(read_messages=False, send_messages=False, connect=False, view_channel=False) read_only_perms = PermissionOverwrite(read_messages=True, send_messages=False, connect=False, view_channel=True) @@ -230,9 +231,18 @@ async def update_event_channel_permissions(self, event_id: int, guild: Guild, is event_role = guild.get_role(event.event_role_id) common_role = guild.get_role(event.common_role_id) category_permissions, signin_permissions = get_event_permissions(guild, event_role, common_role, is_open) + synced_channels = [] signin_channel = guild.get_channel(event.channel_id) category = signin_channel.category - await category.edit(overwrites=category_permissions) + for channel in category.channels: + if channel.permissions_synced: + synced_channels.append(channel) + await category.edit( + name=f"{event.name}{' ' + CATEGORY_ARCHIVED_SUFFIX if not is_open else ''}", + overwrites=category_permissions + ) + for channel in synced_channels: + await channel.edit(sync_permissions=True) await signin_channel.edit(overwrites=signin_permissions) async def delete_event(self, guild: Guild, event_id: int = None, event: Event = None): From 21b3c4c0fc23451a2f87ef20671aa995f5c777a8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:37:45 +0000 Subject: [PATCH 137/404] Removed DB delete as it is performed in delete_event() --- src/extensions/EventTools.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 8eadb7d4..5ad36adb 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -748,17 +748,8 @@ async def remove_event(self, interaction: Interaction, event_id: str): ) return False - event_store = DBSession.get( - EventToolsEvents, - guild_id=interaction.guild.id, - event_id=event_id_int, - ) - await self.delete_event(interaction.guild, event=event) - if event_store is not None: - DBSession.delete(event_store) - await interaction.followup.send( content=COG_STRINGS["events_remove_event_success"].format(name=event.name), ephemeral=self.bot.only_ephemeral From a50d1e4608bfd4b8e3e0b98a67c34f441ba20df6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:39:17 +0000 Subject: [PATCH 138/404] Moved signin menu creation to function --- src/extensions/EventTools.py | 111 ++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 47 deletions(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 5ad36adb..1cd2d2af 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -297,6 +297,54 @@ async def archive_event(self, guild: Guild, event_id: int = None, event: Event = DBSession.update(event_store) return True + async def create_signin(self, event: Event, location: str, discord_event: ScheduledEvent, event_role: Role): + signin_menu = View(timeout=None) + + options = [ + { + "label": COG_STRINGS["events_create_event_sign_out"], + "description": f"Select this option to sign out of {event.name}", + "value": 0, + "emoji": "❎", + "default": True + }, + { + "label": COG_STRINGS["events_create_event_sign_in"], + "description": f"Select this option to sign into {event.name}", + "value": 1, + "emoji": "✅", + "default": False + } + ] + + sign_in_status = Select( + placeholder="Your Sign-in Status", + min_values=1, + max_values=1, + options=[SelectOption(**x) for x in options], + custom_id=get_event_custom_id(discord_event.id, + SIGN_IN_INTERACTION_SUFFIX) + ) + + signin_menu.add_item(sign_in_status) + + signin_embed = Embed( + title=COG_STRINGS["events_create_event_embed_title"].format(name=event.name), + description=COG_STRINGS["events_create_event_embed_description"].format( + name=event.name, + location=location, + role=event_role.mention, + start=int(discord_event.start_time.timestamp()), + end=int(discord_event.start_time.timestamp()), + sign_in=COG_STRINGS["events_create_event_sign_in"], + sign_out=COG_STRINGS["events_create_event_sign_out"] + ), + color=event_role.color, + url=discord_event.url + ) + + return signin_embed, signin_menu + @Cog.listener() async def on_scheduled_event_update(self, before: ScheduledEvent, after: ScheduledEvent): """The event listener for when a Discord Event has an update. @@ -450,53 +498,6 @@ async def create_event( signin_channel ) - signin_menu = View(timeout=None) - - options = [ - { - "label": COG_STRINGS["events_create_event_sign_out"], - "description": f"Select this option to sign out of {event_name}", - "value": 0, - "emoji": "❎", - "default": True - }, - { - "label": COG_STRINGS["events_create_event_sign_in"], - "description": f"Select this option to sign into {event_name}", - "value": 1, - "emoji": "✅", - "default": False - } - ] - - sign_in_status = Select( - placeholder="Your Sign-in Status", - min_values=1, - max_values=1, - options=[SelectOption(**x) for x in options], - custom_id=get_event_custom_id(event.id, - SIGN_IN_INTERACTION_SUFFIX) - ) - - signin_menu.add_item(sign_in_status) - - signin_embed = Embed( - title=COG_STRINGS["events_create_event_embed_title"].format(name=event_name), - description=COG_STRINGS["events_create_event_embed_description"].format( - name=event_name, - location=event_location, - role=event_role.mention, - start=int(event.start_time.timestamp()), - end=int(event.end_time.timestamp()), - sign_in=COG_STRINGS["events_create_event_sign_in"], - sign_out=COG_STRINGS["events_create_event_sign_out"] - ), - color=event_colour, - url=event.url - ) - - await signin_channel.send(embed=signin_embed, view=signin_menu) - event_store = Event( name=event_name, guild_id=interaction.guild.id, @@ -506,6 +507,10 @@ async def create_event( event_id=event.id ) + signin_embed, signin_menu = await self.create_signin(event_store, event_location, event, event_role) + + await signin_channel.send(embed=signin_embed, view=signin_menu) + db_entry = EventToolsEvents( primary_key=primary_key_from_object(event), guild_id=interaction.guild.id, @@ -705,11 +710,23 @@ async def reschedule_event( event_location, signin_channel ) + + event_role = interaction.guild.get_role(event.event_role_id) + if not event_role: + await interaction.followup.send(content=COG_STRINGS["events_reschedule_event_error_missing_role"], ephemeral=True) + return False + event.event_id = discord_event.id event_store.event_id = discord_event.id DBSession.update(event_store) self.events[event.event_id] = event + signin_embed, signin_menu = await self.create_signin(event, event_location, discord_event, event_role) + + await signin_channel.purge() + await signin_channel.send(embed=signin_embed, view=signin_menu) + await signin_channel.category.edit(name=event.name) + await interaction.followup.send( content=COG_STRINGS["events_reschedule_event_success"].format(name=event.name, event_id=event.event_id), From dbd579ed14b0f71b9e5d6727ecd23037b255f13c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:40:00 +0000 Subject: [PATCH 139/404] Fixed incorrect handling of scheduled events when they were yet to start --- src/extensions/EventTools.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 1cd2d2af..7a296027 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -265,6 +265,13 @@ async def delete_event(self, guild: Guild, event_id: int = None, event: Event = else: self.events.pop(event_id, None) + discord_event = guild.get_scheduled_event(event_id) + if discord_event is not None: + if discord_event.status.active: + await discord_event.stop() + else: + await discord_event.cancel() + DBSession.delete(event_store) category_channels = category_channel.channels for channel in category_channels: @@ -626,7 +633,10 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo discord_event = interaction.guild.get_scheduled_event(event.event_id) if discord_event is not None: - await discord_event.end() + if discord_event.status.active: + await discord_event.end() + else: + await discord_event.cancel() return True From b65eab3cc419f35f5620fb73f2ffa54d0b65e613 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:40:25 +0000 Subject: [PATCH 140/404] Added required string key/value --- src/locale/EventTools.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index d0973d63..35fe835f 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -60,6 +60,7 @@ events_reschedule_event_success = "The event `{name} (ID: {event_id})` has succc events_reschedule_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" events_reschedule_event_warn_invalid_dates = "The end time cannot be less-than or equal to the start time ⚠️" events_reschedule_event_warn_invalid_start = "The start date of the event cannot be in the past ⚠️" +events_reschedule_event_error_missing_role = "Unable to find associated role for {name} event. Please contact an admin if this is an error ❌" events_remove_event_name = "events-remove-event" events_remove_event_description = "Entirely deletes either an active or archived event." From 5132b91fe484183c0ba7ce25e22e61223ef8267c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:40:50 +0000 Subject: [PATCH 141/404] Fixed missing `clear_messages` arg in archive_event --- src/extensions/EventTools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/EventTools.py b/src/extensions/EventTools.py index 7a296027..e52b718b 100644 --- a/src/extensions/EventTools.py +++ b/src/extensions/EventTools.py @@ -622,7 +622,7 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo await interaction.followup.send(content=COG_STRINGS[""], ephemeral=True) return False else: - if await self.archive_event(interaction.guild, event_id=event_id_int, event=event): + if await self.archive_event(interaction.guild, event_id=event_id_int, event=event, clear_messages=clear_messages): await interaction.followup.send( content=COG_STRINGS["events_close_event_success"].format( event_name=event.name, From 70f6796fb6577fd804fe685a469b334c28bfae50 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:53:27 +0000 Subject: [PATCH 142/404] Updated README to reflect extension status --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 40676467..66782c6b 100644 --- a/README.md +++ b/README.md @@ -130,14 +130,33 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Environment Variable: `ENABLE_EVENTTOOLS` -### _Not yet implemented!_ +#### /events-create-event \<name\> \<physical location\> \<start time\> \<end time\> \<timezone\> \<common member role\> \<role color\> +- Creates a new event. + +#### /events-open-event \<event name or ID\> + +- Opens the given event. This will show the sign-in menu to members. + +#### /events-close-event \<event name or ID\> [optional: keep-event?] [optional: clear-messages?] + +- Ends the given event. This will hide all the channels from members. +- If keep-event is set to True, the event will be archived, otherwise it's channels and roles will be deleted. +- If clear-messages is set to True, when the event is archived, messages in all channels will be deleted. + +#### /events-reschedule-event \<physical location\> \<start time\> \<end time\> \<timezone\> + +- If an event has been archived, it can be reused and rescheduled for a new date using this command. + +#### /events-remove-event \<event name or ID\> + +- Entirely deletes either an active or archived event. </details> # TODO: - ~~Implement unimplemented commands in VoiceAdmin and AdminTools cogs.~~ -- Implement EventTools cog +- ~~Implement EventTools cog~~ - ~~Implement AutoRoles cog~~ - Add back functionality of previous bot (eg. Music, PingableRoles, etc.) - Add game deal tracker (DealTracker(?) cog) From 200fd608771918ae0f76db924365f5d5cf082a7e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 8 Feb 2023 22:55:31 +0000 Subject: [PATCH 143/404] Updated min-python-version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 66782c6b..d3a75662 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Dependency Versions: <div align=left> - <img src="https://img.shields.io/badge/min%20python%20version-3.8.0-green?style=flat-square" /> + <img src="https://img.shields.io/badge/min%20python%20version-3.9.0-green?style=flat-square" /> <img src="https://img.shields.io/badge/min%20postgres%20version-11-lightgrey?style=flat-square" /> <img src="https://img.shields.io/badge/min%20docker%20version-20.0.0-blue?style=flat-square" /> <img src="https://img.shields.io/badge/min%20docker--compose%20version-1.25.0-blue?style=flat-square" /> From 5f3a8429b83eeeda62eeac4dabdb5bb26f66bba9 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 07:40:03 +0000 Subject: [PATCH 144/404] Added extension TaskList --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d3a75662..a1985b72 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - Entirely deletes either an active or archived event. </details> -# TODO: +# TODO - ~~Implement unimplemented commands in VoiceAdmin and AdminTools cogs.~~ - ~~Implement EventTools cog~~ @@ -163,6 +163,28 @@ The user has control over the child Voice Channel name, and can limit how many/w - Add proper support for SQLite auto increment primary keys - Add proper use of command groups +## Previous extensions to implement + +<pre> +✅ Extension implemented either partially or fully. + +❌ High priority extension not yet implemented. + +⚠️ Low priority extension not yet implemented. +</pre> + +- [x] AdminCog ✅ Implemented as AdminTools +- [x] DefaultRoleCog ✅ Implemented as AutoRoles +- [x] EventCategoriesCog ✅ Implemented as EventTools +- [ ] LogChannelCog ⚠️ +- [ ] MusicCog ⚠️ +- [ ] PingableRolesCog ❌ +- [ ] RoleReactCog ❌ +- [ ] TwitchCog ⚠️ +- [ ] TwitterCog ❌ +- [x] VoicemasterCog ✅ Implemented as VocieAdmin +- [ ] VotingCog ⚠️ + # Quick Setup Guide Requirements needed to run: From da4c9cff179cee195c949dafab406434c652f6ee Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 08:00:08 +0000 Subject: [PATCH 145/404] Added simple contributing guide --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index a1985b72..83203c3c 100644 --- a/README.md +++ b/README.md @@ -224,3 +224,15 @@ pip install -r requirements.txt ```bash python3 src/main.py ``` + +# Contributing Guide + +If you wish to contribute to this bot please use the following paradigms: + +- Ensure that yapf is configured with the configuration defined in `setup.cfg` + - Optionally also configure flake8 to help with linting +- When adding a new extension consider the following: + - Create user-facing strings inside of `src/locale/` using the same name as the extension of the filename (eg. for VoiceAdmin.py extension, there exists VoiceAdmin.toml). The strings can then be loaded with `load_cog_strings(__name__)` from `common.io` + - Each extension should have an associated environment variable to enable/disable it. + - Extensions should be modular, meaning that they should be able to be enabled/disabled with hindering the function of other extensions +- Any file loading or IO operations should be defined in `src/common/io.py` From 858dc19583fb3c096878317d63ecc74671d640fd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 12:25:53 +0000 Subject: [PATCH 146/404] Added missing modesl to __all__ --- src/database/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/models.py b/src/database/models.py index b99f3d3a..7ff8ab18 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -4,7 +4,7 @@ base = declarative_base() -__all__ = ["base", "VoiceAdminParent", "VoiceAdminChild"] +__all__ = ["base", "VoiceAdminParent", "VoiceAdminChild", "AutoRolesConfig", "EventToolsEvents"] class VoiceAdminParent(base): From 757716f6bf0d9f7ff308a5ee9721917ac896bd0b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 12:37:15 +0000 Subject: [PATCH 147/404] Made all deferred responses ephemeral --- src/extensions/AdminTools.py | 4 ++-- src/extensions/AutoRoles.py | 8 ++++---- src/extensions/VoiceAdmin.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/extensions/AdminTools.py b/src/extensions/AdminTools.py index fb2ab1de..ae68eea7 100644 --- a/src/extensions/AdminTools.py +++ b/src/extensions/AdminTools.py @@ -40,7 +40,7 @@ async def get_member_count(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) member_count = interaction.guild.member_count await interaction.followup.send( @@ -80,7 +80,7 @@ async def clear_messages(self, interaction: Interaction, count: int = 5): await interaction.response.send_message(COG_STRINGS["admin_clear_warn_too_many"], ephemeral=True) return False - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) messages = await interaction.channel.purge(limit=count, before=interaction.created_at) await interaction.followup.send( COG_STRINGS["admin_clear_success"].format(count=len(messages)), diff --git a/src/extensions/AutoRoles.py b/src/extensions/AutoRoles.py index 673324f3..e8618871 100644 --- a/src/extensions/AutoRoles.py +++ b/src/extensions/AutoRoles.py @@ -55,7 +55,7 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ roles (Transform[List[Role], RoleListTransformer]): One or many roles mentioned. Do not need to be separated with a delimiter. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) initial_entries = DBSession.list(AutoRolesConfig, guild_id=interaction.guild.id) @@ -104,7 +104,7 @@ async def add_guild_role(self, interaction: Interaction, role: Role): interaction (Interaction): The interaction that triggered the command. role (Role): The role to add. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) db_entry = DBSession.get(AutoRolesConfig, guild_id=role.guild.id, role_id=role.id) @@ -133,7 +133,7 @@ async def remove_guild_role(self, interaction: Interaction, role: Role): interaction (Interaction): The interaction that triggered the command. role (Role): The role to remove. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) db_entry = DBSession.get(AutoRolesConfig, guild_id=role.guild.id, role_id=role.id) @@ -158,7 +158,7 @@ async def list_guild_roles(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) db_items = DBSession.list(AutoRolesConfig, guild_id=interaction.guild.id) diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/VoiceAdmin.py index b012a239..4d377dbc 100644 --- a/src/extensions/VoiceAdmin.py +++ b/src/extensions/VoiceAdmin.py @@ -156,7 +156,7 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann interaction (Interaction): The interaction that triggered the command. channel (VoiceChannel): The Voice Channel to set as a parent Voice Channel. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) if channel_is_parent(channel): await interaction.followup.send(COG_STRINGS["vc_set_parent_warn_already_parent"], ephemeral=True) @@ -197,7 +197,7 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh interaction (Interaction): The interaction that triggered the command. channel (VoiceChannel): The Voice Channel to stop behaving as a parent Voice Channel. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) if not channel_is_parent(channel): await interaction.followup.send(COG_STRINGS["vc_remove_parent_warn_not_parent"], ephemeral=True) @@ -219,7 +219,7 @@ async def get_parent_channels(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) db_items = DBSession.list(VoiceAdminParent) @@ -255,7 +255,7 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): new_name (str, optional): The new name to set the Voice Channel to. Defaults to the default child Voice Channel string. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) voice_state = interaction.user.voice @@ -308,7 +308,7 @@ async def lock_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) voice_state = interaction.user.voice @@ -382,7 +382,7 @@ async def unlock_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) voice_state = interaction.user.voice @@ -428,7 +428,7 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): user_limit (int, optional): The number of members to limit the child Voice Channel to. Defaults to the number of members in the child Voice Channel. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) voice_state = interaction.user.voice @@ -473,7 +473,7 @@ async def unlimit_channel(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) voice_state = interaction.user.voice From 2ca8fe764df09ac0bc796e0139787f8648bc59b6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 12:42:50 +0000 Subject: [PATCH 148/404] Made member count command output clearer --- src/locale/AdminTools.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locale/AdminTools.toml b/src/locale/AdminTools.toml index 4653e4a4..846e0a2f 100644 --- a/src/locale/AdminTools.toml +++ b/src/locale/AdminTools.toml @@ -1,6 +1,6 @@ admin_members_name = "admin-member-count" admin_members_description = "Get the current member count of the server." -admin_members_format = "Current member count — `{count}` ." +admin_members_format = "Current member count — `{count} members` ." admin_user_info_name = "admin-user-info" admin_user_info_title = "{user} — User Info" From 7edf395a885a4164d7e5de0bdcea947a2290fb8f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 13:11:39 +0000 Subject: [PATCH 149/404] Refactored extension loading to seperate dynamic and default extensions --- src/client.py | 43 ++++++++++++++-------- src/extensions/{ => default}/AdminTools.py | 0 src/extensions/default/__init__.py | 0 src/extensions/{ => dynamic}/AutoRoles.py | 0 src/extensions/{ => dynamic}/EventTools.py | 0 src/extensions/{ => dynamic}/VoiceAdmin.py | 0 src/extensions/dynamic/__init__.py | 0 7 files changed, 28 insertions(+), 15 deletions(-) rename src/extensions/{ => default}/AdminTools.py (100%) create mode 100644 src/extensions/default/__init__.py rename src/extensions/{ => dynamic}/AutoRoles.py (100%) rename src/extensions/{ => dynamic}/EventTools.py (100%) rename src/extensions/{ => dynamic}/VoiceAdmin.py (100%) create mode 100644 src/extensions/dynamic/__init__.py diff --git a/src/client.py b/src/client.py index 5f428215..bd4e668c 100644 --- a/src/client.py +++ b/src/client.py @@ -1,10 +1,11 @@ import logging import os -from typing import Dict, List, Union from discord import Intents, Object from discord.ext.commands import Bot +import glob + __all__ = ["EsportsBot"] @@ -20,31 +21,43 @@ def __init__(self, command_prefix: str, all_messages_ephemeral: bool, *args, **k self.logger = logging.getLogger(__name__) self.only_ephemeral = all_messages_ephemeral + def find_extensions(self): + defaults = [] + dynamic = [] + + def get_files(path): + files = [] + for file_path in glob.glob(path): + file = os.path.basename(file_path).split(".")[0] + if file != "__init__": + files.append(file) + return files + + defaults = get_files(os.path.join(os.path.dirname(__file__), "extensions", "default", "*.py")) + dynamic = get_files(os.path.join(os.path.dirname(__file__), "extensions", "dynamic", "*.py")) + + return defaults, dynamic + async def setup_hook(self): """The setup function that is called prior to the bot connecting to the Discord Gateway. """ if not self.only_ephemeral: self.only_ephemeral = os.getenv("ALL_MESSAGES_EPHEMERAL", "FALSE").upper() == "TRUE" - # List of extensions to load. Initialised with default extensions. - enabled_extensions: List[str] = ["AdminTools"] - # Dictionary of Environment variables -> extension name - MODULE_ENV_VARS: Dict[str, - Union[str, - None]] = { - "VOICEADMIN": "VoiceAdmin", - "EVENTTOOLS": "EventTools", - "AUTOROLES": "AutoRoles" - } + default_extensions, dynamic_extensions = self.find_extensions() + enabled_extensions = [] # For each of the enabled Environment variables, add it's respective extension to the list. - for var in MODULE_ENV_VARS: - if MODULE_ENV_VARS.get(var) and os.getenv(f"ENABLE_{var.upper()}", "FALSE").upper() == "TRUE": - enabled_extensions.append(MODULE_ENV_VARS.get(var)) + for extension in dynamic_extensions: + if os.getenv(f"ENABLE_{extension.upper()}", "FALSE").upper() == "TRUE": + enabled_extensions.append(extension) # Load the extensions from the generated list of enabled extensions. + for extension in default_extensions: + await self.load_extension(f"extensions.default.{extension}") + for extension in enabled_extensions: - await self.load_extension(f"extensions.{extension}") + await self.load_extension(f"extensions.dynamic.{extension}") # If in a dev environment, sync the commands to the dev guild. if os.getenv("DEV_GUILD_ID"): diff --git a/src/extensions/AdminTools.py b/src/extensions/default/AdminTools.py similarity index 100% rename from src/extensions/AdminTools.py rename to src/extensions/default/AdminTools.py diff --git a/src/extensions/default/__init__.py b/src/extensions/default/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/extensions/AutoRoles.py b/src/extensions/dynamic/AutoRoles.py similarity index 100% rename from src/extensions/AutoRoles.py rename to src/extensions/dynamic/AutoRoles.py diff --git a/src/extensions/EventTools.py b/src/extensions/dynamic/EventTools.py similarity index 100% rename from src/extensions/EventTools.py rename to src/extensions/dynamic/EventTools.py diff --git a/src/extensions/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py similarity index 100% rename from src/extensions/VoiceAdmin.py rename to src/extensions/dynamic/VoiceAdmin.py diff --git a/src/extensions/dynamic/__init__.py b/src/extensions/dynamic/__init__.py new file mode 100644 index 00000000..e69de29b From c25f406bb053d46c001a42bdc8ade2618e104d43 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 13:13:36 +0000 Subject: [PATCH 150/404] Updated README to reflect extension loading changes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 83203c3c..a3abe275 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,6 @@ If you wish to contribute to this bot please use the following paradigms: - Optionally also configure flake8 to help with linting - When adding a new extension consider the following: - Create user-facing strings inside of `src/locale/` using the same name as the extension of the filename (eg. for VoiceAdmin.py extension, there exists VoiceAdmin.toml). The strings can then be loaded with `load_cog_strings(__name__)` from `common.io` - - Each extension should have an associated environment variable to enable/disable it. + - If your extension should always be enabled, it should be in `extensions/default/`, otherwise it should have an environment variable to toggle it and it should be in `extensions/dynamic/`. - Extensions should be modular, meaning that they should be able to be enabled/disabled with hindering the function of other extensions - Any file loading or IO operations should be defined in `src/common/io.py` From a17e0c16e65f94437c4cd163225c14af78082ebe Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 14:11:06 +0000 Subject: [PATCH 151/404] Added docstrings to Transformer classes --- src/common/discord.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/common/discord.py b/src/common/discord.py index 11b5c85f..dd820610 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -66,6 +66,11 @@ def primary_key_from_object(object: Union[Role, GuildChannel, ScheduledEvent]): class RoleListTransformer(Transformer): + """The transformer class to transform a list of Roles given in a ccommand string to a list of discord.Role objects. + + Returns: + List[Role]: A list of Role objects that were contained in the string that were also valid roles. + """ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: roles_found = re.finditer(ROLE_REGEX, roles) @@ -82,6 +87,14 @@ async def transform(self, interaction: Interaction, roles: str) -> List[Role]: class DatetimeTransformer(Transformer): + """The transformer class to convert a datetime string into a datetime object. + + Raises: + ValueError: When the given string does not fit a datetime format. + + Returns: + datetime: The given string as a datetime object. + """ DATE_REGEX = re.compile(r"(?P<Day>\d{2})\/(?P<Month>\d{2})\/(?P<Year>\d{4}|\d{2})") TIME_REGEX = re.compile( r"(?P<Hour>\d{2}):" @@ -138,6 +151,12 @@ async def transform(self, interaction: Interaction, date_string: str) -> datetim class ColourTransformer(Transformer): + """The transformer that provides named colour autocompletion and converts the corresponding Color object. + Also provides the ability to convert a hex colour string to a Color object from the given string. + + Returns: + Color: The Color object of the colour string or hex string given. + """ async def autocomplete(self, interaction: Interaction, current_str: str) -> List[Choice[str]]: return [ @@ -176,18 +195,34 @@ def get_events(guild: Guild, event_dict: dict, value: str) -> List[Choice[str]]: class EventTransformer(Transformer): + """The transformer that provides autocompletion for exisiting events. Either using a partial name or a partial ID value. + + Returns: + List[Choice[str]]: The list of choices that map a user readable string of available events to their ID as the value. + """ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: return get_events(interaction.guild, self.events | self.archived_events, value) class ActiveEventTransformer(Transformer): + """The event trasnformer that only provides autocompletion for events that are not archived + (ie. scheduled or active events). + + Returns: + List[Choice[str]]: The list of choices that map a user readable string of non-archived events to their ID as the value. + """ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: return get_events(interaction.guild, self.events, value) class ArchivedEventTransformer(Transformer): + """The event trasnformer that only provides autocompletion for events that are archived. + + Returns: + List[Choice[str]]: The list of choices that map a user readable string of archived events to their ID as the value. + """ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: return get_events(interaction.guild, self.archived_events, value) From cec11548e796ad2bb491f9de752c46146dc1e19e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 14:16:33 +0000 Subject: [PATCH 152/404] Added docstrings to io methods --- src/common/io.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/common/io.py b/src/common/io.py index 0851c952..c0cbffd7 100644 --- a/src/common/io.py +++ b/src/common/io.py @@ -9,6 +9,14 @@ def load_cog_toml(cog_path: str) -> Dict: + """Load a cogs TOML file using a modules __name__ attribute as the key. + + Args: + cog_path (str): The relative path of a module. + + Returns: + Dict: A dictionary containng the key/value pairs defined in the cog's TOML file. + """ cog_name = os.path.splitext(cog_path)[-1][1:] path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "locale", f"{cog_name}.toml")) try: @@ -19,6 +27,11 @@ def load_cog_toml(cog_path: str) -> Dict: def load_bot_version(): + """Load the bot's version number from the defined version.txt file. + + Returns: + str: A string containing the bot's current version. + """ file_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "version.txt")) try: with open(file_path, "rt") as file: @@ -28,6 +41,11 @@ def load_bot_version(): def load_timezones(): + """Load a JSON file containing human readble and short timezone strings. + + Returns: + dict: A dictionary of short string to alterntive formats of timezones. + """ file_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "timezone.json")) try: with open(file_path, "r") as file: From 94a9164373e8bc51ff121f5135e751744456fabb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 9 Feb 2023 14:21:23 +0000 Subject: [PATCH 153/404] Added docstrings to __DBSession functions --- src/database/gateway.py | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/database/gateway.py b/src/database/gateway.py index fbc9105a..b62f30cb 100644 --- a/src/database/gateway.py +++ b/src/database/gateway.py @@ -33,6 +33,17 @@ def __init__(self): self.logger.info("Created DB models!") def list(self, table: Table, **args): + """Get multiple values from a query in a given table. + + Args: + table (Table): The table to query. + + Raises: + Exception: If there was an error while accessing the DB. + + Returns: + List: A list of rows from the table that match the paramters given. + """ try: return self.session.query(table).filter_by(**args).all() except Exception as error: @@ -43,6 +54,17 @@ def list(self, table: Table, **args): raise Exception(f"Error occured when using DB list - {error}") def get(self, table: Table, **args): + """Get a single row from a given Table. + + Args: + table (Table): The table to query. + + Raises: + Exception: If there was an error while accessing the DB. + + Returns: + Any: A row matching the given query. Else None if no rows match the query. + """ try: query = self.session.query(table).filter_by(**args).all() return query[0] if query != [] else query @@ -54,6 +76,14 @@ def get(self, table: Table, **args): raise Exception(f"Error occured when using DB get - {error}") def delete(self, record: Any): + """Delete a record in a Table. + + Args: + record (Any): The record to delete. + + Raises: + Exception: If there was an error while accessing the DB. + """ try: self.session.delete(record) self.session.commit() @@ -62,6 +92,14 @@ def delete(self, record: Any): raise Exception(f"Error occured when using DB delete - {error}") def create(self, record: Any): + """Create a new record in a given Table. + + Args: + record (Any): The record to insert. + + Raises: + Exception: If there was an error while accessing the DB. + """ try: self.session.add(record) self.session.commit() @@ -70,6 +108,14 @@ def create(self, record: Any): raise Exception(f"Error occured when using DB create - {error}") def update(self, record: Any): + """Update a given record with new data. + + Args: + record (Any): The record to update with the changes made to it. + + Raises: + Exception: If there was an error while accessing the DB. + """ try: self.session.add(record) self.session.commit() From 5b2ea41a5a7a8d81cc48a4ae04a6a167cf755d13 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 10 Feb 2023 00:17:51 +0100 Subject: [PATCH 154/404] Started implementing basic Music Extension --- secrets.template | 3 +- src/extensions/dynamic/VCMusic.py | 111 ++++++++++++++++++++++++++++++ src/locale/VCMusic.toml | 17 +++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/extensions/dynamic/VCMusic.py create mode 100644 src/locale/VCMusic.toml diff --git a/secrets.template b/secrets.template index f2b83200..2e7c4785 100644 --- a/secrets.template +++ b/secrets.template @@ -23,4 +23,5 @@ DB_OVERRIDE= ENABLE_VOICEADMIN=FALSE ENABLE_EVENTTOOLS=FALSE -ENABLE_AUTOROLES=FALSE \ No newline at end of file +ENABLE_AUTOROLES=FALSE +ENABLE_VCMUSIC=FALSE \ No newline at end of file diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py new file mode 100644 index 00000000..2ed3b5db --- /dev/null +++ b/src/extensions/dynamic/VCMusic.py @@ -0,0 +1,111 @@ +import logging + +from discord import ButtonStyle, Colour, Embed, Interaction, TextChannel +from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) +from discord.ext.commands import Bot, GroupCog +from discord.ui import Button, View + +from client import EsportsBot +from common.discord import ColourTransformer +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) +# AUTHOR_ID = 244050529271939073 # it me :) +AUTHOR_ID = 202978567741505536 # alt account :) +MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" +EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" + + +def make_empty_embed(color: Colour, author: str): + embed = Embed(title=COG_STRINGS["music_embed_title_idle"], color=color) + embed.set_image(url=EMBED_IMAGE_URL) + if author is None: + author = "fuxticks" + embed.set_footer(text=f"Made by {author} 💖") + return embed + + +def make_default_action_row(): + view = View(timeout=None) + + play_button = Button(style=ButtonStyle.secondary, emoji="▶️") + pause_button = Button(style=ButtonStyle.secondary, emoji="⏸️") + add_button = Button(style=ButtonStyle.primary, label=COG_STRINGS["music_button_add_song"], emoji="➕") + view_button = Button(style=ButtonStyle.primary, label=COG_STRINGS["music_button_view_queue"], emoji="📋") + edit_button = Button(style=ButtonStyle.primary, label=COG_STRINGS["music_button_edit_queue"], emoji="✏️") + stop_button = Button(style=ButtonStyle.danger, label=COG_STRINGS["music_button_stop_queue"], emoji="⏹️") + + view.add_item(play_button) + view.add_item(pause_button) + view.add_item(add_button) + view.add_item(view_button) + view.add_item(edit_button) + view.add_item(stop_button) + + return view + + +class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): + + def __init__(self, bot: EsportsBot): + self.bot = bot + self.author = "fuxticks#1809" + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + @GroupCog.listener() + async def on_interaction(self, interaction: Interaction): + if not interaction.data or not interaction.data.get("custom_id"): + return False + + if not interaction.data.get("custom_id").startswith(MUSIC_INTERACTION_PREFIX): + return False + + @GroupCog.listener() + async def on_ready(self): + for guild in self.bot.guilds: + try: + for member in guild.members: + if member.id == AUTHOR_ID: + self.logger.info(f"Found {member} as author !") + self.author = f"{member}" + raise StopIteration + except StopIteration: + break + + @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) + @describe( + channel=COG_STRINGS["music_set_channel_channel_describe"], + clear_messages=COG_STRINGS["music_set_channel_clear_messages_describe"], + embed_color=COG_STRINGS["music_set_channel_embed_color_describe"] + ) + @rename( + channel=COG_STRINGS["music_set_channel_channel_rename"], + clear_messages=COG_STRINGS["music_set_channel_clear_messages_rename"], + embed_color=COG_STRINGS["music_set_channel_embed_color_rename"] + ) + @autocomplete(embed_color=ColourTransformer.autocomplete) + @guild_only() + async def set_channel( + self, + interaction: Interaction, + channel: TextChannel, + clear_messages: bool = False, + embed_color: Transform[Colour, + ColourTransformer] = Colour(0xd462fd) + ): + await interaction.response.defer(ephemeral=True) + + if clear_messages: + await channel.purge() + + embed = make_empty_embed(embed_color, self.author) + view = make_default_action_row() + + await channel.send(embed=embed, view=view) + + await interaction.followup.send(content=COG_STRINGS["music_set_channel_success"].format(channel=channel.mention)) + + +async def setup(bot: Bot): + await bot.add_cog(VCMusic(bot)) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml new file mode 100644 index 00000000..101c186f --- /dev/null +++ b/src/locale/VCMusic.toml @@ -0,0 +1,17 @@ +music_group_name = "music" + +music_embed_title_idle = "No song currently playing...." +music_button_add_song = "Add Song" +music_button_view_queue = "View Queue" +music_button_edit_queue = "Edit Queue" +music_button_stop_queue = "Stop" + +music_set_channel_name = "set-channel" +music_set_channel_description = "Sets the music channel to the given Text Channel." +music_set_channel_channel_describe = "The channel to set." +music_set_channel_channel_rename = "text-channel" +music_set_channel_clear_messages_describe = "If the channel to be set should be cleared first." +music_set_channel_clear_messages_rename = "clear-channel" +music_set_channel_embed_color_describe = "The colour to use for the embed. Defaults to #d462fd." +music_set_channel_embed_color_rename = "color" +music_set_channel_success = "Successfully set {channel} as the music channel ✅" \ No newline at end of file From 26a1a7d8969a2e7ccf069250f3713748ae184f01 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 10 Feb 2023 00:20:26 +0100 Subject: [PATCH 155/404] Updated author log messages --- src/extensions/dynamic/VCMusic.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 2ed3b5db..cb0d54d3 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -19,8 +19,6 @@ def make_empty_embed(color: Colour, author: str): embed = Embed(title=COG_STRINGS["music_embed_title_idle"], color=color) embed.set_image(url=EMBED_IMAGE_URL) - if author is None: - author = "fuxticks" embed.set_footer(text=f"Made by {author} 💖") return embed @@ -49,7 +47,7 @@ class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: EsportsBot): self.bot = bot - self.author = "fuxticks#1809" + self.author = "fuxticks" self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") @@ -64,14 +62,13 @@ async def on_interaction(self, interaction: Interaction): @GroupCog.listener() async def on_ready(self): for guild in self.bot.guilds: - try: - for member in guild.members: - if member.id == AUTHOR_ID: - self.logger.info(f"Found {member} as author !") - self.author = f"{member}" - raise StopIteration - except StopIteration: - break + for member in guild.members: + if member.id == AUTHOR_ID: + self.logger.info(f"Found {member} as VCMusic author !") + self.author = f"{member}" + return True + self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {self.author}") + return False @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( From da0896921450fab7ddfee87567ca40b67902db4e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 10 Feb 2023 00:32:45 +0100 Subject: [PATCH 156/404] Added custom interaction ids to music buttons --- src/extensions/dynamic/VCMusic.py | 55 +++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index cb0d54d3..03109cfd 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -9,6 +9,8 @@ from common.discord import ColourTransformer from common.io import load_cog_toml +from enum import Enum + COG_STRINGS = load_cog_toml(__name__) # AUTHOR_ID = 244050529271939073 # it me :) AUTHOR_ID = 202978567741505536 # alt account :) @@ -16,6 +18,15 @@ EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" +class MusicButtons(Enum): + PLAY = "play" + PAUSE = "pause" + ADD = "add" + VIEW = "view" + EDIT = "edit" + STOP = "stop" + + def make_empty_embed(color: Colour, author: str): embed = Embed(title=COG_STRINGS["music_embed_title_idle"], color=color) embed.set_image(url=EMBED_IMAGE_URL) @@ -26,12 +37,40 @@ def make_empty_embed(color: Colour, author: str): def make_default_action_row(): view = View(timeout=None) - play_button = Button(style=ButtonStyle.secondary, emoji="▶️") - pause_button = Button(style=ButtonStyle.secondary, emoji="⏸️") - add_button = Button(style=ButtonStyle.primary, label=COG_STRINGS["music_button_add_song"], emoji="➕") - view_button = Button(style=ButtonStyle.primary, label=COG_STRINGS["music_button_view_queue"], emoji="📋") - edit_button = Button(style=ButtonStyle.primary, label=COG_STRINGS["music_button_edit_queue"], emoji="✏️") - stop_button = Button(style=ButtonStyle.danger, label=COG_STRINGS["music_button_stop_queue"], emoji="⏹️") + play_button = Button( + style=ButtonStyle.secondary, + emoji="▶️", + custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.PLAY.value}" + ) + pause_button = Button( + style=ButtonStyle.secondary, + emoji="⏸️", + custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.PAUSE.value}" + ) + add_button = Button( + style=ButtonStyle.primary, + label=COG_STRINGS["music_button_add_song"], + emoji="➕", + custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.ADD.value}" + ) + view_button = Button( + style=ButtonStyle.primary, + label=COG_STRINGS["music_button_view_queue"], + emoji="📋", + custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.VIEW.value}" + ) + edit_button = Button( + style=ButtonStyle.primary, + label=COG_STRINGS["music_button_edit_queue"], + emoji="✏️", + custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.EDIT.value}" + ) + stop_button = Button( + style=ButtonStyle.danger, + label=COG_STRINGS["music_button_stop_queue"], + emoji="⏹️", + custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.STOP.value}" + ) view.add_item(play_button) view.add_item(pause_button) @@ -59,6 +98,10 @@ async def on_interaction(self, interaction: Interaction): if not interaction.data.get("custom_id").startswith(MUSIC_INTERACTION_PREFIX): return False + action = interaction.data.get("custom_id") + + await interaction.response.send_message(f"Recieved action: {action}", ephemeral=True) + @GroupCog.listener() async def on_ready(self): for guild in self.bot.guilds: From 1ba4ac8b13a995c1f43f297476f7cef7b923dd50 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 10 Feb 2023 10:34:32 +0000 Subject: [PATCH 157/404] Refactored custom IDs to standard generator function --- src/extensions/dynamic/VCMusic.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 03109cfd..97628f3a 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -27,6 +27,10 @@ class MusicButtons(Enum): STOP = "stop" +def make_custom_id(action: Enum): + return f"{MUSIC_INTERACTION_PREFIX}-{action.value}" + + def make_empty_embed(color: Colour, author: str): embed = Embed(title=COG_STRINGS["music_embed_title_idle"], color=color) embed.set_image(url=EMBED_IMAGE_URL) @@ -37,39 +41,31 @@ def make_empty_embed(color: Colour, author: str): def make_default_action_row(): view = View(timeout=None) - play_button = Button( - style=ButtonStyle.secondary, - emoji="▶️", - custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.PLAY.value}" - ) - pause_button = Button( - style=ButtonStyle.secondary, - emoji="⏸️", - custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.PAUSE.value}" - ) + play_button = Button(style=ButtonStyle.secondary, emoji="▶️", custom_id=make_custom_id(MusicButtons.PLAY)) + pause_button = Button(style=ButtonStyle.secondary, emoji="⏸️", custom_id=make_custom_id(MusicButtons.PAUSE)) add_button = Button( style=ButtonStyle.primary, label=COG_STRINGS["music_button_add_song"], emoji="➕", - custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.ADD.value}" + custom_id=make_custom_id(MusicButtons.ADD) ) view_button = Button( style=ButtonStyle.primary, label=COG_STRINGS["music_button_view_queue"], emoji="📋", - custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.VIEW.value}" + custom_id=make_custom_id(MusicButtons.VIEW) ) edit_button = Button( style=ButtonStyle.primary, label=COG_STRINGS["music_button_edit_queue"], emoji="✏️", - custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.EDIT.value}" + custom_id=make_custom_id(MusicButtons.EDIT) ) stop_button = Button( style=ButtonStyle.danger, label=COG_STRINGS["music_button_stop_queue"], emoji="⏹️", - custom_id=f"{MUSIC_INTERACTION_PREFIX}-{MusicButtons.STOP.value}" + custom_id=make_custom_id(MusicButtons.STOP) ) view.add_item(play_button) From fe955ab1590b5dcfb2f7e2f803a3afba1014df22 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 10 Feb 2023 17:34:44 +0000 Subject: [PATCH 158/404] Implemented handling of different interaction IDs --- src/extensions/dynamic/VCMusic.py | 62 +++++++++++++++++++++++++++---- src/locale/VCMusic.toml | 2 + 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 97628f3a..b28e73c5 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,16 +1,15 @@ import logging +from enum import Enum -from discord import ButtonStyle, Colour, Embed, Interaction, TextChannel +from discord import (ButtonStyle, Colour, Embed, Interaction, TextChannel, TextStyle) from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog -from discord.ui import Button, View +from discord.ui import Button, Modal, TextInput, View from client import EsportsBot from common.discord import ColourTransformer from common.io import load_cog_toml -from enum import Enum - COG_STRINGS = load_cog_toml(__name__) # AUTHOR_ID = 244050529271939073 # it me :) AUTHOR_ID = 202978567741505536 # alt account :) @@ -27,6 +26,12 @@ class MusicButtons(Enum): STOP = "stop" +class MusicModalActions(Enum): + ADD_MODAL = "modal.add" + ADD_MODAL_SINGLE = "modal.add.single" + ADD_MODAL_MULTI = "modal.add.multi" + + def make_custom_id(action: Enum): return f"{MUSIC_INTERACTION_PREFIX}-{action.value}" @@ -94,9 +99,28 @@ async def on_interaction(self, interaction: Interaction): if not interaction.data.get("custom_id").startswith(MUSIC_INTERACTION_PREFIX): return False - action = interaction.data.get("custom_id") - - await interaction.response.send_message(f"Recieved action: {action}", ephemeral=True) + action = interaction.data.get("custom_id").split("-")[-1] + + match action: + case MusicButtons.PLAY.value: + await interaction.response.send_message("Playing music now!", ephemeral=True) + case MusicButtons.PAUSE.value: + await interaction.response.send_message("Pausing music!", ephemeral=True) + case MusicButtons.ADD.value: + await self.add_song_request(interaction) + case MusicButtons.VIEW.value: + await interaction.response.send_message("View queue..", ephemeral=True) + case MusicButtons.EDIT.value: + await interaction.response.send_message("Edit queue..", ephemeral=True) + case MusicButtons.STOP.value: + await interaction.response.send_message("Stopping queue!", ephemeral=True) + case MusicModalActions.ADD_MODAL.value: + await self.add_song_response(interaction) + case _: + await interaction.response.send_message( + f"Recieved action: {interaction.data.get('custom_id')}", + ephemeral=True + ) @GroupCog.listener() async def on_ready(self): @@ -109,6 +133,30 @@ async def on_ready(self): self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {self.author}") return False + async def add_song_request(self, interaction: Interaction): + modal = Modal( + title=COG_STRINGS["music_add_song_modal_title"], + timeout=None, + custom_id=make_custom_id(MusicModalActions.ADD_MODAL) + ) + single = TextInput( + label="Add one song to queue", + custom_id=make_custom_id(MusicModalActions.ADD_MODAL_SINGLE), + required=False + ) + multi = TextInput( + label="Add multiple songs to queue", + custom_id=make_custom_id(MusicModalActions.ADD_MODAL_MULTI), + required=False, + style=TextStyle.paragraph + ) + modal.add_item(single) + modal.add_item(multi) + await interaction.response.send_modal(modal) + + async def add_song_response(self, interaction: Interaction): + await interaction.response.send_message("Thank you for adding some songs!", ephemeral=True) + @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( channel=COG_STRINGS["music_set_channel_channel_describe"], diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 101c186f..e0c5af09 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -6,6 +6,8 @@ music_button_view_queue = "View Queue" music_button_edit_queue = "Edit Queue" music_button_stop_queue = "Stop" +music_add_song_modal_title = "Add song(s) to queue" + music_set_channel_name = "set-channel" music_set_channel_description = "Sets the music channel to the given Text Channel." music_set_channel_channel_describe = "The channel to set." From c6865960771e1b8f68173093c8f2350a6d637a6f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 11 Feb 2023 14:25:53 +0100 Subject: [PATCH 159/404] Added enum for the kind of song request --- src/extensions/dynamic/VCMusic.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index b28e73c5..18650e73 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,5 +1,5 @@ import logging -from enum import Enum +from enum import Enum, IntEnum from discord import (ButtonStyle, Colour, Embed, Interaction, TextChannel, TextStyle) from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) @@ -32,6 +32,14 @@ class MusicModalActions(Enum): ADD_MODAL_MULTI = "modal.add.multi" +class SongRequestType(IntEnum): + STRING = 0 + YT_VIDEO = 1 + YT_PLAYLIST = 2 + YT_THUMBNAIL = 3 + INVALID = 4 + + def make_custom_id(action: Enum): return f"{MUSIC_INTERACTION_PREFIX}-{action.value}" From bf0d8ccd23dfbab6cd144b094da8abddbd8b25ae Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 11 Feb 2023 14:28:45 +0100 Subject: [PATCH 160/404] Added check for if the requesting user is allowed to perform request --- src/extensions/dynamic/VCMusic.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 18650e73..9fd285d1 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,7 +1,7 @@ import logging from enum import Enum, IntEnum -from discord import (ButtonStyle, Colour, Embed, Interaction, TextChannel, TextStyle) +from discord import (ButtonStyle, Colour, Embed, Interaction, Member, TextChannel, TextStyle) from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog from discord.ui import Button, Modal, TextInput, View @@ -164,6 +164,14 @@ async def add_song_request(self, interaction: Interaction): async def add_song_response(self, interaction: Interaction): await interaction.response.send_message("Thank you for adding some songs!", ephemeral=True) + async def check_valid_user(self, user: Member): + bot_in_channel = user.guild.me.voice is not None + user_in_channel = user.voice is not None + + if not bot_in_channel and user_in_channel: + return True + + return user.guild.me.voice.channel == user.voice.channel @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( From 1daf689834ddef057b570ab0a3a468dc3d082d0b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 11 Feb 2023 14:29:29 +0100 Subject: [PATCH 161/404] Implemented outline for handling different request types --- src/extensions/dynamic/VCMusic.py | 69 ++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 9fd285d1..3e95f3b6 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,4 +1,5 @@ import logging +import re from enum import Enum, IntEnum from discord import (ButtonStyle, Colour, Embed, Interaction, Member, TextChannel, TextStyle) @@ -91,6 +92,35 @@ def make_default_action_row(): return view +def parse_request_type(request: str) -> SongRequestType: + website_regex = r"^((http[s]?://)?(www)?)" + if re.search(website_regex, request): + return parse_url_type(request) + else: + return SongRequestType.STRING + + +def parse_url_type(request: str) -> SongRequestType: + yt_desktop_regex = r"youtube.com/watch\?v=" + yt_playlist_regex = r"youtube.com/playlist\?list=" + yt_mobile_regex = r"youtu.be/([a-zA-Z]|[0-9])+" + yt_thumbnail_regex = r"i.ytimg.com/vi/([a-zA-Z]|[0-9])+" + + if re.search(yt_desktop_regex, request): + return SongRequestType.YT_VIDEO + + if re.search(yt_playlist_regex, request): + return SongRequestType.YT_PLAYLIST + + if re.search(yt_mobile_regex, request): + return SongRequestType.YT_VIDEO + + if re.search(yt_thumbnail_regex, request): + return SongRequestType.YT_PLAYLIST + + return SongRequestType.INVALID + + class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: EsportsBot): @@ -163,7 +193,44 @@ async def add_song_request(self, interaction: Interaction): await interaction.response.send_modal(modal) async def add_song_response(self, interaction: Interaction): - await interaction.response.send_message("Thank you for adding some songs!", ephemeral=True) + interaction_data = interaction.data.get("components") + + single_id = make_custom_id(MusicModalActions.ADD_MODAL_SINGLE) + multi_id = make_custom_id(MusicModalActions.ADD_MODAL_MULTI) + + single_request_data = [x for x in interaction_data if x.get("components")[0].get("custom_id") == single_id][0] + multi_request_data = [x for x in interaction_data if x.get("components")[0].get("custom_id") == multi_id][0] + + single_request = single_request_data.get("components")[0].get("value") + multi_request = multi_request_data.get("components")[0].get("value") + + request_list = [x.trim() for x in multi_request.split("\n") if x.trim() not in ('', ' ')] + if single_request.trim() not in ('', ' '): + request_list = [single_request.trim()] + request_list + + failed_requests = [] + for request in request_list: + if not await self.process_song_request(request, interaction): + failed_requests.append(request) + + await interaction.response.send_message(f"Thank you for adding some songs!\n\n{interaction.data}", ephemeral=True) + + async def process_song_request(self, request: str, interaction: Interaction) -> bool: + if not self.check_valid_user(interaction.user): + await interaction.response.send_message(COG_STRINGS["music_add_song_warn_invalid_voice"], ephemeral=True) + return False + + request_type = parse_request_type(request) + + if request_type == SongRequestType.YT_VIDEO or request_type == SongRequestType.YT_PLAYLIST: + pass + elif request_type == SongRequestType.STRING: + pass + else: + return False + + return True + async def check_valid_user(self, user: Member): bot_in_channel = user.guild.me.voice is not None user_in_channel = user.voice is not None From e5ac2a10bc1688acfa2b3d3b2973f94cf6f4381d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 11 Feb 2023 14:29:50 +0100 Subject: [PATCH 162/404] Added missing user string for invalid users --- src/locale/VCMusic.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index e0c5af09..79e9e028 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -7,6 +7,7 @@ music_button_edit_queue = "Edit Queue" music_button_stop_queue = "Stop" music_add_song_modal_title = "Add song(s) to queue" +music_add_song_warn_invalid_voice = "The bot must be in the same voice channel as you to add a song ⚠️" music_set_channel_name = "set-channel" music_set_channel_description = "Sets the music channel to the given Text Channel." From 65175240831ff07512a1a301913bd4003954f704 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 11 Feb 2023 15:08:34 +0100 Subject: [PATCH 163/404] Updated README to include YAPF workaround --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a3abe275..0fffa702 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ If you wish to contribute to this bot please use the following paradigms: - Ensure that yapf is configured with the configuration defined in `setup.cfg` - Optionally also configure flake8 to help with linting + - This project uses match/case statements, consider using [char101's fork](https://github.com/char101/yapf/releases/tag/v0.31.0) of YAPF until the official fork addresses [the issue](https://github.com/google/yapf/issues/983) - When adding a new extension consider the following: - Create user-facing strings inside of `src/locale/` using the same name as the extension of the filename (eg. for VoiceAdmin.py extension, there exists VoiceAdmin.toml). The strings can then be loaded with `load_cog_strings(__name__)` from `common.io` - If your extension should always be enabled, it should be in `extensions/default/`, otherwise it should have an environment variable to toggle it and it should be in `extensions/dynamic/`. From 22cf41b038b13eb4e434a12fa3f2e0a9b6eb6a7d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 08:26:35 +0000 Subject: [PATCH 164/404] Changed type of author when successfully obtained --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 3e95f3b6..5fa5b8dc 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -166,7 +166,7 @@ async def on_ready(self): for member in guild.members: if member.id == AUTHOR_ID: self.logger.info(f"Found {member} as VCMusic author !") - self.author = f"{member}" + self.author = member return True self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {self.author}") return False From dc13325b64f07878dfac877e7bcc148ff1713d45 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 12:32:11 +0000 Subject: [PATCH 165/404] Created viewcount converter function --- src/extensions/dynamic/VCMusic.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 5fa5b8dc..091fd59a 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -121,6 +121,26 @@ def parse_url_type(request: str) -> SongRequestType: return SongRequestType.INVALID +def convert_viewcount_to_float(short_views: str) -> float: + raw = short_views.lower().split(" views")[0] + scale = raw[-1] + power = 1 + match scale: + case 'k': + power = 3 + case 'm': + power = 6 + case 'b': + power = 9 + case _: + if scale.isdigit(): + return float(raw) + else: + return 0 + + return float(raw[:-1]) * (10**power) + + class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: EsportsBot): From 1141eea1d2d00b2ff50403bfb4741640da06b874 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 12:32:45 +0000 Subject: [PATCH 166/404] Added return types of static methods --- src/extensions/dynamic/VCMusic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 091fd59a..0753e81e 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -41,18 +41,18 @@ class SongRequestType(IntEnum): INVALID = 4 -def make_custom_id(action: Enum): +def make_custom_id(action: Enum) -> str: return f"{MUSIC_INTERACTION_PREFIX}-{action.value}" -def make_empty_embed(color: Colour, author: str): +def make_empty_embed(color: Colour, author: str) -> Embed: embed = Embed(title=COG_STRINGS["music_embed_title_idle"], color=color) embed.set_image(url=EMBED_IMAGE_URL) embed.set_footer(text=f"Made by {author} 💖") return embed -def make_default_action_row(): +def make_default_action_row() -> View: view = View(timeout=None) play_button = Button(style=ButtonStyle.secondary, emoji="▶️", custom_id=make_custom_id(MusicButtons.PLAY)) From a090bf7553c0e2e96ea9d3c83d6350c310cf6ef3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 12:53:26 +0000 Subject: [PATCH 167/404] Implemented rudimentary string query for videos --- requirements[music].txt | 1 + src/extensions/dynamic/VCMusic.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 requirements[music].txt diff --git a/requirements[music].txt b/requirements[music].txt new file mode 100644 index 00000000..072148d6 --- /dev/null +++ b/requirements[music].txt @@ -0,0 +1 @@ +youtube-search-python \ No newline at end of file diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 0753e81e..c7698059 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -11,11 +11,14 @@ from common.discord import ColourTransformer from common.io import load_cog_toml +from youtubesearchpython import VideosSearch + COG_STRINGS = load_cog_toml(__name__) # AUTHOR_ID = 244050529271939073 # it me :) AUTHOR_ID = 202978567741505536 # alt account :) MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" +QUERY_RESULT_LIMIT = 15 class MusicButtons(Enum): @@ -141,6 +144,32 @@ def convert_viewcount_to_float(short_views: str) -> float: return float(raw[:-1]) * (10**power) +def perform_string_query(query: str) -> dict: + results = VideosSearch(f"'{query}'", limit=QUERY_RESULT_LIMIT).resultComponents + filtered = list(filter(lambda x: x.get("publishedTime") is None, results)) + if filtered: + return filtered + + keywords = ("lyric", "official", "music", "audio") + + best_result = None + best_views = 0.0 + + for result in results: + video_title = result.get("title").lower() + try: + for keyword in keywords: + if keyword in video_title: + raise StopIteration + except StopIteration: + view_count = convert_viewcount_to_float(result.get("viewCount").get("short")) + if view_count > best_views: + best_result = result + best_views = view_count + + return best_result + + class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: EsportsBot): From d24c56c3ec1bc49a0567edbda5c4a44923b97928 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 13:12:33 +0000 Subject: [PATCH 168/404] Fixed incorrect early return type --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index c7698059..c7708091 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -148,7 +148,7 @@ def perform_string_query(query: str) -> dict: results = VideosSearch(f"'{query}'", limit=QUERY_RESULT_LIMIT).resultComponents filtered = list(filter(lambda x: x.get("publishedTime") is None, results)) if filtered: - return filtered + return filtered[0] keywords = ("lyric", "official", "music", "audio") From 8a2414ee7239b2d4567a022fb9f665e18fae7d51 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 13:13:01 +0000 Subject: [PATCH 169/404] Added preferred keywords for better results --- src/extensions/dynamic/VCMusic.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index c7708091..98d3f64a 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -150,22 +150,31 @@ def perform_string_query(query: str) -> dict: if filtered: return filtered[0] - keywords = ("lyric", "official", "music", "audio") + preferred_keywords = ["official", "music"] + alternate_keywords = ["lyric", "audio"] best_result = None best_views = 0.0 + is_preferred = False + keyword_found = "" + for result in results: video_title = result.get("title").lower() try: - for keyword in keywords: + for keyword in (preferred_keywords + alternate_keywords): if keyword in video_title: + keyword_found = keyword raise StopIteration except StopIteration: - view_count = convert_viewcount_to_float(result.get("viewCount").get("short")) - if view_count > best_views: - best_result = result - best_views = view_count + if not is_preferred or keyword_found in preferred_keywords: + view_count = convert_viewcount_to_float(result.get("viewCount").get("short")) + if view_count > best_views: + best_result = result + best_views = view_count + + if keyword_found in preferred_keywords: + is_preferred = True return best_result From d857df3826469696965d4a8ef78e469a53c762ce Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 13:41:01 +0000 Subject: [PATCH 170/404] Chaged URL requested to correctly fail --- src/extensions/dynamic/VCMusic.py | 33 +++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 98d3f64a..7a4fd934 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -179,6 +179,34 @@ def perform_string_query(query: str) -> dict: return best_result +def parse_string_query_result(result: dict) -> dict: + + def get_video_title(): + views_long = result.get("viewCount").get("text") + duration_long = result.get("accessibility").get("duration") + title_long = result.get("accessibility").get("title") + title = title_long.replace(views_long, "").replace(duration_long, "") + return title + + video_title = get_video_title() + video_url = None + video_thumbnail = None + + video_url = result.get("link") + if parse_url_type(video_url) != SongRequestType.YT_VIDEO: + raise ValueError(f"Unable to find correct video URL type for {video_title}") + + video_thumbnail = result.get("richThumbnail", {}).get("url") + if not video_thumbnail: + thumbnails = sorted(result.get("thumbnails"), key=lambda x: x.get("width"), reverse=True) + video_thumbnail = thumbnails[0].get("url") + + if parse_url_type(video_thumbnail) != SongRequestType.YT_THUMBNAIL: + video_thumbnail = EMBED_IMAGE_URL + + return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} + + class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: EsportsBot): @@ -281,9 +309,10 @@ async def process_song_request(self, request: str, interaction: Interaction) -> request_type = parse_request_type(request) if request_type == SongRequestType.YT_VIDEO or request_type == SongRequestType.YT_PLAYLIST: - pass + return False elif request_type == SongRequestType.STRING: - pass + query = perform_string_query(request) + parsed_result = parse_string_query_result(query) else: return False From f5ed3a5e9d4cb7632457f648e0916c6093f54b2e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 13:42:48 +0000 Subject: [PATCH 171/404] Removed usage of richThumbnails --- src/extensions/dynamic/VCMusic.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 7a4fd934..5953e0cb 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -196,10 +196,8 @@ def get_video_title(): if parse_url_type(video_url) != SongRequestType.YT_VIDEO: raise ValueError(f"Unable to find correct video URL type for {video_title}") - video_thumbnail = result.get("richThumbnail", {}).get("url") - if not video_thumbnail: - thumbnails = sorted(result.get("thumbnails"), key=lambda x: x.get("width"), reverse=True) - video_thumbnail = thumbnails[0].get("url") + thumbnails = sorted(result.get("thumbnails"), key=lambda x: x.get("width"), reverse=True) + video_thumbnail = thumbnails[0].get("url") if parse_url_type(video_thumbnail) != SongRequestType.YT_THUMBNAIL: video_thumbnail = EMBED_IMAGE_URL From 4d12b038f2ed34f14c337f7b019a8d7961f5891f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 13:53:22 +0000 Subject: [PATCH 172/404] Fixed check_valid_user not awaited --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 5953e0cb..f1cb2b27 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -300,7 +300,7 @@ async def add_song_response(self, interaction: Interaction): await interaction.response.send_message(f"Thank you for adding some songs!\n\n{interaction.data}", ephemeral=True) async def process_song_request(self, request: str, interaction: Interaction) -> bool: - if not self.check_valid_user(interaction.user): + if not await self.check_valid_user(interaction.user): await interaction.response.send_message(COG_STRINGS["music_add_song_warn_invalid_voice"], ephemeral=True) return False From ca21680cd7692499274dd0c7eb834e70bbff5b19 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 13:53:36 +0000 Subject: [PATCH 173/404] Fixed issue caused when user not in voice --- src/extensions/dynamic/VCMusic.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index f1cb2b27..330d5223 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -323,6 +323,9 @@ async def check_valid_user(self, user: Member): if not bot_in_channel and user_in_channel: return True + if not user_in_channel: + return False + return user.guild.me.voice.channel == user.voice.channel @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) From a3063e40cc93e7faad06a1c531005594fb750c21 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 13:54:09 +0000 Subject: [PATCH 174/404] Fixed incorrect str.trim() to str.strip() --- src/extensions/dynamic/VCMusic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 330d5223..214b7419 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -288,9 +288,9 @@ async def add_song_response(self, interaction: Interaction): single_request = single_request_data.get("components")[0].get("value") multi_request = multi_request_data.get("components")[0].get("value") - request_list = [x.trim() for x in multi_request.split("\n") if x.trim() not in ('', ' ')] - if single_request.trim() not in ('', ' '): - request_list = [single_request.trim()] + request_list + request_list = [x.strip() for x in multi_request.split("\n") if x.strip() not in ('', ' ')] + if single_request.strip() not in ('', ' '): + request_list = [single_request.strip()] + request_list failed_requests = [] for request in request_list: From 1dbfe29c9156876d11b3d4040efb08ffd652d578 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 13:57:32 +0000 Subject: [PATCH 175/404] Moved user validation check --- src/extensions/dynamic/VCMusic.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 214b7419..e9090eee 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -277,6 +277,10 @@ async def add_song_request(self, interaction: Interaction): await interaction.response.send_modal(modal) async def add_song_response(self, interaction: Interaction): + if not await self.check_valid_user(interaction.user): + await interaction.response.send_message(COG_STRINGS["music_add_song_warn_invalid_voice"], ephemeral=True) + return False + interaction_data = interaction.data.get("components") single_id = make_custom_id(MusicModalActions.ADD_MODAL_SINGLE) @@ -300,9 +304,6 @@ async def add_song_response(self, interaction: Interaction): await interaction.response.send_message(f"Thank you for adding some songs!\n\n{interaction.data}", ephemeral=True) async def process_song_request(self, request: str, interaction: Interaction) -> bool: - if not await self.check_valid_user(interaction.user): - await interaction.response.send_message(COG_STRINGS["music_add_song_warn_invalid_voice"], ephemeral=True) - return False request_type = parse_request_type(request) From dbc62ab59c0afb7193dfb67a42a9c5e479009982 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 13 Feb 2023 16:22:29 +0000 Subject: [PATCH 176/404] Changed response message to music request --- src/extensions/dynamic/VCMusic.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index e9090eee..4eda9f83 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -301,7 +301,11 @@ async def add_song_response(self, interaction: Interaction): if not await self.process_song_request(request, interaction): failed_requests.append(request) - await interaction.response.send_message(f"Thank you for adding some songs!\n\n{interaction.data}", ephemeral=True) + + await interaction.response.send_message( + f"Succesfully added `{len(request_list) - len(failed_requests)}` song(s)!", + ephemeral=True + ) async def process_song_request(self, request: str, interaction: Interaction) -> bool: From 8ec661426ad06a14813bc31e5bf20d9cf56a7954 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Feb 2023 13:12:12 +0100 Subject: [PATCH 177/404] Updated regex to properly categorise requests --- src/extensions/dynamic/VCMusic.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 4eda9f83..4e109205 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -96,18 +96,18 @@ def make_default_action_row() -> View: def parse_request_type(request: str) -> SongRequestType: - website_regex = r"^((http[s]?://)?(www)?)" - if re.search(website_regex, request): + website_regex = r"^(https:\/\/)?(www.)?" + if re.search(website_regex, request).group(): return parse_url_type(request) else: return SongRequestType.STRING def parse_url_type(request: str) -> SongRequestType: - yt_desktop_regex = r"youtube.com/watch\?v=" - yt_playlist_regex = r"youtube.com/playlist\?list=" - yt_mobile_regex = r"youtu.be/([a-zA-Z]|[0-9])+" - yt_thumbnail_regex = r"i.ytimg.com/vi/([a-zA-Z]|[0-9])+" + yt_desktop_regex = r"youtube\.com\/watch\?v=" + yt_playlist_regex = r"youtube\.com\/playlist\?list=" + yt_mobile_regex = r"youtu\.be\/" + yt_thumbnail_regex = r"i\.ytimg\.com\/vi\/" if re.search(yt_desktop_regex, request): return SongRequestType.YT_VIDEO From 86b7ad21de487b06a43a2ccdeb42ee4a39f3f0af Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Feb 2023 14:02:17 +0100 Subject: [PATCH 178/404] Implemented updating of embed upon song request --- src/extensions/dynamic/VCMusic.py | 36 ++++++++++++++++++++++++++++++- src/locale/VCMusic.toml | 2 ++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 4e109205..f3cf2013 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -51,7 +51,7 @@ def make_custom_id(action: Enum) -> str: def make_empty_embed(color: Colour, author: str) -> Embed: embed = Embed(title=COG_STRINGS["music_embed_title_idle"], color=color) embed.set_image(url=EMBED_IMAGE_URL) - embed.set_footer(text=f"Made by {author} 💖") + embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=author)) return embed @@ -210,6 +210,7 @@ class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: EsportsBot): self.bot = bot self.author = "fuxticks" + self.active_players = {} self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") @@ -319,8 +320,41 @@ async def process_song_request(self, request: str, interaction: Interaction) -> else: return False + await self.add_song_to_queue(parsed_result, interaction) + return True + async def add_song_to_queue(self, request: dict, interaction: Interaction) -> bool: + is_active = self.active_players.get(interaction.guild.id) is not None + if is_active: + # add the song but don't play it yet + current_queue = self.active_players.get(interaction.guild.id).get("queue") + current_queue.append(request) + self.active_players[interaction.guild.id]["queue"] = current_queue + else: + new_active = {"queue": [request]} + self.active_players[interaction.guild.id] = new_active + # TODO: Start playing music. + # await self.play_queue(interaction) + await self.update_embed(interaction) + + async def update_embed(self, interaction: Interaction): + current_song = self.active_players.get(interaction.guild.id).get("queue")[0] + + current_embed: Embed = interaction.message.embeds[0] + new_embed = Embed( + title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.get("title")), + color=current_embed.color, + url=current_song.get("url") + ) + new_embed.set_image(url=current_song.get("thumbnail")) + new_embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=self.author)) + + await interaction.message.edit(embed=new_embed) + + async def play_queue(self, interaction: Interaction): + pass + async def check_valid_user(self, user: Member): bot_in_channel = user.guild.me.voice is not None user_in_channel = user.voice is not None diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 79e9e028..7c7574e5 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -1,6 +1,8 @@ music_group_name = "music" music_embed_title_idle = "No song currently playing...." +music_embed_title_playing = "Now playing — {song}" +music_embed_footer = "Made by {author} 💖" music_button_add_song = "Add Song" music_button_view_queue = "View Queue" music_button_edit_queue = "Edit Queue" From 729da983fc0627070af03ef8d107452212061120 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Feb 2023 16:39:21 +0100 Subject: [PATCH 179/404] Updated response for adding songs to queue --- src/extensions/dynamic/VCMusic.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index f3cf2013..8cc98af7 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -297,11 +297,14 @@ async def add_song_response(self, interaction: Interaction): if single_request.strip() not in ('', ' '): request_list = [single_request.strip()] + request_list + self.logger.info(f"Song requests: {request_list}") + failed_requests = [] for request in request_list: if not await self.process_song_request(request, interaction): failed_requests.append(request) + self.logger.info(f"Failed requests: {failed_requests}") await interaction.response.send_message( f"Succesfully added `{len(request_list) - len(failed_requests)}` song(s)!", From d169874c36b536c65212be6b1552456440dedfe5 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 18:51:44 +0100 Subject: [PATCH 180/404] Added music channel DB model --- src/database/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/database/models.py b/src/database/models.py index 7ff8ab18..6466e2b6 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -42,3 +42,10 @@ class EventToolsEvents(base): event_id = Column(BigInteger, nullable=False) event_name = Column(String, nullable=False) is_archived = Column(Boolean, nullable=True, default=False) + + +class MusicChannels(base): + __tablename__ = "music_channels" + guild_id = Column(BigInteger, primary_key=True, nullable=False) + channel_id = Column(BigInteger, nullable=False) + message_id = Column(BigInteger, nullable=False) From 038f461103b53ab6531b009f3dd331e384c289a8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 18:54:38 +0100 Subject: [PATCH 181/404] Added yt-dlp dependancy --- requirements[music].txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements[music].txt b/requirements[music].txt index 072148d6..a5ef45ec 100644 --- a/requirements[music].txt +++ b/requirements[music].txt @@ -1 +1,2 @@ -youtube-search-python \ No newline at end of file +youtube-search-python +yt-dlp \ No newline at end of file From 08ec6d10107a98971027e3b9c776b82a3074e707 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 18:58:54 +0100 Subject: [PATCH 182/404] Implemented interaction enums --- src/extensions/dynamic/VCMusic.py | 477 +++++------------------------- 1 file changed, 73 insertions(+), 404 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 8cc98af7..e6f38593 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,408 +1,77 @@ -import logging -import re -from enum import Enum, IntEnum +from enum import IntEnum -from discord import (ButtonStyle, Colour, Embed, Interaction, Member, TextChannel, TextStyle) -from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) -from discord.ext.commands import Bot, GroupCog -from discord.ui import Button, Modal, TextInput, View - -from client import EsportsBot -from common.discord import ColourTransformer -from common.io import load_cog_toml - -from youtubesearchpython import VideosSearch - -COG_STRINGS = load_cog_toml(__name__) -# AUTHOR_ID = 244050529271939073 # it me :) -AUTHOR_ID = 202978567741505536 # alt account :) MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" -EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" -QUERY_RESULT_LIMIT = 15 - - -class MusicButtons(Enum): - PLAY = "play" - PAUSE = "pause" - ADD = "add" - VIEW = "view" - EDIT = "edit" - STOP = "stop" - - -class MusicModalActions(Enum): - ADD_MODAL = "modal.add" - ADD_MODAL_SINGLE = "modal.add.single" - ADD_MODAL_MULTI = "modal.add.multi" - - -class SongRequestType(IntEnum): - STRING = 0 - YT_VIDEO = 1 - YT_PLAYLIST = 2 - YT_THUMBNAIL = 3 - INVALID = 4 - - -def make_custom_id(action: Enum) -> str: - return f"{MUSIC_INTERACTION_PREFIX}-{action.value}" - - -def make_empty_embed(color: Colour, author: str) -> Embed: - embed = Embed(title=COG_STRINGS["music_embed_title_idle"], color=color) - embed.set_image(url=EMBED_IMAGE_URL) - embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=author)) - return embed - - -def make_default_action_row() -> View: - view = View(timeout=None) - - play_button = Button(style=ButtonStyle.secondary, emoji="▶️", custom_id=make_custom_id(MusicButtons.PLAY)) - pause_button = Button(style=ButtonStyle.secondary, emoji="⏸️", custom_id=make_custom_id(MusicButtons.PAUSE)) - add_button = Button( - style=ButtonStyle.primary, - label=COG_STRINGS["music_button_add_song"], - emoji="➕", - custom_id=make_custom_id(MusicButtons.ADD) - ) - view_button = Button( - style=ButtonStyle.primary, - label=COG_STRINGS["music_button_view_queue"], - emoji="📋", - custom_id=make_custom_id(MusicButtons.VIEW) - ) - edit_button = Button( - style=ButtonStyle.primary, - label=COG_STRINGS["music_button_edit_queue"], - emoji="✏️", - custom_id=make_custom_id(MusicButtons.EDIT) - ) - stop_button = Button( - style=ButtonStyle.danger, - label=COG_STRINGS["music_button_stop_queue"], - emoji="⏹️", - custom_id=make_custom_id(MusicButtons.STOP) - ) - - view.add_item(play_button) - view.add_item(pause_button) - view.add_item(add_button) - view.add_item(view_button) - view.add_item(edit_button) - view.add_item(stop_button) - - return view - - -def parse_request_type(request: str) -> SongRequestType: - website_regex = r"^(https:\/\/)?(www.)?" - if re.search(website_regex, request).group(): - return parse_url_type(request) - else: - return SongRequestType.STRING - - -def parse_url_type(request: str) -> SongRequestType: - yt_desktop_regex = r"youtube\.com\/watch\?v=" - yt_playlist_regex = r"youtube\.com\/playlist\?list=" - yt_mobile_regex = r"youtu\.be\/" - yt_thumbnail_regex = r"i\.ytimg\.com\/vi\/" - - if re.search(yt_desktop_regex, request): - return SongRequestType.YT_VIDEO - - if re.search(yt_playlist_regex, request): - return SongRequestType.YT_PLAYLIST - - if re.search(yt_mobile_regex, request): - return SongRequestType.YT_VIDEO - - if re.search(yt_thumbnail_regex, request): - return SongRequestType.YT_PLAYLIST - - return SongRequestType.INVALID - - -def convert_viewcount_to_float(short_views: str) -> float: - raw = short_views.lower().split(" views")[0] - scale = raw[-1] - power = 1 - match scale: - case 'k': - power = 3 - case 'm': - power = 6 - case 'b': - power = 9 - case _: - if scale.isdigit(): - return float(raw) - else: - return 0 - - return float(raw[:-1]) * (10**power) - - -def perform_string_query(query: str) -> dict: - results = VideosSearch(f"'{query}'", limit=QUERY_RESULT_LIMIT).resultComponents - filtered = list(filter(lambda x: x.get("publishedTime") is None, results)) - if filtered: - return filtered[0] - - preferred_keywords = ["official", "music"] - alternate_keywords = ["lyric", "audio"] - - best_result = None - best_views = 0.0 - - is_preferred = False - keyword_found = "" - - for result in results: - video_title = result.get("title").lower() - try: - for keyword in (preferred_keywords + alternate_keywords): - if keyword in video_title: - keyword_found = keyword - raise StopIteration - except StopIteration: - if not is_preferred or keyword_found in preferred_keywords: - view_count = convert_viewcount_to_float(result.get("viewCount").get("short")) - if view_count > best_views: - best_result = result - best_views = view_count - - if keyword_found in preferred_keywords: - is_preferred = True - - return best_result - - -def parse_string_query_result(result: dict) -> dict: - - def get_video_title(): - views_long = result.get("viewCount").get("text") - duration_long = result.get("accessibility").get("duration") - title_long = result.get("accessibility").get("title") - title = title_long.replace(views_long, "").replace(duration_long, "") - return title - - video_title = get_video_title() - video_url = None - video_thumbnail = None - - video_url = result.get("link") - if parse_url_type(video_url) != SongRequestType.YT_VIDEO: - raise ValueError(f"Unable to find correct video URL type for {video_title}") - - thumbnails = sorted(result.get("thumbnails"), key=lambda x: x.get("width"), reverse=True) - video_thumbnail = thumbnails[0].get("url") - - if parse_url_type(video_thumbnail) != SongRequestType.YT_THUMBNAIL: - video_thumbnail = EMBED_IMAGE_URL - - return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} - - -class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): - - def __init__(self, bot: EsportsBot): - self.bot = bot - self.author = "fuxticks" - self.active_players = {} - self.logger = logging.getLogger(__name__) - self.logger.info(f"{__name__} has been added as a Cog") - - @GroupCog.listener() - async def on_interaction(self, interaction: Interaction): - if not interaction.data or not interaction.data.get("custom_id"): - return False - - if not interaction.data.get("custom_id").startswith(MUSIC_INTERACTION_PREFIX): - return False - - action = interaction.data.get("custom_id").split("-")[-1] - - match action: - case MusicButtons.PLAY.value: - await interaction.response.send_message("Playing music now!", ephemeral=True) - case MusicButtons.PAUSE.value: - await interaction.response.send_message("Pausing music!", ephemeral=True) - case MusicButtons.ADD.value: - await self.add_song_request(interaction) - case MusicButtons.VIEW.value: - await interaction.response.send_message("View queue..", ephemeral=True) - case MusicButtons.EDIT.value: - await interaction.response.send_message("Edit queue..", ephemeral=True) - case MusicButtons.STOP.value: - await interaction.response.send_message("Stopping queue!", ephemeral=True) - case MusicModalActions.ADD_MODAL.value: - await self.add_song_response(interaction) +INTERACTION_SPLIT_CHARACTER = "." + + +class UserActionType(IntEnum): + PLAY = 0 + PAUSE = 1 + STOP = 2 + ADD_SONG = 3 + VIEW_QUEUE = 4 + EDIT_QUEUE = 5 + ADD_SONG_MODAL_SUBMIT = 6 + EDIT_QUEUE_MODAL_SUBMIT = 7 + ADD_SONG_MODAL_SINGLE = 8 + ADD_SONG_MODAL_MULTIPLE = 9 + + @property + def id(self) -> str: + match self: + case UserActionType.PLAY: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionplay" + case UserActionType.PAUSE: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionpause" + case UserActionType.STOP: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionstop" + case UserActionType.ADD_SONG: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionadd" + case UserActionType.VIEW_QUEUE: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionview" + case UserActionType.EDIT_QUEUE: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionedit" + case UserActionType.ADD_SONG_MODAL_SUBMIT: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}submitadd" + case UserActionType.EDIT_QUEUE_MODAL_SUBMIT: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}submitedit" + case UserActionType.ADD_SONG_MODAL_SINGLE: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}addmodalsingle" + case UserActionType.ADD_SONG_MODAL_MULTIPLE: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}addmodalmultiple" case _: - await interaction.response.send_message( - f"Recieved action: {interaction.data.get('custom_id')}", - ephemeral=True - ) - - @GroupCog.listener() - async def on_ready(self): - for guild in self.bot.guilds: - for member in guild.members: - if member.id == AUTHOR_ID: - self.logger.info(f"Found {member} as VCMusic author !") - self.author = member - return True - self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {self.author}") - return False - - async def add_song_request(self, interaction: Interaction): - modal = Modal( - title=COG_STRINGS["music_add_song_modal_title"], - timeout=None, - custom_id=make_custom_id(MusicModalActions.ADD_MODAL) - ) - single = TextInput( - label="Add one song to queue", - custom_id=make_custom_id(MusicModalActions.ADD_MODAL_SINGLE), - required=False - ) - multi = TextInput( - label="Add multiple songs to queue", - custom_id=make_custom_id(MusicModalActions.ADD_MODAL_MULTI), - required=False, - style=TextStyle.paragraph - ) - modal.add_item(single) - modal.add_item(multi) - await interaction.response.send_modal(modal) - - async def add_song_response(self, interaction: Interaction): - if not await self.check_valid_user(interaction.user): - await interaction.response.send_message(COG_STRINGS["music_add_song_warn_invalid_voice"], ephemeral=True) - return False - - interaction_data = interaction.data.get("components") - - single_id = make_custom_id(MusicModalActions.ADD_MODAL_SINGLE) - multi_id = make_custom_id(MusicModalActions.ADD_MODAL_MULTI) - - single_request_data = [x for x in interaction_data if x.get("components")[0].get("custom_id") == single_id][0] - multi_request_data = [x for x in interaction_data if x.get("components")[0].get("custom_id") == multi_id][0] - - single_request = single_request_data.get("components")[0].get("value") - multi_request = multi_request_data.get("components")[0].get("value") - - request_list = [x.strip() for x in multi_request.split("\n") if x.strip() not in ('', ' ')] - if single_request.strip() not in ('', ' '): - request_list = [single_request.strip()] + request_list - - self.logger.info(f"Song requests: {request_list}") - - failed_requests = [] - for request in request_list: - if not await self.process_song_request(request, interaction): - failed_requests.append(request) - - self.logger.info(f"Failed requests: {failed_requests}") - - await interaction.response.send_message( - f"Succesfully added `{len(request_list) - len(failed_requests)}` song(s)!", - ephemeral=True - ) - - async def process_song_request(self, request: str, interaction: Interaction) -> bool: - - request_type = parse_request_type(request) - - if request_type == SongRequestType.YT_VIDEO or request_type == SongRequestType.YT_PLAYLIST: - return False - elif request_type == SongRequestType.STRING: - query = perform_string_query(request) - parsed_result = parse_string_query_result(query) - else: - return False - - await self.add_song_to_queue(parsed_result, interaction) - - return True - - async def add_song_to_queue(self, request: dict, interaction: Interaction) -> bool: - is_active = self.active_players.get(interaction.guild.id) is not None - if is_active: - # add the song but don't play it yet - current_queue = self.active_players.get(interaction.guild.id).get("queue") - current_queue.append(request) - self.active_players[interaction.guild.id]["queue"] = current_queue - else: - new_active = {"queue": [request]} - self.active_players[interaction.guild.id] = new_active - # TODO: Start playing music. - # await self.play_queue(interaction) - await self.update_embed(interaction) - - async def update_embed(self, interaction: Interaction): - current_song = self.active_players.get(interaction.guild.id).get("queue")[0] - - current_embed: Embed = interaction.message.embeds[0] - new_embed = Embed( - title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.get("title")), - color=current_embed.color, - url=current_song.get("url") - ) - new_embed.set_image(url=current_song.get("thumbnail")) - new_embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=self.author)) - - await interaction.message.edit(embed=new_embed) - - async def play_queue(self, interaction: Interaction): - pass - - async def check_valid_user(self, user: Member): - bot_in_channel = user.guild.me.voice is not None - user_in_channel = user.voice is not None - - if not bot_in_channel and user_in_channel: - return True - - if not user_in_channel: - return False - - return user.guild.me.voice.channel == user.voice.channel - - @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) - @describe( - channel=COG_STRINGS["music_set_channel_channel_describe"], - clear_messages=COG_STRINGS["music_set_channel_clear_messages_describe"], - embed_color=COG_STRINGS["music_set_channel_embed_color_describe"] - ) - @rename( - channel=COG_STRINGS["music_set_channel_channel_rename"], - clear_messages=COG_STRINGS["music_set_channel_clear_messages_rename"], - embed_color=COG_STRINGS["music_set_channel_embed_color_rename"] - ) - @autocomplete(embed_color=ColourTransformer.autocomplete) - @guild_only() - async def set_channel( - self, - interaction: Interaction, - channel: TextChannel, - clear_messages: bool = False, - embed_color: Transform[Colour, - ColourTransformer] = Colour(0xd462fd) - ): - await interaction.response.defer(ephemeral=True) - - if clear_messages: - await channel.purge() - - embed = make_empty_embed(embed_color, self.author) - view = make_default_action_row() - - await channel.send(embed=embed, view=view) - - await interaction.followup.send(content=COG_STRINGS["music_set_channel_success"].format(channel=channel.mention)) - + raise ValueError("Invalid enum type given!") + + @classmethod + def from_string(self, string: str) -> "UserActionType": + if not string.startswith(MUSIC_INTERACTION_PREFIX): + raise ValueError(f"Invalid string given for {__class__.__name__}") + + enum_id = string.split(INTERACTION_SPLIT_CHARACTER)[-1] + + match enum_id: + case "actionplay": + return UserActionType.PLAY + case "actionpause": + return UserActionType.PAUSE + case "actionstop": + return UserActionType.STOP + case "actionadd": + return UserActionType.ADD_SONG + case "actionview": + return UserActionType.VIEW_QUEUE + case "actionedit": + return UserActionType.EDIT_QUEUE + case "submitadd": + return UserActionType.ADD_SONG_MODAL_SUBMIT + case "submitedit": + return UserActionType.EDIT_QUEUE_MODAL_SUBMIT + case "addmodalsingle": + return UserActionType.ADD_SONG_MODAL_SINGLE + case "addmodalmultiple": + return UserActionType.ADD_SONG_MODAL_MULTIPLE + case _: + raise ValueError(f"Invalid string given for {__class__.__name__}") -async def setup(bot: Bot): - await bot.add_cog(VCMusic(bot)) + def __str__(self): + return self.id From 47b1f77291a4789bedac1d79d6459ab51aac65d4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 19:00:06 +0100 Subject: [PATCH 183/404] Implemented SongRequest dataclass --- src/extensions/dynamic/VCMusic.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index e6f38593..e781f4e2 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from enum import IntEnum MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" @@ -75,3 +76,21 @@ def from_string(self, string: str) -> "UserActionType": def __str__(self): return self.id + + +class SongRequestType(IntEnum): + INVALID = 0 + STRING = 1 + YOUTUBE_VIDEO = 2 + YOUTUBE_PLAYLIST = 3 + YOUTUBE_THUMBNAIL = 4 + + +@dataclass(slots=True) +class SongRequest: + raw_request: str + request_type: SongRequestType + url: str = None + title: str = None + thumbnail: str = None + stream_data: dict = None \ No newline at end of file From 56a5b2e8b62754eb41849b89bad20e85982126e6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 19:04:08 +0100 Subject: [PATCH 184/404] Implemented request type parsing --- src/extensions/dynamic/VCMusic.py | 32 ++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index e781f4e2..816cb46d 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,3 +1,4 @@ +import re from dataclasses import dataclass from enum import IntEnum @@ -93,4 +94,33 @@ class SongRequest: url: str = None title: str = None thumbnail: str = None - stream_data: dict = None \ No newline at end of file + stream_data: dict = None + + +def parse_request_type(request: str) -> SongRequestType: + website_regex = r"^(https:\/\/)?(www.)?" + if re.search(website_regex, request).group(): + return parse_url_type(request) + else: + return SongRequestType.STRING + + +def parse_url_type(request: str) -> SongRequestType: + yt_desktop_regex = r"youtube\.com\/watch\?v=" + yt_playlist_regex = r"youtube\.com\/playlist\?list=" + yt_mobile_regex = r"youtu\.be\/" + yt_thumbnail_regex = r"i\.ytimg\.com\/vi\/" + + if re.search(yt_desktop_regex, request): + return SongRequestType.YOUTUBE_VIDEO + + if re.search(yt_playlist_regex, request): + return SongRequestType.YOUTUBE_PLAYLIST + + if re.search(yt_mobile_regex, request): + return SongRequestType.YOUTUBE_VIDEO + + if re.search(yt_thumbnail_regex, request): + return SongRequestType.YOUTUBE_THUMBNAIL + + return SongRequestType.INVALID From bb2c46861f9ae41fd0e8935f57983ea327ceace2 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 19:09:56 +0100 Subject: [PATCH 185/404] Implemented basic video info gathering --- src/extensions/dynamic/VCMusic.py | 114 ++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 816cb46d..9c76795b 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,9 +1,14 @@ import re from dataclasses import dataclass from enum import IntEnum +from typing import Union + +from youtubesearchpython import VideosSearch MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" INTERACTION_SPLIT_CHARACTER = "." +EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" +QUERY_RESULT_LIMIT = 15 class UserActionType(IntEnum): @@ -96,6 +101,26 @@ class SongRequest: thumbnail: str = None stream_data: dict = None + async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: + match self.request_type: + case SongRequestType.STRING: + result = string_request_query(self) + case SongRequestType.YOUTUBE_VIDEO: + result = string_request_query(self) + case SongRequestType.YOUTUBE_PLAYLIST: + return None + case _: + raise ValueError("Invalid SongRequestType given!") + + if result == None or result == {}: + return self + + parsed_result = parse_string_query_result(result) + self.url = parsed_result.get("url") + self.title = parsed_result.get("title") + self.thumbnail = parsed_result.get("thumbnail") + return self + def parse_request_type(request: str) -> SongRequestType: website_regex = r"^(https:\/\/)?(www.)?" @@ -124,3 +149,92 @@ def parse_url_type(request: str) -> SongRequestType: return SongRequestType.YOUTUBE_THUMBNAIL return SongRequestType.INVALID + + +def convert_viewcount_to_float(short_views: str) -> float: + raw = short_views.lower().split(" views")[0] + scale = raw[-1] + power = 1 + match scale: + case 'k': + power = 3 + case 'm': + power = 6 + case 'b': + power = 9 + case _: + if scale.isdigit(): + return float(raw) + else: + return 0 + + return float(raw[:-1]) * (10**power) + + +def string_request_query(request: SongRequest) -> dict: + if request.request_type == SongRequestType.STRING: + query = f"'{request.raw_request}'" + else: + query = request.raw_request + + video_results = VideosSearch(query, limit=QUERY_RESULT_LIMIT).resultComponents + if request.request_type != SongRequestType.STRING and video_results: + return video_results[0] + + filtered_results = list(filter(lambda x: x.get("publishedTime") is None, video_results)) + if filtered_results: + return filtered_results[0] + + preferred_keywords = ["official", "music"] + alternate_keywords = ["lyric", "audio"] + + best_result = None + best_views = 0.0 + + is_preferred = False + keyword_found = "" + + for result in video_results: + video_title = result.get("title").lower() + try: + for keyword in (preferred_keywords + alternate_keywords): + if keyword in video_title: + keyword_found = keyword + raise StopIteration + except StopIteration: + if not is_preferred or keyword_found in preferred_keywords: + view_count = convert_viewcount_to_float(result.get("viewCount").get("short")) + if view_count > best_views: + best_result = result + best_views = view_count + + if keyword_found in preferred_keywords: + is_preferred = True + + return best_result + + +def parse_string_query_result(result: dict) -> dict: + + def get_video_title(): + views_long = result.get("viewCount").get("text") + duration_long = result.get("accessibility").get("duration") + title_long = result.get("accessibility").get("title") + title = title_long.replace(views_long, "").replace(duration_long, "") + return title + + video_title = get_video_title() + video_url = None + video_thumbnail = None + + video_url = result.get("link") + if parse_url_type(video_url) != SongRequestType.YOUTUBE_VIDEO: + raise ValueError(f"Unable to find correct video URL type for {video_title}") + + thumbnails = sorted(result.get("thumbnails"), key=lambda x: x.get("width"), reverse=True) + video_thumbnail = thumbnails[0].get("url") + + if parse_url_type(video_thumbnail) != SongRequestType.YOUTUBE_THUMBNAIL: + video_thumbnail = EMBED_IMAGE_URL + + return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} From a065eb5004461be64b3f4eb9571d4e0a94bb9d78 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 19:11:32 +0100 Subject: [PATCH 186/404] Added funciton to get song stream data --- src/extensions/dynamic/VCMusic.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 9c76795b..7689794c 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -4,6 +4,7 @@ from typing import Union from youtubesearchpython import VideosSearch +from yt_dlp import YoutubeDL MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" INTERACTION_SPLIT_CHARACTER = "." @@ -121,6 +122,33 @@ async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: self.thumbnail = parsed_result.get("thumbnail") return self + def get_stream_data(self): + ydl_opts = { + "quiet": "true", + "nowarning": "true", + "format": "bestaudio/best", + "outtmpl": "%(title)s-%(id)s.mp3", + "postprocessors": [{ + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "192", + }], + } + + with YoutubeDL(ydl_opts) as ydl: + if self.url is None and self.request_type != SongRequestType.STRING: + self.url = self.raw_request + info = ydl.extract_info(self.url, download=False) + self.stream_data = info + + if self.title is None: + self.title = self.stream_data.get("title") + + if self.thumbnail is None: + self.thumbnail = self.stream_data.get("thumbnail") + + return info + def parse_request_type(request: str) -> SongRequestType: website_regex = r"^(https:\/\/)?(www.)?" From 7559b18c01399f9833dd0ec676ba33cd04c878d6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 22:41:57 +0100 Subject: [PATCH 187/404] Added skeleton VCMusic groupcog --- src/extensions/dynamic/VCMusic.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 7689794c..81b508f1 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,11 +1,16 @@ +import logging import re from dataclasses import dataclass from enum import IntEnum from typing import Union +from discord.ext.commands import Bot, GroupCog from youtubesearchpython import VideosSearch from yt_dlp import YoutubeDL +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" INTERACTION_SPLIT_CHARACTER = "." EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" @@ -266,3 +271,14 @@ def get_video_title(): video_thumbnail = EMBED_IMAGE_URL return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} + + +class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + + +async def setup(bot: Bot): + await bot.add_cog(VCMusic(bot)) \ No newline at end of file From 18173fd99fff1957f072acc2d85c81515fbc14c3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 22:44:09 +0100 Subject: [PATCH 188/404] Added MusicChannels to __all__ --- src/database/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/models.py b/src/database/models.py index 6466e2b6..5e3bfcf0 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -4,7 +4,7 @@ base = declarative_base() -__all__ = ["base", "VoiceAdminParent", "VoiceAdminChild", "AutoRolesConfig", "EventToolsEvents"] +__all__ = ["base", "VoiceAdminParent", "VoiceAdminChild", "AutoRolesConfig", "EventToolsEvents", "MusicChannels"] class VoiceAdminParent(base): From 53d852c256c57b4b1e762a51d117f5633d9629ce Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 22:50:05 +0100 Subject: [PATCH 189/404] Reimplimented set-channel command --- src/extensions/dynamic/VCMusic.py | 103 ++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 81b508f1..762f5012 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -4,11 +4,17 @@ from enum import IntEnum from typing import Union +from discord import Button, ButtonStyle, Color, Embed, Interaction, TextChannel +from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog +from discord.ui import View from youtubesearchpython import VideosSearch from yt_dlp import YoutubeDL +from common.discord import ColourTransformer from common.io import load_cog_toml +from database.gateway import DBSession +from database.models import MusicChannels COG_STRINGS = load_cog_toml(__name__) MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" @@ -273,12 +279,109 @@ def get_video_title(): return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} +def create_music_embed( + color: Color, + author: str, + title: str = COG_STRINGS["music_embed_title_idle"], + image: str = EMBED_IMAGE_URL +) -> Embed: + embed = Embed(title=title, color=color) + embed.set_image(url=image) + embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=author)) + return embed + + +def create_music_actionbar() -> View: + view = View(timeout=None) + + play_button = Button(style=ButtonStyle.secondary, emoji="▶️", custom_id=UserActionType.PLAY.id) + pause_button = Button(style=ButtonStyle.secondary, emoji="⏸️", custom_id=UserActionType.PAUSE.id) + add_button = Button( + style=ButtonStyle.primary, + label=COG_STRINGS["music_button_add_song"], + emoji="➕", + custom_id=UserActionType.ADD_SONG.id + ) + view_button = Button( + style=ButtonStyle.primary, + label=COG_STRINGS["music_button_view_queue"], + emoji="📋", + custom_id=UserActionType.VIEW_QUEUE.id + ) + edit_button = Button( + style=ButtonStyle.primary, + label=COG_STRINGS["music_button_edit_queue"], + emoji="✏️", + custom_id=UserActionType.EDIT_QUEUE.id + ) + stop_button = Button( + style=ButtonStyle.danger, + label=COG_STRINGS["music_button_stop_queue"], + emoji="⏹️", + custom_id=UserActionType.STOP.id + ) + + view.add_item(play_button) + view.add_item(pause_button) + view.add_item(add_button) + view.add_item(view_button) + view.add_item(edit_button) + view.add_item(stop_button) + + return view + + class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: Bot): self.bot = bot self.logger = logging.getLogger(__name__) + @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) + @describe( + channel=COG_STRINGS["music_set_channel_channel_describe"], + clear_messages=COG_STRINGS["music_set_channel_clear_messages_describe"], + embed_color=COG_STRINGS["music_set_channel_embed_color_describe"] + ) + @rename( + channel=COG_STRINGS["music_set_channel_channel_rename"], + clear_messages=COG_STRINGS["music_set_channel_clear_messages_rename"], + embed_color=COG_STRINGS["music_set_channel_embed_color_rename"] + ) + @autocomplete(embed_color=ColourTransformer.autocomplete) + @guild_only() + async def set_channel( + self, + interaction: Interaction, + channel: TextChannel, + clear_messages: bool = False, + embed_color: Transform[Color, + ColourTransformer] = Color(0xd462fd) + ): + await interaction.response.defer() + + if clear_messages: + await channel.purge(before=interaction.created_at) + + embed = create_music_embed(embed_color, self.author) + view = create_music_actionbar() + + message = await channel.send(embed=embed, view=view) + + existing = DBSession.get(MusicChannels, guild_id=interaction.guild.id) + if existing: + existing.channel_id = channel.id + existing.message_id = message.id + DBSession.update(existing) + else: + new_entry = MusicChannels(guild_id=interaction.guild.id, channel_id=channel.id, message_id=message.id) + DBSession.create(new_entry) + + await interaction.followup.send( + content=COG_STRINGS["music_set_channel_success"].format(channel=channel.mention), + ephemeral=True + ) + async def setup(bot: Bot): await bot.add_cog(VCMusic(bot)) \ No newline at end of file From 1a97fc895ebbabfb23db8b821efbd2ad1dee6523 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 22:51:08 +0100 Subject: [PATCH 190/404] Added log line for when cog is loaded --- src/extensions/dynamic/VCMusic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 762f5012..f7f89be2 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -336,6 +336,7 @@ class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: Bot): self.bot = bot self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( From 7abb51af184a0f9203bfddccb0fd1d6ae6140f07 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 22:53:34 +0100 Subject: [PATCH 191/404] Added interaction listener --- src/extensions/dynamic/VCMusic.py | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index f7f89be2..7d93ca7e 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -338,6 +338,39 @@ def __init__(self, bot: Bot): self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") + @GroupCog.listener() + async def on_interaction(self, interaction: Interaction): + if not interaction.data or not interaction.data.get("custom_id"): + return False + + if not interaction.data.get("custom_id").startswith(MUSIC_INTERACTION_PREFIX): + return False + + try: + user_action = UserActionType.from_string(interaction.data.get("custom_id")) + except ValueError: + return False + + match user_action: + case UserActionType.PLAY: + pass + case UserActionType.PAUSE: + pass + case UserActionType.ADD_SONG: + pass + case UserActionType.VIEW_QUEUE: + pass + case UserActionType.EDIT_QUEUE: + pass + case UserActionType.STOP: + pass + case UserActionType.ADD_SONG_MODAL_SUBMIT: + pass + case UserActionType.EDIT_QUEUE_MODAL_SUBMIT: + pass + case _: + return False + @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( channel=COG_STRINGS["music_set_channel_channel_describe"], From 5389d3887b754f14d5c8a5073be1ca4062b7ae87 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 23:01:38 +0100 Subject: [PATCH 192/404] Implemented ADD_SONG UserAction handler --- src/extensions/dynamic/VCMusic.py | 51 ++++++++++++++++++++++++++++--- src/locale/VCMusic.toml | 2 +- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 7d93ca7e..e2d01acc 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -4,10 +4,12 @@ from enum import IntEnum from typing import Union -from discord import Button, ButtonStyle, Color, Embed, Interaction, TextChannel -from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) +from discord import (ButtonStyle, Color, Embed, Guild, Interaction, Member, + TextChannel, TextStyle) +from discord.app_commands import (Transform, autocomplete, command, describe, + guild_only, rename) from discord.ext.commands import Bot, GroupCog -from discord.ui import View +from discord.ui import Button, Modal, TextInput, View from youtubesearchpython import VideosSearch from yt_dlp import YoutubeDL @@ -355,7 +357,7 @@ async def on_interaction(self, interaction: Interaction): case UserActionType.PLAY: pass case UserActionType.PAUSE: - pass + return await self.add_interaction_hanlder(interaction) case UserActionType.ADD_SONG: pass case UserActionType.VIEW_QUEUE: @@ -371,6 +373,47 @@ async def on_interaction(self, interaction: Interaction): case _: return False + def check_valid_user(self, guild: Guild, user: Member) -> bool: + if not user.voice: + return False + + if not user.voice.channel: + return False + + if not guild.id in self.active_players: + return True + + return self.bot.user in user.voice.channel.members + + async def add_interaction_hanlder(self, interaction: Interaction) -> bool: + if not self.check_valid_user(interaction.guild, interaction.user): + await interaction.response.send_message(COG_STRINGS["music_invalid_voice"], ephemeral=True) + return False + + modal = Modal( + title=COG_STRINGS["music_add_song_modal_title"], + timeout=None, + custom_id=UserActionType.ADD_SONG_MODAL_SUBMIT.id + ) + + single_request = TextInput( + label=COG_STRINGS["music_add_song_modal_single"], + custom_id=UserActionType.ADD_SONG_MODAL_SINGLE.id, + required=False, + ) + + multiple_request = TextInput( + label=COG_STRINGS["music_add_song_modal_multiple"], + custom_id=UserActionType.ADD_SONG_MODAL_MULTIPLE.id, + required=False, + style=TextStyle.paragraph + ) + + modal.add_item(single_request) + modal.add_item(multiple_request) + await interaction.response.send_modal(modal) + return True + @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( channel=COG_STRINGS["music_set_channel_channel_describe"], diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 7c7574e5..626e711a 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -9,7 +9,7 @@ music_button_edit_queue = "Edit Queue" music_button_stop_queue = "Stop" music_add_song_modal_title = "Add song(s) to queue" -music_add_song_warn_invalid_voice = "The bot must be in the same voice channel as you to add a song ⚠️" +music_invalid_voice = "You must be in the same voice channel as the bot to perform this action ⚠️" music_set_channel_name = "set-channel" music_set_channel_description = "Sets the music channel to the given Text Channel." From fa4055d3566996fc8855c190d72c82cee9f1302e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 23:07:21 +0100 Subject: [PATCH 193/404] Added loading of author --- src/extensions/dynamic/VCMusic.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index e2d01acc..1a5a41ec 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -23,6 +23,8 @@ INTERACTION_SPLIT_CHARACTER = "." EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" QUERY_RESULT_LIMIT = 15 +# AUTHOR_ID = 244050529271939073 # main account +AUTHOR_ID = 202978567741505536 # alt account class UserActionType(IntEnum): @@ -337,9 +339,21 @@ class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: Bot): self.bot = bot + self.author = "fuxticks" self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") + @GroupCog.listener() + async def on_ready(self): + for guild in self.bot.guilds: + for member in guild.members: + if member.id == AUTHOR_ID: + self.logger.info(f"Found current discord tag of VCAuthor: {member}") + self.author = member + return True + self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {self.author}") + return False + @GroupCog.listener() async def on_interaction(self, interaction: Interaction): if not interaction.data or not interaction.data.get("custom_id"): From 54ad935d6e14488f61af39705fd98598a222fe15 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 16 Mar 2023 23:13:52 +0100 Subject: [PATCH 194/404] Implemented song queue and playing --- src/extensions/dynamic/VCMusic.py | 199 +++++++++++++++++++++++++++++- src/locale/VCMusic.toml | 5 + 2 files changed, 199 insertions(+), 5 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 1a5a41ec..dbb5ed44 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,13 +1,24 @@ import logging import re from dataclasses import dataclass +from datetime import datetime from enum import IntEnum from typing import Union -from discord import (ButtonStyle, Color, Embed, Guild, Interaction, Member, - TextChannel, TextStyle) -from discord.app_commands import (Transform, autocomplete, command, describe, - guild_only, rename) +from discord import ( + ButtonStyle, + Color, + Embed, + FFmpegPCMAudio, + Guild, + Interaction, + Member, + PCMVolumeTransformer, + TextChannel, + TextStyle +) +from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) +from discord.ext import tasks from discord.ext.commands import Bot, GroupCog from discord.ui import Button, Modal, TextInput, View from youtubesearchpython import VideosSearch @@ -23,8 +34,10 @@ INTERACTION_SPLIT_CHARACTER = "." EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" QUERY_RESULT_LIMIT = 15 +INACTIVE_TIMEOUT = 60 # AUTHOR_ID = 244050529271939073 # main account AUTHOR_ID = 202978567741505536 # alt account +FFMPEG_PLAYER_OPTIONS = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" class UserActionType(IntEnum): @@ -340,6 +353,9 @@ class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: Bot): self.bot = bot self.author = "fuxticks" + self.active_players = {} + self.playing = [] + self.inactive = {} self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") @@ -381,12 +397,61 @@ async def on_interaction(self, interaction: Interaction): case UserActionType.STOP: pass case UserActionType.ADD_SONG_MODAL_SUBMIT: - pass + return await self.add_modal_interaction_handler(interaction) case UserActionType.EDIT_QUEUE_MODAL_SUBMIT: pass case _: return False + def run_tasks(self): + if self.playing: + self.check_playing.start() + + if self.inactive: + self.check_inactive.start() + + @tasks.loop(seconds=5) + async def check_playing(self): + if not self.playing: + self.check_playing.cancel() + self.check_playing.stop() + return + + no_longer_active = [] + now = datetime.now() + + for guild_id in self.playing: + voice_client = self.active_players.get(guild_id).get("voice_client") + if not voice_client.is_playing() and not voice_client.is_paused(): + if not self.play_next_song(guild_id): + + no_longer_active.append(guild_id) + self.inactive[guild_id] = now + await self.update_embed(guild_id) + + for guild in no_longer_active: + self.playing.remove(guild) + + @tasks.loop(seconds=10) + async def check_inactive(self): + if not self.inactive: + self.check_inactive.cancel() + self.check_inactive.stop() + return + + now = datetime.now() + guilds_to_disconnect = [] + + for guild_id in self.inactive: + if (now - self.inactive.get(guild_id)).seconds > INACTIVE_TIMEOUT: + guilds_to_disconnect.append(guild_id) + + for guild in guilds_to_disconnect: + self.inactive.pop(guild) + await self.active_players.get(guild).get("voice_client").disconnect() + self.active_players.pop(guild) + await self.update_embed(guild) + def check_valid_user(self, guild: Guild, user: Member) -> bool: if not user.voice: return False @@ -428,6 +493,130 @@ async def add_interaction_hanlder(self, interaction: Interaction) -> bool: await interaction.response.send_modal(modal) return True + async def add_modal_interaction_handler(self, interaction: Interaction): + await interaction.response.defer(ephemeral=True) + if not self.check_valid_user(interaction.guild, interaction.user): + await interaction.followup.send(content=COG_STRINGS["music_invalid_voice"]) + return False + + await interaction.followup.send(content=COG_STRINGS["music_thinking"], ephemeral=True) + + raw_modal_data = interaction.data.get("components") + + single_request = "" + multiple_request = "" + + for item in raw_modal_data: + if item.get("components")[0].get("custom_id") == UserActionType.ADD_SONG_MODAL_SINGLE.id: + single_request = item.get("components")[0].get("value") + elif item.get("components")[0].get("custom_id") == UserActionType.ADD_SONG_MODAL_MULTIPLE.id: + multiple_request = item.get("components")[0].get("value") + + request_list = [ + SongRequest(x.strip(), + parse_request_type(x.strip())) for x in multiple_request.split("\n") if x.strip() not in ('', + ' ') + ] + if single_request.strip() not in ('', ' '): + request_list = [SongRequest(single_request.strip(), parse_request_type(single_request.strip()))] + request_list + + failed_requests = [] + requests_to_queue = [] + for request in request_list: + result = await request.get_song() + if result is None: + failed_requests.append(request) + elif isinstance(result, list): + requests_to_queue += result + else: + requests_to_queue.append(result) + + await interaction.followup.send( + COG_STRINGS["music_added_song_count"].format(count=len(request_list) - len(failed_requests)), + ephemeral=True + ) + + await self.try_play_queue(interaction, add_to_queue=requests_to_queue) + + return True + + async def try_play_queue(self, interaction: Interaction, add_to_queue: list = []): + if not self.check_valid_user(interaction.guild, interaction.user): + message = COG_STRINGS["music_invalid_voice"] + if interaction.response.is_done(): + await interaction.followup.send(content=message, ephemeral=True) + else: + await interaction.response.send_message(message, ephemeral=True) + return False + + if interaction.guild.id not in self.active_players: + voice_client = await interaction.user.voice.channel.connect() + active_player = {"voice_client": voice_client, "current_song": None, "queue": [], "volume": 100} + self.active_players[interaction.guild.id] = active_player + elif not interaction.guild.me.voice or not interaction.guild.me.voice.channel: + voice_client = await interaction.user.voice.channel.connect() + self.active_players[interaction.guild.id]["voice_client"] = voice_client + + self.active_players[interaction.guild.id]["queue"] += add_to_queue + + is_playing = self.active_players[interaction.guild.id]["voice_client"].is_playing() + is_paused = self.active_players[interaction.guild.id]["voice_client"].is_paused() + has_current_song = self.active_players[interaction.guild.id]["current_song"] is not None + + if is_playing or (is_paused and has_current_song): + return False + + if self.play_next_song(interaction.guild.id): + self.run_tasks() + await self.update_embed(interaction.guild.id) + return True + return False + + def play_next_song(self, guild_id: int): + try: + next_song = self.active_players[guild_id]["queue"].pop() + except IndexError: + self.active_players[guild_id]["current_song"] = None + return False + + if self.active_players[guild_id]["voice_client"].is_playing(): + self.active_players[guild_id]["voice_client"].stop() + + if next_song.stream_data is None: + stream_data = next_song.get_stream_data() + else: + stream_data = next_song.stream_data + + self.active_players[guild_id]["current_song"] = next_song + + voice_source = PCMVolumeTransformer( + FFmpegPCMAudio(stream_data.get("url"), + before_options=FFMPEG_PLAYER_OPTIONS, + options="-vn"), + volume=self.active_players.get(guild_id).get("volume") + ) + + self.active_players[guild_id]["voice_client"].play(voice_source) + self.playing.append(guild_id) + + return True + + async def update_embed(self, guild_id: int): + current_song = self.active_players.get(guild_id).get("current_song") + db_entry = DBSession.get(MusicChannels, guild_id=guild_id) + embed_message = await self.bot.get_guild(guild_id).get_channel(db_entry.channel_id).fetch_message(db_entry.message_id) + + current_embed: Embed = embed_message.embeds[0] + new_embed = Embed( + title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), + color=current_embed.color, + url=current_song.url + ) + new_embed.set_image(url=current_song.thumbnail) + new_embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=self.author)) + + await embed_message.edit(embed=new_embed) + @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( channel=COG_STRINGS["music_set_channel_channel_describe"], diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 626e711a..1c420e74 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -1,4 +1,5 @@ music_group_name = "music" +music_thinking = "Music bot is thinking..." music_embed_title_idle = "No song currently playing...." music_embed_title_playing = "Now playing — {song}" @@ -9,8 +10,12 @@ music_button_edit_queue = "Edit Queue" music_button_stop_queue = "Stop" music_add_song_modal_title = "Add song(s) to queue" +music_add_song_modal_single = "Add single song" +music_add_song_modal_multiple = "Add multiple songs" music_invalid_voice = "You must be in the same voice channel as the bot to perform this action ⚠️" +music_added_song_count = "Successfully added `{count}` song(s) to the queue ✅" + music_set_channel_name = "set-channel" music_set_channel_description = "Sets the music channel to the given Text Channel." music_set_channel_channel_describe = "The channel to set." From d5e2e81e9c4f538c6a176de57d4cabf7552f8df1 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 17 Mar 2023 09:04:29 +0100 Subject: [PATCH 195/404] Fixed incorrect event being handled --- src/extensions/dynamic/VCMusic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index dbb5ed44..6290f7c5 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -387,9 +387,9 @@ async def on_interaction(self, interaction: Interaction): case UserActionType.PLAY: pass case UserActionType.PAUSE: - return await self.add_interaction_hanlder(interaction) - case UserActionType.ADD_SONG: pass + case UserActionType.ADD_SONG: + return await self.add_interaction_hanlder(interaction) case UserActionType.VIEW_QUEUE: pass case UserActionType.EDIT_QUEUE: From 45b1b91ac5313570afd0c68db0f463b5f4b5f519 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 17 Mar 2023 09:18:44 +0100 Subject: [PATCH 196/404] Added function to handle if an interaction needs a response or followup --- src/common/discord.py | 9 +++++++++ src/extensions/dynamic/VCMusic.py | 10 +++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index dd820610..f956cc7f 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -9,6 +9,15 @@ ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") +async def respond_or_followup(message: str, interaction: Interaction, ephemeral=False): + if interaction.response.is_done(): + await interaction.followup.send(content=message, ephemeral=ephemeral) + return False + else: + await interaction.response.send_message(message, ephemeral=ephemeral) + return True + + def make_colour_list(): all_vars = dir(Colour) colour_vars = dir(Colour) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 6290f7c5..6dc1e69e 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -24,7 +24,7 @@ from youtubesearchpython import VideosSearch from yt_dlp import YoutubeDL -from common.discord import ColourTransformer +from common.discord import ColourTransformer, respond_or_followup from common.io import load_cog_toml from database.gateway import DBSession from database.models import MusicChannels @@ -466,7 +466,7 @@ def check_valid_user(self, guild: Guild, user: Member) -> bool: async def add_interaction_hanlder(self, interaction: Interaction) -> bool: if not self.check_valid_user(interaction.guild, interaction.user): - await interaction.response.send_message(COG_STRINGS["music_invalid_voice"], ephemeral=True) + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False modal = Modal( @@ -542,11 +542,7 @@ async def add_modal_interaction_handler(self, interaction: Interaction): async def try_play_queue(self, interaction: Interaction, add_to_queue: list = []): if not self.check_valid_user(interaction.guild, interaction.user): - message = COG_STRINGS["music_invalid_voice"] - if interaction.response.is_done(): - await interaction.followup.send(content=message, ephemeral=True) - else: - await interaction.response.send_message(message, ephemeral=True) + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False if interaction.guild.id not in self.active_players: From 00a9a12d4ca1a77e3530092eff9ff7d1853e1640 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 17 Mar 2023 09:19:10 +0100 Subject: [PATCH 197/404] Changed ephemeral to use bot settings --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 6dc1e69e..f43a7786 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -655,7 +655,7 @@ async def set_channel( await interaction.followup.send( content=COG_STRINGS["music_set_channel_success"].format(channel=channel.mention), - ephemeral=True + ephemeral=self.bot.only_ephemeral ) From ab4739675d52e8da15b9d03fdfb25dcbbdc2a2ac Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 17 Mar 2023 10:00:00 +0100 Subject: [PATCH 198/404] Implemented simple pause handler --- src/extensions/dynamic/VCMusic.py | 20 +++++++++++++++++++- src/locale/VCMusic.toml | 6 +++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index f43a7786..22ed9ba3 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -387,7 +387,7 @@ async def on_interaction(self, interaction: Interaction): case UserActionType.PLAY: pass case UserActionType.PAUSE: - pass + return await self.pause_playback(interaction) case UserActionType.ADD_SONG: return await self.add_interaction_hanlder(interaction) case UserActionType.VIEW_QUEUE: @@ -597,6 +597,24 @@ def play_next_song(self, guild_id: int): return True + async def pause_playback(self, interaction: Interaction): + if not self.check_valid_user(interaction.guild, interaction.user): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + if not interaction.guild.id in self.active_players: + await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) + return False + + voice_client = self.active_players[interaction.guild.id]["voice_client"] + if voice_client.is_paused(): + await respond_or_followup(COG_STRINGS["music_warn_already_paused"], interaction, ephemeral=True) + return False + + voice_client.pause() + await respond_or_followup(COG_STRINGS["music_paused_success"], interaction, ephemeral=True) + return True + async def update_embed(self, guild_id: int): current_song = self.active_players.get(guild_id).get("current_song") db_entry = DBSession.get(MusicChannels, guild_id=guild_id) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 1c420e74..4df34bdd 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -24,4 +24,8 @@ music_set_channel_clear_messages_describe = "If the channel to be set should be music_set_channel_clear_messages_rename = "clear-channel" music_set_channel_embed_color_describe = "The colour to use for the embed. Defaults to #d462fd." music_set_channel_embed_color_rename = "color" -music_set_channel_success = "Successfully set {channel} as the music channel ✅" \ No newline at end of file +music_set_channel_success = "Successfully set {channel} as the music channel ✅" + +music_warn_not_playing = "There is no music currently playing ⚠️" +music_warn_already_paused = "Playback is already paused ⚠️" +music_paused_success = "Playback paused ✅" From 2aa37ef2a5b4a498948075148f0d1c5a1cfb609b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 17 Mar 2023 10:04:37 +0100 Subject: [PATCH 199/404] Added support for skip button --- src/extensions/dynamic/VCMusic.py | 14 +++++++++++--- src/locale/VCMusic.toml | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 22ed9ba3..f25f4a8d 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -308,11 +308,19 @@ def create_music_embed( return embed -def create_music_actionbar() -> View: +def create_music_actionbar(is_paused: bool = False) -> View: view = View(timeout=None) play_button = Button(style=ButtonStyle.secondary, emoji="▶️", custom_id=UserActionType.PLAY.id) pause_button = Button(style=ButtonStyle.secondary, emoji="⏸️", custom_id=UserActionType.PAUSE.id) + playback_button = pause_button if is_paused else play_button + + skip_button = Button( + style=ButtonStyle.primary, + label=COG_STRINGS["music_button_skip_song"], + emoji="⏩", + custom_id=UserActionType.SKIP.id + ) add_button = Button( style=ButtonStyle.primary, label=COG_STRINGS["music_button_add_song"], @@ -338,8 +346,8 @@ def create_music_actionbar() -> View: custom_id=UserActionType.STOP.id ) - view.add_item(play_button) - view.add_item(pause_button) + view.add_item(playback_button) + view.add_item(skip_button) view.add_item(add_button) view.add_item(view_button) view.add_item(edit_button) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 4df34bdd..c1a334da 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -8,6 +8,7 @@ music_button_add_song = "Add Song" music_button_view_queue = "View Queue" music_button_edit_queue = "Edit Queue" music_button_stop_queue = "Stop" +music_button_skip_song = "Skip" music_add_song_modal_title = "Add song(s) to queue" music_add_song_modal_single = "Add single song" From dae257e26c269dbd417d1f56aa8e8ada0f0cd35e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 17 Mar 2023 10:21:58 +0100 Subject: [PATCH 200/404] SKIP action can now be handled --- src/extensions/dynamic/VCMusic.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index f25f4a8d..cb126d3a 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -51,6 +51,7 @@ class UserActionType(IntEnum): EDIT_QUEUE_MODAL_SUBMIT = 7 ADD_SONG_MODAL_SINGLE = 8 ADD_SONG_MODAL_MULTIPLE = 9 + SKIP = 10 @property def id(self) -> str: @@ -75,6 +76,8 @@ def id(self) -> str: return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}addmodalsingle" case UserActionType.ADD_SONG_MODAL_MULTIPLE: return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}addmodalmultiple" + case UserActionType.SKIP: + return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}skipsong" case _: raise ValueError("Invalid enum type given!") @@ -106,6 +109,8 @@ def from_string(self, string: str) -> "UserActionType": return UserActionType.ADD_SONG_MODAL_SINGLE case "addmodalmultiple": return UserActionType.ADD_SONG_MODAL_MULTIPLE + case "skipsong": + return UserActionType.SKIP case _: raise ValueError(f"Invalid string given for {__class__.__name__}") @@ -396,6 +401,8 @@ async def on_interaction(self, interaction: Interaction): pass case UserActionType.PAUSE: return await self.pause_playback(interaction) + case UserActionType.SKIP: + pass case UserActionType.ADD_SONG: return await self.add_interaction_hanlder(interaction) case UserActionType.VIEW_QUEUE: From 2be0759e076f7c43551aabb8f3fb40c770a650e3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 17 Mar 2023 10:22:20 +0100 Subject: [PATCH 201/404] Implemented correct toggle of play/pause button --- src/extensions/dynamic/VCMusic.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index cb126d3a..144a9ed9 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -313,15 +313,15 @@ def create_music_embed( return embed -def create_music_actionbar(is_paused: bool = False) -> View: +def create_music_actionbar(is_paused: bool = True) -> View: view = View(timeout=None) play_button = Button(style=ButtonStyle.secondary, emoji="▶️", custom_id=UserActionType.PLAY.id) pause_button = Button(style=ButtonStyle.secondary, emoji="⏸️", custom_id=UserActionType.PAUSE.id) - playback_button = pause_button if is_paused else play_button + playback_button = play_button if is_paused else pause_button skip_button = Button( - style=ButtonStyle.primary, + style=ButtonStyle.secondary, label=COG_STRINGS["music_button_skip_song"], emoji="⏩", custom_id=UserActionType.SKIP.id @@ -627,6 +627,7 @@ async def pause_playback(self, interaction: Interaction): return False voice_client.pause() + await self.update_embed(interaction.guild.id) await respond_or_followup(COG_STRINGS["music_paused_success"], interaction, ephemeral=True) return True @@ -644,7 +645,12 @@ async def update_embed(self, guild_id: int): new_embed.set_image(url=current_song.thumbnail) new_embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=self.author)) - await embed_message.edit(embed=new_embed) + voice_client_state = self.active_players.get(guild_id).get("voice_client").is_paused() + + await embed_message.edit( + embed=new_embed, + view=create_music_actionbar(False if voice_client_state is None else voice_client_state) + ) @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( From 7abd0dedab414ae67bae41832bd7cbedefe5c5e4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 12:07:42 +0100 Subject: [PATCH 202/404] Implemented play/pause button event handling --- src/extensions/dynamic/VCMusic.py | 48 ++++++++++++++++++++++--------- src/locale/VCMusic.toml | 4 +++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 144a9ed9..777ff117 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -398,7 +398,7 @@ async def on_interaction(self, interaction: Interaction): match user_action: case UserActionType.PLAY: - pass + return await self.resume_or_start_playback(interaction) case UserActionType.PAUSE: return await self.pause_playback(interaction) case UserActionType.SKIP: @@ -612,6 +612,28 @@ def play_next_song(self, guild_id: int): return True + async def resume_or_start_playback(self, interaction: Interaction): + if not self.check_valid_user(interaction.guild, interaction.user): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + if not interaction.guild.id in self.active_players: + return await self.add_interaction_hanlder(interaction) + + if self.active_players.get(interaction.guild.id).get("voice_client").is_playing(): + await respond_or_followup(COG_STRINGS["music_warn_already_playing"], interaction, ephemeral=True) + return False + + if self.active_players.get(interaction.guild.id).get("voice_client").is_paused(): + voice_client = self.active_players.get(interaction.guild.id).get("voice_client") + voice_client.resume() + await self.update_embed(interaction.guild.id) + await respond_or_followup(COG_STRINGS["music_resume_success"], interaction, ephemeral=True) + return True + + await respond_or_followup(COG_STRINGS["music_generic_error"].format(author=self.author), interaction, ephemeral=True) + return False + async def pause_playback(self, interaction: Interaction): if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) @@ -637,20 +659,20 @@ async def update_embed(self, guild_id: int): embed_message = await self.bot.get_guild(guild_id).get_channel(db_entry.channel_id).fetch_message(db_entry.message_id) current_embed: Embed = embed_message.embeds[0] - new_embed = Embed( - title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), - color=current_embed.color, - url=current_song.url - ) - new_embed.set_image(url=current_song.thumbnail) - new_embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=self.author)) + if current_song: + new_embed = create_music_embed( + color=current_embed.color, + author=self.author, + title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), + image=current_song.thumbnail + ) + else: + new_embed = create_music_embed(color=current_embed.color, author=self.author) - voice_client_state = self.active_players.get(guild_id).get("voice_client").is_paused() + voice_client = self.active_players.get(guild_id).get("voice_client") + is_paused = True if voice_client is None else not voice_client.is_playing() - await embed_message.edit( - embed=new_embed, - view=create_music_actionbar(False if voice_client_state is None else voice_client_state) - ) + await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index c1a334da..5062ec7f 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -1,5 +1,6 @@ music_group_name = "music" music_thinking = "Music bot is thinking..." +music_generic_error = "An error occured! If this issue persists please contact an admin or {author} ❌" music_embed_title_idle = "No song currently playing...." music_embed_title_playing = "Now playing — {song}" @@ -30,3 +31,6 @@ music_set_channel_success = "Successfully set {channel} as the music channel ✅ music_warn_not_playing = "There is no music currently playing ⚠️" music_warn_already_paused = "Playback is already paused ⚠️" music_paused_success = "Playback paused ✅" + +music_warn_already_playing = "The bot is already playing something ⚠️" +music_resume_success = "Playback resumed ✅" \ No newline at end of file From 8fbce9c3440beefc6f3b5bd8d989b3f7ba639a88 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 12:13:23 +0100 Subject: [PATCH 203/404] Changed active_players to use dataclass instead of dictionary --- src/extensions/dynamic/VCMusic.py | 69 +++++++++++++++++++------------ 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 777ff117..01923bf2 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,6 +1,6 @@ import logging import re -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum from typing import Union @@ -15,7 +15,8 @@ Member, PCMVolumeTransformer, TextChannel, - TextStyle + TextStyle, + VoiceClient ) from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) from discord.ext import tasks @@ -183,6 +184,22 @@ def get_stream_data(self): return info +@dataclass(slots=True) +class GuildMusicPlayer: + guild: Union[Guild, int] + current_song: Union[None, SongRequest] = None + queue: list = field(default_factory=list) + voice_client: Union[None, VoiceClient] = None + volume: int = 100 + + def __eq__(self, other: "GuildMusicPlayer") -> bool: + if not isinstance(other, GuildMusicPlayer): + return False + value1 = self.guild if isinstance(self.guild, int) else self.guild.id + value2 = other.guild if isinstance(self.guild, int) else other.guild.id + return value1 == value2 + + def parse_request_type(request: str) -> SongRequestType: website_regex = r"^(https:\/\/)?(www.)?" if re.search(website_regex, request).group(): @@ -366,9 +383,9 @@ class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): def __init__(self, bot: Bot): self.bot = bot self.author = "fuxticks" - self.active_players = {} - self.playing = [] - self.inactive = {} + self.active_players: dict[int, GuildMusicPlayer] = {} + self.playing: list[int] = [] + self.inactive: dict[int, datetime] = {} self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") @@ -436,7 +453,7 @@ async def check_playing(self): now = datetime.now() for guild_id in self.playing: - voice_client = self.active_players.get(guild_id).get("voice_client") + voice_client = self.active_players.get(guild_id).voice_client if not voice_client.is_playing() and not voice_client.is_paused(): if not self.play_next_song(guild_id): @@ -463,7 +480,7 @@ async def check_inactive(self): for guild in guilds_to_disconnect: self.inactive.pop(guild) - await self.active_players.get(guild).get("voice_client").disconnect() + await self.active_players.get(guild).voice_client.disconnect() self.active_players.pop(guild) await self.update_embed(guild) @@ -562,17 +579,17 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] if interaction.guild.id not in self.active_players: voice_client = await interaction.user.voice.channel.connect() - active_player = {"voice_client": voice_client, "current_song": None, "queue": [], "volume": 100} + active_player = GuildMusicPlayer(guild=interaction.guild) self.active_players[interaction.guild.id] = active_player elif not interaction.guild.me.voice or not interaction.guild.me.voice.channel: voice_client = await interaction.user.voice.channel.connect() - self.active_players[interaction.guild.id]["voice_client"] = voice_client + self.active_players[interaction.guild.id].voice_client = voice_client - self.active_players[interaction.guild.id]["queue"] += add_to_queue + self.active_players[interaction.guild.id].queue += add_to_queue - is_playing = self.active_players[interaction.guild.id]["voice_client"].is_playing() - is_paused = self.active_players[interaction.guild.id]["voice_client"].is_paused() - has_current_song = self.active_players[interaction.guild.id]["current_song"] is not None + is_playing = self.active_players[interaction.guild.id].voice_client.is_playing() + is_paused = self.active_players[interaction.guild.id].voice_client.is_paused() + has_current_song = self.active_players[interaction.guild.id].current_song is not None if is_playing or (is_paused and has_current_song): return False @@ -585,29 +602,29 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] def play_next_song(self, guild_id: int): try: - next_song = self.active_players[guild_id]["queue"].pop() + next_song = self.active_players[guild_id].queue.pop() except IndexError: - self.active_players[guild_id]["current_song"] = None + self.active_players[guild_id].current_song = None return False - if self.active_players[guild_id]["voice_client"].is_playing(): - self.active_players[guild_id]["voice_client"].stop() + if self.active_players[guild_id].voice_client.is_playing(): + self.active_players[guild_id].voice_client.stop() if next_song.stream_data is None: stream_data = next_song.get_stream_data() else: stream_data = next_song.stream_data - self.active_players[guild_id]["current_song"] = next_song + self.active_players[guild_id].current_song = next_song voice_source = PCMVolumeTransformer( FFmpegPCMAudio(stream_data.get("url"), before_options=FFMPEG_PLAYER_OPTIONS, options="-vn"), - volume=self.active_players.get(guild_id).get("volume") + volume=self.active_players.get(guild_id).volume ) - self.active_players[guild_id]["voice_client"].play(voice_source) + self.active_players[guild_id].voice_client.play(voice_source) self.playing.append(guild_id) return True @@ -620,12 +637,12 @@ async def resume_or_start_playback(self, interaction: Interaction): if not interaction.guild.id in self.active_players: return await self.add_interaction_hanlder(interaction) - if self.active_players.get(interaction.guild.id).get("voice_client").is_playing(): + if self.active_players.get(interaction.guild.id).voice_client.is_playing(): await respond_or_followup(COG_STRINGS["music_warn_already_playing"], interaction, ephemeral=True) return False - if self.active_players.get(interaction.guild.id).get("voice_client").is_paused(): - voice_client = self.active_players.get(interaction.guild.id).get("voice_client") + if self.active_players.get(interaction.guild.id).voice_client.is_paused(): + voice_client = self.active_players.get(interaction.guild.id).voice_client voice_client.resume() await self.update_embed(interaction.guild.id) await respond_or_followup(COG_STRINGS["music_resume_success"], interaction, ephemeral=True) @@ -643,7 +660,7 @@ async def pause_playback(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) return False - voice_client = self.active_players[interaction.guild.id]["voice_client"] + voice_client = self.active_players[interaction.guild.id].voice_client if voice_client.is_paused(): await respond_or_followup(COG_STRINGS["music_warn_already_paused"], interaction, ephemeral=True) return False @@ -654,7 +671,7 @@ async def pause_playback(self, interaction: Interaction): return True async def update_embed(self, guild_id: int): - current_song = self.active_players.get(guild_id).get("current_song") + current_song = self.active_players.get(guild_id).current_song db_entry = DBSession.get(MusicChannels, guild_id=guild_id) embed_message = await self.bot.get_guild(guild_id).get_channel(db_entry.channel_id).fetch_message(db_entry.message_id) @@ -669,7 +686,7 @@ async def update_embed(self, guild_id: int): else: new_embed = create_music_embed(color=current_embed.color, author=self.author) - voice_client = self.active_players.get(guild_id).get("voice_client") + voice_client = self.active_players.get(guild_id).voice_client is_paused = True if voice_client is None else not voice_client.is_playing() await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) From 6e6264995891f5ab3b1dcaa9e84b84d39c393b5d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 12:39:02 +0100 Subject: [PATCH 204/404] Fixed issue where voice_client was not assigned --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 01923bf2..b9a604db 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -579,7 +579,7 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] if interaction.guild.id not in self.active_players: voice_client = await interaction.user.voice.channel.connect() - active_player = GuildMusicPlayer(guild=interaction.guild) + active_player = GuildMusicPlayer(guild=interaction.guild, voice_client=voice_client) self.active_players[interaction.guild.id] = active_player elif not interaction.guild.me.voice or not interaction.guild.me.voice.channel: voice_client = await interaction.user.voice.channel.connect() From 6b18a66c9943964458c3d75f27be2a4116c7e2da Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 13:29:07 +0100 Subject: [PATCH 205/404] Fixed issue where queue was being played in reverse --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index b9a604db..b459b354 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -602,7 +602,7 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] def play_next_song(self, guild_id: int): try: - next_song = self.active_players[guild_id].queue.pop() + next_song = self.active_players[guild_id].queue.pop(0) except IndexError: self.active_players[guild_id].current_song = None return False From 82142f84e6b3b2e9c6054c35546979a6fdc59453 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 14:21:22 +0100 Subject: [PATCH 206/404] Implemented SKIP interaction and handling --- src/extensions/dynamic/VCMusic.py | 30 ++++++++++++++++++++++++++---- src/locale/VCMusic.toml | 5 ++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index b459b354..856bfc6e 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -322,9 +322,10 @@ def create_music_embed( color: Color, author: str, title: str = COG_STRINGS["music_embed_title_idle"], - image: str = EMBED_IMAGE_URL + image: str = EMBED_IMAGE_URL, + url: str = None ) -> Embed: - embed = Embed(title=title, color=color) + embed = Embed(title=title, color=color, url=url) embed.set_image(url=image) embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=author)) return embed @@ -419,7 +420,7 @@ async def on_interaction(self, interaction: Interaction): case UserActionType.PAUSE: return await self.pause_playback(interaction) case UserActionType.SKIP: - pass + return await self.skip_song_handler(interaction) case UserActionType.ADD_SONG: return await self.add_interaction_hanlder(interaction) case UserActionType.VIEW_QUEUE: @@ -670,6 +671,26 @@ async def pause_playback(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_paused_success"], interaction, ephemeral=True) return True + async def skip_song_handler(self, interaction: Interaction): + if not self.check_valid_user(interaction.guild, interaction.user): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + if not interaction.guild.id in self.active_players: + await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) + return False + + if self.play_next_song(interaction.guild.id): + await self.update_embed(interaction.guild.id) + await respond_or_followup(COG_STRINGS["music_skip_success"], interaction, ephemeral=True) + return True + + if self.active_players.get(interaction.guild.id).voice_client.is_playing(): + self.active_players.get(interaction.guild.id).voice_client.stop() + await self.update_embed(interaction.guild.id) + await respond_or_followup(COG_STRINGS["music_warn_no_next_song"], interaction, ephemeral=True) + return False + async def update_embed(self, guild_id: int): current_song = self.active_players.get(guild_id).current_song db_entry = DBSession.get(MusicChannels, guild_id=guild_id) @@ -681,7 +702,8 @@ async def update_embed(self, guild_id: int): color=current_embed.color, author=self.author, title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), - image=current_song.thumbnail + image=current_song.thumbnail, + url=current_song.url ) else: new_embed = create_music_embed(color=current_embed.color, author=self.author) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 5062ec7f..2e0cbb4d 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -33,4 +33,7 @@ music_warn_already_paused = "Playback is already paused ⚠️" music_paused_success = "Playback paused ✅" music_warn_already_playing = "The bot is already playing something ⚠️" -music_resume_success = "Playback resumed ✅" \ No newline at end of file +music_resume_success = "Playback resumed ✅" + +music_warn_no_next_song = "Song skipped (no more songs in queue) ⚠️" +music_skip_success = "Song skipped ✅" \ No newline at end of file From bc10c96e07b246c4a0b3562ad9e34be222843271 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 14:48:19 +0100 Subject: [PATCH 207/404] Impkemented handling of VIEW_QUEUE user interaction --- src/extensions/dynamic/VCMusic.py | 20 +++++++++++++++++++- src/locale/VCMusic.toml | 5 ++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 856bfc6e..48b45848 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -424,7 +424,7 @@ async def on_interaction(self, interaction: Interaction): case UserActionType.ADD_SONG: return await self.add_interaction_hanlder(interaction) case UserActionType.VIEW_QUEUE: - pass + return await self.get_current_queue(interaction) case UserActionType.EDIT_QUEUE: pass case UserActionType.STOP: @@ -691,6 +691,24 @@ async def skip_song_handler(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_warn_no_next_song"], interaction, ephemeral=True) return False + async def get_current_queue(self, interaction: Interaction): + await interaction.response.defer() + if interaction.guild.id not in self.active_players: + await respond_or_followup(COG_STRINGS["music_warn_view_queue_empty"], interaction, ephemeral=True) + return True + + current_queue = self.active_players.get(interaction.guild.id).queue + current_song = self.active_players.get(interaction.guild.id).current_song + + current_song_text = f"__Current Song__\n{COG_STRINGS['music_embed_title_idle'] if not current_song else current_song.title}" + formatted_queue = "\n".join([f"{idx+1}. {song.title}" for idx, song in enumerate(current_queue)]) + current_queue_text = f"__Up Next__\n{COG_STRINGS['music_empty_queue_text'] if not current_queue else formatted_queue}" + + queue_text = f"{current_song_text}\n\n{current_queue_text}" + + await respond_or_followup(queue_text, interaction, ephemeral=True) + return True + async def update_embed(self, guild_id: int): current_song = self.active_players.get(guild_id).current_song db_entry = DBSession.get(MusicChannels, guild_id=guild_id) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 2e0cbb4d..0c7eb972 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -36,4 +36,7 @@ music_warn_already_playing = "The bot is already playing something ⚠️" music_resume_success = "Playback resumed ✅" music_warn_no_next_song = "Song skipped (no more songs in queue) ⚠️" -music_skip_success = "Song skipped ✅" \ No newline at end of file +music_skip_success = "Song skipped ✅" + +music_warn_view_queue_empty = "There are no songs currently queued ⚠️" +music_empty_queue_text = "_No songs currently queued_" \ No newline at end of file From 6f2b806fcd01c164bb3da7efcd3a38dd69a212dc Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 14:52:29 +0100 Subject: [PATCH 208/404] fixed parsing of YOUTUBE_VIDEO song requests --- src/extensions/dynamic/VCMusic.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 48b45848..9a2585d1 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -140,8 +140,14 @@ async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: match self.request_type: case SongRequestType.STRING: result = string_request_query(self) + parsed_result = parse_string_query_result(result) + self.url = parsed_result.get("url") + self.title = parsed_result.get("title") + self.thumbnail = parsed_result.get("thumbnail") + return self case SongRequestType.YOUTUBE_VIDEO: - result = string_request_query(self) + result = self.get_stream_data() + return self case SongRequestType.YOUTUBE_PLAYLIST: return None case _: @@ -150,13 +156,10 @@ async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: if result == None or result == {}: return self - parsed_result = parse_string_query_result(result) - self.url = parsed_result.get("url") - self.title = parsed_result.get("title") - self.thumbnail = parsed_result.get("thumbnail") - return self - def get_stream_data(self): + if self.stream_data is not None: + return self.stream_data + ydl_opts = { "quiet": "true", "nowarning": "true", From 7688a9ef50d2fa3a91099edf7289a95b84d8d699 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 15:12:23 +0100 Subject: [PATCH 209/404] Updated string query algorithm --- src/extensions/dynamic/VCMusic.py | 47 ++++++++++--------------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 9a2585d1..4b13aa47 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -254,7 +254,7 @@ def convert_viewcount_to_float(short_views: str) -> float: def string_request_query(request: SongRequest) -> dict: if request.request_type == SongRequestType.STRING: - query = f"'{request.raw_request}'" + query = f"\"{request.raw_request}\" #music" else: query = request.raw_request @@ -262,49 +262,32 @@ def string_request_query(request: SongRequest) -> dict: if request.request_type != SongRequestType.STRING and video_results: return video_results[0] - filtered_results = list(filter(lambda x: x.get("publishedTime") is None, video_results)) - if filtered_results: - return filtered_results[0] - preferred_keywords = ["official", "music"] alternate_keywords = ["lyric", "audio"] - best_result = None - best_views = 0.0 - - is_preferred = False - keyword_found = "" - for result in video_results: video_title = result.get("title").lower() - try: - for keyword in (preferred_keywords + alternate_keywords): - if keyword in video_title: - keyword_found = keyword - raise StopIteration - except StopIteration: - if not is_preferred or keyword_found in preferred_keywords: - view_count = convert_viewcount_to_float(result.get("viewCount").get("short")) - if view_count > best_views: - best_result = result - best_views = view_count + for keyword in preferred_keywords: + if keyword in video_title: + return result - if keyword_found in preferred_keywords: - is_preferred = True + for keyword in alternate_keywords: + if keyword in video_title: + return result - return best_result + return video_results[0] def parse_string_query_result(result: dict) -> dict: - def get_video_title(): - views_long = result.get("viewCount").get("text") - duration_long = result.get("accessibility").get("duration") - title_long = result.get("accessibility").get("title") - title = title_long.replace(views_long, "").replace(duration_long, "") - return title + # def get_video_title(): + # views_long = result.get("viewCount").get("text") + # duration_long = result.get("accessibility").get("duration") + # title_long = result.get("accessibility").get("title") + # title = title_long.replace(views_long, "").replace(duration_long, "") + # return title - video_title = get_video_title() + video_title = result.get("title") video_url = None video_thumbnail = None From b4f1a2d986fdaf1aa188656628c6fd7d711dd8a5 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 18 Mar 2023 15:21:31 +0100 Subject: [PATCH 210/404] Removed redudant code --- src/extensions/dynamic/VCMusic.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 4b13aa47..3ea3ce31 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -153,9 +153,6 @@ async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: case _: raise ValueError("Invalid SongRequestType given!") - if result == None or result == {}: - return self - def get_stream_data(self): if self.stream_data is not None: return self.stream_data From 6d5f16cbeeacab34f5de733322a2919699c11084 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 19 Mar 2023 12:49:57 +0100 Subject: [PATCH 211/404] Implemented STOP UserAction handling --- src/extensions/dynamic/VCMusic.py | 19 ++++++++++++++++++- src/locale/VCMusic.toml | 4 +++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 3ea3ce31..88a60a36 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -411,7 +411,7 @@ async def on_interaction(self, interaction: Interaction): case UserActionType.EDIT_QUEUE: pass case UserActionType.STOP: - pass + return await self.stop_playback(interaction) case UserActionType.ADD_SONG_MODAL_SUBMIT: return await self.add_modal_interaction_handler(interaction) case UserActionType.EDIT_QUEUE_MODAL_SUBMIT: @@ -714,6 +714,23 @@ async def update_embed(self, guild_id: int): await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) + async def stop_playback(self, interaction: Interaction): + if interaction.guild.id not in self.active_players: + if interaction.guild.voice_client: + await interaction.guild.voice_client.disconnect() + await self.update_embed(interaction.guild.id) + return True + + if not self.check_valid_user(interaction.guild, interaction.user): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + self.active_players.get(interaction.guild.id).queue = [] + self.play_next_song(interaction.guild.id) + await self.update_embed(interaction.guild.id) + await respond_or_followup(COG_STRINGS["music_stopped_success"], interaction, ephemeral=True) + return True + @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @describe( channel=COG_STRINGS["music_set_channel_channel_describe"], diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 0c7eb972..93559608 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -39,4 +39,6 @@ music_warn_no_next_song = "Song skipped (no more songs in queue) ⚠️" music_skip_success = "Song skipped ✅" music_warn_view_queue_empty = "There are no songs currently queued ⚠️" -music_empty_queue_text = "_No songs currently queued_" \ No newline at end of file +music_empty_queue_text = "_No songs currently queued_" + +music_stopped_success = "Playback stopped ✅" \ No newline at end of file From 0f056d78b49c18bdb45767590faa09da9eca6c40 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 19 Mar 2023 13:12:02 +0100 Subject: [PATCH 212/404] Fixed playback not being correctly stopped --- src/extensions/dynamic/VCMusic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 88a60a36..f56cc546 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -725,6 +725,8 @@ async def stop_playback(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False + if self.active_players.get(interaction.guild.id).voice_client.is_playing(): + self.active_players.get(interaction.guild.id).voice_client.stop() self.active_players.get(interaction.guild.id).queue = [] self.play_next_song(interaction.guild.id) await self.update_embed(interaction.guild.id) From 117fc44bac09e72afb014a1e5d16f613c587165d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 19 Mar 2023 13:12:33 +0100 Subject: [PATCH 213/404] Implemented auto deletion of messages after delay --- src/common/discord.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index f956cc7f..b7e46af1 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -9,12 +9,19 @@ ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") -async def respond_or_followup(message: str, interaction: Interaction, ephemeral=False): +async def respond_or_followup( + message: str, + interaction: Interaction, + ephemeral: bool = False, + delete_after: float = 10, + **kwargs +): if interaction.response.is_done(): - await interaction.followup.send(content=message, ephemeral=ephemeral) + message = await interaction.followup.send(content=message, ephemeral=ephemeral, **kwargs) + await message.delete(delay=delete_after) return False else: - await interaction.response.send_message(message, ephemeral=ephemeral) + await interaction.response.send_message(message, ephemeral=ephemeral, delete_after=delete_after, **kwargs) return True From ed8487e8071ca346800438aac4ea2b6ed1ebbe8a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 19 Mar 2023 13:13:49 +0100 Subject: [PATCH 214/404] No longer deletes followup messages when delay is None --- src/common/discord.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/common/discord.py b/src/common/discord.py index b7e46af1..54e4bdd6 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -18,7 +18,8 @@ async def respond_or_followup( ): if interaction.response.is_done(): message = await interaction.followup.send(content=message, ephemeral=ephemeral, **kwargs) - await message.delete(delay=delete_after) + if delete_after: + await message.delete(delay=delete_after) return False else: await interaction.response.send_message(message, ephemeral=ephemeral, delete_after=delete_after, **kwargs) From 17a494394935e843c4b35f008ab20a98bf518347 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 19 Mar 2023 13:16:03 +0100 Subject: [PATCH 215/404] Removed EDIT button until implemented --- src/extensions/dynamic/VCMusic.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index f56cc546..5ee52ea2 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -356,7 +356,8 @@ def create_music_actionbar(is_paused: bool = True) -> View: view.add_item(skip_button) view.add_item(add_button) view.add_item(view_button) - view.add_item(edit_button) + # TOOD: Implement queue editing + # view.add_item(edit_button) view.add_item(stop_button) return view From 3a35f4de24cf316be0c0cf92f8ae28776d696e04 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 19 Mar 2023 13:21:02 +0100 Subject: [PATCH 216/404] Made set music channel command admin only --- src/extensions/dynamic/VCMusic.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 5ee52ea2..898a9000 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -18,7 +18,7 @@ TextStyle, VoiceClient ) -from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename) +from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename, default_permissions, checks) from discord.ext import tasks from discord.ext.commands import Bot, GroupCog from discord.ui import Button, Modal, TextInput, View @@ -746,6 +746,8 @@ async def stop_playback(self, interaction: Interaction): embed_color=COG_STRINGS["music_set_channel_embed_color_rename"] ) @autocomplete(embed_color=ColourTransformer.autocomplete) + @default_permissions(administrator=True) + @checks.has_permissions(administrator=True) @guild_only() async def set_channel( self, From d60a51ef61aa9c649425629108a0e0a5483037dd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 16:34:50 +0100 Subject: [PATCH 217/404] Fixed flake8 violations --- src/extensions/dynamic/VCMusic.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 898a9000..12fd1acc 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -476,7 +476,7 @@ def check_valid_user(self, guild: Guild, user: Member) -> bool: if not user.voice.channel: return False - if not guild.id in self.active_players: + if guild.id not in self.active_players: return True return self.bot.user in user.voice.channel.members @@ -619,7 +619,7 @@ async def resume_or_start_playback(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False - if not interaction.guild.id in self.active_players: + if interaction.guild.id not in self.active_players: return await self.add_interaction_hanlder(interaction) if self.active_players.get(interaction.guild.id).voice_client.is_playing(): @@ -641,7 +641,7 @@ async def pause_playback(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False - if not interaction.guild.id in self.active_players: + if interaction.guild.id not in self.active_players: await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) return False @@ -660,7 +660,7 @@ async def skip_song_handler(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False - if not interaction.guild.id in self.active_players: + if interaction.guild.id not in self.active_players: await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) return False @@ -783,4 +783,4 @@ async def set_channel( async def setup(bot: Bot): - await bot.add_cog(VCMusic(bot)) \ No newline at end of file + await bot.add_cog(VCMusic(bot)) From 6805e21b1530d1d7a9247eed77a226ed1d160e2a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 17:17:34 +0100 Subject: [PATCH 218/404] Made all messages temporary --- src/extensions/dynamic/VCMusic.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 12fd1acc..7d141c22 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -513,10 +513,10 @@ async def add_interaction_hanlder(self, interaction: Interaction) -> bool: async def add_modal_interaction_handler(self, interaction: Interaction): await interaction.response.defer(ephemeral=True) if not self.check_valid_user(interaction.guild, interaction.user): - await interaction.followup.send(content=COG_STRINGS["music_invalid_voice"]) + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False - await interaction.followup.send(content=COG_STRINGS["music_thinking"], ephemeral=True) + await respond_or_followup(COG_STRINGS["music_thinking"], interaction, ephemeral=True, delete_after=20) raw_modal_data = interaction.data.get("components") @@ -548,8 +548,9 @@ async def add_modal_interaction_handler(self, interaction: Interaction): else: requests_to_queue.append(result) - await interaction.followup.send( + await respond_or_followup( COG_STRINGS["music_added_song_count"].format(count=len(request_list) - len(failed_requests)), + interaction, ephemeral=True ) From 001b344a96ecbd3d77239061fea91d0b6aac7f5e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 17:18:09 +0100 Subject: [PATCH 219/404] Implemented warning for when music bot is not setup --- src/extensions/dynamic/VCMusic.py | 24 +++++++++++++++++------- src/locale/VCMusic.toml | 1 + 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 7d141c22..80a70b2c 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -582,7 +582,8 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] if self.play_next_song(interaction.guild.id): self.run_tasks() - await self.update_embed(interaction.guild.id) + if not await self.update_embed(interaction.guild.id): + await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) return True return False @@ -630,7 +631,8 @@ async def resume_or_start_playback(self, interaction: Interaction): if self.active_players.get(interaction.guild.id).voice_client.is_paused(): voice_client = self.active_players.get(interaction.guild.id).voice_client voice_client.resume() - await self.update_embed(interaction.guild.id) + if not await self.update_embed(interaction.guild.id): + await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) await respond_or_followup(COG_STRINGS["music_resume_success"], interaction, ephemeral=True) return True @@ -652,7 +654,8 @@ async def pause_playback(self, interaction: Interaction): return False voice_client.pause() - await self.update_embed(interaction.guild.id) + if not await self.update_embed(interaction.guild.id): + await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) await respond_or_followup(COG_STRINGS["music_paused_success"], interaction, ephemeral=True) return True @@ -666,13 +669,15 @@ async def skip_song_handler(self, interaction: Interaction): return False if self.play_next_song(interaction.guild.id): - await self.update_embed(interaction.guild.id) + if not await self.update_embed(interaction.guild.id): + await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) await respond_or_followup(COG_STRINGS["music_skip_success"], interaction, ephemeral=True) return True if self.active_players.get(interaction.guild.id).voice_client.is_playing(): self.active_players.get(interaction.guild.id).voice_client.stop() - await self.update_embed(interaction.guild.id) + if not await self.update_embed(interaction.guild.id): + await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) await respond_or_followup(COG_STRINGS["music_warn_no_next_song"], interaction, ephemeral=True) return False @@ -697,6 +702,8 @@ async def get_current_queue(self, interaction: Interaction): async def update_embed(self, guild_id: int): current_song = self.active_players.get(guild_id).current_song db_entry = DBSession.get(MusicChannels, guild_id=guild_id) + if not db_entry: + return False embed_message = await self.bot.get_guild(guild_id).get_channel(db_entry.channel_id).fetch_message(db_entry.message_id) current_embed: Embed = embed_message.embeds[0] @@ -715,12 +722,14 @@ async def update_embed(self, guild_id: int): is_paused = True if voice_client is None else not voice_client.is_playing() await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) + return True async def stop_playback(self, interaction: Interaction): if interaction.guild.id not in self.active_players: if interaction.guild.voice_client: await interaction.guild.voice_client.disconnect() - await self.update_embed(interaction.guild.id) + if not await self.update_embed(interaction.guild.id): + await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) return True if not self.check_valid_user(interaction.guild, interaction.user): @@ -731,7 +740,8 @@ async def stop_playback(self, interaction: Interaction): self.active_players.get(interaction.guild.id).voice_client.stop() self.active_players.get(interaction.guild.id).queue = [] self.play_next_song(interaction.guild.id) - await self.update_embed(interaction.guild.id) + if not await self.update_embed(interaction.guild.id): + await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) await respond_or_followup(COG_STRINGS["music_stopped_success"], interaction, ephemeral=True) return True diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 93559608..b04ef8b9 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -1,5 +1,6 @@ music_group_name = "music" music_thinking = "Music bot is thinking..." +music_needs_setup = "The music channel has not been setup, please use the setup command to enable all features of the music bot ⚠️" music_generic_error = "An error occured! If this issue persists please contact an admin or {author} ❌" music_embed_title_idle = "No song currently playing...." From 99291454333ead8d5d6f2b02cf6607941382ba89 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 17:18:22 +0100 Subject: [PATCH 220/404] Added /play command --- src/extensions/dynamic/VCMusic.py | 5 +++++ src/locale/VCMusic.toml | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 80a70b2c..9e1efb9f 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -792,6 +792,11 @@ async def set_channel( ephemeral=self.bot.only_ephemeral ) + @command(name=COG_STRINGS["music_play_name"], description=COG_STRINGS["music_play_description"]) + @guild_only() + async def play_command(self, interaction: Interaction): + return await self.resume_or_start_playback(interaction) + async def setup(bot: Bot): await bot.add_cog(VCMusic(bot)) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index b04ef8b9..61f82715 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -42,4 +42,7 @@ music_skip_success = "Song skipped ✅" music_warn_view_queue_empty = "There are no songs currently queued ⚠️" music_empty_queue_text = "_No songs currently queued_" -music_stopped_success = "Playback stopped ✅" \ No newline at end of file +music_stopped_success = "Playback stopped ✅" + +music_play_name = "play" +music_play_description = "Resumes playback or show the add song dialog if not already playing." \ No newline at end of file From d74eef1b4e81ae6b099a6e1c482165fb602d9e43 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 17:28:16 +0100 Subject: [PATCH 221/404] Added check for tasks if they are already running --- src/extensions/dynamic/VCMusic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 9e1efb9f..68d26194 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -421,10 +421,10 @@ async def on_interaction(self, interaction: Interaction): return False def run_tasks(self): - if self.playing: + if self.playing and not self.check_playing.is_running(): self.check_playing.start() - if self.inactive: + if self.inactive and not self.check_inactive.is_running(): self.check_inactive.start() @tasks.loop(seconds=5) From 62d5cbf5f0ac58e1dc6a3128c650af06256f2246 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 17:29:20 +0100 Subject: [PATCH 222/404] Consolodated music playback ending to function --- src/extensions/dynamic/VCMusic.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 68d26194..65a7bed8 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -591,7 +591,6 @@ def play_next_song(self, guild_id: int): try: next_song = self.active_players[guild_id].queue.pop(0) except IndexError: - self.active_players[guild_id].current_song = None return False if self.active_players[guild_id].voice_client.is_playing(): @@ -674,8 +673,7 @@ async def skip_song_handler(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_skip_success"], interaction, ephemeral=True) return True - if self.active_players.get(interaction.guild.id).voice_client.is_playing(): - self.active_players.get(interaction.guild.id).voice_client.stop() + self.end_playback(interaction.guild.id) if not await self.update_embed(interaction.guild.id): await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) await respond_or_followup(COG_STRINGS["music_warn_no_next_song"], interaction, ephemeral=True) @@ -699,6 +697,16 @@ async def get_current_queue(self, interaction: Interaction): await respond_or_followup(queue_text, interaction, ephemeral=True) return True + def end_playback(self, guild_id: int): + if self.active_players.get(guild_id).voice_client.is_playing(): + self.active_players.get(guild_id).voice_client.stop() + + self.active_players.get(guild_id).queue = [] + self.active_players.get(guild_id).current_song = None + + self.inactive[guild_id] = datetime.now() + self.run_tasks() + async def update_embed(self, guild_id: int): current_song = self.active_players.get(guild_id).current_song db_entry = DBSession.get(MusicChannels, guild_id=guild_id) @@ -736,10 +744,7 @@ async def stop_playback(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False - if self.active_players.get(interaction.guild.id).voice_client.is_playing(): - self.active_players.get(interaction.guild.id).voice_client.stop() - self.active_players.get(interaction.guild.id).queue = [] - self.play_next_song(interaction.guild.id) + self.end_playback(interaction.guild.id) if not await self.update_embed(interaction.guild.id): await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) await respond_or_followup(COG_STRINGS["music_stopped_success"], interaction, ephemeral=True) From 01fff29dc5460b408bde725ff24050fc1e3f6e3b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 17:54:15 +0100 Subject: [PATCH 223/404] Updated request parsing --- src/extensions/dynamic/VCMusic.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 65a7bed8..e2e83ab5 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -172,6 +172,8 @@ def get_stream_data(self): with YoutubeDL(ydl_opts) as ydl: if self.url is None and self.request_type != SongRequestType.STRING: self.url = self.raw_request + if not self.url.startswith("https://"): + self.url = f"https://{self.url}" info = ydl.extract_info(self.url, download=False) self.stream_data = info @@ -201,14 +203,6 @@ def __eq__(self, other: "GuildMusicPlayer") -> bool: def parse_request_type(request: str) -> SongRequestType: - website_regex = r"^(https:\/\/)?(www.)?" - if re.search(website_regex, request).group(): - return parse_url_type(request) - else: - return SongRequestType.STRING - - -def parse_url_type(request: str) -> SongRequestType: yt_desktop_regex = r"youtube\.com\/watch\?v=" yt_playlist_regex = r"youtube\.com\/playlist\?list=" yt_mobile_regex = r"youtu\.be\/" @@ -226,7 +220,7 @@ def parse_url_type(request: str) -> SongRequestType: if re.search(yt_thumbnail_regex, request): return SongRequestType.YOUTUBE_THUMBNAIL - return SongRequestType.INVALID + return SongRequestType.STRING def convert_viewcount_to_float(short_views: str) -> float: @@ -289,13 +283,13 @@ def parse_string_query_result(result: dict) -> dict: video_thumbnail = None video_url = result.get("link") - if parse_url_type(video_url) != SongRequestType.YOUTUBE_VIDEO: + if parse_request_type(video_url) != SongRequestType.YOUTUBE_VIDEO: raise ValueError(f"Unable to find correct video URL type for {video_title}") thumbnails = sorted(result.get("thumbnails"), key=lambda x: x.get("width"), reverse=True) video_thumbnail = thumbnails[0].get("url") - if parse_url_type(video_thumbnail) != SongRequestType.YOUTUBE_THUMBNAIL: + if parse_request_type(video_thumbnail) != SongRequestType.YOUTUBE_THUMBNAIL: video_thumbnail = EMBED_IMAGE_URL return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} From d578a301afce7d2d1fb442dd1e8ff7c29c9abcf3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 18:01:16 +0100 Subject: [PATCH 224/404] Added support for youtube music links --- src/extensions/dynamic/VCMusic.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index e2e83ab5..36560f8e 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -174,6 +174,10 @@ def get_stream_data(self): self.url = self.raw_request if not self.url.startswith("https://"): self.url = f"https://{self.url}" + if "music." in self.url: + self.url = self.url.replace("music.", "www.") + if self.request_type != SongRequestType.YOUTUBE_PLAYLIST and "&list" in self.url: + self.url = self.url.split("&list")[0] info = ydl.extract_info(self.url, download=False) self.stream_data = info From 6b7cbb91d834a4d335c86cb45489cf5d32c45164 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 18:02:00 +0100 Subject: [PATCH 225/404] Fixed issues with inactive players --- src/extensions/dynamic/VCMusic.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 36560f8e..2ff4cdef 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -433,15 +433,13 @@ async def check_playing(self): return no_longer_active = [] - now = datetime.now() for guild_id in self.playing: voice_client = self.active_players.get(guild_id).voice_client if not voice_client.is_playing() and not voice_client.is_paused(): if not self.play_next_song(guild_id): - no_longer_active.append(guild_id) - self.inactive[guild_id] = now + self.end_playback(guild_id) await self.update_embed(guild_id) for guild in no_longer_active: @@ -465,7 +463,6 @@ async def check_inactive(self): self.inactive.pop(guild) await self.active_players.get(guild).voice_client.disconnect() self.active_players.pop(guild) - await self.update_embed(guild) def check_valid_user(self, guild: Guild, user: Member) -> bool: if not user.voice: @@ -621,6 +618,10 @@ async def resume_or_start_playback(self, interaction: Interaction): if interaction.guild.id not in self.active_players: return await self.add_interaction_hanlder(interaction) + if interaction.guild.id in self.inactive: + self.inactive.pop(interaction.guild.id) + return await self.add_interaction_hanlder(interaction) + if self.active_players.get(interaction.guild.id).voice_client.is_playing(): await respond_or_followup(COG_STRINGS["music_warn_already_playing"], interaction, ephemeral=True) return False From 65d4e33d52042bf40f19147c797d5fc602600a4f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 18:05:44 +0100 Subject: [PATCH 226/404] Fixed issues when STOP has no active guild --- src/extensions/dynamic/VCMusic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 2ff4cdef..673ebcf8 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -707,14 +707,14 @@ def end_playback(self, guild_id: int): self.run_tasks() async def update_embed(self, guild_id: int): - current_song = self.active_players.get(guild_id).current_song db_entry = DBSession.get(MusicChannels, guild_id=guild_id) if not db_entry: return False embed_message = await self.bot.get_guild(guild_id).get_channel(db_entry.channel_id).fetch_message(db_entry.message_id) current_embed: Embed = embed_message.embeds[0] - if current_song: + if self.active_players.get(guild_id) and self.active_players.get(guild_id).current_song: + current_song = self.active_players.get(guild_id).current_song new_embed = create_music_embed( color=current_embed.color, author=self.author, From 32c846fcf8037b66800469eaf9e56c1019337c4e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 18:09:47 +0100 Subject: [PATCH 227/404] Fixed issues when STOP has no active guild --- src/extensions/dynamic/VCMusic.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 673ebcf8..7b299608 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -722,11 +722,11 @@ async def update_embed(self, guild_id: int): image=current_song.thumbnail, url=current_song.url ) + voice_client = self.active_players.get(guild_id).voice_client + is_paused = True if voice_client is None else not voice_client.is_playing() else: new_embed = create_music_embed(color=current_embed.color, author=self.author) - - voice_client = self.active_players.get(guild_id).voice_client - is_paused = True if voice_client is None else not voice_client.is_playing() + is_paused = True await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) return True @@ -737,6 +737,7 @@ async def stop_playback(self, interaction: Interaction): await interaction.guild.voice_client.disconnect() if not await self.update_embed(interaction.guild.id): await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) + await respond_or_followup(COG_STRINGS["music_stopped_success"], interaction, ephemeral=True) return True if not self.check_valid_user(interaction.guild, interaction.user): From 87c7692bc6cf563d51da8e1d578ab9b61cd97cc9 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 18:24:39 +0100 Subject: [PATCH 228/404] Added remaining interactions as commands --- src/extensions/dynamic/VCMusic.py | 25 +++++++++++++++++++++++++ src/locale/VCMusic.toml | 17 ++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 7b299608..df56c848 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -802,6 +802,31 @@ async def set_channel( async def play_command(self, interaction: Interaction): return await self.resume_or_start_playback(interaction) + @command(name=COG_STRINGS["music_pause_name"], description=COG_STRINGS["music_pause_description"]) + @guild_only() + async def pause_command(self, interaction: Interaction): + return await self.pause_playback(interaction) + + @command(name=COG_STRINGS["music_skip_name"], description=COG_STRINGS["music_skip_description"]) + @guild_only() + async def skip_command(self, interaction: Interaction): + return await self.skip_song_handler(interaction) + + @command(name=COG_STRINGS["music_add_name"], description=COG_STRINGS["music_add_description"]) + @guild_only() + async def add_songs_command(self, interaction: Interaction): + return await self.add_interaction_hanlder(interaction) + + @command(name=COG_STRINGS["music_view_queue_name"], description=COG_STRINGS["music_view_queue_description"]) + @guild_only() + async def view_queue(self, interaction: Interaction): + return await self.view_queue(interaction) + + @command(name=COG_STRINGS["music_stop_name"], description=COG_STRINGS["music_stop_description"]) + @guild_only() + async def stop_command(self, interaction: Interaction): + return await self.stop_playback(interaction) + async def setup(bot: Bot): await bot.add_cog(VCMusic(bot)) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 61f82715..b41fad51 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -45,4 +45,19 @@ music_empty_queue_text = "_No songs currently queued_" music_stopped_success = "Playback stopped ✅" music_play_name = "play" -music_play_description = "Resumes playback or show the add song dialog if not already playing." \ No newline at end of file +music_play_description = "Resumes playback or show the add song dialog if not already playing." + +music_pause_name = "pause" +music_pause_description = "Pauses playback if currently playing." + +music_skip_name = "skip-song" +music_skip_description = "Skips the current song. Ends playback if last song in queue." + +music_add_name = "add-music" +music_add_description = "Add song(s) to the queue." + +music_view_queue_name = "view-queue" +music_view_queue_description = "See the current queue." + +music_stop_name = "stop" +music_stop_description = "Stops the current playback." \ No newline at end of file From c4c0c35d18ccc138f8fe4537ec622438f1b81469 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 18:26:38 +0100 Subject: [PATCH 229/404] Fixed non-ephemeral messages --- src/extensions/dynamic/VCMusic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index df56c848..7ee51497 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -679,7 +679,7 @@ async def skip_song_handler(self, interaction: Interaction): return False async def get_current_queue(self, interaction: Interaction): - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) if interaction.guild.id not in self.active_players: await respond_or_followup(COG_STRINGS["music_warn_view_queue_empty"], interaction, ephemeral=True) return True @@ -773,7 +773,7 @@ async def set_channel( embed_color: Transform[Color, ColourTransformer] = Color(0xd462fd) ): - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) if clear_messages: await channel.purge(before=interaction.created_at) @@ -820,7 +820,7 @@ async def add_songs_command(self, interaction: Interaction): @command(name=COG_STRINGS["music_view_queue_name"], description=COG_STRINGS["music_view_queue_description"]) @guild_only() async def view_queue(self, interaction: Interaction): - return await self.view_queue(interaction) + return await self.get_current_queue(interaction) @command(name=COG_STRINGS["music_stop_name"], description=COG_STRINGS["music_stop_description"]) @guild_only() From 7b2a1281de0b2cf590db0a9c90121b9d2b0d32ca Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 18:36:07 +0100 Subject: [PATCH 230/404] Updated README with VCMusic info --- README.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a3abe275..d751e2e4 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,42 @@ The user has control over the child Voice Channel name, and can limit how many/w - Entirely deletes either an active or archived event. </details> +<details> +<summary>VCMusic</summary> + +## VCMusic + +### Environment Variable: `ENABLE_VCMUSIC` + +#### /music set-channel \<channel\> [optional: color] [optional: clear-channel] + +- Sets the channel to define as the music channel. + +#### /music play + +- Resumes or starts playback. + +#### /music pause + +- Pauses playback. + +#### /music skip-song + +- Skips the current song. Stops playback if the last song in the queue. + +#### /music add-music + +- Opens the dialogue to add one or many songs to the queue. + +#### /music view-queue + +- Shows the current queue. + +#### /music stop + +- Stop the current playback. +</details> + # TODO - ~~Implement unimplemented commands in VoiceAdmin and AdminTools cogs.~~ @@ -177,7 +213,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - [x] DefaultRoleCog ✅ Implemented as AutoRoles - [x] EventCategoriesCog ✅ Implemented as EventTools - [ ] LogChannelCog ⚠️ -- [ ] MusicCog ⚠️ +- [x] MusicCog ✅ Implemented as VCMusic - [ ] PingableRolesCog ❌ - [ ] RoleReactCog ❌ - [ ] TwitchCog ⚠️ From 8344a7035b4220159125f9285418ff140e80be74 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 18:47:38 +0100 Subject: [PATCH 231/404] Bot now joins self-deafened for user privacy --- src/extensions/dynamic/VCMusic.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 7ee51497..1f2982c4 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -566,6 +566,13 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] voice_client = await interaction.user.voice.channel.connect() self.active_players[interaction.guild.id].voice_client = voice_client + if not interaction.guild.me.voice.deaf: + await interaction.guild.change_voice_state( + channel=interaction.guild.me.voice.channel, + self_deaf=True, + self_mute=False + ) + self.active_players[interaction.guild.id].queue += add_to_queue is_playing = self.active_players[interaction.guild.id].voice_client.is_playing() From 2575129d97cebaecf73901fda5a2ac0d4bdd9e8f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 19:13:58 +0100 Subject: [PATCH 232/404] Added set-volume command --- README.md | 4 ++++ src/extensions/dynamic/VCMusic.py | 37 ++++++++++++++++++++++++++++--- src/locale/VCMusic.toml | 9 +++++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d751e2e4..d065b82c 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,10 @@ The user has control over the child Voice Channel name, and can limit how many/w #### /music stop +#### /music volume \<volume\> + +- Sets the volume percentage between 0-100 + - Stop the current playback. </details> diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 1f2982c4..8df0ddd7 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -18,7 +18,17 @@ TextStyle, VoiceClient ) -from discord.app_commands import (Transform, autocomplete, command, describe, guild_only, rename, default_permissions, checks) +from discord.app_commands import ( + Transform, + autocomplete, + command, + describe, + guild_only, + rename, + default_permissions, + checks, + Range +) from discord.ext import tasks from discord.ext.commands import Bot, GroupCog from discord.ui import Button, Modal, TextInput, View @@ -303,10 +313,11 @@ def create_music_embed( color: Color, author: str, title: str = COG_STRINGS["music_embed_title_idle"], + description: str = None, image: str = EMBED_IMAGE_URL, url: str = None ) -> Embed: - embed = Embed(title=title, color=color, url=url) + embed = Embed(title=title, description=description, color=color, url=url) embed.set_image(url=image) embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=author)) return embed @@ -609,7 +620,7 @@ def play_next_song(self, guild_id: int): FFmpegPCMAudio(stream_data.get("url"), before_options=FFMPEG_PLAYER_OPTIONS, options="-vn"), - volume=self.active_players.get(guild_id).volume + volume=float(self.active_players.get(guild_id).volume) / float(100) ) self.active_players[guild_id].voice_client.play(voice_source) @@ -726,6 +737,7 @@ async def update_embed(self, guild_id: int): color=current_embed.color, author=self.author, title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), + description=COG_STRINGS["music_embed_current_volume"].format(value=self.active_players.get(guild_id).volume), image=current_song.thumbnail, url=current_song.url ) @@ -834,6 +846,25 @@ async def view_queue(self, interaction: Interaction): async def stop_command(self, interaction: Interaction): return await self.stop_playback(interaction) + @command(name=COG_STRINGS["music_volume_name"], description=COG_STRINGS["music_volume_description"]) + @describe(volume=COG_STRINGS["music_volume_volume_describe"]) + @rename(volume=COG_STRINGS["music_volume_volume_rename"]) + @guild_only() + async def set_volume(self, interaction: Interaction, volume: Range[int, 0, 100]): + if not self.check_valid_user(interaction.guild, interaction.user): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + if interaction.guild.id not in self.active_players: + await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) + return False + + self.active_players.get(interaction.guild.id).voice_client.source.volume = float(volume) / float(100) + self.active_players.get(interaction.guild.id).volume = volume + await self.update_embed(interaction.guild.id) + await respond_or_followup(COG_STRINGS["music_volume_set_success"].format(value=volume), interaction) + return True + async def setup(bot: Bot): await bot.add_cog(VCMusic(bot)) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index b41fad51..d58ad22d 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -5,6 +5,7 @@ music_generic_error = "An error occured! If this issue persists please contact a music_embed_title_idle = "No song currently playing...." music_embed_title_playing = "Now playing — {song}" +music_embed_current_volume = "Current Volume: `{value}%` 🎵" music_embed_footer = "Made by {author} 💖" music_button_add_song = "Add Song" music_button_view_queue = "View Queue" @@ -60,4 +61,10 @@ music_view_queue_name = "view-queue" music_view_queue_description = "See the current queue." music_stop_name = "stop" -music_stop_description = "Stops the current playback." \ No newline at end of file +music_stop_description = "Stops the current playback." + +music_volume_name = "set-volume" +music_volume_description = "Sets the volume of playback." +music_volume_volume_describe = "The volume between 0 and 100 to set to." +music_volume_volume_rename = "volume" +music_volume_set_success = "Volume has been set to `{value}%` ✅" \ No newline at end of file From 88f5527b2d7d910c946e67a931535ea16beda8e8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 19:27:07 +0100 Subject: [PATCH 233/404] Added read-only flag to set-channel command --- README.md | 2 +- src/extensions/dynamic/VCMusic.py | 24 +++++++++++++++++++++--- src/locale/VCMusic.toml | 2 ++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d065b82c..8046caba 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Environment Variable: `ENABLE_VCMUSIC` -#### /music set-channel \<channel\> [optional: color] [optional: clear-channel] +#### /music set-channel \<channel\> [optional: color] [optional: clear-channel] [optional: read-only] - Sets the channel to define as the music channel. diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 8df0ddd7..e33eb84c 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -14,6 +14,7 @@ Interaction, Member, PCMVolumeTransformer, + PermissionOverwrite, TextChannel, TextStyle, VoiceClient @@ -773,12 +774,14 @@ async def stop_playback(self, interaction: Interaction): @describe( channel=COG_STRINGS["music_set_channel_channel_describe"], clear_messages=COG_STRINGS["music_set_channel_clear_messages_describe"], - embed_color=COG_STRINGS["music_set_channel_embed_color_describe"] + embed_color=COG_STRINGS["music_set_channel_embed_color_describe"], + read_only=COG_STRINGS["music_set_channel_read_only_describe"] ) @rename( channel=COG_STRINGS["music_set_channel_channel_rename"], clear_messages=COG_STRINGS["music_set_channel_clear_messages_rename"], - embed_color=COG_STRINGS["music_set_channel_embed_color_rename"] + embed_color=COG_STRINGS["music_set_channel_embed_color_rename"], + read_only=COG_STRINGS["music_set_channel_read_only_rename"] ) @autocomplete(embed_color=ColourTransformer.autocomplete) @default_permissions(administrator=True) @@ -790,7 +793,8 @@ async def set_channel( channel: TextChannel, clear_messages: bool = False, embed_color: Transform[Color, - ColourTransformer] = Color(0xd462fd) + ColourTransformer] = Color(0xd462fd), + read_only: bool = True ): await interaction.response.defer(ephemeral=True) @@ -816,6 +820,20 @@ async def set_channel( ephemeral=self.bot.only_ephemeral ) + if read_only: + await channel.set_permissions( + interaction.guild.default_role, + overwrite=PermissionOverwrite(read_messages=True, + send_messages=False, + view_channel=True) + ) + await channel.set_permissions( + interaction.guild.me, + PermissionOverwrite(read_messages=True, + send_messages=True, + view_channel=True) + ) + @command(name=COG_STRINGS["music_play_name"], description=COG_STRINGS["music_play_description"]) @guild_only() async def play_command(self, interaction: Interaction): diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index d58ad22d..f6a8ed3f 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -28,6 +28,8 @@ music_set_channel_clear_messages_describe = "If the channel to be set should be music_set_channel_clear_messages_rename = "clear-channel" music_set_channel_embed_color_describe = "The colour to use for the embed. Defaults to #d462fd." music_set_channel_embed_color_rename = "color" +music_set_channel_read_only_describe = "If enabled, non-admins will not be able to send messages in the music channel." +music_set_channel_read_only_rename = "read-only" music_set_channel_success = "Successfully set {channel} as the music channel ✅" music_warn_not_playing = "There is no music currently playing ⚠️" From e2589402d0b1ca31ca342aaedcb217a13177d85c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 19:30:29 +0100 Subject: [PATCH 234/404] Refactored UserActionType id generation --- src/extensions/dynamic/VCMusic.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index e33eb84c..d75bcdb9 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -67,29 +67,30 @@ class UserActionType(IntEnum): @property def id(self) -> str: + base = f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}" match self: case UserActionType.PLAY: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionplay" + return f"{base}actionplay" case UserActionType.PAUSE: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionpause" + return f"{base}actionpause" case UserActionType.STOP: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionstop" + return f"{base}actionstop" case UserActionType.ADD_SONG: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionadd" + return f"{base}actionadd" case UserActionType.VIEW_QUEUE: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionview" + return f"{base}actionview" case UserActionType.EDIT_QUEUE: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}actionedit" + return f"{base}actionedit" case UserActionType.ADD_SONG_MODAL_SUBMIT: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}submitadd" + return f"{base}submitadd" case UserActionType.EDIT_QUEUE_MODAL_SUBMIT: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}submitedit" + return f"{base}submitedit" case UserActionType.ADD_SONG_MODAL_SINGLE: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}addmodalsingle" + return f"{base}addmodalsingle" case UserActionType.ADD_SONG_MODAL_MULTIPLE: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}addmodalmultiple" + return f"{base}addmodalmultiple" case UserActionType.SKIP: - return f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}skipsong" + return f"{base}skipsong" case _: raise ValueError("Invalid enum type given!") From 97a450ac3f9adf1965e61feea4b1f679f7d1f898 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 19:31:33 +0100 Subject: [PATCH 235/404] Added VOLUME user action --- src/extensions/dynamic/VCMusic.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index d75bcdb9..18d7b5f7 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -64,6 +64,7 @@ class UserActionType(IntEnum): ADD_SONG_MODAL_SINGLE = 8 ADD_SONG_MODAL_MULTIPLE = 9 SKIP = 10 + VOLUME = 11 @property def id(self) -> str: @@ -91,6 +92,8 @@ def id(self) -> str: return f"{base}addmodalmultiple" case UserActionType.SKIP: return f"{base}skipsong" + case UserActionType.VOLUME: + return f"{base}volume" case _: raise ValueError("Invalid enum type given!") @@ -124,6 +127,8 @@ def from_string(self, string: str) -> "UserActionType": return UserActionType.ADD_SONG_MODAL_MULTIPLE case "skipsong": return UserActionType.SKIP + case "volume": + return UserActionType.VOLUME case _: raise ValueError(f"Invalid string given for {__class__.__name__}") From f9365b9635f71a4133c826768f5479bc1ba35ffb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 19:56:47 +0100 Subject: [PATCH 236/404] Added volume button --- src/extensions/dynamic/VCMusic.py | 84 +++++++++++++++++++++++++++++++ src/locale/VCMusic.toml | 5 ++ 2 files changed, 89 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 18d7b5f7..0f5cbc7a 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -65,6 +65,8 @@ class UserActionType(IntEnum): ADD_SONG_MODAL_MULTIPLE = 9 SKIP = 10 VOLUME = 11 + VOLUME_MODAL_SUBMIT = 12 + VOLUME_MODAL_VALUE = 13 @property def id(self) -> str: @@ -94,6 +96,10 @@ def id(self) -> str: return f"{base}skipsong" case UserActionType.VOLUME: return f"{base}volume" + case UserActionType.VOLUME_MODAL_SUBMIT: + return f"{base}submitvolume" + case UserActionType.VOLUME_MODAL_VALUE: + return f"{base}volumemodalvalue" case _: raise ValueError("Invalid enum type given!") @@ -129,6 +135,10 @@ def from_string(self, string: str) -> "UserActionType": return UserActionType.SKIP case "volume": return UserActionType.VOLUME + case "submitvolume": + return UserActionType.VOLUME_MODAL_SUBMIT + case "volumemodalvalue": + return UserActionType.VOLUME_MODAL_VALUE case _: raise ValueError(f"Invalid string given for {__class__.__name__}") @@ -343,6 +353,12 @@ def create_music_actionbar(is_paused: bool = True) -> View: emoji="⏩", custom_id=UserActionType.SKIP.id ) + volume_button = Button( + style=ButtonStyle.primary, + label=COG_STRINGS["music_button_set_volume"], + emoji="🔊", + custom_id=UserActionType.VOLUME.id + ) add_button = Button( style=ButtonStyle.primary, label=COG_STRINGS["music_button_add_song"], @@ -370,6 +386,7 @@ def create_music_actionbar(is_paused: bool = True) -> View: view.add_item(playback_button) view.add_item(skip_button) + view.add_item(volume_button) view.add_item(add_button) view.add_item(view_button) # TOOD: Implement queue editing @@ -421,6 +438,8 @@ async def on_interaction(self, interaction: Interaction): return await self.pause_playback(interaction) case UserActionType.SKIP: return await self.skip_song_handler(interaction) + case UserActionType.VOLUME: + return await self.set_volume_handler(interaction) case UserActionType.ADD_SONG: return await self.add_interaction_hanlder(interaction) case UserActionType.VIEW_QUEUE: @@ -429,6 +448,8 @@ async def on_interaction(self, interaction: Interaction): pass case UserActionType.STOP: return await self.stop_playback(interaction) + case UserActionType.VOLUME_MODAL_SUBMIT: + return await self.set_volume_submit_handler(interaction) case UserActionType.ADD_SONG_MODAL_SUBMIT: return await self.add_modal_interaction_handler(interaction) case UserActionType.EDIT_QUEUE_MODAL_SUBMIT: @@ -494,6 +515,69 @@ def check_valid_user(self, guild: Guild, user: Member) -> bool: return self.bot.user in user.voice.channel.members + async def set_volume_handler(self, interaction: Interaction) -> bool: + if not self.check_valid_user(interaction.guild, interaction.user): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + if interaction.guild.id not in self.active_players: + await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) + return False + + modal = Modal( + title=COG_STRINGS["music_volume_modal_title"], + timeout=None, + custom_id=UserActionType.VOLUME_MODAL_SUBMIT.id + ) + + volume = TextInput( + label=COG_STRINGS["music_volume_modal_volume"], + custom_id=UserActionType.VOLUME_MODAL_VALUE.id, + required=True, + ) + + modal.add_item(volume) + await interaction.response.send_modal(modal) + return True + + async def set_volume_submit_handler(self, interaction: Interaction) -> bool: + if not self.check_valid_user(interaction.guild, interaction.user): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + if interaction.guild.id not in self.active_players: + await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) + return False + + raw_modal_data = interaction.data.get("components") + + raw_volume_value = "" + + for item in raw_modal_data: + if item.get("components")[0].get("custom_id") == UserActionType.VOLUME_MODAL_VALUE.id: + raw_volume_value = item.get("components")[0].get("value") + break + + if not raw_volume_value.isdigit(): + await respond_or_followup( + COG_STRINGS["music_volume_modal_invalid"].format(supplied=raw_volume_value), + interaction, + ephemeral=True + ) + return False + + volume_value = int(raw_volume_value) + if volume_value < 0: + volume_value = 0 + elif volume_value > 100: + volume_value = 100 + + self.active_players.get(interaction.guild.id).voice_client.source.volume = float(volume_value) / float(100) + self.active_players.get(interaction.guild.id).volume = volume_value + await self.update_embed(interaction.guild.id) + await respond_or_followup(COG_STRINGS["music_volume_set_success"].format(value=volume_value), interaction) + return True + async def add_interaction_hanlder(self, interaction: Interaction) -> bool: if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index f6a8ed3f..105dd76e 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -7,12 +7,17 @@ music_embed_title_idle = "No song currently playing...." music_embed_title_playing = "Now playing — {song}" music_embed_current_volume = "Current Volume: `{value}%` 🎵" music_embed_footer = "Made by {author} 💖" +music_button_set_volume = "Set Volume" music_button_add_song = "Add Song" music_button_view_queue = "View Queue" music_button_edit_queue = "Edit Queue" music_button_stop_queue = "Stop" music_button_skip_song = "Skip" +music_volume_modal_title = "Set the volume of the bot" +music_volume_modal_volume = "Provide a value between 0 and 100" +music_volume_modal_invalid = "The supplied value of `{supplied}` is not a valid volume value ⚠️" + music_add_song_modal_title = "Add song(s) to queue" music_add_song_modal_single = "Add single song" music_add_song_modal_multiple = "Add multiple songs" From d126778e7c2a30220ef444b983e99633aa8ab59a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 19:57:02 +0100 Subject: [PATCH 237/404] Fixed issue when using read-only flag --- src/extensions/dynamic/VCMusic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 0f5cbc7a..e12a39c6 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -919,9 +919,9 @@ async def set_channel( ) await channel.set_permissions( interaction.guild.me, - PermissionOverwrite(read_messages=True, - send_messages=True, - view_channel=True) + overwrite=PermissionOverwrite(read_messages=True, + send_messages=True, + view_channel=True) ) @command(name=COG_STRINGS["music_play_name"], description=COG_STRINGS["music_play_description"]) From 768dcf0e85e46f3cab463036184782dc3e50ee3a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 20:01:47 +0100 Subject: [PATCH 238/404] Updated fetching of author --- src/extensions/dynamic/VCMusic.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index e12a39c6..6e82933b 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -47,8 +47,8 @@ EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" QUERY_RESULT_LIMIT = 15 INACTIVE_TIMEOUT = 60 -# AUTHOR_ID = 244050529271939073 # main account -AUTHOR_ID = 202978567741505536 # alt account +AUTHOR_ID = 244050529271939073 # main account +# AUTHOR_ID = 202978567741505536 # alt account FFMPEG_PLAYER_OPTIONS = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" @@ -409,14 +409,7 @@ def __init__(self, bot: Bot): @GroupCog.listener() async def on_ready(self): - for guild in self.bot.guilds: - for member in guild.members: - if member.id == AUTHOR_ID: - self.logger.info(f"Found current discord tag of VCAuthor: {member}") - self.author = member - return True - self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {self.author}") - return False + self.update_author.start() @GroupCog.listener() async def on_interaction(self, interaction: Interaction): @@ -503,6 +496,16 @@ async def check_inactive(self): await self.active_players.get(guild).voice_client.disconnect() self.active_players.pop(guild) + @tasks.loop(hours=12) + async def update_author(self): + new_author = await self.bot.fetch_user(AUTHOR_ID) + if new_author: + self.author = new_author + self.logger.info(f"Found current discord tag of VCAuthor: {self.author}") + return True + self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {self.author}") + return False + def check_valid_user(self, guild: Guild, user: Member) -> bool: if not user.voice: return False From e23365a0e97440d076b18f4ffedc9e08ae9dd43c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 20:10:45 +0100 Subject: [PATCH 239/404] No longer auto delete view_queue messages --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 6e82933b..113fd3ab 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -805,7 +805,7 @@ async def get_current_queue(self, interaction: Interaction): queue_text = f"{current_song_text}\n\n{current_queue_text}" - await respond_or_followup(queue_text, interaction, ephemeral=True) + await respond_or_followup(queue_text, interaction, ephemeral=True, delete_after=None) return True def end_playback(self, guild_id: int): From bc6004233de6ccfe488c1e14623492c031b0d1e6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 20:11:15 +0100 Subject: [PATCH 240/404] Added function to escape special discord characters in titles --- src/extensions/dynamic/VCMusic.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 113fd3ab..1cd682bb 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -209,7 +209,7 @@ def get_stream_data(self): self.stream_data = info if self.title is None: - self.title = self.stream_data.get("title") + self.title = escape_discord_characters(self.stream_data.get("title")) if self.thumbnail is None: self.thumbnail = self.stream_data.get("thumbnail") @@ -309,7 +309,7 @@ def parse_string_query_result(result: dict) -> dict: # title = title_long.replace(views_long, "").replace(duration_long, "") # return title - video_title = result.get("title") + video_title = escape_discord_characters(result.get("title")) video_url = None video_thumbnail = None @@ -326,6 +326,14 @@ def parse_string_query_result(result: dict) -> dict: return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} +def escape_discord_characters(title: str): + characters_to_escape = ['`', '|', "_", "~"] + escaped_title = title + for character in characters_to_escape: + escaped_title = escaped_title.replace(character, f"\{character}") + return escaped_title + + def create_music_embed( color: Color, author: str, From e9ce61538efe173510e138dae2a7ac980930280d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 20:14:46 +0100 Subject: [PATCH 241/404] Fixed flak8 escape character violation --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 1cd682bb..ac1776e2 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -330,7 +330,7 @@ def escape_discord_characters(title: str): characters_to_escape = ['`', '|', "_", "~"] escaped_title = title for character in characters_to_escape: - escaped_title = escaped_title.replace(character, f"\{character}") + escaped_title = escaped_title.replace(character, f"\\{character}") return escaped_title From 1ea843ae37729ef78bd2d0cac093f0a395e1dc76 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 20:23:01 +0100 Subject: [PATCH 242/404] Added first request priority to increase feedback speed --- src/extensions/dynamic/VCMusic.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index ac1776e2..130d7fe9 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -645,6 +645,13 @@ async def add_modal_interaction_handler(self, interaction: Interaction): if single_request.strip() not in ('', ' '): request_list = [SongRequest(single_request.strip(), parse_request_type(single_request.strip()))] + request_list + first_success = 0 + if request_list: + first_request = request_list.pop(0) + song = first_request.get_song() + if await self.try_play_queue(interaction, add_to_queue=[song]): + first_success = 1 + failed_requests = [] requests_to_queue = [] for request in request_list: @@ -657,7 +664,7 @@ async def add_modal_interaction_handler(self, interaction: Interaction): requests_to_queue.append(result) await respond_or_followup( - COG_STRINGS["music_added_song_count"].format(count=len(request_list) - len(failed_requests)), + COG_STRINGS["music_added_song_count"].format(count=len(request_list) - len(failed_requests) + first_success), interaction, ephemeral=True ) @@ -693,7 +700,7 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] has_current_song = self.active_players[interaction.guild.id].current_song is not None if is_playing or (is_paused and has_current_song): - return False + return True if self.play_next_song(interaction.guild.id): self.run_tasks() From 3858ba3da986808621eab822bb0b10f7a89a520e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 20:27:01 +0100 Subject: [PATCH 243/404] Added google-api-python-client to requirements --- requirements[music].txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements[music].txt b/requirements[music].txt index a5ef45ec..3af9877d 100644 --- a/requirements[music].txt +++ b/requirements[music].txt @@ -1,2 +1,3 @@ youtube-search-python -yt-dlp \ No newline at end of file +yt-dlp +google-api-python-client \ No newline at end of file From e48a5097cb14b2abf7737118e2cc921c3dfb68fc Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 20:57:49 +0100 Subject: [PATCH 244/404] Added checking for channel moving and abandonment --- src/extensions/dynamic/VCMusic.py | 46 +++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 130d7fe9..3f86e46f 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -17,7 +17,8 @@ PermissionOverwrite, TextChannel, TextStyle, - VoiceClient + VoiceClient, + VoiceState ) from discord.app_commands import ( Transform, @@ -419,6 +420,33 @@ def __init__(self, bot: Bot): async def on_ready(self): self.update_author.start() + @GroupCog.listener() + async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): + guild_id = before.channel.guild.id if before.channel else after.channel.guild.id + if member.id != self.bot.user.id: + if guild_id not in self.active_players: + return + + if before.channel: + if before.channel.guild.me not in before.channel.members: + return + members_left = [x for x in before.channel.members if not x.bot] + if not members_left: + await self.active_players.get(guild_id).voice_client.disconnect() + return + + if before.channel and not after.channel: + # Bot has disconnected from a channel, ensure that cleanup has occured + await self.cleanup_after_disconnect(guild_id) + return + + if before.channel and after.channel: + if guild_id in self.active_players: + self.active_players.get(guild_id).voice_client = after.channel.guild.voice_client + if guild_id in self.inactive: + self.inactive.pop(guild_id) + return + @GroupCog.listener() async def on_interaction(self, interaction: Interaction): if not interaction.data or not interaction.data.get("custom_id"): @@ -465,6 +493,20 @@ def run_tasks(self): if self.inactive and not self.check_inactive.is_running(): self.check_inactive.start() + async def cleanup_after_disconnect(self, guild_id): + needs_update = False + if guild_id in self.active_players: + self.active_players.pop(guild_id) + needs_update = True + if guild_id in self.playing: + self.playing.remove(guild_id) + needs_update = True + if guild_id in self.inactive: + self.inactive.pop(guild_id) + needs_update = True + if needs_update: + await self.update_embed(guild_id) + @tasks.loop(seconds=5) async def check_playing(self): if not self.playing: @@ -500,9 +542,7 @@ async def check_inactive(self): guilds_to_disconnect.append(guild_id) for guild in guilds_to_disconnect: - self.inactive.pop(guild) await self.active_players.get(guild).voice_client.disconnect() - self.active_players.pop(guild) @tasks.loop(hours=12) async def update_author(self): From 67485f9cbbf588506355b3463312fc4f63df4e3a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 20:59:18 +0100 Subject: [PATCH 245/404] Fixed get_song not being awaited --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 3f86e46f..568af4db 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -688,7 +688,7 @@ async def add_modal_interaction_handler(self, interaction: Interaction): first_success = 0 if request_list: first_request = request_list.pop(0) - song = first_request.get_song() + song = await first_request.get_song() if await self.try_play_queue(interaction, add_to_queue=[song]): first_success = 1 From 0e221e3f2c8e4f86934f4771e2a1d1a5afaacd39 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 21:07:22 +0100 Subject: [PATCH 246/404] Sorted imports --- src/extensions/dynamic/VCMusic.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 568af4db..2c9401a7 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -21,15 +21,15 @@ VoiceState ) from discord.app_commands import ( + Range, Transform, autocomplete, + checks, command, + default_permissions, describe, guild_only, - rename, - default_permissions, - checks, - Range + rename ) from discord.ext import tasks from discord.ext.commands import Bot, GroupCog From 94ca8f5cb18d4c9707b3a073cfb850d6dd40af33 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 21:34:40 +0100 Subject: [PATCH 247/404] Implemented handling of youtube playlists --- secrets.template | 4 ++ src/extensions/dynamic/VCMusic.py | 77 +++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/secrets.template b/secrets.template index 2e7c4785..99eddd38 100644 --- a/secrets.template +++ b/secrets.template @@ -21,6 +21,10 @@ PGADMIN_DEFAULT_PASSWORD= DB_OVERRIDE= ################### +## VCMusic Vars ## +# These variables are used in the VCMusic extension +GOOGLE_API= + ENABLE_VOICEADMIN=FALSE ENABLE_EVENTTOOLS=FALSE ENABLE_AUTOROLES=FALSE diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 2c9401a7..80f78576 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1,10 +1,13 @@ import logging +import os import re from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum from typing import Union +from urllib.parse import parse_qs, urlparse +import googleapiclient.discovery from discord import ( ButtonStyle, Color, @@ -52,6 +55,9 @@ # AUTHOR_ID = 202978567741505536 # alt account FFMPEG_PLAYER_OPTIONS = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" +GOOGLE_API_KEY = os.getenv("GOOGLE_API") +YOUTUBE_API = googleapiclient.discovery.build("youtube", "v3", developerKey=GOOGLE_API_KEY) + class UserActionType(IntEnum): PLAY = 0 @@ -177,7 +183,11 @@ async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: result = self.get_stream_data() return self case SongRequestType.YOUTUBE_PLAYLIST: - return None + if not GOOGLE_API_KEY: + return None + playlist_items = get_playlist_items(self.raw_request) + song_requests = parse_playlist_response(self.raw_request, playlist_items) + return song_requests case _: raise ValueError("Invalid SongRequestType given!") @@ -335,6 +345,65 @@ def escape_discord_characters(title: str): return escaped_title +def get_playlist_items(playlist_url: str) -> list[dict]: + api = YOUTUBE_API.playlistItems() + query = parse_qs(urlparse(playlist_url).query, keep_blank_values=True) + if not query: + youtube_id = playlist_url.split("/")[-1] + else: + youtube_id = query["list"][0] + + api_args = {"part": "snippet", "maxResults": 50, "playlistId": youtube_id} + + api_request = api.list(**api_args) + + video_responses = [] + while api_request: + response = api_request.execute() + video_responses += response["items"] + api_request = api.list_next(api_request, response) + + return video_responses + + +def parse_playlist_response(original_request: str, playlist_items: list[dict]) -> list[SongRequest]: + formatted_requests = [] + for item in playlist_items: + title, url, thumbnail = parse_playlist_item(item) + song = SongRequest( + raw_request=original_request, + request_type=SongRequestType.YOUTUBE_VIDEO, + title=title, + url=url, + thumbnail=thumbnail + ) + formatted_requests.append(song) + return formatted_requests + + +def parse_playlist_item(item: dict): + snippet = item.get("snippet") + + chosen_thumbnail = None + all_thumbnails = snippet.get("thumbnails") + if "maxres" in all_thumbnails: + chosen_thumbnail = all_thumbnails.get("maxres").get("url") + else: + any_thumbnail_res = list(all_thumbnails)[0] + chosen_thumbnail = all_thumbnails.get(any_thumbnail_res).get("url") + + url = None + if item.get("kind") == "youtube#video": + video_id = item.get("id") + else: + video_id = item.get("snippet").get("resourceId").get("videoId") + url = "https://youtube.com/watch?v={}".format(video_id) + + title = escape_discord_characters(snippet.get("title")) + + return (title, url, chosen_thumbnail) + + def create_music_embed( color: Color, author: str, @@ -689,8 +758,10 @@ async def add_modal_interaction_handler(self, interaction: Interaction): if request_list: first_request = request_list.pop(0) song = await first_request.get_song() - if await self.try_play_queue(interaction, add_to_queue=[song]): - first_success = 1 + if song is None or song is []: + first_request = 0 + elif await self.try_play_queue(interaction, add_to_queue=song if isinstance(song, list) else [song]): + first_success = len(song) if isinstance(song, list) else 1 failed_requests = [] requests_to_queue = [] From 7eafa89a1bdef945edf9819a4cc414217f4da729 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 21:50:09 +0100 Subject: [PATCH 248/404] Added queue truncating for long queues --- src/extensions/dynamic/VCMusic.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 80f78576..f1ee3f03 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -926,9 +926,23 @@ async def get_current_queue(self, interaction: Interaction): current_song = self.active_players.get(interaction.guild.id).current_song current_song_text = f"__Current Song__\n{COG_STRINGS['music_embed_title_idle'] if not current_song else current_song.title}" - formatted_queue = "\n".join([f"{idx+1}. {song.title}" for idx, song in enumerate(current_queue)]) - current_queue_text = f"__Up Next__\n{COG_STRINGS['music_empty_queue_text'] if not current_queue else formatted_queue}" + QUEUE_CUTOFF = 15 + if len(current_queue) > 2 * QUEUE_CUTOFF + 5: + first_set = current_queue[:QUEUE_CUTOFF] + last_set = current_queue[-QUEUE_CUTOFF:] + remaining = len(current_queue) - 2 * QUEUE_CUTOFF + first_set_formatted = "\n".join([f"{idx+1}. {song.title}" for idx, song in enumerate(first_set)]) + last_set_formatted = "\n".join( + [f"{idx+1+remaining+QUEUE_CUTOFF}. {song.title}" for idx, + song in enumerate(last_set)] + ) + separator = f"\n\n... and **`{remaining}`** more ... \n\n" + formatted_queue = f"{first_set_formatted}{separator}{last_set_formatted}" + else: + formatted_queue = "\n".join([f"{idx+1}. {song.title}" for idx, song in enumerate(current_queue)]) + + current_queue_text = f"__Up Next__\n{COG_STRINGS['music_empty_queue_text'] if not current_queue else formatted_queue}" queue_text = f"{current_song_text}\n\n{current_queue_text}" await respond_or_followup(queue_text, interaction, ephemeral=True, delete_after=None) From ed2e7959f1d7af5ee1784b88afe2ee4f4887c61f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 22:04:37 +0100 Subject: [PATCH 249/404] Added shuffle-queue command --- README.md | 4 ++++ src/extensions/dynamic/VCMusic.py | 34 +++++++++++++++++++++++++++++++ src/locale/VCMusic.toml | 7 ++++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8046caba..287ad81b 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,10 @@ The user has control over the child Voice Channel name, and can limit how many/w - Skips the current song. Stops playback if the last song in the queue. +#### /music shuffle-queue + +- Shuffles the current queue. + #### /music add-music - Opens the dialogue to add one or many songs to the queue. diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index f1ee3f03..61ed7637 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum +from random import shuffle from typing import Union from urllib.parse import parse_qs, urlparse @@ -74,6 +75,7 @@ class UserActionType(IntEnum): VOLUME = 11 VOLUME_MODAL_SUBMIT = 12 VOLUME_MODAL_VALUE = 13 + SHUFFLE = 14 @property def id(self) -> str: @@ -107,6 +109,8 @@ def id(self) -> str: return f"{base}submitvolume" case UserActionType.VOLUME_MODAL_VALUE: return f"{base}volumemodalvalue" + case UserActionType.SHUFFLE: + return f"{base}shuffle" case _: raise ValueError("Invalid enum type given!") @@ -146,6 +150,8 @@ def from_string(self, string: str) -> "UserActionType": return UserActionType.VOLUME_MODAL_SUBMIT case "volumemodalvalue": return UserActionType.VOLUME_MODAL_VALUE + case "shuffle": + return UserActionType.SHUFFLE case _: raise ValueError(f"Invalid string given for {__class__.__name__}") @@ -431,6 +437,12 @@ def create_music_actionbar(is_paused: bool = True) -> View: emoji="⏩", custom_id=UserActionType.SKIP.id ) + shuffle_button = Button( + style=ButtonStyle.secondary, + label=COG_STRINGS["music_button_shuffle_queue"], + emoji="🔀", + custom_id=UserActionType.SHUFFLE.id + ) volume_button = Button( style=ButtonStyle.primary, label=COG_STRINGS["music_button_set_volume"], @@ -464,6 +476,7 @@ def create_music_actionbar(is_paused: bool = True) -> View: view.add_item(playback_button) view.add_item(skip_button) + view.add_item(shuffle_button) view.add_item(volume_button) view.add_item(add_button) view.add_item(view_button) @@ -536,6 +549,8 @@ async def on_interaction(self, interaction: Interaction): return await self.pause_playback(interaction) case UserActionType.SKIP: return await self.skip_song_handler(interaction) + case UserActionType.SHUFFLE: + return await self.shuffle_queue_handler(interaction) case UserActionType.VOLUME: return await self.set_volume_handler(interaction) case UserActionType.ADD_SONG: @@ -635,6 +650,20 @@ def check_valid_user(self, guild: Guild, user: Member) -> bool: return self.bot.user in user.voice.channel.members + async def shuffle_queue_handler(self, interaction: Interaction) -> bool: + if not self.check_valid_user(interaction.guild, interaction.user): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + if interaction.guild.id not in self.active_players: + await respond_or_followup(COG_STRINGS["music_warn_not_playing"], interaction, ephemeral=True) + return False + + current_queue = self.active_players.get(interaction.guild.id).queue + shuffle(current_queue) + self.active_players.get(interaction.guild.id).queue = current_queue + await respond_or_followup(COG_STRINGS["music_shuffle_queue_success"], interaction, ephemeral=True) + async def set_volume_handler(self, interaction: Interaction) -> bool: if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) @@ -1116,6 +1145,11 @@ async def set_volume(self, interaction: Interaction, volume: Range[int, 0, 100]) await respond_or_followup(COG_STRINGS["music_volume_set_success"].format(value=volume), interaction) return True + @command(name=COG_STRINGS["music_shuffle_name"], description=COG_STRINGS["music_shuffle_description"]) + @guild_only() + async def shuffle_queue(self, interaction: Interaction): + return await self.shuffle_queue_handler(interaction) + async def setup(bot: Bot): await bot.add_cog(VCMusic(bot)) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 105dd76e..3e3365aa 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -13,6 +13,7 @@ music_button_view_queue = "View Queue" music_button_edit_queue = "Edit Queue" music_button_stop_queue = "Stop" music_button_skip_song = "Skip" +music_button_shuffle_queue = "Shuffle" music_volume_modal_title = "Set the volume of the bot" music_volume_modal_volume = "Provide a value between 0 and 100" @@ -74,4 +75,8 @@ music_volume_name = "set-volume" music_volume_description = "Sets the volume of playback." music_volume_volume_describe = "The volume between 0 and 100 to set to." music_volume_volume_rename = "volume" -music_volume_set_success = "Volume has been set to `{value}%` ✅" \ No newline at end of file +music_volume_set_success = "Volume has been set to `{value}%` ✅" + +music_shuffle_name = "shuffle-queue" +music_shuffle_description = "Shuffles the current queue." +music_shuffle_queue_success = "Shuffled the queue ✅" \ No newline at end of file From 7039cba5bd85837bf18b359bb43302ccd51deffe Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 22:16:52 +0100 Subject: [PATCH 250/404] Added requesting user to SongRequest and embed info --- src/extensions/dynamic/VCMusic.py | 29 +++++++++++++++++++++-------- src/locale/VCMusic.toml | 3 ++- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 61ed7637..30882007 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -171,6 +171,7 @@ class SongRequestType(IntEnum): class SongRequest: raw_request: str request_type: SongRequestType + request_member: Member url: str = None title: str = None thumbnail: str = None @@ -192,7 +193,7 @@ async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: if not GOOGLE_API_KEY: return None playlist_items = get_playlist_items(self.raw_request) - song_requests = parse_playlist_response(self.raw_request, playlist_items) + song_requests = parse_playlist_response(self.raw_request, self.request_member, playlist_items) return song_requests case _: raise ValueError("Invalid SongRequestType given!") @@ -372,13 +373,14 @@ def get_playlist_items(playlist_url: str) -> list[dict]: return video_responses -def parse_playlist_response(original_request: str, playlist_items: list[dict]) -> list[SongRequest]: +def parse_playlist_response(original_request: str, original_member: Member, playlist_items: list[dict]) -> list[SongRequest]: formatted_requests = [] for item in playlist_items: title, url, thumbnail = parse_playlist_item(item) song = SongRequest( raw_request=original_request, request_type=SongRequestType.YOUTUBE_VIDEO, + request_member=original_member, title=title, url=url, thumbnail=thumbnail @@ -776,12 +778,19 @@ async def add_modal_interaction_handler(self, interaction: Interaction): multiple_request = item.get("components")[0].get("value") request_list = [ - SongRequest(x.strip(), - parse_request_type(x.strip())) for x in multiple_request.split("\n") if x.strip() not in ('', - ' ') + SongRequest(raw_requst=x.strip(), + request_type=parse_request_type(x.strip()), + request_member=interaction.user) for x in multiple_request.split("\n") if x.strip() not in ('', + ' ') ] if single_request.strip() not in ('', ' '): - request_list = [SongRequest(single_request.strip(), parse_request_type(single_request.strip()))] + request_list + request_list = [ + SongRequest( + single_request.strip(), + parse_request_type(single_request.strip()), + request_member=interaction.user + ) + ] + request_list first_success = 0 if request_list: @@ -996,11 +1005,15 @@ async def update_embed(self, guild_id: int): current_embed: Embed = embed_message.embeds[0] if self.active_players.get(guild_id) and self.active_players.get(guild_id).current_song: current_song = self.active_players.get(guild_id).current_song + volume = COG_STRINGS["music_embed_current_volume"].format(value=self.active_players.get(guild_id).volume) + user = COG_STRINGS["music_embed_request_user"].format(user=current_song.request_member.mention) new_embed = create_music_embed( color=current_embed.color, author=self.author, - title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), - description=COG_STRINGS["music_embed_current_volume"].format(value=self.active_players.get(guild_id).volume), + title=COG_STRINGS["music_embed_title_playing"].format( + song=current_song.title + ), + description=f"{user}\n{volume}", image=current_song.thumbnail, url=current_song.url ) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 3e3365aa..5e09664d 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -5,7 +5,8 @@ music_generic_error = "An error occured! If this issue persists please contact a music_embed_title_idle = "No song currently playing...." music_embed_title_playing = "Now playing — {song}" -music_embed_current_volume = "Current Volume: `{value}%` 🎵" +music_embed_current_volume = "Current Volume — `{value}%` 🎵" +music_embed_request_user = "Requested by — {user}" music_embed_footer = "Made by {author} 💖" music_button_set_volume = "Set Volume" music_button_add_song = "Add Song" From 5db1b4609c127c92b311e7ad9d75f0f727b626dd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 22:25:38 +0100 Subject: [PATCH 251/404] Added queue length indicator to embed --- src/extensions/dynamic/VCMusic.py | 9 +++++---- src/locale/VCMusic.toml | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 30882007..d5d12618 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -849,6 +849,8 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] has_current_song = self.active_players[interaction.guild.id].current_song is not None if is_playing or (is_paused and has_current_song): + if not await self.update_embed(interaction.guild.id): + await respond_or_followup(COG_STRINGS["music_needs_setup"], interaction, ephemeral=True, delete_after=None) return True if self.play_next_song(interaction.guild.id): @@ -1007,13 +1009,12 @@ async def update_embed(self, guild_id: int): current_song = self.active_players.get(guild_id).current_song volume = COG_STRINGS["music_embed_current_volume"].format(value=self.active_players.get(guild_id).volume) user = COG_STRINGS["music_embed_request_user"].format(user=current_song.request_member.mention) + queue_length = COG_STRINGS["music_embed_queue_length"].format(length=len(self.active_players.get(guild_id).queue)) new_embed = create_music_embed( color=current_embed.color, author=self.author, - title=COG_STRINGS["music_embed_title_playing"].format( - song=current_song.title - ), - description=f"{user}\n{volume}", + title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), + description=f"{user}\n{volume}\n{queue_length}", image=current_song.thumbnail, url=current_song.url ) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 5e09664d..5d7194be 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -7,6 +7,7 @@ music_embed_title_idle = "No song currently playing...." music_embed_title_playing = "Now playing — {song}" music_embed_current_volume = "Current Volume — `{value}%` 🎵" music_embed_request_user = "Requested by — {user}" +music_embed_queue_length = "Songs in queue — `{length}`" music_embed_footer = "Made by {author} 💖" music_button_set_volume = "Set Volume" music_button_add_song = "Add Song" From 1fd749a4083e600cc2652c4be8503114d67b609f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 22:54:04 +0100 Subject: [PATCH 252/404] Moved EMBED_IMAGE_URL to environment variable --- secrets.template | 1 + src/extensions/dynamic/VCMusic.py | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/secrets.template b/secrets.template index 99eddd38..13fcba76 100644 --- a/secrets.template +++ b/secrets.template @@ -23,6 +23,7 @@ DB_OVERRIDE= ## VCMusic Vars ## # These variables are used in the VCMusic extension +MUSIC_DEFAULT_IMAGE=https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp GOOGLE_API= ENABLE_VOICEADMIN=FALSE diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index d5d12618..4d9c2566 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -47,15 +47,13 @@ from database.models import MusicChannels COG_STRINGS = load_cog_toml(__name__) -MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" -INTERACTION_SPLIT_CHARACTER = "." -EMBED_IMAGE_URL = "https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp" +AUTHOR_ID = 244050529271939073 QUERY_RESULT_LIMIT = 15 INACTIVE_TIMEOUT = 60 -AUTHOR_ID = 244050529271939073 # main account -# AUTHOR_ID = 202978567741505536 # alt account +EMBED_IMAGE_URL = os.getenv("MUSIC_DEFAULT_IMAGE") +MUSIC_INTERACTION_PREFIX = f"{__name__}.interaction" +INTERACTION_SPLIT_CHARACTER = "." FFMPEG_PLAYER_OPTIONS = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" - GOOGLE_API_KEY = os.getenv("GOOGLE_API") YOUTUBE_API = googleapiclient.discovery.build("youtube", "v3", developerKey=GOOGLE_API_KEY) From 8c60ac21403a4653768c0ae2d84960524acfd093 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 20 Mar 2023 22:54:27 +0100 Subject: [PATCH 253/404] Added docstrings to static functions --- src/extensions/dynamic/VCMusic.py | 127 +++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 9 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 4d9c2566..42de992d 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -167,6 +167,9 @@ class SongRequestType(IntEnum): @dataclass(slots=True) class SongRequest: + """Represents all the information known about a song request. This can represent a song with only it's request data, + with basic metadata or with data capable of having it's audio streamed. + """ raw_request: str request_type: SongRequestType request_member: Member @@ -176,6 +179,18 @@ class SongRequest: stream_data: dict = None async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: + """For STRING requests, fetches basic metadata such as title and URL. For YOUTUBE_VIDEO requests, fetches all streaming + data. For YOUTUBE_PLAYLIST requests, finds all the videos in the playlist and returns basic metadata such as title and + URL for each video as a list. + + Raises: + ValueError: If an unknown SongRequestType is given, the song data cannot be gathered and raises a ValueError. + + Returns: + Union[list[SongRequest], SongRequest, None]: If the request given is a playlist, get_song will return each song in + the playlist as it's own SongRequest. If the GOOGLE_API environment variable is missing None is returned. For + STRING and YOUTUBE_VIDEO requests, and SongRequest with it's metadata filled in will be returned. + """ match self.request_type: case SongRequestType.STRING: result = string_request_query(self) @@ -196,7 +211,12 @@ async def get_song(self) -> Union[list["SongRequest"], "SongRequest", None]: case _: raise ValueError("Invalid SongRequestType given!") - def get_stream_data(self): + def get_stream_data(self) -> dict: + """Gets the data required to stream a given SongRequest to a Discord VoiceClient. + + Returns: + dict: A dictionary containing all the data, and more, needed to stream to a Discord VoiceClient. + """ if self.stream_data is not None: return self.stream_data @@ -235,6 +255,9 @@ def get_stream_data(self): @dataclass(slots=True) class GuildMusicPlayer: + """Contains all the data required for music to be played in a Guild. Stores the VoiceClient for a guild along with + queue data, current song and the volume at which to play at. + """ guild: Union[Guild, int] current_song: Union[None, SongRequest] = None queue: list = field(default_factory=list) @@ -250,6 +273,14 @@ def __eq__(self, other: "GuildMusicPlayer") -> bool: def parse_request_type(request: str) -> SongRequestType: + """Get the kind of request a given string is. + + Args: + request (str): The request to parse. + + Returns: + SongRequestType: The type of request the given string was. + """ yt_desktop_regex = r"youtube\.com\/watch\?v=" yt_playlist_regex = r"youtube\.com\/playlist\?list=" yt_mobile_regex = r"youtu\.be\/" @@ -271,6 +302,14 @@ def parse_request_type(request: str) -> SongRequestType: def convert_viewcount_to_float(short_views: str) -> float: + """Convert the short string for views of a YouTube video to a float value. + + Args: + short_views (str): The view count as per the short formatting YouTube uses. + + Returns: + float: The viewcount as a float. + """ raw = short_views.lower().split(" views")[0] scale = raw[-1] power = 1 @@ -291,6 +330,15 @@ def convert_viewcount_to_float(short_views: str) -> float: def string_request_query(request: SongRequest) -> dict: + """Find YouTube videos that fit the given song request. The algorithm is weighted to try and find + "music" videos as the general purpose of the bot is for music. + + Args: + request (SongRequest): The song request to query. + + Returns: + dict: All the metadata about the found video result. + """ if request.request_type == SongRequestType.STRING: query = f"\"{request.raw_request}\" #music" else: @@ -317,13 +365,18 @@ def string_request_query(request: SongRequest) -> dict: def parse_string_query_result(result: dict) -> dict: + """Get the relevant data from a string_request_query dictionary. Most of the data returned is garbage + and so only the relevant data is needed. + + Args: + result (dict): The result from a string_request_query. - # def get_video_title(): - # views_long = result.get("viewCount").get("text") - # duration_long = result.get("accessibility").get("duration") - # title_long = result.get("accessibility").get("title") - # title = title_long.replace(views_long, "").replace(duration_long, "") - # return title + Raises: + ValueError: If the given result has malformed or missing data. + + Returns: + dict: A dictionary with keys 'title', 'url' and 'thumbnail'. + """ video_title = escape_discord_characters(result.get("title")) video_url = None @@ -342,7 +395,16 @@ def parse_string_query_result(result: dict) -> dict: return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} -def escape_discord_characters(title: str): +def escape_discord_characters(title: str) -> str: + """Some video titles use characters that are interpreted by discord as formatting characters. To avoid + resulting in weird formatting, escape every potential character. + + Args: + title (str): The video title to escape the characters of. + + Returns: + str: A title that has been escaped. + """ characters_to_escape = ['`', '|', "_", "~"] escaped_title = title for character in characters_to_escape: @@ -351,6 +413,14 @@ def escape_discord_characters(title: str): def get_playlist_items(playlist_url: str) -> list[dict]: + """For a given playlist URL, find the individual videos in the playlist. + + Args: + playlist_url (str): The URL of the playlist. + + Returns: + list[dict]: A list of dictionaries, where each item in the list contains data about a video in the playlist. + """ api = YOUTUBE_API.playlistItems() query = parse_qs(urlparse(playlist_url).query, keep_blank_values=True) if not query: @@ -372,6 +442,16 @@ def get_playlist_items(playlist_url: str) -> list[dict]: def parse_playlist_response(original_request: str, original_member: Member, playlist_items: list[dict]) -> list[SongRequest]: + """Parse the data obtained from get_playlist_items to individual SongRequests. + + Args: + original_request (str): The original raw request. + original_member (Member): The member that requested the playlist. + playlist_items (list[dict]): The list of videos in the playlist. + + Returns: + list[SongRequest]: Each item from the playlist converted into its own SongRequest object. + """ formatted_requests = [] for item in playlist_items: title, url, thumbnail = parse_playlist_item(item) @@ -387,7 +467,15 @@ def parse_playlist_response(original_request: str, original_member: Member, play return formatted_requests -def parse_playlist_item(item: dict): +def parse_playlist_item(item: dict) -> tuple[str, str, str]: + """Parse an individual playlist item's data into a tuple of its title, url and thumbnail url. + + Args: + item (dict): The item obtained from get_playlist_items. + + Returns: + tuple[str, str, str]: A tuple containing the title, url and thumbnail of the video. + """ snippet = item.get("snippet") chosen_thumbnail = None @@ -418,6 +506,19 @@ def create_music_embed( image: str = EMBED_IMAGE_URL, url: str = None ) -> Embed: + """Creates an embed with the author footer. + + Args: + color (Color): The color of the embed. + author (str): The author of the music bot. + title (str, optional): The title of the embed. Defaults to COG_STRINGS["music_embed_title_idle"]. + description (str, optional): The description of the embed.. Defaults to None. + image (str, optional): The image to set in the embed.. Defaults to EMBED_IMAGE_URL. + url (str, optional): The URL of the embed, get's applied to the title. Defaults to None. + + Returns: + Embed: An embed with the given attributes, and sets the author in the footer. + """ embed = Embed(title=title, description=description, color=color, url=url) embed.set_image(url=image) embed.set_footer(text=COG_STRINGS["music_embed_footer"].format(author=author)) @@ -425,6 +526,14 @@ def create_music_embed( def create_music_actionbar(is_paused: bool = True) -> View: + """Creates the View containing all the music functions of the music bot. + + Args: + is_paused (bool, optional): If the bot's playback is in the `is_paused()` state. Defaults to True. + + Returns: + View: A view containing all the actions of the music bot. + """ view = View(timeout=None) play_button = Button(style=ButtonStyle.secondary, emoji="▶️", custom_id=UserActionType.PLAY.id) From 0d606fb14d4d05561a608e45c6c261450fb97295 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 11:46:38 +0100 Subject: [PATCH 254/404] Finished adding docstrings for all functions --- src/extensions/dynamic/VCMusic.py | 230 ++++++++++++++++++++++++++++-- 1 file changed, 220 insertions(+), 10 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 42de992d..343253f9 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -613,6 +613,15 @@ async def on_ready(self): @GroupCog.listener() async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): + """Used to check when if the bot has been moved to another channel or disconnected. Also used + to check if the bot has been abandoned in a channel, in which case it disconnects itself and + performs the cleanup. + + Args: + member (Member): The member whos VoiceState changed. + before (VoiceState): The VoiceState before the change. + after (VoiceState): The VoiceState after the change. + """ guild_id = before.channel.guild.id if before.channel else after.channel.guild.id if member.id != self.bot.user.id: if guild_id not in self.active_players: @@ -640,6 +649,16 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: @GroupCog.listener() async def on_interaction(self, interaction: Interaction): + """Used to listen for the VCMusic interactions. This function will only act upon + interactions whos custom IDs begin with the MUSIC_INTERACTION_PREFIX, and will then + attempt to parse the action to a UserActionType enum and perform the appropriate action. + + Args: + interaction (Interaction): The interaction that has occured. + + Returns: + bool: If the handling of the interaction was successful. + """ if not interaction.data or not interaction.data.get("custom_id"): return False @@ -680,13 +699,21 @@ async def on_interaction(self, interaction: Interaction): return False def run_tasks(self): + """Ensures that the check_playing and check_inactive tasks are running. + """ if self.playing and not self.check_playing.is_running(): self.check_playing.start() if self.inactive and not self.check_inactive.is_running(): self.check_inactive.start() - async def cleanup_after_disconnect(self, guild_id): + async def cleanup_after_disconnect(self, guild_id: int): + """Ensures that a given guild is not left active, playing or inactive + after it disconnects, and ensures that the embed has been properly reset. + + Args: + guild_id (int): The ID of the guild to cleanup. + """ needs_update = False if guild_id in self.active_players: self.active_players.pop(guild_id) @@ -702,6 +729,9 @@ async def cleanup_after_disconnect(self, guild_id): @tasks.loop(seconds=5) async def check_playing(self): + """For each guild that is currently marked as playing, check if it's playback has stopped. + For those that have, attempt to play the next song, but if no next song mark as inactive. + """ if not self.playing: self.check_playing.cancel() self.check_playing.stop() @@ -722,6 +752,9 @@ async def check_playing(self): @tasks.loop(seconds=10) async def check_inactive(self): + """For each guild marked as inactive, check if has been longer than INACTIVE_TIMEOUT since + it was marked as inactive, and if so disconnect it. + """ if not self.inactive: self.check_inactive.cancel() self.check_inactive.stop() @@ -739,6 +772,11 @@ async def check_inactive(self): @tasks.loop(hours=12) async def update_author(self): + """Ensure that the author we acquired is still up to date + + Returns: + bool: True if the a user with ID of AUTHOR_ID is found else False. + """ new_author = await self.bot.fetch_user(AUTHOR_ID) if new_author: self.author = new_author @@ -748,6 +786,16 @@ async def update_author(self): return False def check_valid_user(self, guild: Guild, user: Member) -> bool: + """Checks if a given user is allowed to control the music bot at + a given time. + + Args: + guild (Guild): The guild in which the user is. + user (Member): The member attempting to perform an action. + + Returns: + bool: True if the user is allowed to control the bot, False otherwise. + """ if not user.voice: return False @@ -760,6 +808,13 @@ def check_valid_user(self, guild: Guild, user: Member) -> bool: return self.bot.user in user.voice.channel.members async def shuffle_queue_handler(self, interaction: Interaction) -> bool: + """The interaction handler for when the custom ID matches the UserActionType + of SHUFFLE. This handler will peform the necessary checks and if successful, + will shuffle the queue from the guild where the interaction came from. + + Args: + interaction (Interaction): The interaction to handle + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -774,6 +829,13 @@ async def shuffle_queue_handler(self, interaction: Interaction) -> bool: await respond_or_followup(COG_STRINGS["music_shuffle_queue_success"], interaction, ephemeral=True) async def set_volume_handler(self, interaction: Interaction) -> bool: + """The interaction handler for when the custom ID of an interaction + matches the UserActionType of VOLUME. This handler will perform the + necessary checks, and if successful will show a modal to set the volume. + + Args: + interaction (Interaction): The interaction to handle. + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -799,6 +861,13 @@ async def set_volume_handler(self, interaction: Interaction) -> bool: return True async def set_volume_submit_handler(self, interaction: Interaction) -> bool: + """The handler for when the custom ID of an interaction matches the UserActionType + of VOLUME_MODAL_SUBMIT. This handler will perform the necessary checks, and if + successful, will set the volume of the playback to given volume. + + Args: + interaction (Interaction): The interaction to handle. + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -837,6 +906,14 @@ async def set_volume_submit_handler(self, interaction: Interaction) -> bool: return True async def add_interaction_hanlder(self, interaction: Interaction) -> bool: + """The interaction handler for when the custom ID of an interaction matches + the UserActionType of ADD_SONG. This handler will perform the necessary checks, + and if successful, will show the modal to allow the user to add songs to the + queue. + + Args: + interaction (Interaction): The interaction to handle. + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -865,7 +942,15 @@ async def add_interaction_hanlder(self, interaction: Interaction) -> bool: await interaction.response.send_modal(modal) return True - async def add_modal_interaction_handler(self, interaction: Interaction): + async def add_modal_interaction_handler(self, interaction: Interaction) -> bool: + """The interaction handler for when the custom ID of an interaction matches + the UserActionType of ADD_MODAL_SUBMIT. This handler will perform the necessary + checks, and if successful will attempt to parse the values of the modal as song + requests. If any of the song requests are successful, playback will begin. + + Args: + interaction (Interaction): The interaction to handle. + """ await interaction.response.defer(ephemeral=True) if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) @@ -929,7 +1014,20 @@ async def add_modal_interaction_handler(self, interaction: Interaction): return True - async def try_play_queue(self, interaction: Interaction, add_to_queue: list = []): + async def try_play_queue(self, interaction: Interaction, add_to_queue: list = []) -> bool: + """Attempt to start playback in a given guild. The current queue will be appended to + by the add_to_queue arg, and if no song is currently playing or paused, playback will + start. If the guild from which the interaction came is currently marked as inactive, + ensure that it no longer is. If the guild is not already playing, ensure that it is + marked as playing. + + Args: + interaction (Interaction): The interaction to handle + add_to_queue (list, optional): The songs to add to the queue, if any. Defaults to []. + + Returns: + bool: If playback is successful. + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -967,7 +1065,17 @@ async def try_play_queue(self, interaction: Interaction, add_to_queue: list = [] return True return False - def play_next_song(self, guild_id: int): + def play_next_song(self, guild_id: int) -> bool: + """Get the next song in the queue and play it. Does not check if the current song + has ended. If there are no songs in the queue, simply returns and does not modify + playback of the current song if any. + + Args: + guild_id (int): The ID of the guild in which to play the next song. + + Returns: + bool: If a new song was started. + """ try: next_song = self.active_players[guild_id].queue.pop(0) except IndexError: @@ -995,7 +1103,14 @@ def play_next_song(self, guild_id: int): return True - async def resume_or_start_playback(self, interaction: Interaction): + async def resume_or_start_playback(self, interaction: Interaction) -> bool: + """The interaction handler for when the custom ID matches the UserActionType + of PLAY. This handler performs the necessary checks and if successful, either + resumes the currently paused song, or starts playback of the queue. + + Args: + interaction (Interaction): The interaction to handle. + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -1022,7 +1137,14 @@ async def resume_or_start_playback(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_generic_error"].format(author=self.author), interaction, ephemeral=True) return False - async def pause_playback(self, interaction: Interaction): + async def pause_playback(self, interaction: Interaction) -> bool: + """The interaction handler for when the custom ID of an interaction + matches the UserActionType of PAUSE. This handler will perform the + necessary checks and if successful, will pause the current playback. + + Args: + interaction (Interaction): The interaction to handle. + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -1042,7 +1164,14 @@ async def pause_playback(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_paused_success"], interaction, ephemeral=True) return True - async def skip_song_handler(self, interaction: Interaction): + async def skip_song_handler(self, interaction: Interaction) -> bool: + """This handler is for when the custom ID of an interaction matches the + UserActionType of SKIP. The handler will perform the necessary checks and + if successful, will skip the currently playing song. + + Args: + interaction (Interaction): The interaction to handle. + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -1063,7 +1192,13 @@ async def skip_song_handler(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["music_warn_no_next_song"], interaction, ephemeral=True) return False - async def get_current_queue(self, interaction: Interaction): + # TODO: Rename this. + async def get_current_queue(self, interaction: Interaction) -> bool: + """Handles sending the current queue to a user that requested it. + + Args: + interaction (Interaction): The interaction of the requesting user. + """ await interaction.response.defer(ephemeral=True) if interaction.guild.id not in self.active_players: await respond_or_followup(COG_STRINGS["music_warn_view_queue_empty"], interaction, ephemeral=True) @@ -1096,6 +1231,12 @@ async def get_current_queue(self, interaction: Interaction): return True def end_playback(self, guild_id: int): + """If a guild is currently playing, stop playing. Also ensures that the guild is + properly marked as inactive, and that it is no longer marked as playing. + + Args: + guild_id (int): The ID of the guild to stop playback in. + """ if self.active_players.get(guild_id).voice_client.is_playing(): self.active_players.get(guild_id).voice_client.stop() @@ -1103,9 +1244,20 @@ def end_playback(self, guild_id: int): self.active_players.get(guild_id).current_song = None self.inactive[guild_id] = datetime.now() + # TODO: Check self.playing list if present. self.run_tasks() - async def update_embed(self, guild_id: int): + async def update_embed(self, guild_id: int) -> bool: + """Update the embed of a given guild. If there is a song playing, ensure that + it's data is displayed, otherwise ensure that the embed is reset to default. + Also ensures the the action row has the correct buttons. + + Args: + guild_id (int): The ID of the guild to update. + + Returns: + bool: If the embed was able to be updated. + """ db_entry = DBSession.get(MusicChannels, guild_id=guild_id) if not db_entry: return False @@ -1134,7 +1286,14 @@ async def update_embed(self, guild_id: int): await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) return True - async def stop_playback(self, interaction: Interaction): + async def stop_playback(self, interaction: Interaction) -> bool: + """The interaction handler for when the custom ID of an interaction matches + the UserActionType of STOP. This handler performs the necessary checks and if + successful, will clear the queue and end playback of the current song. + + Args: + interaction (Interaction): The interaction to handle. + """ if interaction.guild.id not in self.active_players: if interaction.guild.voice_client: await interaction.guild.voice_client.disconnect() @@ -1179,6 +1338,16 @@ async def set_channel( ColourTransformer] = Color(0xd462fd), read_only: bool = True ): + """The command used to set a given channel as the defined Music Channel. This can be used to reset a channel + if something has gone wrong or to update the color of the embed. + + Args: + interaction (Interaction): The interaction of the command + channel (TextChannel): The channel to set as the music channel. + clear_messages (bool, optional): If the messages in the channel should be cleared. Defaults to False. + embed_color (Transform[Color, ColourTransformer], optional): The color to use for the embed. Defaults to Color(0xd462fd). + read_only (bool, optional): If the music channel should be read only. Users can interact with the music bot via the buttons. Defaults to True. + """ await interaction.response.defer(ephemeral=True) if clear_messages: @@ -1220,31 +1389,61 @@ async def set_channel( @command(name=COG_STRINGS["music_play_name"], description=COG_STRINGS["music_play_description"]) @guild_only() async def play_command(self, interaction: Interaction): + """The command used to either resume playback or start playback. Invokes the PLAY UserActionType handler. + + Args: + interaction (Interaction): The interaction of the command. + """ return await self.resume_or_start_playback(interaction) @command(name=COG_STRINGS["music_pause_name"], description=COG_STRINGS["music_pause_description"]) @guild_only() async def pause_command(self, interaction: Interaction): + """The command used to pause playback. Invokes the PAUSE UserActionType handler. + + Args: + interaction (Interaction): The interaction of the command. + """ return await self.pause_playback(interaction) @command(name=COG_STRINGS["music_skip_name"], description=COG_STRINGS["music_skip_description"]) @guild_only() async def skip_command(self, interaction: Interaction): + """The command used to skip the current song. Invokes the SKIP UserActionType handler. + + Args: + interaction (Interaction): The interaction of the command. + """ return await self.skip_song_handler(interaction) @command(name=COG_STRINGS["music_add_name"], description=COG_STRINGS["music_add_description"]) @guild_only() async def add_songs_command(self, interaction: Interaction): + """The command to add songs to the queue. Invokes the ADD_SONG UserActionType interaction handler. + + Args: + interaction (Interaction): The interaction of the command. + """ return await self.add_interaction_hanlder(interaction) @command(name=COG_STRINGS["music_view_queue_name"], description=COG_STRINGS["music_view_queue_description"]) @guild_only() async def view_queue(self, interaction: Interaction): + """The command to view the current queue. Invokes the VIEW_QUEUE UserActionType interaction handler. + + Args: + interaction (Interaction): The interaction of the command. + """ return await self.get_current_queue(interaction) @command(name=COG_STRINGS["music_stop_name"], description=COG_STRINGS["music_stop_description"]) @guild_only() async def stop_command(self, interaction: Interaction): + """THe command to stop playback. Invokes the STOP UserActionType interaction handler. + + Args: + interaction (Interaction): The interaction of the command. + """ return await self.stop_playback(interaction) @command(name=COG_STRINGS["music_volume_name"], description=COG_STRINGS["music_volume_description"]) @@ -1252,6 +1451,12 @@ async def stop_command(self, interaction: Interaction): @rename(volume=COG_STRINGS["music_volume_volume_rename"]) @guild_only() async def set_volume(self, interaction: Interaction, volume: Range[int, 0, 100]): + """The command to set the volume of the playback. + + Args: + interaction (Interaction): The interaction of the command. + volume (Range[int, 0, 100]): The percentage value to set the volume to. Between 0-100 inclusive. + """ if not self.check_valid_user(interaction.guild, interaction.user): await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) return False @@ -1269,6 +1474,11 @@ async def set_volume(self, interaction: Interaction, volume: Range[int, 0, 100]) @command(name=COG_STRINGS["music_shuffle_name"], description=COG_STRINGS["music_shuffle_description"]) @guild_only() async def shuffle_queue(self, interaction: Interaction): + """The command to shuffle the queue. Invokes the SHUFFLE UserActionType interaction handler. + + Args: + interaction (Interaction): The interaction of the command. + """ return await self.shuffle_queue_handler(interaction) From 3ab347ae48eb8ff81cc07c99989d52ffa19f578e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 12:49:46 +0100 Subject: [PATCH 255/404] Converted AutoRoles to a GroupCog --- src/extensions/dynamic/AutoRoles.py | 8 ++++---- src/locale/AutoRoles.toml | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/AutoRoles.py b/src/extensions/dynamic/AutoRoles.py index e8618871..0a248ed4 100644 --- a/src/extensions/dynamic/AutoRoles.py +++ b/src/extensions/dynamic/AutoRoles.py @@ -3,7 +3,7 @@ from discord import Color, Embed, Interaction, Member, Role from discord.app_commands import (Transform, checks, command, default_permissions, describe, guild_only, rename) -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Bot, GroupCog from client import EsportsBot from common.discord import (RoleListTransformer, get_role, primary_key_from_object) @@ -14,19 +14,19 @@ COG_STRINGS = load_cog_toml(__name__) -class AutoRoles(Cog): +class AutoRoles(GroupCog, name=COG_STRINGS["roles_group_name"]): def __init__(self, bot: EsportsBot): self.bot = bot self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") - @Cog.listener() + @GroupCog.listener() async def on_member_join(self, member: Member): if not member.pending: await self.assign_roles(member) - @Cog.listener() + @GroupCog.listener() async def on_member_update(self, before: Member, after: Member): if before.pending and not after.pending: self.assign_roles(after) diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml index 53f391b5..78f53b14 100644 --- a/src/locale/AutoRoles.toml +++ b/src/locale/AutoRoles.toml @@ -1,3 +1,5 @@ +roles_group_name = "roles" + roles_set_list_name = "roles-set-list" roles_set_list_description = "Set the list of automatically applied roles when a user joins." roles_set_list_param_describe = "The list of roles to give." From f89de78461b60d1087ec2520885d76212bb004eb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 12:50:40 +0100 Subject: [PATCH 256/404] Updated commands to no longer use 'roles' prefix --- src/locale/AutoRoles.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml index 78f53b14..06137b03 100644 --- a/src/locale/AutoRoles.toml +++ b/src/locale/AutoRoles.toml @@ -1,6 +1,6 @@ roles_group_name = "roles" -roles_set_list_name = "roles-set-list" +roles_set_list_name = "set-list" roles_set_list_description = "Set the list of automatically applied roles when a user joins." roles_set_list_param_describe = "The list of roles to give." roles_set_list_param_rename = "roles" @@ -8,26 +8,26 @@ roles_set_warn_empty = "No roles were configured to be applied when members join roles_set_success_title = "Finished Configuring AutoRoles!" roles_set_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" -roles_add_role_name = "roles-add-role" +roles_add_role_name = "add-role" roles_add_role_description = "Add a role to the list of automatically applied roles." roles_add_role_param_describe = "The role to add." roles_add_role_param_rename = "role" roles_add_role_success = "Succesfully added {role} to the AutoRoles config ✅" roles_add_role_warn_already_added = "The role given is already in the list of roles to apply ⚠️" -roles_remove_role_name = "roles-remove-role" +roles_remove_role_name = "remove-role" roles_remove_role_description = "Remove a role from the list of automatically applied roles." roles_remove_role_param_describe = "The role to remove." roles_remove_role_param_rename = "role" roles_remove_role_success = "Succesfully removed {role} from the AutoRoles config ✅" roles_remove_role_warn_not_added = "The role given is not in the list of roles to apply ⚠️" -roles_get_list_name = "roles-get-list" +roles_get_list_name = "get-list" roles_get_list_description = "Get the list of currently configured AutoRoles." roles_get_list_warn_no_roles = "No roles are currently configured to be applied when a member joins ⚠️" roles_get_list_success_title = "Current AutoRoles Config" roles_get_list_success_description = "Below is the list of roles that will be automatically applied to new members when they join the server: \n\n {roles}" -roles_clear_list_name = "roles-clear-list" +roles_clear_list_name = "clear-list" roles_clear_list_description = "Clears the list of roles to apply." roles_clear_list_success = "The list of roles has been cleared ✅" \ No newline at end of file From 71606c2570da93af9f313ab46c3629ea79afc741 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 12:52:55 +0100 Subject: [PATCH 257/404] Converted EventTools to a GroupCog --- src/extensions/dynamic/EventTools.py | 20 +++++++------------- src/locale/EventTools.toml | 2 ++ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/extensions/dynamic/EventTools.py b/src/extensions/dynamic/EventTools.py index e52b718b..ba572fc0 100644 --- a/src/extensions/dynamic/EventTools.py +++ b/src/extensions/dynamic/EventTools.py @@ -30,7 +30,7 @@ guild_only, rename ) -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Bot, GroupCog from discord.ui import Select, View from client import EsportsBot @@ -179,7 +179,7 @@ async def schedule_event( return discord_event -class EventTools(Cog): +class EventTools(GroupCog, name=COG_STRINGS["events_group_name"]): def __init__(self, bot: EsportsBot): self.bot = bot @@ -352,7 +352,7 @@ async def create_signin(self, event: Event, location: str, discord_event: Schedu return signin_embed, signin_menu - @Cog.listener() + @GroupCog.listener() async def on_scheduled_event_update(self, before: ScheduledEvent, after: ScheduledEvent): """The event listener for when a Discord Event has an update. @@ -379,7 +379,7 @@ async def on_scheduled_event_update(self, before: ScheduledEvent, after: Schedul if after.status == EventStatus.ended: await self.archive_event(after.guild, event_id=after.id) - @Cog.listener() + @GroupCog.listener() async def on_interaction(self, interaction: Interaction): f"""The event listener for when a user performs an interaction. @@ -533,12 +533,8 @@ async def create_event( await interaction.followup.send("Created event!", ephemeral=True) @command(name=COG_STRINGS["events_open_event_name"], description=COG_STRINGS["events_open_event_description"]) - @describe( - event_id=COG_STRINGS["events_open_event_event_id_describe"], - ) - @rename( - event_id=COG_STRINGS["events_open_event_event_id_rename"], - ) + @describe(event_id=COG_STRINGS["events_open_event_event_id_describe"], ) + @rename(event_id=COG_STRINGS["events_open_event_event_id_rename"], ) @autocomplete(event_id=ActiveEventTransformer.autocomplete) @default_permissions(administrator=True) @checks.has_permissions(administrator=True) @@ -746,9 +742,7 @@ async def reschedule_event( @command(name=COG_STRINGS["events_remove_event_name"], description=COG_STRINGS["events_remove_event_description"]) @describe(event_id=COG_STRINGS["events_remove_event_event_id_describe"]) - @rename( - event_id=COG_STRINGS["events_remove_event_event_id_rename"], - ) + @rename(event_id=COG_STRINGS["events_remove_event_event_id_rename"], ) @autocomplete(event_id=EventTransformer.autocomplete) @default_permissions(administrator=True) @checks.has_permissions(administrator=True) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index 35fe835f..365c535e 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -1,3 +1,5 @@ +events_group_name = "events" + events_create_event_name = "events-create-event" events_create_event_description = "Create a new event." events_create_event_title_describe = "The name of the event." From 787062adb85d5082741e0366097b8fc32d57ee57 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 12:53:45 +0100 Subject: [PATCH 258/404] Updated commands to no longer use 'events' prefix --- src/locale/EventTools.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml index 365c535e..778f4f8d 100644 --- a/src/locale/EventTools.toml +++ b/src/locale/EventTools.toml @@ -1,6 +1,6 @@ events_group_name = "events" -events_create_event_name = "events-create-event" +events_create_event_name = "create-event" events_create_event_description = "Create a new event." events_create_event_title_describe = "The name of the event." events_create_event_title_rename = "event-name" @@ -25,7 +25,7 @@ events_create_event_embed_description = "Welcome to the {name} sign-in channel!\ events_signin_status_success = "You are now `{status}` to {name} ✅" events_signin_status_failed = "There was an issue while trying to update your sign-in status. Please try again later, or contact an admin ⚠️" -events_open_event_name = "events-open-event" +events_open_event_name = "open-event" events_open_event_description = "Opens a given event." events_open_event_event_id_describe = "The name or ID of the event to open." events_open_event_event_id_rename = "event" @@ -33,7 +33,7 @@ events_open_event_success = "Successfully opened `{event_name}` ✅" events_open_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" events_open_event_error_missing_event = "There was an error finding the discord event to start, please contact an admin! ❌" -events_close_event_name = "events-close-event" +events_close_event_name = "close-event" events_close_event_description = "Closes a given event." events_close_event_event_id_describe = "The name or ID of the event to close." events_close_event_archive_describe = "Set to 'True' if you want to retain the channels, otherwise 'False' will delete the channels." @@ -46,7 +46,7 @@ events_close_event_success_no_archive = "Successfully closed `{event_name}` and events_close_event_warn_invalid_id = "The given ID of `{event}` is not valid. Please select the event from the list or give the ID of the event ⚠️" events_close_event_warn_missing_event = "There was an error finding the discord event to end ⚠️" -events_reschedule_event_name = "events-reschedule-event" +events_reschedule_event_name = "reschedule-event" events_reschedule_event_description = "Reuse an archived channel and schedule it again." events_reschedule_event_event_id_describe = "The name or ID of the event to reschedule." events_reschedule_event_event_location_describe = "The physical location of the event." @@ -64,7 +64,7 @@ events_reschedule_event_warn_invalid_dates = "The end time cannot be less-than o events_reschedule_event_warn_invalid_start = "The start date of the event cannot be in the past ⚠️" events_reschedule_event_error_missing_role = "Unable to find associated role for {name} event. Please contact an admin if this is an error ❌" -events_remove_event_name = "events-remove-event" +events_remove_event_name = "remove-event" events_remove_event_description = "Entirely deletes either an active or archived event." events_remove_event_event_id_describe = "The name or ID of the event to delete." events_remove_event_event_id_rename = "event" From a95a20083d5fe1996a70e3f98956c705c9ba72b7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 12:55:18 +0100 Subject: [PATCH 259/404] Converted VoiceAdmin to a GroupCog --- src/extensions/dynamic/VoiceAdmin.py | 6 +++--- src/locale/VoiceAdmin.toml | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 4d377dbc..f930033c 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -3,7 +3,7 @@ from discord import Interaction, Member, VoiceChannel, VoiceState from discord.app_commands import (checks, command, default_permissions, describe, guild_only, rename) from discord.errors import Forbidden -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Bot, GroupCog from client import EsportsBot from common.discord import primary_key_from_object @@ -39,7 +39,7 @@ def member_is_owner(member: Member, channel: VoiceChannel, db_entry: VoiceAdminC return db_entry.owner_id == member.id -class VoiceAdmin(Cog): +class VoiceAdmin(GroupCog, name=COG_STRINGS["vc_group_name"]): def __init__(self, bot: EsportsBot): """VoiceAdmin cog is used to dynamically create and manage Voice Channels, @@ -56,7 +56,7 @@ def __init__(self, bot: EsportsBot): self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") - @Cog.listener() + @GroupCog.listener() async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): """The listener used to track when users join/leave Voice Channels that the Bot has access to. diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml index 0f1eae7a..099629ff 100644 --- a/src/locale/VoiceAdmin.toml +++ b/src/locale/VoiceAdmin.toml @@ -1,3 +1,5 @@ +vc_group_name = "voice" + default_vc_name = "{name}'s VC" vc_limited_icon_with_delimited = "​📌​" vc_locked_icon_with_delimiter = "​🔒​" From 96382cfa9624f7f2aba99fad35988380a3873455 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 12:56:15 +0100 Subject: [PATCH 260/404] Updated commands to no longer use 'vc' prefix --- src/locale/VoiceAdmin.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml index 099629ff..3f1f0ee4 100644 --- a/src/locale/VoiceAdmin.toml +++ b/src/locale/VoiceAdmin.toml @@ -5,7 +5,7 @@ vc_limited_icon_with_delimited = "​📌​" vc_locked_icon_with_delimiter = "​🔒​" vc_must_be_owner = "You must be the VC owner to do this." -vc_set_parent_name = "vc-set-parent" +vc_set_parent_name = "set-parent" vc_set_parent_description = "Set a Voice Channel to be a parent Voice Channel." vc_set_parent_param_describe = "The Voice Channel to add to the parent Voice Channel list." vc_set_parent_param_rename = "voice-channel" @@ -13,7 +13,7 @@ vc_set_parent_success = "`{channel}` is now a parent Voice Channel ✅" vc_set_parent_warn_already_parent = "The Voice Channel selected is already a parent Voice Channel ⚠️" vc_set_parent_warn_already_child = "The Voice Channel selected cannot be a child Voice Channel ⚠️" -vc_remove_parent_name = "vc-remove-parent" +vc_remove_parent_name = "remove-parent" vc_remove_parent_description = "Remove a Voice Channel from being a parent Voice Channel." vc_remove_parent_param_describe = "The Voice Channel to remove from the parent Voice Channel list." vc_remove_parent_param_rename = "voice-channel" @@ -21,12 +21,12 @@ vc_remove_parent_success = "`{channel}` is no longer a parent Voice Channel ✅" vc_remove_parent_warn_not_parent = "The Voice Channel selected is not currently a parent Voice Channel ⚠️" vc_remove_parent_warn_already_child = "The Voice Channel selected cannot be a child Voice Channel ⚠️" -vc_get_parents_name = "vc-get-parents" +vc_get_parents_name = "get-parents" vc_get_parents_description = "Get the list of current parent Voice Channels." vc_get_parents_empty = "There are currently no parent Voice channels in this server ⚠️" vc_get_parents_format = "Current parent Voice Channels in this server: \n {channels}" -vc_rename_name = "vc-rename" +vc_rename_name = "rename" vc_rename_description = "Rename your current Voice Channel." vc_rename_param_describe = "The new name of the VC. Leaving this empty will reset the name to default." vc_rename_param_rename = "new-name" @@ -34,20 +34,20 @@ vc_rename_success = "You have renamed your Voice Channel to `{name}` ✅" vc_rename_warn_no_voice = "You cannot rename a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" vc_rename_warn_not_owner = "You cannot rename your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" -vc_lock_name = "vc-lock" +vc_lock_name = "lock" vc_lock_description = "Only allow current members to (re)join your VC." vc_lock_success = "Your Voice Channel is now locked ✅" vc_lock_warn_no_voice = "You cannot lock a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" vc_lock_warn_not_owner = "You canont lock your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" -vc_unlock_name = "vc-unlock" +vc_unlock_name = "unlock" vc_unlock_description = "Allow anyone to join your VC again." vc_unlock_success = "Your Voice Channel is now unlocked ✅" vc_unlock_warn_no_voice = "You cannot unlock a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" vc_unlock_warn_not_owner = "You canont unlock your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" vc_unlock_warn_not_locked = "You cannot unlock your Voice Channel as it is not locked ⚠️" -vc_limit_name = "vc-limit" +vc_limit_name = "limit" vc_limit_description = "Set the member count limit of your VC." vc_limit_param_describe = "Number of members (1-99) to limit the voice channel to. If ommitted, uses the current member count." vc_limit_param_rename = "member-limit" @@ -56,7 +56,7 @@ vc_limit_warn_no_voice = "You cannot limit a Voice Channel as you are not curren vc_limit_warn_not_owner = "You canont limit your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" vc_limit_warn_too_many = "A Voice Channel has a maximum limit of 99 members, please provide a value less-than or equal to 99 ⚠️" -vc_unlimit_name = "vc-unlimit" +vc_unlimit_name = "remove-limit" vc_unlimit_description = "Remove the member count limit of your VC." vc_unlimit_success = "The member count limit has been removed from your Voice Channel ✅" vc_unlimit_warn_no_voice = "You cannot remove the limit from a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" From 9d77a2a46155e4e3f99fd4885c0b96642960239f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 12:57:36 +0100 Subject: [PATCH 261/404] Converted AdminTools to a GroupCog --- src/extensions/default/AdminTools.py | 4 ++-- src/locale/AdminTools.toml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/AdminTools.py b/src/extensions/default/AdminTools.py index ae68eea7..78bdedac 100644 --- a/src/extensions/default/AdminTools.py +++ b/src/extensions/default/AdminTools.py @@ -2,7 +2,7 @@ from discord import Interaction from discord.app_commands import (checks, command, default_permissions, describe, guild_only, rename) -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Bot, GroupCog from client import EsportsBot from common.io import load_bot_version, load_cog_toml @@ -10,7 +10,7 @@ COG_STRINGS = load_cog_toml(__name__) -class AdminTools(Cog): +class AdminTools(GroupCog, name=COG_STRINGS["admin_group_name"]): def __init__(self, bot: EsportsBot): """AdminTools cog is used to manage basic Administrator/Moderation tools. diff --git a/src/locale/AdminTools.toml b/src/locale/AdminTools.toml index 846e0a2f..dfe36d75 100644 --- a/src/locale/AdminTools.toml +++ b/src/locale/AdminTools.toml @@ -1,3 +1,5 @@ +admin_group_name = "admin" + admin_members_name = "admin-member-count" admin_members_description = "Get the current member count of the server." admin_members_format = "Current member count — `{count} members` ." From dc247f70fc991fcd07565526e8c87f2b17844f76 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 12:58:34 +0100 Subject: [PATCH 262/404] Updated commands to no longer use 'admin' prefix --- src/locale/AdminTools.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/locale/AdminTools.toml b/src/locale/AdminTools.toml index dfe36d75..68e1199b 100644 --- a/src/locale/AdminTools.toml +++ b/src/locale/AdminTools.toml @@ -1,21 +1,21 @@ admin_group_name = "admin" -admin_members_name = "admin-member-count" +admin_members_name = "member-count" admin_members_description = "Get the current member count of the server." admin_members_format = "Current member count — `{count} members` ." -admin_user_info_name = "admin-user-info" +admin_user_info_name = "user-info" admin_user_info_title = "{user} — User Info" admin_user_info_description = "Showing the user info for {mention} \n" -admin_clear_name = "admin-clear-messages" +admin_clear_name = "clear-messages" admin_clear_description = "Delete a specific number of messages in the given channel. " admin_clear_param_describe = "The number of messages to delete. Defaults to 5 messages." admin_clear_param_rename = "message-count" admin_clear_warn_too_many = "The maximum number of messages that can be deleted in one go is 100 ⚠️" admin_clear_success = "Successfully deleted `{count}` message(s) ✅" -admin_version_name = "admin-get-version" +admin_version_name = "get-version" admin_version_description = "Get the current version of the Bot." admin_version_format = "Current version — `{version}`" admin_version_missing = "No current bot version set ⚠️" \ No newline at end of file From b47b61993997815c4e8f9b9c8a86c6371197c93f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 13:02:48 +0100 Subject: [PATCH 263/404] Updated README to reflect cog grouped command names --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8d75a77b..c57a6cd1 100644 --- a/README.md +++ b/README.md @@ -31,16 +31,16 @@ All commands in this cog require the user to have the administrator permission i ### Current Commands: -#### /admin-member-count +#### /admin member-count - Get the current member count of the server. -#### /admin-clear-messages [optional: message-count] +#### /admin clear-messages [optional: message-count] - Delete a specific number of messages in the given channel. Defaults to 5 messages, with a maximum of 100 messages. -#### /admin-get-version +#### /admin get-version - Get the current version of the Bot. @@ -59,35 +59,35 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Current Commands: -#### /vc-set-parent \<voice-channel\> +#### /voice set-parent \<voice-channel\> - Set a Voice Channel to be a parent Voice Channel. -#### /vc-remove-parent \<voice-channel\> +#### /voice remove-parent \<voice-channel\> - Remove a Voice Channel from being a parent Voice Channel. -#### /vc-get-parents +#### /voice get-parents - Get the list of current parent Voice Channels. -#### /vc-rename \<new-name\> +#### /voice rename \<new-name\> - Rename your current Voice Channel -#### /vc-lock +#### /voice lock - Only allow current members to (re)join your Voice Channel. -#### /vc-unlock +#### /voice unlock - Allow anyone to join your Voice Channel again. -#### /vc-limit +#### /voice limit - Set the member count limit of your Voice Channel. -#### /vc-unlimit +#### /voice remove-limit - Remove the member count limit of your Voice Channel. @@ -100,24 +100,24 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Environment Variable: `ENABLE_AUTOROLES` -#### /roles-set-list \<One or many roles mentioned\> +#### /roles set-list \<One or many roles mentioned\> - Sets the roles to be given to new users when they join the guild/server. - If one or more the of the roles are valid, any roles previously configured will be removed. -#### /roles-add-role \<role\> +#### /roles add-role \<role\> - Adds a role to the list of roles without overriding the currently configured roles. -#### /roles-remove-role \<role\> +#### /roles remove-role \<role\> - Removes a role from the list of currently configured roles. -#### /roles-get-list +#### /roles get-list - Gets the list of currently configured AutoRoles. -#### /roles-clear-list +#### /roles clear-list - Clears all roles from the list of configured AutoRoles. @@ -130,25 +130,25 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Environment Variable: `ENABLE_EVENTTOOLS` -#### /events-create-event \<name\> \<physical location\> \<start time\> \<end time\> \<timezone\> \<common member role\> \<role color\> +#### /events create-event \<name\> \<physical location\> \<start time\> \<end time\> \<timezone\> \<common member role\> \<role color\> - Creates a new event. -#### /events-open-event \<event name or ID\> +#### /events open-event \<event name or ID\> - Opens the given event. This will show the sign-in menu to members. -#### /events-close-event \<event name or ID\> [optional: keep-event?] [optional: clear-messages?] +#### /events close-event \<event name or ID\> [optional: keep-event?] [optional: clear-messages?] - Ends the given event. This will hide all the channels from members. - If keep-event is set to True, the event will be archived, otherwise it's channels and roles will be deleted. - If clear-messages is set to True, when the event is archived, messages in all channels will be deleted. -#### /events-reschedule-event \<physical location\> \<start time\> \<end time\> \<timezone\> +#### /events reschedule-event \<physical location\> \<start time\> \<end time\> \<timezone\> - If an event has been archived, it can be reused and rescheduled for a new date using this command. -#### /events-remove-event \<event name or ID\> +#### /events remove-event \<event name or ID\> - Entirely deletes either an active or archived event. </details> From 5490c9aafa45db584e41e0375065aae37d8e25e9 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 13:03:13 +0100 Subject: [PATCH 264/404] Updated TODO list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c57a6cd1..c21b42b7 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - Add back functionality of previous bot (eg. Music, PingableRoles, etc.) - Add game deal tracker (DealTracker(?) cog) - Add proper support for SQLite auto increment primary keys -- Add proper use of command groups +- ~~Add proper use of command groups~~ ## Previous extensions to implement From bf0f946d8289357eb9de5b1b4d982af21e92117e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 13:23:08 +0100 Subject: [PATCH 265/404] Implemented skeleton for handling invalid voice channel names --- src/extensions/dynamic/VoiceAdmin.py | 9 +++++++++ src/locale/VoiceAdmin.toml | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index f930033c..61cab94f 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -39,6 +39,11 @@ def member_is_owner(member: Member, channel: VoiceChannel, db_entry: VoiceAdminC return db_entry.owner_id == member.id +def check_vc_name_allowed(new_name: str) -> bool: + # TOOD: Implement banned words list. + return True + + class VoiceAdmin(GroupCog, name=COG_STRINGS["vc_group_name"]): def __init__(self, bot: EsportsBot): @@ -270,6 +275,10 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): await interaction.followup.send(COG_STRINGS["vc_rename_warn_not_owner"], ephemeral=True) return False + if not check_vc_name_allowed(new_name): + await interaction.followup.send(COG_STRINGS["vc_rename_warn_invalid_name"], ephemeral=True) + return False + name_set = new_name if new_name else f"{interaction.user.display_name}'s VC" if not new_name: diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml index 3f1f0ee4..9258a16f 100644 --- a/src/locale/VoiceAdmin.toml +++ b/src/locale/VoiceAdmin.toml @@ -23,7 +23,7 @@ vc_remove_parent_warn_already_child = "The Voice Channel selected cannot be a ch vc_get_parents_name = "get-parents" vc_get_parents_description = "Get the list of current parent Voice Channels." -vc_get_parents_empty = "There are currently no parent Voice channels in this server ⚠️" +vc_get_parents_empty = "There are currently no parent Voice channels in this server vc_get_parents_format = "Current parent Voice Channels in this server: \n {channels}" vc_rename_name = "rename" @@ -33,6 +33,7 @@ vc_rename_param_rename = "new-name" vc_rename_success = "You have renamed your Voice Channel to `{name}` ✅" vc_rename_warn_no_voice = "You cannot rename a Voice Channel as you are not currently in a child Voice Channel. Please create a Voice Channel to do this ⚠️" vc_rename_warn_not_owner = "You cannot rename your Voice Channel as you are not the owner. Please make sure you own the child Voice Channel to do this ⚠️" +vc_rename_warn_invalid_name = "The name you have provided is not allowed. If this you believe this is a mistake, please contact an administrator ⚠️" vc_lock_name = "lock" vc_lock_description = "Only allow current members to (re)join your VC." From 9c86e88eba0bdef8800626c15afdb38a39d0404d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 14:32:05 +0100 Subject: [PATCH 266/404] Implemented IO function to load banned_words.txt --- src/common/io.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/common/io.py b/src/common/io.py index c0cbffd7..ca1286e3 100644 --- a/src/common/io.py +++ b/src/common/io.py @@ -54,3 +54,21 @@ def load_timezones(): return zones except FileNotFoundError: return {} + + +def load_banned_words(): + """Load a text file where each line contains a banned word. + + Returns: + list: A list of banned words. + """ + file_path = os.path.abspath(os.join(os.path.dirname(__file__), "..", "..", "banned_words.txt")) + try: + lines = [] + with open(file_path, "rt") as file: + for line in file.readlines(): + if not line.startswith("#"): + lines.append(line.strip()) + return lines + except FileNotFoundError: + return [] \ No newline at end of file From d9e31b10bb1e2282e6c06c3437021ab28f7db710 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 14:32:57 +0100 Subject: [PATCH 267/404] Re-added old banned_words.txt --- banned_words.txt | 1102 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1102 insertions(+) create mode 100644 banned_words.txt diff --git a/banned_words.txt b/banned_words.txt new file mode 100644 index 00000000..a471c5a1 --- /dev/null +++ b/banned_words.txt @@ -0,0 +1,1102 @@ +# Originally Obtained From: https://www.cs.cmu.edu/~biglou/resources/bad-words.txt +abbo +abo +abortion +abuse +alligatorbait +anal +analannie +analsex +anus +argie +aroused +arse +arsehole +assbagger +ass +asses +assfuck +assfucker +asshore +assklown +asslick +asslicker +asslover +assman +assmonkey +assmunch +assmuncher +asspacker +asspirate +asspuppies +assranger +asswhore +asswipe +babe +babies +backdoorman +badfuck +balllicker +balls +ballsack +banging +barelylegal +bast +bastard +bazongas +bazooms +beaner +beastality +beastial +beastiality +beatoff +beat-off +beatyourmeat +bestial +bestiality +biatch +bigass +bigbastard +bigbutt +bitch +bitcher +bitches +bitchez +bitchin +bitching +bitchslap +bitchy +biteme +blowjob +boang +bogan +bohunk +bollick +bollock +bondage +boner +boob +boobies +boobs +booby +boody +boong +boonga +boonie +booty +bootycall +bra +breast +breastjob +breastlover +breastman +brothel +bulldike +bulldyke +bullshit +bumblefuck +bumfuck +bunga +bunghole +buried +butchbabes +butchdike +butchdyke +butt +buttbang +butt-bang +buttface +buttfuck +butt-fuck +buttfucker +butt-fucker +buttfuckers +butt-fuckers +butthead +buttman +buttmunch +buttmuncher +buttpirate +buttplug +buttstain +byatch +cacker +cameljockey +cameltoe +carpetmuncher +carruth +cherrypopper +chickslick +chink +chinky +choad +chode +clamdigger +clamdiver +clit +clitoris +clogwog +cocaine +cock +cockblock +cockblocker +cockcowboy +cockfight +cockhead +cockknob +cocklicker +cocklover +cocknob +cockqueen +cockrider +cocksman +cocksmith +cocksmoker +cocksucer +cocksuck +cocksucked +cocksucker +cocksucking +cocktease +cocky +cohee +coitus +colored +coloured +coom +coomer +coon +coondog +copulate +cornhole +crackpipe +crackwhore +crack-whore +crapola +crapper +crappy +crime +crimes +criminal +criminals +crotch +crotchjockey +crotchmonkey +crotchrot +cum +cumbubble +cumfest +cumjockey +cumm +cummer +cumming +cumquat +cumqueen +cumshot +cunilingus +cunillingus +cunn +cunnilingus +cunntt +cunt +cunteyed +cuntfuck +cuntfucker +cuntlick +cuntlicker +cuntlicking +cuntsucker +cybersex +cyberslimer +dago +dahmer +darkie +darky +datnigga +deapthroat +deepthroat +defecate +dego +dick +dickbrain +dickforbrains +dickhead +dickless +dicklick +dicklicker +dickman +dickwad +dickweed +diddle +dike +dildo +dipshit +dipstick +dix +dixiedike +dixiedyke +doggiestyle +doggystyle +dong +doodoo +doo-doo +dripdick +dumbass +dumbbitch +dumbfuck +dyefly +dyke +easyslut +eatballs +eatme +eatpussy +ecstacy +ejaculate +ejaculated +ejaculating +ejaculation +enema +erect +erection +ero +escort +ethnic +excrement +facefucker +faeces +fag +fagging +faggot +fagot +fannyfucker +farty +fastfuck +fatah +fatass +fatfuck +fatfucker +fatso +fckcum +feces +felatio +felch +felcher +felching +fellatio +feltch +feltcher +feltching +fetish +fingerfood +fingerfuck +fingerfucked +fingerfucker +fingerfuckers +fingerfucking +fister +fistfuck +fistfucked +fistfucker +fistfucking +fisting +flange +flatulence +floo +flydie +flydye +fok +fondle +footaction +footfuck +footfucker +footlicker +footstar +foreskin +forni +fornicate +foursome +fourtwenty +freakfuck +freakyfucker +freefuck +fu +fubar +fuc +fucck +fuck +fucka +fuckable +fuckbag +fuckbuddy +fucked +fuckedup +fucker +fuckers +fuckface +fuckfest +fuckfreak +fuckfriend +fuckhead +fuckher +fuckin +fuckina +fucking +fuckingbitch +fuckinnuts +fuckinright +fuckit +fuckknob +fuckme +fuckmehard +fuckmonkey +fuckoff +fuckpig +fucks +fucktard +fuckwhore +fuckyou +fudgepacker +fugly +fuk +fuks +funfuck +fuuck +gangbang +gangbanged +gangbanger +gangsta +gatorbait +gaymuthafuckinwhore +gaysex +geni +genital +getiton +ginzo +gipp +givehead +goddamnmuthafucker +goldenshower +gonorrehea +gonzagas +gook +gotohell +goy +goyim +greaseball +gringo +groe +grostulation +gubba +gummer +gyp +gypo +gypp +gyppie +gyppo +gyppy +hamas +handjob +hapa +hardon +harem +headfuck +hebe +heeb +henhouse +herpes +hijack +hijacker +hijacking +hillbillies +hindoo +hiscock +hitler +hitlerism +hitlerist +hiv +hobo +hodgie +hoes +holestuffer +homicide +homobangers +honger +honkers +hook +hooker +hookers +hooters +hore +hork +horney +horniest +horny +horseshit +hosejob +hoser +hotpussy +hottotrot +hummer +hussy +hustler +hymen +hymie +iblowu +ikey +incest +insest +intercourse +interracial +intheass +inthebuff +jackass +jackoff +jackshit +japcrap +jerkoff +jew +jewish +jiga +jigaboo +jigg +jigga +jiggabo +jigger +jiggy +jihad +jijjiboo +jimfish +jism +jiz +jizim +jizjuice +jizm +jizz +jizzim +jizzum +joint +juggalo +jugs +junglebunny +kaffer +kaffir +kaffre +kafir +kanake +kigger +kike +kissass +kkk +knockers +kock +kondum +koom +koomer +koon +kotex +krap +krappy +kum +kumbubble +kumbullbe +kummer +kumming +kumquat +kums +kunilingus +kunnilingus +kunt +kys +kyke +lactate +lapdance +lesbayn +lesbin +lesbo +lez +lezbe +lezbefriends +lezbo +lezz +lezzo +liberal +libido +licker +lickme +limey +limpdick +limy +lingerie +liquor +livesex +loadedgun +loli +lolita +looser +lotion +lovebone +lovegoo +lovegun +lovejuice +lovemuscle +lovepistol +loverocket +lowlife +lsd +lubejob +luckycammeltoe +lugan +lynch +macaca +magicwand +mams +manhater +manpaste +mastabate +mastabater +masterbate +masterblaster +mastrabator +masturbate +masturbating +mattressprincess +meatbeatter +meatrack +meth +mgger +mggor +mickeyfinn +mideast +milf +mockey +mockie +mocky +mofo +moky +molest +molestation +molester +molestor +moneyshot +mooncricket +mormon +moslem +mosshead +mothafuck +mothafucka +mothafuckaz +mothafucked +mothafucker +mothafuckin +mothafucking +mothafuckings +motherfuck +motherfucked +motherfucker +motherfuckin +motherfucking +motherfuckings +motherlovebone +muff +muffdive +muffdiver +muffindiver +mufflikcer +mulatto +muncher +munt +nastybitch +nastyho +nastyslut +nastywhore +nazi +negro +negroes +negroid +negro's +nig +niger +nigg +nigga +niggah +niggaracci +niggard +niggarded +niggarding +niggardliness +niggardliness's +niggardly +niggards +niggard's +niggaz +nigger +niggerhead +niggerhole +niggers +nigger's +niggle +niggled +niggles +niggling +nigglings +niggor +niggur +niglet +nignog +nigr +nigra +nigre +nipple +nipplering +nittit +nlgger +nlggor +nofuckingway +nook +nookey +nookie +noonan +nooner +nudger +nutfucker +nymph +ontherag +oral +orga +orgasim +orgasm +orgies +orgy +osama +paki +palesimian +palestinian +pansies +pansy +panti +panties +payo +pearlnecklace +peck +pecker +peckerwood +pee +peehole +pee-pee +peepshow +peepshpw +pendy +penetration +peni5 +penile +penis +penises +penthouse +period +perv +phonesex +phuk +phuked +phuking +phukked +phukking +phungky +phuq +pi55 +picaninny +piccaninny +pickaninny +piker +pikey +piky +pimper +pimpjuic +pimpjuice +pimpsimp +pindick +piss +pissed +pisser +pisses +pisshead +pissin +pissing +pissoff +pistol +pixie +pixy +playboy +playgirl +pocha +pocho +pocketpool +pohm +polack +pom +pommie +pommy +poo +poon +poontang +poop +pooper +pooperscooper +pooping +poorwhitetrash +popimp +porchmonkey +porn +pornflick +pornking +porno +pornography +pornprincess +premature +pric +prick +prickhead +primetime +prostitute +protestant +pu55i +pu55y +pube +pubic +pubiclice +pud +pudboy +pudd +puddboy +puke +puntang +purinapricness +puss +pussie +pussies +pussy +pussycat +pussyeater +pussyfucker +pussylicker +pussylips +pussylover +pussypounder +pusy +quashie +queef +queer +quickie +quim +ra8s +rabbi +racial +racist +radical +radicals +raghead +randy +rape +raped +raper +rapist +rearend +rearentry +rectum +redlight +reestie +rentafuck +republican +rere +retard +retarded +ribbed +rigger +rimjob +rimming +roundeye +rump +sadis +sadom +samckdaddy +sandm +sandnigger +scag +scallywag +scat +schlong +screw +screwyou +scrotum +scum +semen +seppo +servant +sex +sexed +sexfarm +sexhound +sexhouse +sexing +sexkitten +sexpot +sexslave +sextogo +sextoy +sextoys +sexual +sexually +sexwhore +sexy +sexymoma +sexy-slim +shag +shaggin +shagging +shat +shav +shawtypimp +sheeney +shhit +shinola +shit +shitcan +shitdick +shite +shiteater +shited +shitface +shitfaced +shitfit +shitforbrains +shitfuck +shitfucker +shitfull +shithapens +shithappens +shithead +shithouse +shiting +shitlist +shitola +shitoutofluck +shits +shitstain +shitted +shitter +shitting +shitty +shortfuck +sissy +sixtynine +sixtyniner +skank +skankbitch +skankfuck +skankwhore +skanky +skankybitch +skankywhore +skinflute +skum +skumbag +slant +slanteye +slapper +slaughter +slav +slave +slavedriver +sleezebag +sleezeball +slideitin +slimebucket +slopehead +slopey +slopy +slut +sluts +slutt +slutting +slutty +slutwear +slutwhore +smack +smackthemonkey +smut +snatch +snatchpatch +snigger +sniggered +sniggering +sniggers +snigger's +snot +snowback +snownigger +sodom +sodomise +sodomite +sodomize +sodomy +sonofabitch +sonofbitch +sooty +spaghettibender +spaghettinigger +spank +spankthemonkey +sperm +spermacide +spermbag +spermhearder +spermherder +spic +spick +spig +spigotty +spik +spitter +splittail +spooge +spreadeagle +spunk +spunky +squaw +stagg +stiffy +strapon +stringer +stripclub +stroking +stupidfuck +stupidfucker +suckdick +sucker +suckme +suckmyass +suckmydick +suckmytit +suckoff +suicide +swallow +swallower +swalow +swastika +syphilis +taff +tampon +tang +tantra +tarbaby +tard +teat +teste +testicle +testicles +thicklips +thirdeye +thirdleg +threesome +threeway +timbernigger +tinkle +tit +titbitnipply +titfuck +titfucker +titfuckin +titjob +titlicker +titlover +tits +tittie +titties +titty +tongethruster +tonguethrust +tonguetramp +tortur +tosser +towelhead +trailertrash +tramp +trannie +tranny +transexual +transsexual +transvestite +triplex +trisexual +trots +tuckahoe +tunneloflove +turd +turnon +twink +twinkie +twobitwhore +uck +uk +unfuckable +upskirt +uptheass +upthebutt +urinary +urinate +urine +usama +uterus +vagina +vaginal +vatican +vibr +vibrater +vibrator +vietcong +violence +virgin +virginbreaker +vomit +vulva +wab +wank +wanker +wanking +waysted +weenie +weewee +welcher +welfare +wetb +wetback +wetspot +whacker +whash +whigger +whiskeydick +whiskydick +whit +whitenigger +whites +whitetrash +whitey +whiz +whop +whore +whorefucker +whorehouse +wigger +willie +williewanker +willy +wn +wog +wop +wtf +wuss +wuzzie +xtc +yankee +yellowman +zigabo +zipperhead From c8658013b6a7e3b2b22e8db6d14e2d593e0dfe02 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 16:44:38 +0100 Subject: [PATCH 268/404] Added helper functions for banned word detection --- src/extensions/dynamic/VoiceAdmin.py | 50 +++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 61cab94f..bc05ffa7 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -7,11 +7,12 @@ from client import EsportsBot from common.discord import primary_key_from_object -from common.io import load_cog_toml +from common.io import load_cog_toml, load_banned_words from database.gateway import DBSession from database.models import VoiceAdminChild, VoiceAdminParent COG_STRINGS = load_cog_toml(__name__) +BANNED_WORDS = load_banned_words() def channel_is_child(channel: VoiceChannel): @@ -44,6 +45,53 @@ def check_vc_name_allowed(new_name: str) -> bool: return True +def simple_leet_substitution(input_string: str) -> str: + leet_characters = { + "a": ["4", + "@"], + "b": ["8", + "ß", + "l3"], + "e": ["3"], + "g": ["6"], + "i": ["1", + "!"], + "r": ["2"], + "s": ["5"], + "t": ["7", + "+"], + "": ["_", + "-", + "'", + "|", + "~", + "\""] + } + + output_string = input_string + for replace_with, to_replace in leet_characters.items(): + for character in to_replace: + output_string = output_string.replace(character, replace_with) + + return output_string + + +def check_word_position(input_word: str, matched_banned_word: str) -> bool: + if input_word == matched_banned_word: + # The input word is the banned word + return False + + if input_word.index(matched_banned_word) == 0: + # The banned word is at the start of the input word + return False + + if input_word.index(matched_banned_word) == len(input_word) - len(matched_banned_word): + # The banned word is at the end of the input word + return False + + return True + + class VoiceAdmin(GroupCog, name=COG_STRINGS["vc_group_name"]): def __init__(self, bot: EsportsBot): From 39f6f7e2441a0829eb84e8b48749580ce4655b61 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 16:57:10 +0100 Subject: [PATCH 269/404] Implemented basic voice channel name checking --- src/extensions/dynamic/VoiceAdmin.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index bc05ffa7..0087ae0a 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -41,7 +41,15 @@ def member_is_owner(member: Member, channel: VoiceChannel, db_entry: VoiceAdminC def check_vc_name_allowed(new_name: str) -> bool: - # TOOD: Implement banned words list. + # TOOD: Remove hidden characters (zero width space, alternate white space characters) + trimmed_name = new_name.strip() + if trimmed_name in ('', ' '): + return True + + leet_sub_name = simple_leet_substitution(trimmed_name) + for word in BANNED_WORDS: + if word in leet_sub_name: + return check_word_position(leet_sub_name, word) return True From b0b7f926f6ecef4774f765880ff8d4ca935132d4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 17:08:41 +0100 Subject: [PATCH 270/404] Added RoleReact skeleton --- src/extensions/default/RoleReact.py | 19 +++++++++++++++++++ src/locale/RoleReact.toml | 1 + 2 files changed, 20 insertions(+) create mode 100644 src/extensions/default/RoleReact.py create mode 100644 src/locale/RoleReact.toml diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py new file mode 100644 index 00000000..8a842336 --- /dev/null +++ b/src/extensions/default/RoleReact.py @@ -0,0 +1,19 @@ +import logging + +from discord.ext.commands import Bot, GroupCog + +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) + + +class RoleReact(GroupCog, name=COG_STRINGS["react_group_name"]): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + +async def setup(bot: Bot): + await bot.add_cog(RoleReact(bot)) diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml new file mode 100644 index 00000000..9789b28a --- /dev/null +++ b/src/locale/RoleReact.toml @@ -0,0 +1 @@ +react_group_name = "reactmenu" \ No newline at end of file From 1cf04c0076cf721fe64c0c013f9a797b33fd3954 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 17:09:33 +0100 Subject: [PATCH 271/404] Updated TODO list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c21b42b7..c4dff5e5 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - [ ] LogChannelCog ⚠️ - [x] MusicCog ✅ Implemented as VCMusic - [ ] PingableRolesCog ❌ -- [ ] RoleReactCog ❌ +- [ ] RoleReactCog ✅ WIP implementation in RoleReact - [ ] TwitchCog ⚠️ - [ ] TwitterCog ❌ - [x] VoicemasterCog ✅ Implemented as VocieAdmin From 71e68052bb265c4943fddf221c082529c29874ed Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 17:46:26 +0100 Subject: [PATCH 272/404] Fixed missing end of line --- src/locale/VoiceAdmin.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml index 9258a16f..aa600714 100644 --- a/src/locale/VoiceAdmin.toml +++ b/src/locale/VoiceAdmin.toml @@ -23,7 +23,7 @@ vc_remove_parent_warn_already_child = "The Voice Channel selected cannot be a ch vc_get_parents_name = "get-parents" vc_get_parents_description = "Get the list of current parent Voice Channels." -vc_get_parents_empty = "There are currently no parent Voice channels in this server +vc_get_parents_empty = "There are currently no parent Voice channels in this server ⚠️" vc_get_parents_format = "Current parent Voice Channels in this server: \n {channels}" vc_rename_name = "rename" From 32b696073900c9de934763e11efec4867d01e166 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 17:46:47 +0100 Subject: [PATCH 273/404] Fixed incorrect path loading --- src/common/io.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/io.py b/src/common/io.py index ca1286e3..a82316da 100644 --- a/src/common/io.py +++ b/src/common/io.py @@ -62,7 +62,7 @@ def load_banned_words(): Returns: list: A list of banned words. """ - file_path = os.path.abspath(os.join(os.path.dirname(__file__), "..", "..", "banned_words.txt")) + file_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "banned_words.txt")) try: lines = [] with open(file_path, "rt") as file: @@ -71,4 +71,4 @@ def load_banned_words(): lines.append(line.strip()) return lines except FileNotFoundError: - return [] \ No newline at end of file + return [] From d26db7e4fcf20c5d8fb198c6e1a95e02adcd01a4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 17:50:42 +0100 Subject: [PATCH 274/404] Moved permission checks to be Cog wide --- src/extensions/default/AdminTools.py | 13 +++---------- src/extensions/dynamic/AutoRoles.py | 19 +++---------------- src/extensions/dynamic/EventTools.py | 18 ++---------------- 3 files changed, 8 insertions(+), 42 deletions(-) diff --git a/src/extensions/default/AdminTools.py b/src/extensions/default/AdminTools.py index 78bdedac..9dfea3c3 100644 --- a/src/extensions/default/AdminTools.py +++ b/src/extensions/default/AdminTools.py @@ -1,7 +1,7 @@ import logging from discord import Interaction -from discord.app_commands import (checks, command, default_permissions, describe, guild_only, rename) +from discord.app_commands import (command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog from client import EsportsBot @@ -10,6 +10,8 @@ COG_STRINGS = load_cog_toml(__name__) +@default_permissions(administrator=True) +@guild_only() class AdminTools(GroupCog, name=COG_STRINGS["admin_group_name"]): def __init__(self, bot: EsportsBot): @@ -31,9 +33,6 @@ def __init__(self, bot: EsportsBot): self.logger.info(f"{__name__} has been added as a Cog") @command(name=COG_STRINGS["admin_members_name"], description=COG_STRINGS["admin_members_description"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def get_member_count(self, interaction: Interaction): """The command used to get the current member count in the current guild/server. @@ -50,9 +49,6 @@ async def get_member_count(self, interaction: Interaction): return True @command(name=COG_STRINGS["admin_version_name"], description=COG_STRINGS["admin_version_description"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only async def get_bot_version(self, interaction: Interaction): """The command used to get the global current version of the Bot. @@ -65,9 +61,6 @@ async def get_bot_version(self, interaction: Interaction): @command(name=COG_STRINGS["admin_clear_name"], description=COG_STRINGS["admin_clear_description"]) @describe(count=COG_STRINGS["admin_clear_param_describe"]) @rename(count=COG_STRINGS["admin_clear_param_rename"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only async def clear_messages(self, interaction: Interaction, count: int = 5): """The command used to bulk delete messages in the current channel. Defaults to 5 messages if no value is given, and has a maximum value of 100. diff --git a/src/extensions/dynamic/AutoRoles.py b/src/extensions/dynamic/AutoRoles.py index 0a248ed4..64107085 100644 --- a/src/extensions/dynamic/AutoRoles.py +++ b/src/extensions/dynamic/AutoRoles.py @@ -2,7 +2,7 @@ from typing import List from discord import Color, Embed, Interaction, Member, Role -from discord.app_commands import (Transform, checks, command, default_permissions, describe, guild_only, rename) +from discord.app_commands import (Transform, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog from client import EsportsBot @@ -14,6 +14,8 @@ COG_STRINGS = load_cog_toml(__name__) +@default_permissions(administrator=True) +@guild_only() class AutoRoles(GroupCog, name=COG_STRINGS["roles_group_name"]): def __init__(self, bot: EsportsBot): @@ -41,9 +43,6 @@ async def assign_roles(self, member: Member): @command(name=COG_STRINGS["roles_set_list_name"], description=COG_STRINGS["roles_set_list_description"]) @describe(roles=COG_STRINGS["roles_set_list_param_describe"]) @rename(roles=COG_STRINGS["roles_set_list_param_rename"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[Role], RoleListTransformer]): """The command used to set the list of roles to give to members when the join the guild/server. @@ -94,9 +93,6 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ @command(name=COG_STRINGS["roles_add_role_name"], description=COG_STRINGS["roles_add_role_description"]) @describe(role=COG_STRINGS["roles_add_role_param_describe"]) @rename(role=COG_STRINGS["roles_add_role_param_rename"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def add_guild_role(self, interaction: Interaction, role: Role): """The command that adds a role to the list of roles, without overriding the currently configured roles. @@ -123,9 +119,6 @@ async def add_guild_role(self, interaction: Interaction, role: Role): @command(name=COG_STRINGS["roles_remove_role_name"], description=COG_STRINGS["roles_remove_role_description"]) @describe(role=COG_STRINGS["roles_remove_role_param_describe"]) @rename(role=COG_STRINGS["roles_remove_role_param_rename"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def remove_guild_role(self, interaction: Interaction, role: Role): """The command used to remove a role from the list of currently configured roles in a given guild/server. @@ -149,9 +142,6 @@ async def remove_guild_role(self, interaction: Interaction, role: Role): return True @command(name=COG_STRINGS["roles_get_list_name"], description=COG_STRINGS["roles_get_list_description"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def list_guild_roles(self, interaction: Interaction): """The command to get the current list of roles that are configured for a given guild/server. @@ -178,9 +168,6 @@ async def list_guild_roles(self, interaction: Interaction): return True @command(name=COG_STRINGS["roles_clear_list_name"], description=COG_STRINGS["roles_clear_list_description"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def clear_guild_roles(self, interaction: Interaction): """The command used to entirely clear the list of Roles for a given guild/server. diff --git a/src/extensions/dynamic/EventTools.py b/src/extensions/dynamic/EventTools.py index ba572fc0..4c8d24e3 100644 --- a/src/extensions/dynamic/EventTools.py +++ b/src/extensions/dynamic/EventTools.py @@ -22,7 +22,6 @@ Choice, Transform, autocomplete, - checks, choices, command, default_permissions, @@ -179,6 +178,8 @@ async def schedule_event( return discord_event +@default_permissions(administrator=True) +@guild_only() class EventTools(GroupCog, name=COG_STRINGS["events_group_name"]): def __init__(self, bot: EsportsBot): @@ -444,9 +445,6 @@ async def on_interaction(self, interaction: Interaction): value=TIMEZONES.get(x).get("_alias")) for x in TIMEZONES] ) @autocomplete(event_colour=ColourTransformer.autocomplete) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def create_event( self, interaction: Interaction, @@ -536,9 +534,6 @@ async def create_event( @describe(event_id=COG_STRINGS["events_open_event_event_id_describe"], ) @rename(event_id=COG_STRINGS["events_open_event_event_id_rename"], ) @autocomplete(event_id=ActiveEventTransformer.autocomplete) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def open_event(self, interaction: Interaction, event_id: str): await interaction.response.defer(ephemeral=True) if not event_id.isdigit(): @@ -587,9 +582,6 @@ async def open_event(self, interaction: Interaction, event_id: str): clear_messages=COG_STRINGS["events_close_events_clear_messages_rename"], ) @autocomplete(event_id=ActiveEventTransformer.autocomplete) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def close_event(self, interaction: Interaction, event_id: str, archive: bool = True, clear_messages: bool = False): await interaction.response.defer(ephemeral=True) if not event_id.isdigit(): @@ -656,9 +648,6 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo value=TIMEZONES.get(x).get("_alias")) for x in TIMEZONES] ) @autocomplete(event_id=ArchivedEventTransformer.autocomplete) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def reschedule_event( self, interaction: Interaction, @@ -744,9 +733,6 @@ async def reschedule_event( @describe(event_id=COG_STRINGS["events_remove_event_event_id_describe"]) @rename(event_id=COG_STRINGS["events_remove_event_event_id_rename"], ) @autocomplete(event_id=EventTransformer.autocomplete) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def remove_event(self, interaction: Interaction, event_id: str): await interaction.response.defer(ephemeral=True) From 2cef09dbd58c4937d5ee6c8e8ed9dd75da2a99d4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 17:56:45 +0100 Subject: [PATCH 275/404] Added skeleton command structure --- src/extensions/default/RoleReact.py | 33 +++++++++++++++++++++++++++++ src/locale/RoleReact.toml | 14 +++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 8a842336..be0eca03 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,12 +1,17 @@ import logging +from discord import Interaction +from discord.app_commands import (command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog from common.io import load_cog_toml COG_STRINGS = load_cog_toml(__name__) +ROLE_REACT_INTERACTION_PREFIX = f"{__name__}.interaction" +@default_permissions(administrator=True) +@guild_only() class RoleReact(GroupCog, name=COG_STRINGS["react_group_name"]): def __init__(self, bot: Bot): @@ -14,6 +19,34 @@ def __init__(self, bot: Bot): self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") + @GroupCog.listener() + async def on_interaction(self, interaction: Interaction): + pass + + @command(name=COG_STRINGS["react_create_menu_name"], description=COG_STRINGS["react_create_menu_description"]) + @describe() + @rename() + async def create_menu(self, interaction: Interaction): + pass + + @command(name=COG_STRINGS["react_add_item_name"], description=COG_STRINGS["react_add_item_description"]) + @describe() + @rename() + async def add_item(self, interaction: Interaction): + pass + + @command(name=COG_STRINGS["react_remove_item_name"], description=COG_STRINGS["react_remove_item_description"]) + @describe() + @rename() + async def remove_item(self, interaction: Interaction): + pass + + @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) + @describe() + @rename() + async def delete_menu(self, interaction: Interaction): + pass + async def setup(bot: Bot): await bot.add_cog(RoleReact(bot)) diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index 9789b28a..db8622b4 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -1 +1,13 @@ -react_group_name = "reactmenu" \ No newline at end of file +react_group_name = "reactmenu" + +react_create_menu_name = "create" +react_create_menu_description = "Creates a new role react menu." + +react_add_item_name = "add-role" +react_add_item_description = "Add a role to an existing menu." + +react_remove_item_name = "remove-role" +react_remove_item_description = "Remove a role from an existing menu." + +react_delete_menu_name = "delete" +react_delete_menu_description = "Deletes an existing menu." \ No newline at end of file From dbfecd42d5960bea44506fc09305c82fd69545da Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 17:58:02 +0100 Subject: [PATCH 276/404] Updated command group names for clarity --- README.md | 10 +++++----- src/locale/AutoRoles.toml | 2 +- src/locale/RoleReact.toml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c4dff5e5..19900d70 100644 --- a/README.md +++ b/README.md @@ -100,24 +100,24 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Environment Variable: `ENABLE_AUTOROLES` -#### /roles set-list \<One or many roles mentioned\> +#### /autoroles set-list \<One or many roles mentioned\> - Sets the roles to be given to new users when they join the guild/server. - If one or more the of the roles are valid, any roles previously configured will be removed. -#### /roles add-role \<role\> +#### /autoroles add-role \<role\> - Adds a role to the list of roles without overriding the currently configured roles. -#### /roles remove-role \<role\> +#### /autoroles remove-role \<role\> - Removes a role from the list of currently configured roles. -#### /roles get-list +#### /autoroles get-list - Gets the list of currently configured AutoRoles. -#### /roles clear-list +#### /autoroles clear-list - Clears all roles from the list of configured AutoRoles. diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml index 06137b03..95f28881 100644 --- a/src/locale/AutoRoles.toml +++ b/src/locale/AutoRoles.toml @@ -1,4 +1,4 @@ -roles_group_name = "roles" +roles_group_name = "autoroles" roles_set_list_name = "set-list" roles_set_list_description = "Set the list of automatically applied roles when a user joins." diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index db8622b4..88693268 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -1,4 +1,4 @@ -react_group_name = "reactmenu" +react_group_name = "reactroles" react_create_menu_name = "create" react_create_menu_description = "Creates a new role react menu." From 0cc4e6ed6f154e918138ecbed0ec237aee942307 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 18:12:24 +0100 Subject: [PATCH 277/404] Split VoiceAdmin into admin and user Cogs --- README.md | 4 ++-- src/extensions/dynamic/VoiceAdmin.py | 35 ++++++++++++++++------------ src/locale/VoiceAdmin.toml | 1 + 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 19900d70..ce187e11 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,11 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Current Commands: -#### /voice set-parent \<voice-channel\> +#### /voice-admin set-parent \<voice-channel\> - Set a Voice Channel to be a parent Voice Channel. -#### /voice remove-parent \<voice-channel\> +#### /voice-admin remove-parent \<voice-channel\> - Remove a Voice Channel from being a parent Voice Channel. diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 0087ae0a..8242099b 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -1,7 +1,7 @@ import logging from discord import Interaction, Member, VoiceChannel, VoiceState -from discord.app_commands import (checks, command, default_permissions, describe, guild_only, rename) +from discord.app_commands import (command, default_permissions, describe, guild_only, rename) from discord.errors import Forbidden from discord.ext.commands import Bot, GroupCog @@ -100,7 +100,9 @@ def check_word_position(input_word: str, matched_banned_word: str) -> bool: return True -class VoiceAdmin(GroupCog, name=COG_STRINGS["vc_group_name"]): +@default_permissions(administrator=True) +@guild_only() +class VoiceAdmin(GroupCog, name=COG_STRINGS["vc_admin_group_name"]): def __init__(self, bot: EsportsBot): """VoiceAdmin cog is used to dynamically create and manage Voice Channels, @@ -115,7 +117,7 @@ def __init__(self, bot: EsportsBot): """ self.bot = bot self.logger = logging.getLogger(__name__) - self.logger.info(f"{__name__} has been added as a Cog") + self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") @GroupCog.listener() async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): @@ -205,9 +207,6 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: @command(name=COG_STRINGS["vc_set_parent_name"], description=COG_STRINGS["vc_set_parent_description"]) @describe(channel=COG_STRINGS["vc_set_parent_param_describe"]) @rename(channel=COG_STRINGS["vc_set_parent_param_rename"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def set_parent_channel(self, interaction: Interaction, channel: VoiceChannel): """The command used to set a given Voice Channel to be a parent Voice Channel. @@ -246,9 +245,6 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann @command(name=COG_STRINGS["vc_remove_parent_name"], description=COG_STRINGS["vc_remove_parent_description"]) @describe(channel=COG_STRINGS["vc_remove_parent_param_describe"]) @rename(channel=COG_STRINGS["vc_remove_parent_param_rename"]) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() async def remove_parent_channel(self, interaction: Interaction, channel: VoiceChannel): """The command used to stop a channel from being a parent Voice Channel. @@ -272,8 +268,21 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh ) return True + +@guild_only() +class VoiceAdminUser(GroupCog, name=COG_STRINGS["vc_group_name"]): + + def __init__(self, bot: Bot): + """VoiceAdminUser cog is used to manage the user facing commands for the VoiceAdmin cog. + + Args: + bot (Bot): The instance of the bot to attach the cog to. + """ + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") + @command(name=COG_STRINGS["vc_get_parents_name"], description=COG_STRINGS["vc_get_parents_description"]) - @guild_only() async def get_parent_channels(self, interaction: Interaction): """The command used to get a list of the currently set parent Voice Channels in the current guild/server. @@ -304,7 +313,6 @@ async def get_parent_channels(self, interaction: Interaction): ) @describe(new_name=COG_STRINGS["vc_rename_param_describe"]) @rename(new_name=COG_STRINGS["vc_rename_param_rename"]) - @guild_only() async def rename_channel(self, interaction: Interaction, new_name: str = ""): """The command users can use to rename their child Voice Channels. @@ -362,7 +370,6 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): name=COG_STRINGS["vc_lock_name"], description=f"{COG_STRINGS['vc_lock_description']} {COG_STRINGS['vc_must_be_owner']}" ) - @guild_only() async def lock_channel(self, interaction: Interaction): """The command that allows users to lock who can join their child Voice Channels. It will set the members who are allowed to join the child Voice Channel to those who are @@ -438,7 +445,6 @@ async def lock_channel(self, interaction: Interaction): name=COG_STRINGS["vc_unlock_name"], description=f"{COG_STRINGS['vc_unlock_description']} {COG_STRINGS['vc_must_be_owner']}" ) - @guild_only() async def unlock_channel(self, interaction: Interaction): """The command users can use to re-allow anyone to join their child Voice Channels. @@ -481,7 +487,6 @@ async def unlock_channel(self, interaction: Interaction): ) @describe(user_limit=COG_STRINGS["vc_limit_param_describe"]) @rename(user_limit=COG_STRINGS["vc_limit_param_rename"]) - @guild_only() async def limit_channel(self, interaction: Interaction, user_limit: int = 0): """The command that allows users to set a member count limit on their child Voice Channels. If no user limit is provided, the current number of members in the channel is set as the limit. @@ -529,7 +534,6 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): name=COG_STRINGS["vc_unlimit_name"], description=f"{COG_STRINGS['vc_unlimit_description']} {COG_STRINGS['vc_must_be_owner']}" ) - @guild_only() async def unlimit_channel(self, interaction: Interaction): """The command that allows users to remove the member count limit on their child Voice Channels. @@ -567,3 +571,4 @@ async def unlimit_channel(self, interaction: Interaction): async def setup(bot: Bot): await bot.add_cog(VoiceAdmin(bot)) + await bot.add_cog(VoiceAdminUser(bot)) diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml index aa600714..7501bceb 100644 --- a/src/locale/VoiceAdmin.toml +++ b/src/locale/VoiceAdmin.toml @@ -1,4 +1,5 @@ vc_group_name = "voice" +vc_admin_group_name = "voice-admin" default_vc_name = "{name}'s VC" vc_limited_icon_with_delimited = "​📌​" From 5c83137bebdcd733543a60d00b5cb5272ad986f7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 18:22:35 +0100 Subject: [PATCH 278/404] Fixed an issue when parent voice channel not in category --- src/extensions/dynamic/VoiceAdmin.py | 31 +++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 8242099b..1a9f1fdd 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -143,7 +143,16 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: if not channel_is_child(before.channel): return - if not before.channel.category.permissions_for(before.channel.guild.me).manage_channels: + if not before.channel.category and not member.guild.me.guild_permissions.manage_channels: + self.logger.error( + f"Missing permission `manage_channels` for category {before.channel.category.name} " + f"(channelid - {before.channel.category.id}) in guild {before.channel.guild.name} " + f"(guildid - {before.channel.guild.id})" + ) + return + elif before.channel.category and not before.channel.category.permissions_for( + before.channel.guild.me + ).manage_channels: self.logger.error( f"Missing permission `manage_channels` for category {before.channel.category.name} " f"(channelid - {before.channel.category.id}) in guild {before.channel.guild.name} " @@ -177,7 +186,14 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: if not channel_is_parent(after.channel): return - if not after.channel.category.permissions_for(after.channel.guild.me).manage_channels: + if not after.channel.category and not member.guild.me.guild_permissions.manage_channels: + self.logger.error( + f"Missing permission `manage_channels` for category {after.channel.category.name} " + f"(channelid - {after.channel.category.id}) in guild {after.channel.guild.name} " + f"(guildid - {after.channel.guild.id})" + ) + return + elif after.channel.category and not after.channel.category.permissions_for(after.channel.guild.me).manage_channels: self.logger.error( f"Missing permission `manage_channels` for category {after.channel.category.name} " f"(channelid - {after.channel.category.id}) in guild {after.channel.guild.name} " @@ -185,9 +201,14 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: ) return - new_child_channel: VoiceChannel = await after.channel.category.create_voice_channel( - name=f"{member.display_name}'s VC" - ) + if after.channel.category: + new_child_channel: VoiceChannel = await after.channel.category.create_voice_channel( + name=f"{member.display_name}'s VC" + ) + else: + new_child_channel: VoiceChannel = await after.channel.guild.create_voice_channel( + name=f"{member.display_name}'s VC" + ) db_entry: VoiceAdminChild = VoiceAdminChild( primary_key=primary_key_from_object(new_child_channel), guild_id=new_child_channel.guild.id, From 45c291f829833db17c4914b95642cc87564d4b69 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 18:34:42 +0100 Subject: [PATCH 279/404] Fixed locking/unlocking issues when child voice channel not in category --- src/extensions/dynamic/VoiceAdmin.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 1a9f1fdd..43595b57 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -1,6 +1,6 @@ import logging -from discord import Interaction, Member, VoiceChannel, VoiceState +from discord import Interaction, Member, VoiceChannel, VoiceState, PermissionOverwrite from discord.app_commands import (command, default_permissions, describe, guild_only, rename) from discord.errors import Forbidden from discord.ext.commands import Bot, GroupCog @@ -444,6 +444,18 @@ async def lock_channel(self, interaction: Interaction): f"(guildid - {voice_channel.guild.id} | channelid - {voice_channel.id}" ) + try: + await voice_channel.set_permissions( + voice_channel.guild.default_role, + overwrite=PermissionOverwrite(speak=False, + connect=False) + ) + except Forbidden: + self.logger.error( + f"Unable to change permissions for {voice_channel.guild.me.top_role.name} Role for child Voice channel " + f"(guildid - {voice_channel.guild.id} | channelid - {voice_channel.id}" + ) + members = voice_channel.members for member in members: try: @@ -492,12 +504,14 @@ async def unlock_channel(self, interaction: Interaction): if not db_entry.is_locked: if not voice_channel.permissions_synced: await voice_channel.edit(sync_permissions=True) + await voice_channel.set_permissions(voice_channel.guild.default_role, overwrite=None) await interaction.followup.send(COG_STRINGS["vc_unlock_warn_not_locked"], ephemeral=True) return False db_entry.is_locked = False DBSession.update(db_entry) await voice_channel.edit(sync_permissions=True) + await voice_channel.set_permissions(voice_channel.guild.default_role, overwrite=None) await interaction.followup.send(COG_STRINGS["vc_unlock_success"], ephemeral=self.bot.only_ephemeral) return True From f16d611bb838d0e75a8be9d37c528b3623a898d9 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 18:45:02 +0100 Subject: [PATCH 280/404] Split VCMusic into admin and user Cogs --- src/extensions/dynamic/VCMusic.py | 221 +++++++++++++++--------------- src/locale/VCMusic.toml | 1 + 2 files changed, 108 insertions(+), 114 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 343253f9..145de08b 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -24,17 +24,7 @@ VoiceClient, VoiceState ) -from discord.app_commands import ( - Range, - Transform, - autocomplete, - checks, - command, - default_permissions, - describe, - guild_only, - rename -) +from discord.app_commands import (Range, Transform, autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext import tasks from discord.ext.commands import Bot, GroupCog from discord.ui import Button, Modal, TextInput, View @@ -48,6 +38,7 @@ COG_STRINGS = load_cog_toml(__name__) AUTHOR_ID = 244050529271939073 +CURRENT_AUTHOR = "fuxticks#1809" QUERY_RESULT_LIMIT = 15 INACTIVE_TIMEOUT = 60 EMBED_IMAGE_URL = os.getenv("MUSIC_DEFAULT_IMAGE") @@ -596,21 +587,119 @@ def create_music_actionbar(is_paused: bool = True) -> View: return view -class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): +@default_permissions(administrator=True) +@guild_only() +class VCMusicAdmin(GroupCog, name=COG_STRINGS["music_admin_group_name"]): def __init__(self, bot: Bot): self.bot = bot - self.author = "fuxticks" - self.active_players: dict[int, GuildMusicPlayer] = {} - self.playing: list[int] = [] - self.inactive: dict[int, datetime] = {} self.logger = logging.getLogger(__name__) - self.logger.info(f"{__name__} has been added as a Cog") + self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") @GroupCog.listener() async def on_ready(self): self.update_author.start() + @tasks.loop(hours=12) + async def update_author(self): + """Ensure that the author we acquired is still up to date + + Returns: + bool: True if the a user with ID of AUTHOR_ID is found else False. + """ + new_author = await self.bot.fetch_user(AUTHOR_ID) + if new_author: + CURRENT_AUTHOR = new_author + self.logger.info(f"Found current discord tag of VCMusic: {CURRENT_AUTHOR}") + return True + self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {CURRENT_AUTHOR}") + return False + + @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) + @describe( + channel=COG_STRINGS["music_set_channel_channel_describe"], + clear_messages=COG_STRINGS["music_set_channel_clear_messages_describe"], + embed_color=COG_STRINGS["music_set_channel_embed_color_describe"], + read_only=COG_STRINGS["music_set_channel_read_only_describe"] + ) + @rename( + channel=COG_STRINGS["music_set_channel_channel_rename"], + clear_messages=COG_STRINGS["music_set_channel_clear_messages_rename"], + embed_color=COG_STRINGS["music_set_channel_embed_color_rename"], + read_only=COG_STRINGS["music_set_channel_read_only_rename"] + ) + @autocomplete(embed_color=ColourTransformer.autocomplete) + async def set_channel( + self, + interaction: Interaction, + channel: TextChannel, + clear_messages: bool = False, + embed_color: Transform[Color, + ColourTransformer] = Color(0xd462fd), + read_only: bool = True + ): + """The command used to set a given channel as the defined Music Channel. This can be used to reset a channel + if something has gone wrong or to update the color of the embed. + + Args: + interaction (Interaction): The interaction of the command + channel (TextChannel): The channel to set as the music channel. + clear_messages (bool, optional): If the messages in the channel should be cleared. Defaults to False. + embed_color (Transform[Color, ColourTransformer], optional): The color to use for the embed. + Defaults to Color(0xd462fd). + read_only (bool, optional): If the music channel should be read only. Users can interact with the music bot via + the buttons. Defaults to True. + """ + await interaction.response.defer(ephemeral=True) + + if clear_messages: + await channel.purge(before=interaction.created_at) + + embed = create_music_embed(embed_color, self.author) + view = create_music_actionbar() + + message = await channel.send(embed=embed, view=view) + + existing = DBSession.get(MusicChannels, guild_id=interaction.guild.id) + if existing: + existing.channel_id = channel.id + existing.message_id = message.id + DBSession.update(existing) + else: + new_entry = MusicChannels(guild_id=interaction.guild.id, channel_id=channel.id, message_id=message.id) + DBSession.create(new_entry) + + await interaction.followup.send( + content=COG_STRINGS["music_set_channel_success"].format(channel=channel.mention), + ephemeral=self.bot.only_ephemeral + ) + + if read_only: + await channel.set_permissions( + interaction.guild.default_role, + overwrite=PermissionOverwrite(read_messages=True, + send_messages=False, + view_channel=True) + ) + await channel.set_permissions( + interaction.guild.me, + overwrite=PermissionOverwrite(read_messages=True, + send_messages=True, + view_channel=True) + ) + + +@guild_only() +class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): + + def __init__(self, bot: Bot): + self.bot = bot + self.active_players: dict[int, GuildMusicPlayer] = {} + self.playing: list[int] = [] + self.inactive: dict[int, datetime] = {} + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") + @GroupCog.listener() async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState): """Used to check when if the bot has been moved to another channel or disconnected. Also used @@ -770,21 +859,6 @@ async def check_inactive(self): for guild in guilds_to_disconnect: await self.active_players.get(guild).voice_client.disconnect() - @tasks.loop(hours=12) - async def update_author(self): - """Ensure that the author we acquired is still up to date - - Returns: - bool: True if the a user with ID of AUTHOR_ID is found else False. - """ - new_author = await self.bot.fetch_user(AUTHOR_ID) - if new_author: - self.author = new_author - self.logger.info(f"Found current discord tag of VCAuthor: {self.author}") - return True - self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {self.author}") - return False - def check_valid_user(self, guild: Guild, user: Member) -> bool: """Checks if a given user is allowed to control the music bot at a given time. @@ -1312,82 +1386,7 @@ async def stop_playback(self, interaction: Interaction) -> bool: await respond_or_followup(COG_STRINGS["music_stopped_success"], interaction, ephemeral=True) return True - @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) - @describe( - channel=COG_STRINGS["music_set_channel_channel_describe"], - clear_messages=COG_STRINGS["music_set_channel_clear_messages_describe"], - embed_color=COG_STRINGS["music_set_channel_embed_color_describe"], - read_only=COG_STRINGS["music_set_channel_read_only_describe"] - ) - @rename( - channel=COG_STRINGS["music_set_channel_channel_rename"], - clear_messages=COG_STRINGS["music_set_channel_clear_messages_rename"], - embed_color=COG_STRINGS["music_set_channel_embed_color_rename"], - read_only=COG_STRINGS["music_set_channel_read_only_rename"] - ) - @autocomplete(embed_color=ColourTransformer.autocomplete) - @default_permissions(administrator=True) - @checks.has_permissions(administrator=True) - @guild_only() - async def set_channel( - self, - interaction: Interaction, - channel: TextChannel, - clear_messages: bool = False, - embed_color: Transform[Color, - ColourTransformer] = Color(0xd462fd), - read_only: bool = True - ): - """The command used to set a given channel as the defined Music Channel. This can be used to reset a channel - if something has gone wrong or to update the color of the embed. - - Args: - interaction (Interaction): The interaction of the command - channel (TextChannel): The channel to set as the music channel. - clear_messages (bool, optional): If the messages in the channel should be cleared. Defaults to False. - embed_color (Transform[Color, ColourTransformer], optional): The color to use for the embed. Defaults to Color(0xd462fd). - read_only (bool, optional): If the music channel should be read only. Users can interact with the music bot via the buttons. Defaults to True. - """ - await interaction.response.defer(ephemeral=True) - - if clear_messages: - await channel.purge(before=interaction.created_at) - - embed = create_music_embed(embed_color, self.author) - view = create_music_actionbar() - - message = await channel.send(embed=embed, view=view) - - existing = DBSession.get(MusicChannels, guild_id=interaction.guild.id) - if existing: - existing.channel_id = channel.id - existing.message_id = message.id - DBSession.update(existing) - else: - new_entry = MusicChannels(guild_id=interaction.guild.id, channel_id=channel.id, message_id=message.id) - DBSession.create(new_entry) - - await interaction.followup.send( - content=COG_STRINGS["music_set_channel_success"].format(channel=channel.mention), - ephemeral=self.bot.only_ephemeral - ) - - if read_only: - await channel.set_permissions( - interaction.guild.default_role, - overwrite=PermissionOverwrite(read_messages=True, - send_messages=False, - view_channel=True) - ) - await channel.set_permissions( - interaction.guild.me, - overwrite=PermissionOverwrite(read_messages=True, - send_messages=True, - view_channel=True) - ) - @command(name=COG_STRINGS["music_play_name"], description=COG_STRINGS["music_play_description"]) - @guild_only() async def play_command(self, interaction: Interaction): """The command used to either resume playback or start playback. Invokes the PLAY UserActionType handler. @@ -1397,7 +1396,6 @@ async def play_command(self, interaction: Interaction): return await self.resume_or_start_playback(interaction) @command(name=COG_STRINGS["music_pause_name"], description=COG_STRINGS["music_pause_description"]) - @guild_only() async def pause_command(self, interaction: Interaction): """The command used to pause playback. Invokes the PAUSE UserActionType handler. @@ -1407,7 +1405,6 @@ async def pause_command(self, interaction: Interaction): return await self.pause_playback(interaction) @command(name=COG_STRINGS["music_skip_name"], description=COG_STRINGS["music_skip_description"]) - @guild_only() async def skip_command(self, interaction: Interaction): """The command used to skip the current song. Invokes the SKIP UserActionType handler. @@ -1417,7 +1414,6 @@ async def skip_command(self, interaction: Interaction): return await self.skip_song_handler(interaction) @command(name=COG_STRINGS["music_add_name"], description=COG_STRINGS["music_add_description"]) - @guild_only() async def add_songs_command(self, interaction: Interaction): """The command to add songs to the queue. Invokes the ADD_SONG UserActionType interaction handler. @@ -1427,7 +1423,6 @@ async def add_songs_command(self, interaction: Interaction): return await self.add_interaction_hanlder(interaction) @command(name=COG_STRINGS["music_view_queue_name"], description=COG_STRINGS["music_view_queue_description"]) - @guild_only() async def view_queue(self, interaction: Interaction): """The command to view the current queue. Invokes the VIEW_QUEUE UserActionType interaction handler. @@ -1437,7 +1432,6 @@ async def view_queue(self, interaction: Interaction): return await self.get_current_queue(interaction) @command(name=COG_STRINGS["music_stop_name"], description=COG_STRINGS["music_stop_description"]) - @guild_only() async def stop_command(self, interaction: Interaction): """THe command to stop playback. Invokes the STOP UserActionType interaction handler. @@ -1449,7 +1443,6 @@ async def stop_command(self, interaction: Interaction): @command(name=COG_STRINGS["music_volume_name"], description=COG_STRINGS["music_volume_description"]) @describe(volume=COG_STRINGS["music_volume_volume_describe"]) @rename(volume=COG_STRINGS["music_volume_volume_rename"]) - @guild_only() async def set_volume(self, interaction: Interaction, volume: Range[int, 0, 100]): """The command to set the volume of the playback. @@ -1472,7 +1465,6 @@ async def set_volume(self, interaction: Interaction, volume: Range[int, 0, 100]) return True @command(name=COG_STRINGS["music_shuffle_name"], description=COG_STRINGS["music_shuffle_description"]) - @guild_only() async def shuffle_queue(self, interaction: Interaction): """The command to shuffle the queue. Invokes the SHUFFLE UserActionType interaction handler. @@ -1483,4 +1475,5 @@ async def shuffle_queue(self, interaction: Interaction): async def setup(bot: Bot): + await bot.add_cog(VCMusicAdmin(bot)) await bot.add_cog(VCMusic(bot)) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index 5d7194be..ece2a2cd 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -1,4 +1,5 @@ music_group_name = "music" +music_admin_group_name = "music-admin" music_thinking = "Music bot is thinking..." music_needs_setup = "The music channel has not been setup, please use the setup command to enable all features of the music bot ⚠️" music_generic_error = "An error occured! If this issue persists please contact an admin or {author} ❌" From 9cd795dba8dbcc27213aa5ad748b00b2dc300ef8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 21 Mar 2023 18:45:16 +0100 Subject: [PATCH 281/404] Fixed flake8 violations --- src/extensions/dynamic/VCMusic.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 145de08b..8071322a 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1281,7 +1281,11 @@ async def get_current_queue(self, interaction: Interaction) -> bool: current_queue = self.active_players.get(interaction.guild.id).queue current_song = self.active_players.get(interaction.guild.id).current_song - current_song_text = f"__Current Song__\n{COG_STRINGS['music_embed_title_idle'] if not current_song else current_song.title}" + current_song_text = "__Current Song__\n" + if current_song: + current_song_text += current_song.title + else: + current_song_text += COG_STRINGS['music_embed_title_idle'] QUEUE_CUTOFF = 15 if len(current_queue) > 2 * QUEUE_CUTOFF + 5: @@ -1328,7 +1332,7 @@ async def update_embed(self, guild_id: int) -> bool: Args: guild_id (int): The ID of the guild to update. - + Returns: bool: If the embed was able to be updated. """ From a5c85cc745bd09438e2fa034a83e356e75555cc7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 23 Mar 2023 13:28:20 +0100 Subject: [PATCH 282/404] Implemented basic creating empty role menus --- src/common/discord.py | 4 ++-- src/database/models.py | 17 ++++++++++++- src/extensions/default/RoleReact.py | 37 ++++++++++++++++++++++++----- src/locale/RoleReact.toml | 3 +++ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index 54e4bdd6..e78613d3 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import List, Union -from discord import Colour, Guild, Interaction, Role, ScheduledEvent +from discord import Colour, Guild, Interaction, Role, ScheduledEvent, Message from discord.abc import GuildChannel from discord.app_commands import Choice, Transformer @@ -78,7 +78,7 @@ async def get_role(guild: Guild, role_id: int): return role -def primary_key_from_object(object: Union[Role, GuildChannel, ScheduledEvent]): +def primary_key_from_object(object: Union[Role, GuildChannel, ScheduledEvent, Message]): return int(f"{object.guild.id % 1000}{object.id % 1000}") diff --git a/src/database/models.py b/src/database/models.py index 5e3bfcf0..51a1ef27 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -4,7 +4,15 @@ base = declarative_base() -__all__ = ["base", "VoiceAdminParent", "VoiceAdminChild", "AutoRolesConfig", "EventToolsEvents", "MusicChannels"] +__all__ = [ + "base", + "VoiceAdminParent", + "VoiceAdminChild", + "AutoRolesConfig", + "EventToolsEvents", + "MusicChannels", + "RoleReactMenus" +] class VoiceAdminParent(base): @@ -49,3 +57,10 @@ class MusicChannels(base): guild_id = Column(BigInteger, primary_key=True, nullable=False) channel_id = Column(BigInteger, nullable=False) message_id = Column(BigInteger, nullable=False) + + +class RoleReactMenus(base): + __tablename__ = "rolereact_menus" + primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + guild_id = Column(BigInteger, nullable=False) + message_id = Column(BigInteger, nullable=False) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index be0eca03..6c849edc 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,13 +1,17 @@ import logging -from discord import Interaction -from discord.app_commands import (command, default_permissions, describe, guild_only, rename) +from discord import Color, Embed, Interaction, Message +from discord.app_commands import (Transform, autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog +from common.discord import ColourTransformer, primary_key_from_object from common.io import load_cog_toml +from database.gateway import DBSession +from database.models import RoleReactMenus COG_STRINGS = load_cog_toml(__name__) ROLE_REACT_INTERACTION_PREFIX = f"{__name__}.interaction" +EMPTY_ROLE_MENU = COG_STRINGS["react_empty_menu"] @default_permissions(administrator=True) @@ -24,10 +28,31 @@ async def on_interaction(self, interaction: Interaction): pass @command(name=COG_STRINGS["react_create_menu_name"], description=COG_STRINGS["react_create_menu_description"]) - @describe() - @rename() - async def create_menu(self, interaction: Interaction): - pass + @describe(embed_color=COG_STRINGS["react_create_menu_embed_color_describe"]) + @rename(embed_color=COG_STRINGS["react_create_menu_embed_color_rename"]) + @autocomplete(color=ColourTransformer.autocomplete) + async def create_menu( + self, + interaction: Interaction, + embed_color: Transform[Color, + ColourTransformer] = Color.random(), + ): + embed = Embed( + title="Role Menu", + description="", + ) + embed.set_footer(text="Not yet configured...") + + message: Message = await interaction.channel.send(embed=embed) + p_key = primary_key_from_object(message) + db_item = RoleReactMenus(primary_key=p_key, guild_id=interaction.guild.id, message_id=message.id) + DBSession.create(db_item) + + embed.description = EMPTY_ROLE_MENU.format(message_id=message.id) + embed.set_footer(text=f"Role menu ID: {message.id}") + embed.color = embed_color + await message.edit(embed=embed) + await interaction.response.send_message("Menu created!", ephemeral=True) @command(name=COG_STRINGS["react_add_item_name"], description=COG_STRINGS["react_add_item_description"]) @describe() diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index 88693268..39b0c01a 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -1,7 +1,10 @@ react_group_name = "reactroles" +react_empty_menu = "This menu currently has no roles in it. Add roles with the command by using the id of `{message_id}`" react_create_menu_name = "create" react_create_menu_description = "Creates a new role react menu." +react_create_menu_embed_color_describe = "Sets the colour of the embed for the role menu." +react_create_menu_embed_color_rename = "embed-colour" react_add_item_name = "add-role" react_add_item_description = "Add a role to an existing menu." From 7593c620d9bf48e04a5dec575fdec5fa6d09ed39 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 23 Mar 2023 14:50:56 +0100 Subject: [PATCH 283/404] Implemented basic role adding to menu --- src/extensions/default/RoleReact.py | 48 +++++++++++++++++++++++++---- src/locale/RoleReact.toml | 8 +++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 6c849edc..aadc819c 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,8 +1,9 @@ import logging -from discord import Color, Embed, Interaction, Message +from discord import Color, Embed, Interaction, Message, Role from discord.app_commands import (Transform, autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog +from discord.ui import View, Select from common.discord import ColourTransformer, primary_key_from_object from common.io import load_cog_toml @@ -30,7 +31,7 @@ async def on_interaction(self, interaction: Interaction): @command(name=COG_STRINGS["react_create_menu_name"], description=COG_STRINGS["react_create_menu_description"]) @describe(embed_color=COG_STRINGS["react_create_menu_embed_color_describe"]) @rename(embed_color=COG_STRINGS["react_create_menu_embed_color_rename"]) - @autocomplete(color=ColourTransformer.autocomplete) + @autocomplete(embed_color=ColourTransformer.autocomplete) async def create_menu( self, interaction: Interaction, @@ -55,10 +56,45 @@ async def create_menu( await interaction.response.send_message("Menu created!", ephemeral=True) @command(name=COG_STRINGS["react_add_item_name"], description=COG_STRINGS["react_add_item_description"]) - @describe() - @rename() - async def add_item(self, interaction: Interaction): - pass + @describe( + message_id=COG_STRINGS["react_add_item_message_id_describe"], + role=COG_STRINGS["react_add_item_role_describe"], + emoji=COG_STRINGS["react_add_item_emoji_describe"], + description=COG_STRINGS["react_add_item_description_describe"] + ) + @rename( + message_id=COG_STRINGS["react_add_item_message_id_rename"], + role=COG_STRINGS["react_add_item_role_rename"], + emoji=COG_STRINGS["react_add_item_emoji_rename"], + description=COG_STRINGS["react_add_item_description_rename"] + ) + async def add_item( + self, + interaction: Interaction, + message_id: str, + role: Role, + emoji: str = None, + description: str = None + ): + await interaction.response.defer(ephemeral=True) + message = await interaction.channel.fetch_message(message_id) + message_embed = message.embeds[0] + no_roles = not message.components + + if no_roles: + message_embed.description = "**__Active Roles__**\n" + view = View(timeout=None) + menu = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=1) + else: + view = View.from_message(message, timeout=None) + menu = view.children[0] + view = view.clear_items() + + menu.max_values = len(menu.options) + 1 + menu.add_option(label=role.name, value=role.id, description=description, emoji=emoji) + view.add_item(menu) + message_embed.description += f"\n{emoji} {role.mention} - {description}" + await message.edit(embed=message_embed, view=view) @command(name=COG_STRINGS["react_remove_item_name"], description=COG_STRINGS["react_remove_item_description"]) @describe() diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index 39b0c01a..c3a36ae8 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -8,6 +8,14 @@ react_create_menu_embed_color_rename = "embed-colour" react_add_item_name = "add-role" react_add_item_description = "Add a role to an existing menu." +react_add_item_message_id_describe = "The ID of the menu to add to." +react_add_item_message_id_rename = "menu-id" +react_add_item_role_describe = "The role to add to the menu." +react_add_item_role_rename = "role" +react_add_item_emoji_describe = "The emoji to associate with the role (visual only)." +react_add_item_emoji_rename = "emoji" +react_add_item_description_describe = "The description of the role." +react_add_item_description_rename = "role-description" react_remove_item_name = "remove-role" react_remove_item_description = "Remove a role from an existing menu." From abe81e6186aec8d5c32bdf83aa5ee04876a768e8 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 24 Mar 2023 10:30:16 +0100 Subject: [PATCH 284/404] Added menu ID auto completion --- src/common/discord.py | 19 ++++++++++++++++++- src/extensions/default/RoleReact.py | 9 ++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index e78613d3..461147d4 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -2,10 +2,13 @@ from datetime import datetime from typing import List, Union -from discord import Colour, Guild, Interaction, Role, ScheduledEvent, Message +from discord import Colour, Guild, Interaction, Role, ScheduledEvent, Message, PartialEmoji from discord.abc import GuildChannel from discord.app_commands import Choice, Transformer +from database.models import RoleReactMenus +from database.gateway import DBSession + ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") @@ -199,6 +202,12 @@ async def transform(self, interaction: Interaction, input_string: str) -> Colour return Colour.default() +class EmojiTransformer(Transformer): + + async def transform(self, interaction: Interaction, value: str) -> PartialEmoji: + return PartialEmoji.from_str(value) + + def get_events(guild: Guild, event_dict: dict, value: str) -> List[Choice[str]]: filtered_events = [] guild_events = [event_dict.get(x) for x in event_dict if event_dict.get(x).guild_id == guild.id] @@ -243,3 +252,11 @@ class ArchivedEventTransformer(Transformer): async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: return get_events(interaction.guild, self.archived_events, value) + + +class RoleReactMenuTransformer(Transformer): + + async def autocomplete(self, interaction: Interaction, value: Union[int, float, str]) -> List[Choice[str]]: + guild_role_menus = DBSession.list(RoleReactMenus, guild_id=interaction.guild.id) + choices = [Choice(name=f"Role menu ID: {x.message_id}", value=str(x.message_id)) for x in guild_role_menus] + return choices \ No newline at end of file diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index aadc819c..cd45f526 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,11 +1,11 @@ import logging -from discord import Color, Embed, Interaction, Message, Role +from discord import Color, Embed, Interaction, Message, Role, PartialEmoji from discord.app_commands import (Transform, autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog from discord.ui import View, Select -from common.discord import ColourTransformer, primary_key_from_object +from common.discord import ColourTransformer, primary_key_from_object, RoleReactMenuTransformer, EmojiTransformer from common.io import load_cog_toml from database.gateway import DBSession from database.models import RoleReactMenus @@ -68,12 +68,14 @@ async def create_menu( emoji=COG_STRINGS["react_add_item_emoji_rename"], description=COG_STRINGS["react_add_item_description_rename"] ) + @autocomplete(message_id=RoleReactMenuTransformer.autocomplete) async def add_item( self, interaction: Interaction, message_id: str, role: Role, - emoji: str = None, + emoji: Transform[PartialEmoji, + EmojiTransformer] = None, description: str = None ): await interaction.response.defer(ephemeral=True) @@ -95,6 +97,7 @@ async def add_item( view.add_item(menu) message_embed.description += f"\n{emoji} {role.mention} - {description}" await message.edit(embed=message_embed, view=view) + await interaction.followup.send("Role added!") @command(name=COG_STRINGS["react_remove_item_name"], description=COG_STRINGS["react_remove_item_description"]) @describe() From 090a6e4772bb12d80beeee3644ce0eefd8b11acd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 24 Mar 2023 10:33:49 +0100 Subject: [PATCH 285/404] Menu ID filtering based on current input --- src/common/discord.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index 461147d4..7228e158 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -258,5 +258,11 @@ class RoleReactMenuTransformer(Transformer): async def autocomplete(self, interaction: Interaction, value: Union[int, float, str]) -> List[Choice[str]]: guild_role_menus = DBSession.list(RoleReactMenus, guild_id=interaction.guild.id) - choices = [Choice(name=f"Role menu ID: {x.message_id}", value=str(x.message_id)) for x in guild_role_menus] - return choices \ No newline at end of file + if value: + choices = [ + Choice(name=f"Role menu ID: {x.message_id}", + value=str(x.message_id)) for x in guild_role_menus if value in str(x.message_id) + ] + else: + choices = [Choice(name=f"Role menu ID: {x.message_id}", value=str(x.message_id)) for x in guild_role_menus] + return choices[:25] \ No newline at end of file From c52fdf9fe337723c86122ff6586e62908ab4eb7b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 24 Mar 2023 10:47:18 +0100 Subject: [PATCH 286/404] Added checks for valid react menu IDs --- src/extensions/default/RoleReact.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index cd45f526..000388ec 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,11 +1,11 @@ import logging -from discord import Color, Embed, Interaction, Message, Role, PartialEmoji +from discord import Color, Embed, Interaction, Message, Role, PartialEmoji, NotFound from discord.app_commands import (Transform, autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog from discord.ui import View, Select -from common.discord import ColourTransformer, primary_key_from_object, RoleReactMenuTransformer, EmojiTransformer +from common.discord import ColourTransformer, primary_key_from_object, RoleReactMenuTransformer, EmojiTransformer, respond_or_followup from common.io import load_cog_toml from database.gateway import DBSession from database.models import RoleReactMenus @@ -79,7 +79,17 @@ async def add_item( description: str = None ): await interaction.response.defer(ephemeral=True) - message = await interaction.channel.fetch_message(message_id) + try: + message = await interaction.channel.fetch_message(message_id) + except NotFound: + await respond_or_followup(f"Unable to find message with ID `{message_id}`", interaction, ephemeral=True) + return + + valid_message = DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=message.id) + if not valid_message: + await respond_or_followup(f"The given ID of `{message_id}` is not a role react menu", interaction, ephemeral=True) + return + message_embed = message.embeds[0] no_roles = not message.components @@ -97,7 +107,7 @@ async def add_item( view.add_item(menu) message_embed.description += f"\n{emoji} {role.mention} - {description}" await message.edit(embed=message_embed, view=view) - await interaction.followup.send("Role added!") + await respond_or_followup("Role added!", interaction, ephemeral=True) @command(name=COG_STRINGS["react_remove_item_name"], description=COG_STRINGS["react_remove_item_description"]) @describe() From be29f64932b88ef97802bbde1af80438e8d902ae Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 24 Mar 2023 10:57:40 +0100 Subject: [PATCH 287/404] Added checks for valid react menu IDs --- src/extensions/default/RoleReact.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 000388ec..91df3020 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,5 +1,7 @@ import logging +from typing import Union + from discord import Color, Embed, Interaction, Message, Role, PartialEmoji, NotFound from discord.app_commands import (Transform, autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog @@ -15,6 +17,21 @@ EMPTY_ROLE_MENU = COG_STRINGS["react_empty_menu"] +async def validate_message_id(interaction: Interaction, message_id: int) -> Union[None, Message]: + try: + message = await interaction.channel.fetch_message(message_id) + except NotFound: + await respond_or_followup(f"Unable to find message with ID `{message_id}`", interaction, ephemeral=True) + return None + + valid_message = DBSession.get(RoleReactMenus, guild_id=message.guild.id, message_id=message.id) + if not valid_message: + await respond_or_followup(f"The given ID of `{message_id}` is not a role react menu", interaction, ephemeral=True) + return None + + return message + + @default_permissions(administrator=True) @guild_only() class RoleReact(GroupCog, name=COG_STRINGS["react_group_name"]): @@ -79,17 +96,9 @@ async def add_item( description: str = None ): await interaction.response.defer(ephemeral=True) - try: - message = await interaction.channel.fetch_message(message_id) - except NotFound: - await respond_or_followup(f"Unable to find message with ID `{message_id}`", interaction, ephemeral=True) - return - - valid_message = DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=message.id) - if not valid_message: - await respond_or_followup(f"The given ID of `{message_id}` is not a role react menu", interaction, ephemeral=True) + message = await validate_message_id(interaction, message_id) + if not message: return - message_embed = message.embeds[0] no_roles = not message.components From db61adf67c74d370d7475d10c26d6d8798c827d4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 24 Mar 2023 11:44:31 +0100 Subject: [PATCH 288/404] Basic implementation of menu deletion --- src/extensions/default/RoleReact.py | 17 +++++++++++++---- src/locale/RoleReact.toml | 4 +++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 91df3020..4315dda8 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -125,10 +125,19 @@ async def remove_item(self, interaction: Interaction): pass @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) - @describe() - @rename() - async def delete_menu(self, interaction: Interaction): - pass + @describe(message_id=COG_STRINGS["react_delete_menu_message_id_describe"]) + @rename(message_id=COG_STRINGS["react_delete_menu_message_id_rename"]) + @autocomplete(message_id=RoleReactMenuTransformer.autocomplete) + async def delete_menu(self, interaction: Interaction, message_id: str): + await interaction.response.defer(ephemeral=True) + message = await validate_message_id(interaction, message_id) + if not message: + return + + db_item = DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=message.id) + DBSession.delete(db_item) + await message.delete() + await respond_or_followup(f"Role menu with id `{message.id}` deleted!", interaction, ephemeral=True) async def setup(bot: Bot): diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index c3a36ae8..c5d413b4 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -21,4 +21,6 @@ react_remove_item_name = "remove-role" react_remove_item_description = "Remove a role from an existing menu." react_delete_menu_name = "delete" -react_delete_menu_description = "Deletes an existing menu." \ No newline at end of file +react_delete_menu_description = "Deletes an existing menu." +react_delete_menu_message_id_describe = "The ID of the menu to delete." +react_delete_menu_message_id_rename = "menu-id" \ No newline at end of file From 936e85b13ca8d1ea28eebf463f070f7e71d8d988 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 24 Mar 2023 11:44:54 +0100 Subject: [PATCH 289/404] Updated role select option text --- src/extensions/default/RoleReact.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 4315dda8..8df6c2ac 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -112,9 +112,9 @@ async def add_item( view = view.clear_items() menu.max_values = len(menu.options) + 1 - menu.add_option(label=role.name, value=role.id, description=description, emoji=emoji) + menu.add_option(label=f"@{role.name}", value=role.id, description=description, emoji=emoji) view.add_item(menu) - message_embed.description += f"\n{emoji} {role.mention} - {description}" + message_embed.description += f"\n{emoji if emoji else ''} {role.mention} - {description if description else ''}" await message.edit(embed=message_embed, view=view) await respond_or_followup("Role added!", interaction, ephemeral=True) From cdf6932557f807ad2620459ba3ca43f22447a8a0 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 26 Mar 2023 16:20:57 +0200 Subject: [PATCH 290/404] Added RoleReactRoleTransformer to get roles from menu --- src/common/discord.py | 54 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/common/discord.py b/src/common/discord.py index 7228e158..5d82178b 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -5,6 +5,7 @@ from discord import Colour, Guild, Interaction, Role, ScheduledEvent, Message, PartialEmoji from discord.abc import GuildChannel from discord.app_commands import Choice, Transformer +from discord.ui import View from database.models import RoleReactMenus from database.gateway import DBSession @@ -254,6 +255,38 @@ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choic return get_events(interaction.guild, self.archived_events, value) +async def get_roles_from_select(interaction: Interaction, message_id: int) -> list[Role]: + message = await interaction.channel.fetch_message(message_id) + if not message.components: + return [] + + guild_roles = interaction.guild.roles + guild_roles = {str(x.id): x for x in guild_roles} + + view = View.from_message(message) + menu = view.children[0] + roles = [] + for option in menu.options: + role = guild_roles.get(option.value) + if role: + roles.append(role) + + return roles + + +def get_menu_id_from_args(interaction: Interaction) -> int: + interaction_options = {"options": []} + for item in interaction.data.get("options"): + if item.get("type") == 1: + interaction_options = item + break + + for argument in interaction_options.get("options"): + if argument.get("name") == "menu-id": + return argument.get("value") + return 0 + + class RoleReactMenuTransformer(Transformer): async def autocomplete(self, interaction: Interaction, value: Union[int, float, str]) -> List[Choice[str]]: @@ -265,4 +298,23 @@ async def autocomplete(self, interaction: Interaction, value: Union[int, float, ] else: choices = [Choice(name=f"Role menu ID: {x.message_id}", value=str(x.message_id)) for x in guild_role_menus] - return choices[:25] \ No newline at end of file + return choices[:25] + + +class RoleReactRoleTransformer(Transformer): + + async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: + menu_id = get_menu_id_from_args(interaction) + if not DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=menu_id): + return [] + menu_roles = await get_roles_from_select(interaction, menu_id) + if value: + choices = [ + Choice(name=f"{'' if x.name.startswith('@') else '@'}{x.name}", + value=str(x.id)) for x in menu_roles if value.replace("@", + "") in x.name + ] + else: + choices = [Choice(name=f"{'' if x.name.startswith('@') else '@'}{x.name}", value=str(x.id)) for x in menu_roles] + + return choices[:25] From e5014e6791c5f21a5567499303dace07759306bb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 26 Mar 2023 16:21:19 +0200 Subject: [PATCH 291/404] Partially implemented removal of roles from menu --- src/extensions/default/RoleReact.py | 73 +++++++++++++++++++++++------ src/locale/RoleReact.toml | 4 ++ 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 8df6c2ac..77b4a660 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -7,7 +7,7 @@ from discord.ext.commands import Bot, GroupCog from discord.ui import View, Select -from common.discord import ColourTransformer, primary_key_from_object, RoleReactMenuTransformer, EmojiTransformer, respond_or_followup +from common.discord import ColourTransformer, get_roles_from_select, primary_key_from_object, RoleReactMenuTransformer, EmojiTransformer, respond_or_followup, RoleReactRoleTransformer from common.io import load_cog_toml from database.gateway import DBSession from database.models import RoleReactMenus @@ -32,6 +32,28 @@ async def validate_message_id(interaction: Interaction, message_id: int) -> Unio return message +def no_roles_embed(title: str = "Role Menu", embed_color: Color = Color.default(), message_id: int = None) -> Embed: + description = "" if not message_id else EMPTY_ROLE_MENU.format(message_id=message_id) + embed = Embed(title=title, description=description, color=embed_color) + if message_id: + embed.set_footer(text=f"Menu ID: {message_id}") + else: + embed.set_footer(text="Not yet configured...") + + return embed + + +def exclude_roles_from_select(role_exclude_list: list[str], menu: Select) -> Select: + exisiting_options = menu.options + menu.options = [] + + for option in exisiting_options: + if option.value not in role_exclude_list: + menu.add_option(label=option.label, value=option.value, description=option.description, emoji=option.emoji) + + return menu + + @default_permissions(administrator=True) @guild_only() class RoleReact(GroupCog, name=COG_STRINGS["react_group_name"]): @@ -55,20 +77,14 @@ async def create_menu( embed_color: Transform[Color, ColourTransformer] = Color.random(), ): - embed = Embed( - title="Role Menu", - description="", - ) - embed.set_footer(text="Not yet configured...") + embed = no_roles_embed(color=embed_color) message: Message = await interaction.channel.send(embed=embed) p_key = primary_key_from_object(message) db_item = RoleReactMenus(primary_key=p_key, guild_id=interaction.guild.id, message_id=message.id) DBSession.create(db_item) - embed.description = EMPTY_ROLE_MENU.format(message_id=message.id) - embed.set_footer(text=f"Role menu ID: {message.id}") - embed.color = embed_color + embed = no_roles_embed(color=embed_color) await message.edit(embed=embed) await interaction.response.send_message("Menu created!", ephemeral=True) @@ -112,17 +128,46 @@ async def add_item( view = view.clear_items() menu.max_values = len(menu.options) + 1 - menu.add_option(label=f"@{role.name}", value=role.id, description=description, emoji=emoji) + menu.add_option(label=f"@{role.name}", value=str(role.id), description=description, emoji=emoji) view.add_item(menu) message_embed.description += f"\n{emoji if emoji else ''} {role.mention} - {description if description else ''}" await message.edit(embed=message_embed, view=view) await respond_or_followup("Role added!", interaction, ephemeral=True) @command(name=COG_STRINGS["react_remove_item_name"], description=COG_STRINGS["react_remove_item_description"]) - @describe() - @rename() - async def remove_item(self, interaction: Interaction): - pass + @describe( + message_id=COG_STRINGS["react_remove_item_message_id_describe"], + role_id=COG_STRINGS["react_remove_item_role_id_describe"] + ) + @rename( + message_id=COG_STRINGS["react_remove_item_message_id_rename"], + role_id=COG_STRINGS["react_remove_item_role_id_rename"] + ) + @autocomplete(message_id=RoleReactMenuTransformer.autocomplete, role_id=RoleReactRoleTransformer.autocomplete) + async def remove_item(self, interaction: Interaction, message_id: str, role_id: str): + await interaction.response.defer(ephemeral=True) + message = await validate_message_id(interaction, message_id) + if not message: + return + + message_embed = message.embeds[0] + no_roles = not message.components + if no_roles: + await respond_or_followup("No roles to remove!", interaction, ephemeral=True) + return + + view = View.from_message(message, timeout=None) + menu = view.children[0] + view = view.clear_items() + menu = exclude_roles_from_select(role_id, menu) + # TODO: Check number of options > 0 + new_description = "**__Active Roles__**\n" + for option in menu.options: + new_description += f"\n{option.emoji if option.emoji else ''} <&@{option.value}> - {option.description if option.description else ''}" + view.add_item(menu) + message_embed.description = new_description + await message.edit(embed=message_embed, view=view) + await respond_or_followup("Successfully removed role!", interaction, ephemeral=True) @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) @describe(message_id=COG_STRINGS["react_delete_menu_message_id_describe"]) diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index c5d413b4..a6c1b701 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -19,6 +19,10 @@ react_add_item_description_rename = "role-description" react_remove_item_name = "remove-role" react_remove_item_description = "Remove a role from an existing menu." +react_remove_item_message_id_describe = "The ID of the menu to remove the role from." +react_remove_item_message_id_rename = "menu-id" +react_remove_item_role_id_describe = "The role to remove from the menu." +react_remove_item_role_id_rename = "role" react_delete_menu_name = "delete" react_delete_menu_description = "Deletes an existing menu." From 6b35b14e24595e3f8913b7eb1f2e6ab3ac07865a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 28 Mar 2023 11:08:53 +0200 Subject: [PATCH 292/404] Added check for empty role list --- src/extensions/default/RoleReact.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 77b4a660..eff46955 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -160,12 +160,15 @@ async def remove_item(self, interaction: Interaction, message_id: str, role_id: menu = view.children[0] view = view.clear_items() menu = exclude_roles_from_select(role_id, menu) - # TODO: Check number of options > 0 - new_description = "**__Active Roles__**\n" - for option in menu.options: - new_description += f"\n{option.emoji if option.emoji else ''} <&@{option.value}> - {option.description if option.description else ''}" - view.add_item(menu) - message_embed.description = new_description + if not menu.options: + message_embed = no_roles_embed(embed_color=message_embed.color, message_id=message_id) + else: + new_description = "**__Active Roles__**\n" + for option in menu.options: + new_description += f"\n{option.emoji if option.emoji else ''} <&@{option.value}> - {option.description if option.description else ''}" + view.add_item(menu) + message_embed.description = new_description + await message.edit(embed=message_embed, view=view) await respond_or_followup("Successfully removed role!", interaction, ephemeral=True) From 2285e5e560ab4cf2c71bb9354efab100a457e407 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 28 Mar 2023 11:09:17 +0200 Subject: [PATCH 293/404] Fixed no_roles_embed usage --- src/extensions/default/RoleReact.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index eff46955..4e64d9d4 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -77,14 +77,14 @@ async def create_menu( embed_color: Transform[Color, ColourTransformer] = Color.random(), ): - embed = no_roles_embed(color=embed_color) + embed = no_roles_embed(embed_color=embed_color) message: Message = await interaction.channel.send(embed=embed) p_key = primary_key_from_object(message) db_item = RoleReactMenus(primary_key=p_key, guild_id=interaction.guild.id, message_id=message.id) DBSession.create(db_item) - embed = no_roles_embed(color=embed_color) + embed = no_roles_embed(embed_color=embed_color, message_id=message.id) await message.edit(embed=embed) await interaction.response.send_message("Menu created!", ephemeral=True) From 1c654011394f86136fed71a055ca2e22fdd8fa83 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 28 Mar 2023 11:10:35 +0200 Subject: [PATCH 294/404] Sorted imports --- src/extensions/default/RoleReact.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 4e64d9d4..85a14441 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,13 +1,19 @@ import logging - from typing import Union -from discord import Color, Embed, Interaction, Message, Role, PartialEmoji, NotFound +from discord import (Color, Embed, Interaction, Message, NotFound, PartialEmoji, Role) from discord.app_commands import (Transform, autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog -from discord.ui import View, Select - -from common.discord import ColourTransformer, get_roles_from_select, primary_key_from_object, RoleReactMenuTransformer, EmojiTransformer, respond_or_followup, RoleReactRoleTransformer +from discord.ui import Select, View + +from common.discord import ( + ColourTransformer, + EmojiTransformer, + RoleReactMenuTransformer, + RoleReactRoleTransformer, + primary_key_from_object, + respond_or_followup +) from common.io import load_cog_toml from database.gateway import DBSession from database.models import RoleReactMenus From d694d101688615ba8c12eaa9bcb05f94fe87f7f6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 28 Mar 2023 12:29:58 +0200 Subject: [PATCH 295/404] Moved user strings to RoleReact.toml --- src/extensions/default/RoleReact.py | 54 +++++++++++++++++++++++------ src/locale/RoleReact.toml | 15 +++++++- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 85a14441..30d2eb56 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -27,12 +27,20 @@ async def validate_message_id(interaction: Interaction, message_id: int) -> Unio try: message = await interaction.channel.fetch_message(message_id) except NotFound: - await respond_or_followup(f"Unable to find message with ID `{message_id}`", interaction, ephemeral=True) + await respond_or_followup( + COG_STRINGS["react_warn_message_not_found"].format(message_id=message_id), + interaction, + ephemeral=True + ) return None valid_message = DBSession.get(RoleReactMenus, guild_id=message.guild.id, message_id=message.id) if not valid_message: - await respond_or_followup(f"The given ID of `{message_id}` is not a role react menu", interaction, ephemeral=True) + await respond_or_followup( + COG_STRINGS["react_warn_invalid_message_found"].format(message_id=message_id), + interaction, + ephemeral=True + ) return None return message @@ -44,7 +52,7 @@ def no_roles_embed(title: str = "Role Menu", embed_color: Color = Color.default( if message_id: embed.set_footer(text=f"Menu ID: {message_id}") else: - embed.set_footer(text="Not yet configured...") + embed.set_footer(text=COG_STRINGS["react_footer_no_id"]) return embed @@ -92,7 +100,7 @@ async def create_menu( embed = no_roles_embed(embed_color=embed_color, message_id=message.id) await message.edit(embed=embed) - await interaction.response.send_message("Menu created!", ephemeral=True) + await interaction.response.send_message(COG_STRINGS["react_create_menu_success"], ephemeral=True) @command(name=COG_STRINGS["react_add_item_name"], description=COG_STRINGS["react_add_item_description"]) @describe( @@ -125,7 +133,7 @@ async def add_item( no_roles = not message.components if no_roles: - message_embed.description = "**__Active Roles__**\n" + message_embed.description = f"{COG_STRINGS['react_embed_description_title']}\n" view = View(timeout=None) menu = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=1) else: @@ -136,9 +144,19 @@ async def add_item( menu.max_values = len(menu.options) + 1 menu.add_option(label=f"@{role.name}", value=str(role.id), description=description, emoji=emoji) view.add_item(menu) - message_embed.description += f"\n{emoji if emoji else ''} {role.mention} - {description if description else ''}" + message_embed.description += "\n" + message_embed.description += COG_STRINGS["react_role_emoji"].format(emoji=emoji) if emoji else "" + message_embed.description += role.mention + message_embed.description += COG_STRINGS["react_role_description"].format( + description=description + ) if description else "" await message.edit(embed=message_embed, view=view) - await respond_or_followup("Role added!", interaction, ephemeral=True) + await respond_or_followup( + COG_STRINGS["react_add_item_success"].format(role=role.name, + menu_id=message_id), + interaction, + ephemeral=True + ) @command(name=COG_STRINGS["react_remove_item_name"], description=COG_STRINGS["react_remove_item_description"]) @describe( @@ -159,7 +177,7 @@ async def remove_item(self, interaction: Interaction, message_id: str, role_id: message_embed = message.embeds[0] no_roles = not message.components if no_roles: - await respond_or_followup("No roles to remove!", interaction, ephemeral=True) + await respond_or_followup(COG_STRINGS["react_remove_item_warn_no_items"], interaction, ephemeral=True) return view = View.from_message(message, timeout=None) @@ -171,12 +189,22 @@ async def remove_item(self, interaction: Interaction, message_id: str, role_id: else: new_description = "**__Active Roles__**\n" for option in menu.options: - new_description += f"\n{option.emoji if option.emoji else ''} <&@{option.value}> - {option.description if option.description else ''}" + new_description += "\n" + new_description += COG_STRINGS["react_role_emoji"].format(emoji=option.emoji) if option.emoji else "" + new_description += f"<&@{option.value}>" + new_description += COG_STRINGS["react_role_description"].format( + description=option.description + ) if option.description else "" view.add_item(menu) message_embed.description = new_description await message.edit(embed=message_embed, view=view) - await respond_or_followup("Successfully removed role!", interaction, ephemeral=True) + await respond_or_followup( + COG_STRINGS["react_remove_item_success"].format(role_iid=role_id, + menu_id=message_id), + interaction, + ephemeral=True + ) @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) @describe(message_id=COG_STRINGS["react_delete_menu_message_id_describe"]) @@ -191,7 +219,11 @@ async def delete_menu(self, interaction: Interaction, message_id: str): db_item = DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=message.id) DBSession.delete(db_item) await message.delete() - await respond_or_followup(f"Role menu with id `{message.id}` deleted!", interaction, ephemeral=True) + await respond_or_followup( + COG_STRINGS["react_delete_menu_success"].format(menu_id=message.id), + interaction, + ephemeral=True + ) async def setup(bot: Bot): diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index a6c1b701..e7e542a8 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -1,10 +1,19 @@ react_group_name = "reactroles" +react_warn_message_not_found = "Unable to find a message in this channel with ID `{message_id}` ⚠️" +react_warn_invalid_message_found = "The message with ID `{message_id}` is not a role react menu ⚠️" + +react_embed_title = "Role Reaction Menu" +react_embed_description_title = "**__Menu Roles__**" +react_role_emoji = "{emoji} — " +react_role_description = " — {description}" react_empty_menu = "This menu currently has no roles in it. Add roles with the command by using the id of `{message_id}`" +react_footer_no_id = "Menu not initalised... ⚠️" react_create_menu_name = "create" react_create_menu_description = "Creates a new role react menu." react_create_menu_embed_color_describe = "Sets the colour of the embed for the role menu." react_create_menu_embed_color_rename = "embed-colour" +react_create_menu_success = "Successfully created a new menu, use the add-role command to add roles to it ✅" react_add_item_name = "add-role" react_add_item_description = "Add a role to an existing menu." @@ -16,6 +25,7 @@ react_add_item_emoji_describe = "The emoji to associate with the role (visual on react_add_item_emoji_rename = "emoji" react_add_item_description_describe = "The description of the role." react_add_item_description_rename = "role-description" +react_add_item_success = "Successfully added @{role} to menu with ID `{menu_id}` ✅" react_remove_item_name = "remove-role" react_remove_item_description = "Remove a role from an existing menu." @@ -23,8 +33,11 @@ react_remove_item_message_id_describe = "The ID of the menu to remove the role f react_remove_item_message_id_rename = "menu-id" react_remove_item_role_id_describe = "The role to remove from the menu." react_remove_item_role_id_rename = "role" +react_remove_item_warn_no_items "There are no roles to remove from this menu ⚠️" +react_remove_item_success = "Successfully removed <&@{role_id}> from menu with ID `{menu_id} ✅" react_delete_menu_name = "delete" react_delete_menu_description = "Deletes an existing menu." react_delete_menu_message_id_describe = "The ID of the menu to delete." -react_delete_menu_message_id_rename = "menu-id" \ No newline at end of file +react_delete_menu_message_id_rename = "menu-id" +react_delete_menu_success = "Successfully deleted menu with ID `{menu_id}` ✅" \ No newline at end of file From 115e2dbdad626ea306d9da3168ac63d3bc441d8d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 28 Mar 2023 12:58:32 +0200 Subject: [PATCH 296/404] Added TODO --- src/extensions/default/RoleReact.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 30d2eb56..5c810e5f 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -141,6 +141,8 @@ async def add_item( menu = view.children[0] view = view.clear_items() + # TODO: Allow for more than 25 options in a single menu + menu.max_values = len(menu.options) + 1 menu.add_option(label=f"@{role.name}", value=str(role.id), description=description, emoji=emoji) view.add_item(menu) From 88d47aeedacfe3e6c283f495dcd2adcc0725c825 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 28 Mar 2023 13:11:09 +0200 Subject: [PATCH 297/404] Fixed missing assignment --- src/locale/RoleReact.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index e7e542a8..2a098d65 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -33,7 +33,7 @@ react_remove_item_message_id_describe = "The ID of the menu to remove the role f react_remove_item_message_id_rename = "menu-id" react_remove_item_role_id_describe = "The role to remove from the menu." react_remove_item_role_id_rename = "role" -react_remove_item_warn_no_items "There are no roles to remove from this menu ⚠️" +react_remove_item_warn_no_items = "There are no roles to remove from this menu ⚠️" react_remove_item_success = "Successfully removed <&@{role_id}> from menu with ID `{menu_id} ✅" react_delete_menu_name = "delete" From 86db6fe5b27b3baab9d5fb0692b7fb5a1d9051eb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 28 Mar 2023 13:34:48 +0200 Subject: [PATCH 298/404] Fixed no_roles_embed not using RoleReact.toml --- src/extensions/default/RoleReact.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 5c810e5f..a7e04bfe 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -46,7 +46,11 @@ async def validate_message_id(interaction: Interaction, message_id: int) -> Unio return message -def no_roles_embed(title: str = "Role Menu", embed_color: Color = Color.default(), message_id: int = None) -> Embed: +def no_roles_embed( + title: str = COG_STRINGS["react_embed_title"], + embed_color: Color = Color.default(), + message_id: int = None +) -> Embed: description = "" if not message_id else EMPTY_ROLE_MENU.format(message_id=message_id) embed = Embed(title=title, description=description, color=embed_color) if message_id: From bcf5fc5221a9b16fc04d8d900a67bc54445ea14f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 08:26:46 +0200 Subject: [PATCH 299/404] Interaction handler now applies and removes roles --- src/extensions/default/RoleReact.py | 25 ++++++++++++++++++++++++- src/locale/RoleReact.toml | 1 + 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index a7e04bfe..a7ca6766 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -11,6 +11,7 @@ EmojiTransformer, RoleReactMenuTransformer, RoleReactRoleTransformer, + get_roles_from_select, primary_key_from_object, respond_or_followup ) @@ -83,7 +84,28 @@ def __init__(self, bot: Bot): @GroupCog.listener() async def on_interaction(self, interaction: Interaction): - pass + if not interaction.data or not interaction.data.get("custom_id"): + return False + + if not interaction.data.get("custom_id").startswith(ROLE_REACT_INTERACTION_PREFIX): + return False + + await interaction.response.defer() + + selected_role_ids = interaction.data.get("values") + menu_options = await get_roles_from_select(interaction, interaction.message.id) + unselected_roles = [] + selected_roles = [] + + for role in menu_options: + if str(role.id) in selected_role_ids: + selected_roles.append(role) + else: + unselected_roles.append(role) + + await interaction.user.remove_roles(*unselected_roles) + await interaction.user.add_roles(*selected_roles) + await respond_or_followup(COG_STRINGS["react_roles_updated"], interaction, ephemeral=True, delete_after=5) @command(name=COG_STRINGS["react_create_menu_name"], description=COG_STRINGS["react_create_menu_description"]) @describe(embed_color=COG_STRINGS["react_create_menu_embed_color_describe"]) @@ -143,6 +165,7 @@ async def add_item( else: view = View.from_message(message, timeout=None) menu = view.children[0] + menu.custom_id = ROLE_REACT_INTERACTION_PREFIX view = view.clear_items() # TODO: Allow for more than 25 options in a single menu diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml index 2a098d65..c7be7102 100644 --- a/src/locale/RoleReact.toml +++ b/src/locale/RoleReact.toml @@ -1,6 +1,7 @@ react_group_name = "reactroles" react_warn_message_not_found = "Unable to find a message in this channel with ID `{message_id}` ⚠️" react_warn_invalid_message_found = "The message with ID `{message_id}` is not a role react menu ⚠️" +react_roles_updated = "Roles updated successfully ✅" react_embed_title = "Role Reaction Menu" react_embed_description_title = "**__Menu Roles__**" From 400f1cb1713856c12346b9073f6d2dbc9a80dd60 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 12:30:20 +0200 Subject: [PATCH 300/404] Implemented helper functions --- src/extensions/default/RoleReact.py | 299 ++++++++-------------------- 1 file changed, 88 insertions(+), 211 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index a7ca6766..ad71912e 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,27 +1,39 @@ -import logging +from dataclasses import dataclass from typing import Union -from discord import (Color, Embed, Interaction, Message, NotFound, PartialEmoji, Role) -from discord.app_commands import (Transform, autocomplete, command, default_permissions, describe, guild_only, rename) -from discord.ext.commands import Bot, GroupCog -from discord.ui import Select, View +from discord import Role, Embed, Color, Guild, Interaction, NotFound, Message +from discord.ui import View, Select -from common.discord import ( - ColourTransformer, - EmojiTransformer, - RoleReactMenuTransformer, - RoleReactRoleTransformer, - get_roles_from_select, - primary_key_from_object, - respond_or_followup -) from common.io import load_cog_toml +from common.discord import respond_or_followup from database.gateway import DBSession from database.models import RoleReactMenus COG_STRINGS = load_cog_toml(__name__) ROLE_REACT_INTERACTION_PREFIX = f"{__name__}.interaction" -EMPTY_ROLE_MENU = COG_STRINGS["react_empty_menu"] + + +@dataclass +class RoleOption: + role_id: int + role: Role = None + emoji: str = None + description: str = None + + def __str__(self): + out = "" + if self.emoji: + out += COG_STRINGS["react_role_emoji"].format(emoji=self.emoji) + + if self.role: + out += self.role.mention + else: + out += f"<&@{self.role_id}>" + + if self.description: + out += COG_STRINGS["react_role_description"].format(description=self.description) + + return out async def validate_message_id(interaction: Interaction, message_id: int) -> Union[None, Message]: @@ -47,213 +59,78 @@ async def validate_message_id(interaction: Interaction, message_id: int) -> Unio return message -def no_roles_embed( - title: str = COG_STRINGS["react_embed_title"], - embed_color: Color = Color.default(), - message_id: int = None -) -> Embed: - description = "" if not message_id else EMPTY_ROLE_MENU.format(message_id=message_id) - embed = Embed(title=title, description=description, color=embed_color) - if message_id: - embed.set_footer(text=f"Menu ID: {message_id}") - else: - embed.set_footer(text=COG_STRINGS["react_footer_no_id"]) - - return embed - - -def exclude_roles_from_select(role_exclude_list: list[str], menu: Select) -> Select: - exisiting_options = menu.options - menu.options = [] - - for option in exisiting_options: - if option.value not in role_exclude_list: - menu.add_option(label=option.label, value=option.value, description=option.description, emoji=option.emoji) - - return menu - - -@default_permissions(administrator=True) -@guild_only() -class RoleReact(GroupCog, name=COG_STRINGS["react_group_name"]): - - def __init__(self, bot: Bot): - self.bot = bot - self.logger = logging.getLogger(__name__) - self.logger.info(f"{__name__} has been added as a Cog") - - @GroupCog.listener() - async def on_interaction(self, interaction: Interaction): - if not interaction.data or not interaction.data.get("custom_id"): - return False - - if not interaction.data.get("custom_id").startswith(ROLE_REACT_INTERACTION_PREFIX): - return False - - await interaction.response.defer() - - selected_role_ids = interaction.data.get("values") - menu_options = await get_roles_from_select(interaction, interaction.message.id) - unselected_roles = [] - selected_roles = [] - - for role in menu_options: - if str(role.id) in selected_role_ids: - selected_roles.append(role) +def options_from_view(view: View, guild: Guild = None) -> list[RoleOption]: + options = [] + for child in view.children: + for option in child.options: + if guild: + option_role = guild.get_role(int(option.value)) else: - unselected_roles.append(role) - - await interaction.user.remove_roles(*unselected_roles) - await interaction.user.add_roles(*selected_roles) - await respond_or_followup(COG_STRINGS["react_roles_updated"], interaction, ephemeral=True, delete_after=5) + option_role = None + options.append( + RoleOption(role_id=option.value, + role=option_role, + emoji=option.emoji, + description=option.description) + ) + return options + + +def view_from_options(options: list[RoleOption]) -> View: + if len(options) > 25 * 25: + raise ValueError(f"Too many options supplied to a single view. Option count exceeds 25 * 25 ({len(options)})") + + view = View(timeout=None) + child_select = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=0) + for idx, option in enumerate(options): + child_select.add_option( + label=f"@{option.role.name}", + value=str(option.role_id), + description=option.description, + emoji=option.emoji + ) + child_select.max_values += 1 - @command(name=COG_STRINGS["react_create_menu_name"], description=COG_STRINGS["react_create_menu_description"]) - @describe(embed_color=COG_STRINGS["react_create_menu_embed_color_describe"]) - @rename(embed_color=COG_STRINGS["react_create_menu_embed_color_rename"]) - @autocomplete(embed_color=ColourTransformer.autocomplete) - async def create_menu( - self, - interaction: Interaction, - embed_color: Transform[Color, - ColourTransformer] = Color.random(), - ): - embed = no_roles_embed(embed_color=embed_color) + if idx % 25 == 0: + view.add_item(child_select) + child_select = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=0) - message: Message = await interaction.channel.send(embed=embed) - p_key = primary_key_from_object(message) - db_item = RoleReactMenus(primary_key=p_key, guild_id=interaction.guild.id, message_id=message.id) - DBSession.create(db_item) + return view - embed = no_roles_embed(embed_color=embed_color, message_id=message.id) - await message.edit(embed=embed) - await interaction.response.send_message(COG_STRINGS["react_create_menu_success"], ephemeral=True) - @command(name=COG_STRINGS["react_add_item_name"], description=COG_STRINGS["react_add_item_description"]) - @describe( - message_id=COG_STRINGS["react_add_item_message_id_describe"], - role=COG_STRINGS["react_add_item_role_describe"], - emoji=COG_STRINGS["react_add_item_emoji_describe"], - description=COG_STRINGS["react_add_item_description_describe"] +def no_options_embed(menu_id: int = None, color: Color = Color.random()) -> Embed: + description = COG_STRINGS["react_footer_no_id"] if not menu_id else COG_STRINGS["react_empty_menu"].format( + message_id=menu_id ) - @rename( - message_id=COG_STRINGS["react_add_item_message_id_rename"], - role=COG_STRINGS["react_add_item_role_rename"], - emoji=COG_STRINGS["react_add_item_emoji_rename"], - description=COG_STRINGS["react_add_item_description_rename"] - ) - @autocomplete(message_id=RoleReactMenuTransformer.autocomplete) - async def add_item( - self, - interaction: Interaction, - message_id: str, - role: Role, - emoji: Transform[PartialEmoji, - EmojiTransformer] = None, - description: str = None - ): - await interaction.response.defer(ephemeral=True) - message = await validate_message_id(interaction, message_id) - if not message: - return - message_embed = message.embeds[0] - no_roles = not message.components - - if no_roles: - message_embed.description = f"{COG_STRINGS['react_embed_description_title']}\n" - view = View(timeout=None) - menu = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=1) - else: - view = View.from_message(message, timeout=None) - menu = view.children[0] - menu.custom_id = ROLE_REACT_INTERACTION_PREFIX - view = view.clear_items() - - # TODO: Allow for more than 25 options in a single menu - - menu.max_values = len(menu.options) + 1 - menu.add_option(label=f"@{role.name}", value=str(role.id), description=description, emoji=emoji) - view.add_item(menu) - message_embed.description += "\n" - message_embed.description += COG_STRINGS["react_role_emoji"].format(emoji=emoji) if emoji else "" - message_embed.description += role.mention - message_embed.description += COG_STRINGS["react_role_description"].format( - description=description - ) if description else "" - await message.edit(embed=message_embed, view=view) - await respond_or_followup( - COG_STRINGS["react_add_item_success"].format(role=role.name, - menu_id=message_id), - interaction, - ephemeral=True - ) + embed = Embed(title=COG_STRINGS["react_embed_title"], description=description, color=color) + if menu_id: + embed.set_footer(text=f"Menu ID: {menu_id}") + else: + embed.set_footer(text=COG_STRINGS["react_footer_no_id"]) - @command(name=COG_STRINGS["react_remove_item_name"], description=COG_STRINGS["react_remove_item_description"]) - @describe( - message_id=COG_STRINGS["react_remove_item_message_id_describe"], - role_id=COG_STRINGS["react_remove_item_role_id_describe"] - ) - @rename( - message_id=COG_STRINGS["react_remove_item_message_id_rename"], - role_id=COG_STRINGS["react_remove_item_role_id_rename"] - ) - @autocomplete(message_id=RoleReactMenuTransformer.autocomplete, role_id=RoleReactRoleTransformer.autocomplete) - async def remove_item(self, interaction: Interaction, message_id: str, role_id: str): - await interaction.response.defer(ephemeral=True) - message = await validate_message_id(interaction, message_id) - if not message: - return + return embed - message_embed = message.embeds[0] - no_roles = not message.components - if no_roles: - await respond_or_followup(COG_STRINGS["react_remove_item_warn_no_items"], interaction, ephemeral=True) - return - view = View.from_message(message, timeout=None) - menu = view.children[0] - view = view.clear_items() - menu = exclude_roles_from_select(role_id, menu) - if not menu.options: - message_embed = no_roles_embed(embed_color=message_embed.color, message_id=message_id) - else: - new_description = "**__Active Roles__**\n" - for option in menu.options: - new_description += "\n" - new_description += COG_STRINGS["react_role_emoji"].format(emoji=option.emoji) if option.emoji else "" - new_description += f"<&@{option.value}>" - new_description += COG_STRINGS["react_role_description"].format( - description=option.description - ) if option.description else "" - view.add_item(menu) - message_embed.description = new_description +def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: Color = Color.random()) -> list[Embed]: + if len(options) > 25 * 25: + raise ValueError(f"Too many options supplied to a single message. Option count exceeds 25 * 25 ({len(options)})") - await message.edit(embed=message_embed, view=view) - await respond_or_followup( - COG_STRINGS["react_remove_item_success"].format(role_iid=role_id, - menu_id=message_id), - interaction, - ephemeral=True - ) + if not options: + return [no_options_embed(menu_id=menu_id, color=color)] - @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) - @describe(message_id=COG_STRINGS["react_delete_menu_message_id_describe"]) - @rename(message_id=COG_STRINGS["react_delete_menu_message_id_rename"]) - @autocomplete(message_id=RoleReactMenuTransformer.autocomplete) - async def delete_menu(self, interaction: Interaction, message_id: str): - await interaction.response.defer(ephemeral=True) - message = await validate_message_id(interaction, message_id) - if not message: - return + embeds = [] + embed_item = Embed(title=COG_STRINGS["react_embed_title"], description="**__Active Roles__**", color=color) - db_item = DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=message.id) - DBSession.delete(db_item) - await message.delete() - await respond_or_followup( - COG_STRINGS["react_delete_menu_success"].format(menu_id=message.id), - interaction, - ephemeral=True - ) + for idx, option in enumerate(options): + embed_item.description += f"\n{option!s}" + if idx % 25 == 0: + embeds.append(embed_item) + embed_item = Embed(title="​", description="", color=color) + if menu_id: + footer_text = f"Menu ID: {menu_id}" + else: + footer_text = COG_STRINGS["react_footer_no_id"] + embeds[-1].set_footer(text=footer_text) -async def setup(bot: Bot): - await bot.add_cog(RoleReact(bot)) + return embeds From 66da320b056a53ad71fcb77c17f4b75d85f85b34 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 13:15:10 +0200 Subject: [PATCH 301/404] Added interaction handler to add/remove chosen roles --- src/extensions/default/RoleReact.py | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index ad71912e..150bf6d2 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,8 +1,11 @@ from dataclasses import dataclass from typing import Union +import logging from discord import Role, Embed, Color, Guild, Interaction, NotFound, Message from discord.ui import View, Select +from discord.app_commands import guild_only, default_permissions +from discord.ext.commands import GroupCog, Bot from common.io import load_cog_toml from common.discord import respond_or_followup @@ -134,3 +137,38 @@ def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: C embeds[-1].set_footer(text=footer_text) return embeds + + +@default_permissions(administrator=True) +@guild_only() +class RoleReact(GroupCog, name=COG_STRINGS["react_group_name"]): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + @GroupCog.listener() + async def on_interaction(self, interaction: Interaction): + if not interaction.data or not interaction.data.get("custom_id"): + return False + + if not interaction.data.get("custom_id").startswith(ROLE_REACT_INTERACTION_PREFIX): + return False + + await interaction.response.defer() + selected_role_ids = interaction.data.get("values") + message_view = View.from_message(interaction.message) + view_options = options_from_view(message_view, interaction.guild) + unselected_roles = [] + selected_roles = [] + + for option in view_options: + if str(option.role.id) in selected_role_ids: + selected_roles.append(option.role) + else: + unselected_roles.append(option.role) + + await interaction.user.remove_roles(*unselected_roles) + await interaction.user.add_roles(*selected_roles) + await respond_or_followup(COG_STRINGS["react_roles_updated"], interaction, ephemeral=True, delete_after=5) \ No newline at end of file From 8c1edefc5bc654e9f46399b06cbf97695b773cfd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:03:16 +0200 Subject: [PATCH 302/404] Re-added create-menu command --- src/extensions/default/RoleReact.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 150bf6d2..9b844a0d 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -4,11 +4,11 @@ from discord import Role, Embed, Color, Guild, Interaction, NotFound, Message from discord.ui import View, Select -from discord.app_commands import guild_only, default_permissions +from discord.app_commands import guild_only, default_permissions, Transform, command, describe, rename, autocomplete from discord.ext.commands import GroupCog, Bot from common.io import load_cog_toml -from common.discord import respond_or_followup +from common.discord import primary_key_from_object, respond_or_followup, ColourTransformer from database.gateway import DBSession from database.models import RoleReactMenus @@ -171,4 +171,21 @@ async def on_interaction(self, interaction: Interaction): await interaction.user.remove_roles(*unselected_roles) await interaction.user.add_roles(*selected_roles) - await respond_or_followup(COG_STRINGS["react_roles_updated"], interaction, ephemeral=True, delete_after=5) \ No newline at end of file + await respond_or_followup(COG_STRINGS["react_roles_updated"], interaction, ephemeral=True, delete_after=5) + + @command(name=COG_STRINGS["react_create_menu_name"], description=COG_STRINGS["react_create_menu_description"]) + @describe(embed_color=COG_STRINGS["react_create_menu_embed_color_describe"]) + @rename(embed_color=COG_STRINGS["react_create_menu_embed_color_rename"]) + @autocomplete(embed_color=ColourTransformer.autocomplete) + async def create_menu(self, interaction: Interaction, color: Transform[Color, ColourTransformer] = Color.random()): + await interaction.response.defer() + + message = await interaction.channel.send("​") + db_primary_key = primary_key_from_object(message) + db_item = RoleReactMenus(primary_key=db_primary_key, guild_id=interaction.guild.id, message_id=message.id) + DBSession.create(db_item) + + message_embeds = embeds_from_options([], menu_id=message.id, color=color) + await message.edit(embeds=message_embeds) + + await respond_or_followup(COG_STRINGS["react_create_menu_success"], ephemeral=self.bot.only_ephemeral) \ No newline at end of file From b9729ed4be2ff6b5d9874703909015d8e43f9d04 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:14:13 +0200 Subject: [PATCH 303/404] Reimplemented add-role command --- src/extensions/default/RoleReact.py | 51 +++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 9b844a0d..b0134c80 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -2,13 +2,13 @@ from typing import Union import logging -from discord import Role, Embed, Color, Guild, Interaction, NotFound, Message +from discord import Role, Embed, Color, Guild, Interaction, NotFound, Message, PartialEmoji from discord.ui import View, Select from discord.app_commands import guild_only, default_permissions, Transform, command, describe, rename, autocomplete from discord.ext.commands import GroupCog, Bot from common.io import load_cog_toml -from common.discord import primary_key_from_object, respond_or_followup, ColourTransformer +from common.discord import primary_key_from_object, respond_or_followup, ColourTransformer, EmojiTransformer, RoleReactMenuTransformer from database.gateway import DBSession from database.models import RoleReactMenus @@ -188,4 +188,49 @@ async def create_menu(self, interaction: Interaction, color: Transform[Color, Co message_embeds = embeds_from_options([], menu_id=message.id, color=color) await message.edit(embeds=message_embeds) - await respond_or_followup(COG_STRINGS["react_create_menu_success"], ephemeral=self.bot.only_ephemeral) \ No newline at end of file + await respond_or_followup(COG_STRINGS["react_create_menu_success"], ephemeral=self.bot.only_ephemeral) + + @command(name=COG_STRINGS["react_add_item_name"], description=COG_STRINGS["react_add_item_description"]) + @describe( + message_id=COG_STRINGS["react_add_item_message_id_describe"], + role=COG_STRINGS["react_add_item_role_describe"], + emoji=COG_STRINGS["react_add_item_emoji_describe"], + description=COG_STRINGS["react_add_item_description_describe"] + ) + @rename( + message_id=COG_STRINGS["react_add_item_message_id_rename"], + role=COG_STRINGS["react_add_item_role_rename"], + emoji=COG_STRINGS["react_add_item_emoji_rename"], + description=COG_STRINGS["react_add_item_description_rename"] + ) + @autocomplete(message_id=RoleReactMenuTransformer.autocomplete) + async def add_role( + self, + interaction: Interaction, + menu_id: str, + role: Role, + emoji: Transform[PartialEmoji, + EmojiTransformer] = None, + description: str = None + ): + await interaction.response.defer() + + message = await validate_message_id(interaction, menu_id) + if not message: + return + + embed_color = message.embeds[0].color + message_view = View.from_message(message) + current_options = options_from_view(message_view, interaction.guild) + current_options.append(RoleOption(role_id=role.id, role=role, emoji=emoji, description=description)) + + updated_view = view_from_options(current_options) + updated_embeds = embeds_from_options(current_options, menu_id, embed_color) + + await message.edit(view=updated_view, embeds=updated_embeds) + await respond_or_followup( + COG_STRINGS["react_add_item_success"].format(role=role.name, + menu_id=menu_id), + interaction, + ephemeral=True + ) \ No newline at end of file From b63fd99bebcc3b26f48f3070978c31a24d26ea3c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:43:15 +0200 Subject: [PATCH 304/404] Added check for None view --- src/extensions/default/RoleReact.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index b0134c80..3a492943 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -63,6 +63,9 @@ async def validate_message_id(interaction: Interaction, message_id: int) -> Unio def options_from_view(view: View, guild: Guild = None) -> list[RoleOption]: + if not view: + return [] + options = [] for child in view.children: for option in child.options: From b6822b5b2e548f9daa0d523d49fdb0751d86093c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:43:49 +0200 Subject: [PATCH 305/404] Ensured that final items are appened to lists --- src/extensions/default/RoleReact.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 3a492943..1a9122ed 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -100,6 +100,8 @@ def view_from_options(options: list[RoleOption]) -> View: if idx % 25 == 0: view.add_item(child_select) child_select = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=0) + elif idx == len(options) - 1: + view.add_item(child_select) return view @@ -132,6 +134,9 @@ def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: C if idx % 25 == 0: embeds.append(embed_item) embed_item = Embed(title="​", description="", color=color) + elif idx == len(options) - 1: + embeds.append(embed_item) + if menu_id: footer_text = f"Menu ID: {menu_id}" else: From daadca6da2731d04913ab8dfb72a369297bb7006 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:44:05 +0200 Subject: [PATCH 306/404] Reimplemented remove-role command --- src/extensions/default/RoleReact.py | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 1a9122ed..37b92070 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -241,4 +241,39 @@ async def add_role( menu_id=menu_id), interaction, ephemeral=True + ) + + async def remove_item(self, interaction: Interaction, menu_id: str, role_id: str): + await interaction.response.defer() + + message = await validate_message_id(interaction, menu_id) + if not message: + return + + embed_color = message.embeds[0].color + message_view = View.from_message(message) + current_options = options_from_view(message_view, interaction.guild) + + if not current_options: + await respond_or_followup(COG_STRINGS["react_remove_item_warn_no_items"], interaction, ephemeral=True) + return + + option_to_remove = None + for option in current_options: + if str(option.role_id) == role_id: + option_to_remove = option + break + + if option_to_remove: + current_options.remove(option_to_remove) + + updated_view = view_from_options(current_options) + updated_embeds = embeds_from_options(current_options, menu_id, embed_color) + + await message.edit(view=updated_view, embeds=updated_embeds) + await respond_or_followup( + COG_STRINGS["react_remove_item_success"].format(role_id=role_id, + menu_id=menu_id), + interaction, + ephemeral=True ) \ No newline at end of file From 94998fd33fa42062892cb6cfa396494e029ec02a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:47:30 +0200 Subject: [PATCH 307/404] Reimplemented delete-menu command --- src/extensions/default/RoleReact.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 37b92070..574b4f01 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -65,7 +65,7 @@ async def validate_message_id(interaction: Interaction, message_id: int) -> Unio def options_from_view(view: View, guild: Guild = None) -> list[RoleOption]: if not view: return [] - + options = [] for child in view.children: for option in child.options: @@ -198,6 +198,28 @@ async def create_menu(self, interaction: Interaction, color: Transform[Color, Co await respond_or_followup(COG_STRINGS["react_create_menu_success"], ephemeral=self.bot.only_ephemeral) + @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) + @describe(message_id=COG_STRINGS["react_delete_menu_message_id_describe"]) + @rename(message_id=COG_STRINGS["react_delete_menu_message_id_rename"]) + @autocomplete(message_id=RoleReactMenuTransformer.autocomplete) + async def delete_menu(self, interaction: Interaction, menu_id: str): + await interaction.response.defer() + + message = await validate_message_id(interaction, menu_id) + if not message: + return + + db_item = DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=message.id) + if db_item: + DBSession.delete(db_item) + + await message.delete() + await respond_or_followup( + COG_STRINGS["react_delete_menu_success"].format(menu_id=message.id), + interaction, + ephemeral=True + ) + @command(name=COG_STRINGS["react_add_item_name"], description=COG_STRINGS["react_add_item_description"]) @describe( message_id=COG_STRINGS["react_add_item_message_id_describe"], From 915fb2800060a569b9a257b17ea8e86738a66ed0 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:48:18 +0200 Subject: [PATCH 308/404] Sorted imports --- src/extensions/default/RoleReact.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 574b4f01..df4ec86a 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -1,14 +1,20 @@ +import logging from dataclasses import dataclass from typing import Union -import logging - -from discord import Role, Embed, Color, Guild, Interaction, NotFound, Message, PartialEmoji -from discord.ui import View, Select -from discord.app_commands import guild_only, default_permissions, Transform, command, describe, rename, autocomplete -from discord.ext.commands import GroupCog, Bot +from discord import (Color, Embed, Guild, Interaction, Message, NotFound, PartialEmoji, Role) +from discord.app_commands import (Transform, autocomplete, command, default_permissions, describe, guild_only, rename) +from discord.ext.commands import Bot, GroupCog +from discord.ui import Select, View + +from common.discord import ( + ColourTransformer, + EmojiTransformer, + RoleReactMenuTransformer, + primary_key_from_object, + respond_or_followup +) from common.io import load_cog_toml -from common.discord import primary_key_from_object, respond_or_followup, ColourTransformer, EmojiTransformer, RoleReactMenuTransformer from database.gateway import DBSession from database.models import RoleReactMenus From 9f0f9df70875b423b9472af9f0c1dd159bd1d5ee Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:57:30 +0200 Subject: [PATCH 309/404] Updated RoleReactRoleTransformer --- src/common/discord.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index 5d82178b..d6ac5f58 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -255,22 +255,15 @@ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choic return get_events(interaction.guild, self.archived_events, value) -async def get_roles_from_select(interaction: Interaction, message_id: int) -> list[Role]: - message = await interaction.channel.fetch_message(message_id) - if not message.components: +def get_roles_from_select(view: View, guild: Guild) -> list[Role]: + if not view: return [] - guild_roles = interaction.guild.roles - guild_roles = {str(x.id): x for x in guild_roles} - - view = View.from_message(message) - menu = view.children[0] roles = [] - for option in menu.options: - role = guild_roles.get(option.value) - if role: - roles.append(role) - + guild_roles = {str(x.id): x for x in guild.roles} + for child in view.children: + for option in child.options: + roles.append(guild_roles.get(option.value)) return roles @@ -307,7 +300,10 @@ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choic menu_id = get_menu_id_from_args(interaction) if not DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=menu_id): return [] - menu_roles = await get_roles_from_select(interaction, menu_id) + + message = await interaction.channel.fetch_message(menu_id) + view = View.from_message(message) + menu_roles = get_roles_from_select(view, interaction.guild) if value: choices = [ Choice(name=f"{'' if x.name.startswith('@') else '@'}{x.name}", From b50259b63f3ec18fb97efe6ced5a3924e0298e7d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 14:57:58 +0200 Subject: [PATCH 310/404] Manually get role list rather than relying on guild.get_role --- src/extensions/default/RoleReact.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index df4ec86a..2210a5e9 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -72,11 +72,12 @@ def options_from_view(view: View, guild: Guild = None) -> list[RoleOption]: if not view: return [] + guild_roles = {str(x.id): x for x in guild.roles} options = [] for child in view.children: for option in child.options: if guild: - option_role = guild.get_role(int(option.value)) + option_role = guild_roles.get(option.value) else: option_role = None options.append( From 05bea33170430278c02a10e398792ebc9583cdae Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 15:00:16 +0200 Subject: [PATCH 311/404] Added setup hook --- src/extensions/default/RoleReact.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 2210a5e9..4a54840c 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -305,4 +305,8 @@ async def remove_item(self, interaction: Interaction, menu_id: str, role_id: str menu_id=menu_id), interaction, ephemeral=True - ) \ No newline at end of file + ) + + +async def setup(bot: Bot): + await bot.add_cog(RoleReact(bot)) \ No newline at end of file From a78740a3a217e4a520c7e837afa2192c3a8a5d00 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 15:01:14 +0200 Subject: [PATCH 312/404] Fixed incorrect parameter name `embed_color` --- src/extensions/default/RoleReact.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 4a54840c..c116fadf 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -189,9 +189,9 @@ async def on_interaction(self, interaction: Interaction): await respond_or_followup(COG_STRINGS["react_roles_updated"], interaction, ephemeral=True, delete_after=5) @command(name=COG_STRINGS["react_create_menu_name"], description=COG_STRINGS["react_create_menu_description"]) - @describe(embed_color=COG_STRINGS["react_create_menu_embed_color_describe"]) - @rename(embed_color=COG_STRINGS["react_create_menu_embed_color_rename"]) - @autocomplete(embed_color=ColourTransformer.autocomplete) + @describe(color=COG_STRINGS["react_create_menu_embed_color_describe"]) + @rename(color=COG_STRINGS["react_create_menu_embed_color_rename"]) + @autocomplete(color=ColourTransformer.autocomplete) async def create_menu(self, interaction: Interaction, color: Transform[Color, ColourTransformer] = Color.random()): await interaction.response.defer() From 7c5c5cf3b60273931db619ccd93cb0ba1fdbd2b7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 15:02:18 +0200 Subject: [PATCH 313/404] Fixed incorrect parameter name `message_id` --- src/extensions/default/RoleReact.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index c116fadf..a14831c2 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -206,9 +206,9 @@ async def create_menu(self, interaction: Interaction, color: Transform[Color, Co await respond_or_followup(COG_STRINGS["react_create_menu_success"], ephemeral=self.bot.only_ephemeral) @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) - @describe(message_id=COG_STRINGS["react_delete_menu_message_id_describe"]) - @rename(message_id=COG_STRINGS["react_delete_menu_message_id_rename"]) - @autocomplete(message_id=RoleReactMenuTransformer.autocomplete) + @describe(menu_id=COG_STRINGS["react_delete_menu_message_id_describe"]) + @rename(menu_id=COG_STRINGS["react_delete_menu_message_id_rename"]) + @autocomplete(menu_id=RoleReactMenuTransformer.autocomplete) async def delete_menu(self, interaction: Interaction, menu_id: str): await interaction.response.defer() @@ -229,18 +229,18 @@ async def delete_menu(self, interaction: Interaction, menu_id: str): @command(name=COG_STRINGS["react_add_item_name"], description=COG_STRINGS["react_add_item_description"]) @describe( - message_id=COG_STRINGS["react_add_item_message_id_describe"], + menu_id=COG_STRINGS["react_add_item_message_id_describe"], role=COG_STRINGS["react_add_item_role_describe"], emoji=COG_STRINGS["react_add_item_emoji_describe"], description=COG_STRINGS["react_add_item_description_describe"] ) @rename( - message_id=COG_STRINGS["react_add_item_message_id_rename"], + menu_id=COG_STRINGS["react_add_item_message_id_rename"], role=COG_STRINGS["react_add_item_role_rename"], emoji=COG_STRINGS["react_add_item_emoji_rename"], description=COG_STRINGS["react_add_item_description_rename"] ) - @autocomplete(message_id=RoleReactMenuTransformer.autocomplete) + @autocomplete(menu_id=RoleReactMenuTransformer.autocomplete) async def add_role( self, interaction: Interaction, From 2b8102b0b4bf68781b7a8c14beec5f24e40fafb1 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 18:24:57 +0200 Subject: [PATCH 314/404] Added new line at end of document --- src/extensions/default/RoleReact.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index a14831c2..1cd21c9a 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -309,4 +309,4 @@ async def remove_item(self, interaction: Interaction, menu_id: str, role_id: str async def setup(bot: Bot): - await bot.add_cog(RoleReact(bot)) \ No newline at end of file + await bot.add_cog(RoleReact(bot)) From d1362ab51bd292def936f8d13374cc61077b5891 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 18:38:15 +0200 Subject: [PATCH 315/404] Added missing `interaction` argument --- src/extensions/default/RoleReact.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 1cd21c9a..021e5c2f 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -203,7 +203,7 @@ async def create_menu(self, interaction: Interaction, color: Transform[Color, Co message_embeds = embeds_from_options([], menu_id=message.id, color=color) await message.edit(embeds=message_embeds) - await respond_or_followup(COG_STRINGS["react_create_menu_success"], ephemeral=self.bot.only_ephemeral) + await respond_or_followup(COG_STRINGS["react_create_menu_success"], interaction, ephemeral=self.bot.only_ephemeral) @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) @describe(menu_id=COG_STRINGS["react_delete_menu_message_id_describe"]) From 31406cfc4df2ae9a3f36f7ef9d3f501e67c1f279 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 18:38:53 +0200 Subject: [PATCH 316/404] Fixed incorrect modulo arithmetic --- src/extensions/default/RoleReact.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 021e5c2f..124b9341 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -104,7 +104,7 @@ def view_from_options(options: list[RoleOption]) -> View: ) child_select.max_values += 1 - if idx % 25 == 0: + if idx + 1 % 25 == 0: view.add_item(child_select) child_select = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=0) elif idx == len(options) - 1: @@ -138,7 +138,7 @@ def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: C for idx, option in enumerate(options): embed_item.description += f"\n{option!s}" - if idx % 25 == 0: + if idx + 1 % 25 == 0: embeds.append(embed_item) embed_item = Embed(title="​", description="", color=color) elif idx == len(options) - 1: From 219fcdd4af226f8c5536330cd97e3dc574177246 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 18:39:20 +0200 Subject: [PATCH 317/404] Fixed non-unique custom IDs for multiple select items --- src/extensions/default/RoleReact.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 124b9341..f67c1eb9 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -19,7 +19,7 @@ from database.models import RoleReactMenus COG_STRINGS = load_cog_toml(__name__) -ROLE_REACT_INTERACTION_PREFIX = f"{__name__}.interaction" +ROLE_REACT_INTERACTION_PREFIX = f"{__name__}." @dataclass @@ -106,7 +106,7 @@ def view_from_options(options: list[RoleOption]) -> View: if idx + 1 % 25 == 0: view.add_item(child_select) - child_select = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=0) + child_select = Select(custom_id=f"{ROLE_REACT_INTERACTION_PREFIX}.{idx}", min_values=0, max_values=0) elif idx == len(options) - 1: view.add_item(child_select) From 52804c7c27ce88cc182caa6edf7673a497090513 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 18:50:31 +0200 Subject: [PATCH 318/404] Added missing command decorators to remove-item command --- src/extensions/default/RoleReact.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index f67c1eb9..4b6cf744 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -11,6 +11,7 @@ ColourTransformer, EmojiTransformer, RoleReactMenuTransformer, + RoleReactRoleTransformer, primary_key_from_object, respond_or_followup ) @@ -272,6 +273,16 @@ async def add_role( ephemeral=True ) + @command(name=COG_STRINGS["react_remove_item_name"], description=COG_STRINGS["react_remove_item_description"]) + @describe( + menu_id=COG_STRINGS["react_remove_item_message_id_describe"], + role_id=COG_STRINGS["react_remove_item_role_id_describe"] + ) + @rename( + menu_id=COG_STRINGS["react_remove_item_message_id_rename"], + role_id=COG_STRINGS["react_remove_item_role_id_rename"] + ) + @autocomplete(menu_id=RoleReactMenuTransformer.autocomplete, role_id=RoleReactRoleTransformer.autocomplete) async def remove_item(self, interaction: Interaction, menu_id: str, role_id: str): await interaction.response.defer() From 0ed123007fca23cf6a1d7e4d42e45035405d9dc7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 18:58:48 +0200 Subject: [PATCH 319/404] Fixed modulo brackets --- src/extensions/default/RoleReact.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 4b6cf744..a63ef77b 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -105,7 +105,7 @@ def view_from_options(options: list[RoleOption]) -> View: ) child_select.max_values += 1 - if idx + 1 % 25 == 0: + if (idx + 1) % 5 == 0: view.add_item(child_select) child_select = Select(custom_id=f"{ROLE_REACT_INTERACTION_PREFIX}.{idx}", min_values=0, max_values=0) elif idx == len(options) - 1: @@ -139,7 +139,7 @@ def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: C for idx, option in enumerate(options): embed_item.description += f"\n{option!s}" - if idx + 1 % 25 == 0: + if (idx + 1) % 25 == 0: embeds.append(embed_item) embed_item = Embed(title="​", description="", color=color) elif idx == len(options) - 1: From 83c01f8a0c067536fb462af7c77d96972f0db47d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 19:02:16 +0200 Subject: [PATCH 320/404] Updated `get_roles_from_select` function name --- src/common/discord.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index d6ac5f58..c62d7cba 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -255,7 +255,7 @@ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choic return get_events(interaction.guild, self.archived_events, value) -def get_roles_from_select(view: View, guild: Guild) -> list[Role]: +def get_roles_from_view(view: View, guild: Guild) -> list[Role]: if not view: return [] @@ -303,7 +303,7 @@ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choic message = await interaction.channel.fetch_message(menu_id) view = View.from_message(message) - menu_roles = get_roles_from_select(view, interaction.guild) + menu_roles = get_roles_from_view(view, interaction.guild) if value: choices = [ Choice(name=f"{'' if x.name.startswith('@') else '@'}{x.name}", From 93800cd3190a8000317f2e989da70021d3a0b7d1 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 21:45:34 +0200 Subject: [PATCH 321/404] Fixed roles not applying with multiple Select views --- src/extensions/default/RoleReact.py | 54 ++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index a63ef77b..b5739783 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -21,6 +21,7 @@ COG_STRINGS = load_cog_toml(__name__) ROLE_REACT_INTERACTION_PREFIX = f"{__name__}." +MAX_VIEW_ITEM_COUNT = 25 @dataclass @@ -91,11 +92,14 @@ def options_from_view(view: View, guild: Guild = None) -> list[RoleOption]: def view_from_options(options: list[RoleOption]) -> View: - if len(options) > 25 * 25: - raise ValueError(f"Too many options supplied to a single view. Option count exceeds 25 * 25 ({len(options)})") + if len(options) > MAX_VIEW_ITEM_COUNT * MAX_VIEW_ITEM_COUNT: + raise ValueError( + f"Too many options supplied to a single view. " + f"Option count exceeds {MAX_VIEW_ITEM_COUNT} * {MAX_VIEW_ITEM_COUNT} ({len(options)})" + ) view = View(timeout=None) - child_select = Select(custom_id=ROLE_REACT_INTERACTION_PREFIX, min_values=0, max_values=0) + child_select = Select(custom_id=f"{ROLE_REACT_INTERACTION_PREFIX}{0}", min_values=0, max_values=0) for idx, option in enumerate(options): child_select.add_option( label=f"@{option.role.name}", @@ -105,9 +109,13 @@ def view_from_options(options: list[RoleOption]) -> View: ) child_select.max_values += 1 - if (idx + 1) % 5 == 0: + if (idx + 1) % MAX_VIEW_ITEM_COUNT == 0: view.add_item(child_select) - child_select = Select(custom_id=f"{ROLE_REACT_INTERACTION_PREFIX}.{idx}", min_values=0, max_values=0) + child_select = Select( + custom_id=f"{ROLE_REACT_INTERACTION_PREFIX}{(idx+1)//MAX_VIEW_ITEM_COUNT}", + min_values=0, + max_values=0 + ) elif idx == len(options) - 1: view.add_item(child_select) @@ -128,8 +136,11 @@ def no_options_embed(menu_id: int = None, color: Color = Color.random()) -> Embe def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: Color = Color.random()) -> list[Embed]: - if len(options) > 25 * 25: - raise ValueError(f"Too many options supplied to a single message. Option count exceeds 25 * 25 ({len(options)})") + if len(options) > MAX_VIEW_ITEM_COUNT * MAX_VIEW_ITEM_COUNT: + raise ValueError( + f"Too many options supplied to a single message. " + f"Option count exceeds {MAX_VIEW_ITEM_COUNT} * {MAX_VIEW_ITEM_COUNT} ({len(options)})" + ) if not options: return [no_options_embed(menu_id=menu_id, color=color)] @@ -139,7 +150,7 @@ def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: C for idx, option in enumerate(options): embed_item.description += f"\n{option!s}" - if (idx + 1) % 25 == 0: + if (idx + 1) % MAX_VIEW_ITEM_COUNT == 0: embeds.append(embed_item) embed_item = Embed(title="​", description="", color=color) elif idx == len(options) - 1: @@ -155,6 +166,22 @@ def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: C return embeds +def get_roles_from_select(view: View, guild: Guild, child_index: int) -> list[Role]: + try: + select_menu = view.children[child_index] + except IndexError: + return [] + + select_roles = [] + guild_roles = {str(x.id): x for x in guild.roles} + for option in select_menu.options: + role = guild_roles.get(option.value) + if role: + select_roles.append(role) + + return select_roles + + @default_permissions(administrator=True) @guild_only() class RoleReact(GroupCog, name=COG_STRINGS["react_group_name"]): @@ -175,15 +202,16 @@ async def on_interaction(self, interaction: Interaction): await interaction.response.defer() selected_role_ids = interaction.data.get("values") message_view = View.from_message(interaction.message) - view_options = options_from_view(message_view, interaction.guild) + select_index = interaction.data.get("custom_id").split(".")[-1] + select_roles = get_roles_from_select(message_view, interaction.guild, int(select_index)) unselected_roles = [] selected_roles = [] - for option in view_options: - if str(option.role.id) in selected_role_ids: - selected_roles.append(option.role) + for role in select_roles: + if str(role.id) in selected_role_ids: + selected_roles.append(role) else: - unselected_roles.append(option.role) + unselected_roles.append(role) await interaction.user.remove_roles(*unselected_roles) await interaction.user.add_roles(*selected_roles) From 48b8e489a0023f52fef3c4b9c7078692f1813d58 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 21:54:13 +0200 Subject: [PATCH 322/404] Updated README for RoleReact status --- README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ce187e11..52fc1ad8 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,34 @@ All commands in this cog require the user to have the administrator permission i </details> +<details> +<summary>RoleReact</summary> + +## RoleReact + +RoleReact cog is used to allow users to self-assign roles from a defined list of roles set by admins. +All commands in this cog require the user to have the administrator permissions in a given guild/server. + +### Current Commands: + +#### /reactroles create [optional: color] + +- Creates a new menu to add roles to. + +#### /reactroles delete \<menu ID\> + +- Deletes a given menu and it's message. + +#### /reactroles add-role \<menu ID\> \<role\> [optional: emoji] [optional: description] + +- Add a role to a given menu. Optionally give the role an emoji and/or description. + +#### /reactroles remove-role \<menu ID\> \<role\> + +- Remove a role from a given menu. + +</details> + <details> <summary>VoiceAdmin</summary> @@ -223,7 +251,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - [ ] LogChannelCog ⚠️ - [x] MusicCog ✅ Implemented as VCMusic - [ ] PingableRolesCog ❌ -- [ ] RoleReactCog ✅ WIP implementation in RoleReact +- [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ - [ ] TwitterCog ❌ - [x] VoicemasterCog ✅ Implemented as VocieAdmin From 5276910481eaf59d6b8590a4eb2427ddb2b47bb0 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 22:00:56 +0200 Subject: [PATCH 323/404] Added docstrings to Transformers and helpers --- src/common/discord.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/common/discord.py b/src/common/discord.py index c62d7cba..8630938c 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -256,6 +256,15 @@ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choic def get_roles_from_view(view: View, guild: Guild) -> list[Role]: + """Get a list of roles from a View for a RoleReact message. + + Args: + view (View): The view containing the Select items with role options. + guild (Guild): The guild in which the view/message exist. + + Returns: + list[Role]: A list of roles that are the options in the select menu(s). + """ if not view: return [] @@ -268,6 +277,14 @@ def get_roles_from_view(view: View, guild: Guild) -> list[Role]: def get_menu_id_from_args(interaction: Interaction) -> int: + """Get the given menu ID from the already supplied arguments of an interaction. + + Args: + interaction (Interaction): The interaction containing the already given menu ID + + Returns: + int: The menu ID of the menu given. + """ interaction_options = {"options": []} for item in interaction.data.get("options"): if item.get("type") == 1: @@ -281,6 +298,11 @@ def get_menu_id_from_args(interaction: Interaction) -> int: class RoleReactMenuTransformer(Transformer): + """The autocomplete transformer to provide a list of RoleReact menu IDs for a given guild. + + Returns: + List[Choice[str]]: A list of exisitng menu IDs in a guild. + """ async def autocomplete(self, interaction: Interaction, value: Union[int, float, str]) -> List[Choice[str]]: guild_role_menus = DBSession.list(RoleReactMenus, guild_id=interaction.guild.id) @@ -295,6 +317,11 @@ async def autocomplete(self, interaction: Interaction, value: Union[int, float, class RoleReactRoleTransformer(Transformer): + """The autocomplete transformer to provide a list of Roles that are in the already provided RoleReact menu. + + Returns: + List[Choice[str]]: A list of Roles for the RoleReact menu currently chosen. + """ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: menu_id = get_menu_id_from_args(interaction) From 5d1048e746ecbf8044f503f6fd72f6074510603e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 22:23:30 +0200 Subject: [PATCH 324/404] Added docstrings --- src/extensions/default/RoleReact.py | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index b5739783..19c8fa50 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -48,6 +48,16 @@ def __str__(self): async def validate_message_id(interaction: Interaction, message_id: int) -> Union[None, Message]: + """Check if a given message ID is a RoleReact menu message. If it is, return the message object + for the given message ID. + + Args: + interaction (Interaction): The interaction. Used to obtain the channel to then fetch the message. + message_id (int): The ID of the message to check. + + Returns: + Union[None, Message]: The message of the given ID if it is a RoleReact menu message. None otherwise. + """ try: message = await interaction.channel.fetch_message(message_id) except NotFound: @@ -71,6 +81,15 @@ async def validate_message_id(interaction: Interaction, message_id: int) -> Unio def options_from_view(view: View, guild: Guild = None) -> list[RoleOption]: + """Generate a list of RoleOption from a discord View containing select menu(s). + + Args: + view (View): The view to obtain the available options from. + guild (Guild, optional): The guild in which the view/message exists in. Defaults to None. + + Returns: + list[RoleOption]: The list of RoleOption that correspond to the available options in the given View. + """ if not view: return [] @@ -92,6 +111,17 @@ def options_from_view(view: View, guild: Guild = None) -> list[RoleOption]: def view_from_options(options: list[RoleOption]) -> View: + """Generate a view with one or many Select menus given a list of RoleOption. + + Args: + options (list[RoleOption]): The list of RoleOption to turn into Select menu(s) + + Raises: + ValueError: If the number of options exceeds the number of items allowed in a single view. + + Returns: + View: The view containing the select menu(s) with the RoleOptions as the selectable options. + """ if len(options) > MAX_VIEW_ITEM_COUNT * MAX_VIEW_ITEM_COUNT: raise ValueError( f"Too many options supplied to a single view. " @@ -123,6 +153,15 @@ def view_from_options(options: list[RoleOption]) -> View: def no_options_embed(menu_id: int = None, color: Color = Color.random()) -> Embed: + """Create an embed for which there are no selectable roles currently available. + + Args: + menu_id (int, optional): The ID of the menu. Defaults to None. + color (Color, optional): The color of the embed. Defaults to Color.random(). + + Returns: + Embed: The embed generated with placeholder text. + """ description = COG_STRINGS["react_footer_no_id"] if not menu_id else COG_STRINGS["react_empty_menu"].format( message_id=menu_id ) @@ -136,6 +175,20 @@ def no_options_embed(menu_id: int = None, color: Color = Color.random()) -> Embe def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: Color = Color.random()) -> list[Embed]: + """Create an embed or many given a list of RoleOption. If there are too many roles for a single embed, multiple + embeds will be created to contain all roles. + + Args: + options (list[RoleOption]): The options available that are selectable in the eventual message's View. + menu_id (int, optional): The ID of the message in which the embed will be sent. Defaults to None. + color (Color, optional): The color of the embed. Defaults to Color.random(). + + Raises: + ValueError: If there are too many options for the View, the embed would be invalid and thus raises a ValueError. + + Returns: + list[Embed]: The embeds that contain the selectable options. + """ if len(options) > MAX_VIEW_ITEM_COUNT * MAX_VIEW_ITEM_COUNT: raise ValueError( f"Too many options supplied to a single message. " @@ -167,6 +220,16 @@ def embeds_from_options(options: list[RoleOption], menu_id: int = None, color: C def get_roles_from_select(view: View, guild: Guild, child_index: int) -> list[Role]: + """Get a list of Roles from a specific Select menu in a View using the index value. + + Args: + view (View): The view in which the Select menu exists. + guild (Guild): The guild in which the View/message exists. + child_index (int): The index of the menu in the View's children list. + + Returns: + list[Role]: The list of roles that a Select menu has as options. + """ try: select_menu = view.children[child_index] except IndexError: @@ -193,6 +256,12 @@ def __init__(self, bot: Bot): @GroupCog.listener() async def on_interaction(self, interaction: Interaction): + """Listens for when a user has (de)selected options in a RoleReact Select menu and + handles the changes to the requested roles. + + Args: + interaction (Interaction): The interaction performed. + """ if not interaction.data or not interaction.data.get("custom_id"): return False @@ -222,6 +291,13 @@ async def on_interaction(self, interaction: Interaction): @rename(color=COG_STRINGS["react_create_menu_embed_color_rename"]) @autocomplete(color=ColourTransformer.autocomplete) async def create_menu(self, interaction: Interaction, color: Transform[Color, ColourTransformer] = Color.random()): + """The command to create a new RoleReact menu/message. This command must be used to initalise a message as it + serves to ensure that not any message can be turned into a RoleReact message. + + Args: + interaction (Interaction): The interaction of the command. + color (Transform[Color, ColourTransformer], optional): The color of the embed. Defaults to Color.random(). + """ await interaction.response.defer() message = await interaction.channel.send("​") @@ -239,6 +315,13 @@ async def create_menu(self, interaction: Interaction, color: Transform[Color, Co @rename(menu_id=COG_STRINGS["react_delete_menu_message_id_rename"]) @autocomplete(menu_id=RoleReactMenuTransformer.autocomplete) async def delete_menu(self, interaction: Interaction, menu_id: str): + """Deletes a menu from the database as well as the actual message containing the RoleReact menu. If a message + is manually deleted, it will still appear in the RoleReactMenuTransformer autocomplete options. + + Args: + interaction (Interaction): The interaction of the command. + menu_id (str): The ID of the menu to delete. + """ await interaction.response.defer() message = await validate_message_id(interaction, menu_id) @@ -279,6 +362,16 @@ async def add_role( EmojiTransformer] = None, description: str = None ): + """The command to add a role to a specific RoleReact menu. The emoji or description do not need to be unique, + and are purely visual aids for users to better understand a role. + + Args: + interaction (Interaction): The interaction of the command. + menu_id (str): The ID of the menu to add to. + role (Role): The role to add to the menu. + emoji (Transform[PartialEmoji, EmojiTransformer], optional): The emoji to associate with the role. Defaults to None. + description (str, optional): The description of the role. Defaults to None. + """ await interaction.response.defer() message = await validate_message_id(interaction, menu_id) @@ -312,6 +405,14 @@ async def add_role( ) @autocomplete(menu_id=RoleReactMenuTransformer.autocomplete, role_id=RoleReactRoleTransformer.autocomplete) async def remove_item(self, interaction: Interaction, menu_id: str, role_id: str): + """The command to remove a role from a given menu ID. The role ID can either be given manually or selected + from the autocompleted list of roles in the menu selected as the first argument. + + Args: + interaction (Interaction): The interaction of the command. + menu_id (str): The ID of the menu to remove the role from. + role_id (str): The ID of the role to remove from the menu. + """ await interaction.response.defer() message = await validate_message_id(interaction, menu_id) From 672510d99452864861ff50693b9b738e9fc39619 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 22:27:25 +0200 Subject: [PATCH 325/404] Fixed spelling error in VoiceAdmin --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 52fc1ad8..c85ccfeb 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ - [ ] TwitterCog ❌ -- [x] VoicemasterCog ✅ Implemented as VocieAdmin +- [x] VoicemasterCog ✅ Implemented as VoiceAdmin - [ ] VotingCog ⚠️ # Quick Setup Guide From 9dd0c7b6b3a314f7e71dcbfa94aba7bfb6c0dfcd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 22:38:11 +0200 Subject: [PATCH 326/404] Started implementation of new PingableRolesCog --- README.md | 2 +- secrets.template | 3 ++- src/extensions/dynamic/UserRoles.py | 31 +++++++++++++++++++++++++++++ src/locale/UserRoles.toml | 2 ++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/extensions/dynamic/UserRoles.py create mode 100644 src/locale/UserRoles.toml diff --git a/README.md b/README.md index c85ccfeb..3fd6442c 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - [x] EventCategoriesCog ✅ Implemented as EventTools - [ ] LogChannelCog ⚠️ - [x] MusicCog ✅ Implemented as VCMusic -- [ ] PingableRolesCog ❌ +- [ ] PingableRolesCog ✅ WIP implementation in UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ - [ ] TwitterCog ❌ diff --git a/secrets.template b/secrets.template index 13fcba76..0cdd1732 100644 --- a/secrets.template +++ b/secrets.template @@ -29,4 +29,5 @@ GOOGLE_API= ENABLE_VOICEADMIN=FALSE ENABLE_EVENTTOOLS=FALSE ENABLE_AUTOROLES=FALSE -ENABLE_VCMUSIC=FALSE \ No newline at end of file +ENABLE_VCMUSIC=FALSE +ENABLE_USERROLES=FALSE \ No newline at end of file diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py new file mode 100644 index 00000000..9272b9a4 --- /dev/null +++ b/src/extensions/dynamic/UserRoles.py @@ -0,0 +1,31 @@ +from discord.app_commands import guild_only, default_permissions +from discord.ext.commands import GroupCog, Bot +from common.io import load_cog_toml +import logging + +COG_STRINGS = load_cog_toml(__name__) + + +@default_permissions(administrator=True) +@guild_only() +class UserRolesAdmin(GroupCog, name=COG_STRINGS["users_admin_group_name"]): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") + + +@default_permissions(administrator=True) +@guild_only() +class UserRoles(GroupCog, name=COG_STRINGS["users_group_name"]): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") + + +async def setup(bot: Bot): + await bot.add_cog(UserRolesAdmin(bot)) + await bot.add_cog(UserRoles(bot)) diff --git a/src/locale/UserRoles.toml b/src/locale/UserRoles.toml new file mode 100644 index 00000000..6240eb90 --- /dev/null +++ b/src/locale/UserRoles.toml @@ -0,0 +1,2 @@ +users_admin_group_name = "pingable-admin" +users_group_name = "pingable" \ No newline at end of file From da3ef04f16d58333ea175432c303ed80dd971b1f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 29 Mar 2023 22:40:28 +0200 Subject: [PATCH 327/404] Updated clarity of TODO list --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3fd6442c..82d8a276 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,8 @@ The user has control over the child Voice Channel name, and can limit how many/w <pre> ✅ Extension implemented either partially or fully. +⏳ Extension currently being implemented not yet ready. + ❌ High priority extension not yet implemented. ⚠️ Low priority extension not yet implemented. @@ -250,7 +252,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - [x] EventCategoriesCog ✅ Implemented as EventTools - [ ] LogChannelCog ⚠️ - [x] MusicCog ✅ Implemented as VCMusic -- [ ] PingableRolesCog ✅ WIP implementation in UserRoles +- [ ] PingableRolesCog ⏳ WIP implementation in UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ - [ ] TwitterCog ❌ From ba24cddb9a4946b93d59f3fd8ca2a790bf19a915 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 30 Mar 2023 10:18:41 +0200 Subject: [PATCH 328/404] Started implementation of TwitterCog --- README.md | 2 +- secrets.template | 12 +++++++++++- src/extensions/dynamic/TwitterTracker.py | 22 ++++++++++++++++++++++ src/locale/TwitterTracker.toml | 1 + 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/extensions/dynamic/TwitterTracker.py create mode 100644 src/locale/TwitterTracker.toml diff --git a/README.md b/README.md index 82d8a276..8537f5f6 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ The user has control over the child Voice Channel name, and can limit how many/w - [ ] PingableRolesCog ⏳ WIP implementation in UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ -- [ ] TwitterCog ❌ +- [ ] TwitterCog ⏳ WIP implementation in TwitterTracker - [x] VoicemasterCog ✅ Implemented as VoiceAdmin - [ ] VotingCog ⚠️ diff --git a/secrets.template b/secrets.template index 0cdd1732..d1ca6ae4 100644 --- a/secrets.template +++ b/secrets.template @@ -25,9 +25,19 @@ DB_OVERRIDE= # These variables are used in the VCMusic extension MUSIC_DEFAULT_IMAGE=https://static.wixstatic.com/media/d8a4c5_b42c82e4532c4f8e9f9b2f2d9bb5a53e~mv2.png/v1/fill/w_287,h_287,al_c,q_85,usm_0.66_1.00_0.01/esportslogo.webp GOOGLE_API= +################### + +## TwitterTracker Vars ## +# These variables are used in the TwitterTracker extension. +TWITTER_CONSUMER_KEY= +TWITTER_CONSUMER_SECRET= +TWITTER_ACCESS_TOKEN= +TWITTER_ACCESS_TOKEN_SECRET= +################### ENABLE_VOICEADMIN=FALSE ENABLE_EVENTTOOLS=FALSE ENABLE_AUTOROLES=FALSE ENABLE_VCMUSIC=FALSE -ENABLE_USERROLES=FALSE \ No newline at end of file +ENABLE_USERROLES=FALSE +ENABLE_TWITTERTRACKER=FALSE \ No newline at end of file diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py new file mode 100644 index 00000000..c812d76d --- /dev/null +++ b/src/extensions/dynamic/TwitterTracker.py @@ -0,0 +1,22 @@ +import logging + +from discord.app_commands import default_permissions, guild_only +from discord.ext.commands import Bot, GroupCog + +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) + + +@default_permissions(administrator=True) +@guild_only() +class TwitterTracker(GroupCog, name=COG_STRINGS["twitter_group_name"]): + + def __init__(self, bot: Bot): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + +async def setup(bot: Bot): + await bot.add_cog(TwitterTracker(bot)) diff --git a/src/locale/TwitterTracker.toml b/src/locale/TwitterTracker.toml new file mode 100644 index 00000000..a73c3190 --- /dev/null +++ b/src/locale/TwitterTracker.toml @@ -0,0 +1 @@ +twitter_group_name = "twitter" \ No newline at end of file From 30de08c5469bb9f8facc6ed4283f989ee5a49db9 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 30 Mar 2023 13:14:59 +0200 Subject: [PATCH 329/404] Basic webhook creation command --- src/extensions/dynamic/TwitterTracker.py | 30 +++++++++++++++++++++++- src/locale/TwitterTracker.toml | 9 ++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index c812d76d..ef8ff360 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -1,11 +1,14 @@ import logging -from discord.app_commands import default_permissions, guild_only +from discord import Interaction, TextChannel +from discord.app_commands import default_permissions, guild_only, command, describe, rename from discord.ext.commands import Bot, GroupCog +from common.discord import respond_or_followup from common.io import load_cog_toml COG_STRINGS = load_cog_toml(__name__) +WEBHOOK_PREFIX = __name__ @default_permissions(administrator=True) @@ -16,6 +19,31 @@ def __init__(self, bot: Bot): self.bot = bot self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") + self.webhooks = {} + + @command(name=COG_STRINGS["twitter_create_webhook_name"], description=COG_STRINGS["twitter_create_webhook_description"]) + @describe( + name=COG_STRINGS["twitter_create_webhook_name_describe"], + channel=COG_STRINGS["twitter_create_webhook_channel_describe"] + ) + @rename( + name=COG_STRINGS["twitter_create_webhook_name_rename"], + channel=COG_STRINGS["twitter_create_webhook_channel_rename"] + ) + async def create_webhook(self, interaction: Interaction, name: str = None, channel: TextChannel = None): + await interaction.response.defer(ephemeral=True) + + if not channel: + channel = interaction.channel + + if not name: + name = f"{WEBHOOK_PREFIX}-{channel.name}" + + webhook = await channel.create_webhook(name=name) + if not interaction.guild.id in self.webhooks: + self.webhooks[interaction.guild.id] = {} + self.webhooks[interaction.guild.id][webhook.id] = webhook + await respond_or_followup(f"Created webhook: {webhook.url}", interaction, ephemeral=True, delete_after=None) async def setup(bot: Bot): diff --git a/src/locale/TwitterTracker.toml b/src/locale/TwitterTracker.toml index a73c3190..e65a65e1 100644 --- a/src/locale/TwitterTracker.toml +++ b/src/locale/TwitterTracker.toml @@ -1 +1,8 @@ -twitter_group_name = "twitter" \ No newline at end of file +twitter_group_name = "twitter" + +twitter_create_webhook_name = "add-channel" +twitter_create_webhook_description = "Adds a channel as a place to forward tweets to." +twitter_create_webhook_name_rename = "webhook-name" +twitter_create_webhook_name_describe = "The custom name of the webhook for the channel." +twitter_create_webhook_channel_rename = "channel" +twitter_create_webhook_channel_describe = "The text channel to forward the tweets to." \ No newline at end of file From 0181d14e9c93c358059c527c57835ccfdc712679 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 30 Mar 2023 13:51:22 +0200 Subject: [PATCH 330/404] Added TwitterWebhookIDTransformer --- src/common/discord.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/common/discord.py b/src/common/discord.py index 8630938c..a12d401a 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -341,3 +341,18 @@ async def autocomplete(self, interaction: Interaction, value: str) -> List[Choic choices = [Choice(name=f"{'' if x.name.startswith('@') else '@'}{x.name}", value=str(x.id)) for x in menu_roles] return choices[:25] + + +class TwitterWebhookIDTransformer(Transformer): + + async def autocomplete(self, interaction: Interaction, value: Union[int, str]) -> List[Choice[str]]: + guild_webhooks = self.webhooks.get(interaction.guild.id) + if value: + choices = [ + Choice(name=f"{guild_webhooks.get(x).name} ({x})", + value=str(x)) for x in guild_webhooks if value.lower() in guild_webhooks.get(x).name.lower() + ] + else: + choices = [Choice(name=f"{guild_webhooks.get(x).name} ({x})", value=str(x)) for x in guild_webhooks] + + return choices[:25] From 16167087eccbf6c35119a6df8a794c884f3f6d36 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 30 Mar 2023 13:53:18 +0200 Subject: [PATCH 331/404] Basic webhook deleting command --- src/extensions/dynamic/TwitterTracker.py | 26 ++++++++++++++++++++++-- src/locale/TwitterTracker.toml | 7 ++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index ef8ff360..8cf0c2b8 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -1,10 +1,10 @@ import logging from discord import Interaction, TextChannel -from discord.app_commands import default_permissions, guild_only, command, describe, rename +from discord.app_commands import (autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog -from common.discord import respond_or_followup +from common.discord import TwitterWebhookIDTransformer, respond_or_followup from common.io import load_cog_toml COG_STRINGS = load_cog_toml(__name__) @@ -45,6 +45,28 @@ async def create_webhook(self, interaction: Interaction, name: str = None, chann self.webhooks[interaction.guild.id][webhook.id] = webhook await respond_or_followup(f"Created webhook: {webhook.url}", interaction, ephemeral=True, delete_after=None) + @command(name=COG_STRINGS["twitter_delete_webhook_name"], description=COG_STRINGS["twitter_delete_webhook_description"]) + @describe(webhook_id=COG_STRINGS["twitter_delete_webhook_webhook_id_describe"]) + @rename(webhook_id=COG_STRINGS["twitter_delete_webhook_webhook_id_rename"]) + @autocomplete(webhook_id=TwitterWebhookIDTransformer.autocomplete) + async def delete_webhook(self, interaction: Interaction, webhook_id: str): + await interaction.response.defer(ephemeral=True) + + if not webhook_id.isdigit(): + await respond_or_followup("Invalid webhook ID provided!", interaction, ephemeral=True) + return + + webbhook_id_int = int(webhook_id) + guild_webhooks = self.webhooks.get(interaction.guild.id) + from discord import Webhook + webhook: Webhook = guild_webhooks.pop(webbhook_id_int, None) + if not webhook: + await respond_or_followup("The provided webhook ID is not a TwitterTracker webhook!", interaction, ephemeral=True) + return + + await webhook.delete() + await respond_or_followup("Succesfully deleted webhook!", interaction, ephemeral=True) + async def setup(bot: Bot): await bot.add_cog(TwitterTracker(bot)) diff --git a/src/locale/TwitterTracker.toml b/src/locale/TwitterTracker.toml index e65a65e1..2c03cfa8 100644 --- a/src/locale/TwitterTracker.toml +++ b/src/locale/TwitterTracker.toml @@ -5,4 +5,9 @@ twitter_create_webhook_description = "Adds a channel as a place to forward tweet twitter_create_webhook_name_rename = "webhook-name" twitter_create_webhook_name_describe = "The custom name of the webhook for the channel." twitter_create_webhook_channel_rename = "channel" -twitter_create_webhook_channel_describe = "The text channel to forward the tweets to." \ No newline at end of file +twitter_create_webhook_channel_describe = "The text channel to forward the tweets to." + +twitter_delete_webhook_name = "delete-webhook" +twitter_delete_webhook_description = "Removes a given webhook." +twitter_delete_webhook_webhook_id_rename = "webhook" +twitter_delete_webhook_webhook_id_describe = "The webhook to delete." From 8a6d73bee8ecc4e4f7ef2a271bd83fe2f204fdc1 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 08:58:40 +0200 Subject: [PATCH 332/404] Added loading of webhooks on_ready --- src/extensions/dynamic/TwitterTracker.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index 8cf0c2b8..60ea4db8 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -21,6 +21,19 @@ def __init__(self, bot: Bot): self.logger.info(f"{__name__} has been added as a Cog") self.webhooks = {} + @GroupCog.listener() + async def on_ready(self): + webhook_count = 0 + for guild in self.bot.guilds: + guild_webhooks = await guild.webhooks() + self.webhooks[guild.id] = {} + for webhook in guild_webhooks: + if webhook.name.startswith(WEBHOOK_PREFIX): + self.webhooks[guild.id][webhook.id] = webhook + webhook_count += 1 + + self.logger.info(f"Found {webhook_count} webhook(s) across {len(self.webhooks)} guild(s)") + @command(name=COG_STRINGS["twitter_create_webhook_name"], description=COG_STRINGS["twitter_create_webhook_description"]) @describe( name=COG_STRINGS["twitter_create_webhook_name_describe"], @@ -37,8 +50,9 @@ async def create_webhook(self, interaction: Interaction, name: str = None, chann channel = interaction.channel if not name: - name = f"{WEBHOOK_PREFIX}-{channel.name}" + name = channel.name + name = f"{WEBHOOK_PREFIX}-{name}" webhook = await channel.create_webhook(name=name) if not interaction.guild.id in self.webhooks: self.webhooks[interaction.guild.id] = {} From c6ae8eaeca84cbcea10e380805812815343d1494 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 08:59:18 +0200 Subject: [PATCH 333/404] Added check for valid webhook ID that arent for Twitter --- src/extensions/dynamic/TwitterTracker.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index 60ea4db8..6110982a 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -72,12 +72,19 @@ async def delete_webhook(self, interaction: Interaction, webhook_id: str): webbhook_id_int = int(webhook_id) guild_webhooks = self.webhooks.get(interaction.guild.id) - from discord import Webhook - webhook: Webhook = guild_webhooks.pop(webbhook_id_int, None) + webhook = guild_webhooks.pop(webbhook_id_int, None) if not webhook: await respond_or_followup("The provided webhook ID is not a TwitterTracker webhook!", interaction, ephemeral=True) return + if not webhook.name.startswith(WEBHOOK_PREFIX): + await respond_or_followup( + "The provided webhook ID is not for a TwitterTracker webhook!", + interaction, + ephemeral=True + ) + return + await webhook.delete() await respond_or_followup("Succesfully deleted webhook!", interaction, ephemeral=True) From b33218f6ee9cc3b3935f17c6979749fa1ebe7d3a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 10:14:06 +0200 Subject: [PATCH 334/404] Load tracked accounts from DB on_ready --- src/database/models.py | 12 +++++++++++- src/extensions/dynamic/TwitterTracker.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/database/models.py b/src/database/models.py index 51a1ef27..75d95adc 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -11,7 +11,8 @@ "AutoRolesConfig", "EventToolsEvents", "MusicChannels", - "RoleReactMenus" + "RoleReactMenus", + "TwitterTrackerAccounts" ] @@ -64,3 +65,12 @@ class RoleReactMenus(base): primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) message_id = Column(BigInteger, nullable=False) + + +class TwitterTrackerAccounts(base): + __tablename__ = "twittertracker_accounts" + primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + guild_id = Column(BigInteger, nullable=False) + twitter_id = Column(BigInteger, nullable=False) + twitter_name = Column(String, nullable=False) + webhook_id = Column(BigInteger, nullable=False) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index 6110982a..91e640fe 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -6,11 +6,17 @@ from common.discord import TwitterWebhookIDTransformer, respond_or_followup from common.io import load_cog_toml +from database.gateway import DBSession +from database.models import TwitterTrackerAccounts COG_STRINGS = load_cog_toml(__name__) WEBHOOK_PREFIX = __name__ +def entry_primary_key(twitter_id: int, webhook_id: int) -> int: + return int(f"{twitter_id % 1000}{webhook_id % 1000}") + + @default_permissions(administrator=True) @guild_only() class TwitterTracker(GroupCog, name=COG_STRINGS["twitter_group_name"]): @@ -20,6 +26,7 @@ def __init__(self, bot: Bot): self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__} has been added as a Cog") self.webhooks = {} + self.accounts = {} @GroupCog.listener() async def on_ready(self): @@ -32,7 +39,20 @@ async def on_ready(self): self.webhooks[guild.id][webhook.id] = webhook webhook_count += 1 + all_accounts = DBSession.list(TwitterTrackerAccounts) + account_count = 0 + for entry in all_accounts: + if entry.guild_id not in self.accounts: + self.accounts[entry.guild_id] = {} + + if entry.twitter_id not in self.accounts.get(entry.guild_id): + self.accounts[entry.guild_id][entry.twitter_id] = [] + account_count += 1 + + self.accounts[entry.guild_id][entry.twitter_id].append(entry) + self.logger.info(f"Found {webhook_count} webhook(s) across {len(self.webhooks)} guild(s)") + self.logger.info(f"Found {account_count} account(s) across {len(self.webhooks)} guild(s)") @command(name=COG_STRINGS["twitter_create_webhook_name"], description=COG_STRINGS["twitter_create_webhook_description"]) @describe( From 4ec94efeef4fdcade47c60d63c1927ae1e7b39f4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 11:21:36 +0200 Subject: [PATCH 335/404] Added account tracking command --- src/extensions/dynamic/TwitterTracker.py | 60 ++++++++++++++++++++---- src/locale/TwitterTracker.toml | 7 +++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index 91e640fe..863dbe31 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -42,18 +42,23 @@ async def on_ready(self): all_accounts = DBSession.list(TwitterTrackerAccounts) account_count = 0 for entry in all_accounts: - if entry.guild_id not in self.accounts: - self.accounts[entry.guild_id] = {} - - if entry.twitter_id not in self.accounts.get(entry.guild_id): - self.accounts[entry.guild_id][entry.twitter_id] = [] - account_count += 1 - - self.accounts[entry.guild_id][entry.twitter_id].append(entry) + account_count += self.add_account(entry) self.logger.info(f"Found {webhook_count} webhook(s) across {len(self.webhooks)} guild(s)") self.logger.info(f"Found {account_count} account(s) across {len(self.webhooks)} guild(s)") + def add_account(self, db_entry: TwitterTrackerAccounts): + ret_val = 0 + if db_entry.guild_id not in self.accounts: + self.accounts[db_entry.guild_id] = {} + + if db_entry.twitter_id not in self.accounts.get(db_entry.guild_id): + self.accounts[db_entry.guild_id][db_entry.twitter_id] = [] + ret_val = 1 + + self.accounts[db_entry.guild_id][db_entry.twitter_id].append(db_entry) + return ret_val + @command(name=COG_STRINGS["twitter_create_webhook_name"], description=COG_STRINGS["twitter_create_webhook_description"]) @describe( name=COG_STRINGS["twitter_create_webhook_name_describe"], @@ -108,6 +113,45 @@ async def delete_webhook(self, interaction: Interaction, webhook_id: str): await webhook.delete() await respond_or_followup("Succesfully deleted webhook!", interaction, ephemeral=True) + @command(name=COG_STRINGS["twitter_track_account_name"], description=COG_STRINGS["twitter_track_account_description"]) + @describe( + twitter_id=COG_STRINGS["twitter_track_account_twitter_id_describe"], + webhook_id=COG_STRINGS["twitter_track_account_webhook_id_describe"] + ) + @rename( + twitter_id=COG_STRINGS["twitter_track_account_twitter_id_rename"], + webhook_id=COG_STRINGS["twitter_track_account_webhook_id_rename"] + ) + @autocomplete(webhook_id=TwitterWebhookIDTransformer.autocomplete) + async def track_account(self, interaction: Interaction, twitter_id: str, webhook_id: str): + await interaction.response.defer(ephemeral=True) + + if not twitter_id.isdigit(): + await respond_or_followup("The given twitter ID is not valid!", interaction, ephemeral=True) + return + + if not webhook_id.isdigit(): + await respond_or_followup("The given webhook ID is not valid!", interaction, ephemeral=True) + return + + current_webhooks = self.accounts.get(interaction.guild.id, {}).get(int(twitter_id), []) + exists = [x for x in current_webhooks if int(webhook_id) == x.webhook_id] + if exists: + await respond_or_followup("The account given is already tracked with that webhhook!", interaction, ephemeral=True) + return + + db_item = TwitterTrackerAccounts( + primary_key=entry_primary_key(int(twitter_id), + int(webhook_id)), + guild_id=interaction.guild.id, + twitter_id=int(twitter_id), + webhook_id=int(webhook_id), + twitter_name="TwitterHandle" + ) + DBSession.create(db_item) + self.add_account(db_item) + await respond_or_followup(f"Now trackiing {twitter_id} in {webhook_id}", interaction, ephemeral=True) + async def setup(bot: Bot): await bot.add_cog(TwitterTracker(bot)) diff --git a/src/locale/TwitterTracker.toml b/src/locale/TwitterTracker.toml index 2c03cfa8..faa2bd06 100644 --- a/src/locale/TwitterTracker.toml +++ b/src/locale/TwitterTracker.toml @@ -11,3 +11,10 @@ twitter_delete_webhook_name = "delete-webhook" twitter_delete_webhook_description = "Removes a given webhook." twitter_delete_webhook_webhook_id_rename = "webhook" twitter_delete_webhook_webhook_id_describe = "The webhook to delete." + +twitter_track_account_name = "track" +twitter_track_account_description = "Add an account to track the tweets of." +twitter_track_account_twitter_id_rename = "twitter-id" +twitter_track_account_twitter_id_describe = "The ID of the twitter account to track." +twitter_track_account_webhook_id_rename = "webhook" +twitter_track_account_webhook_id_describe = "The webhook to send the tweets to." From 3872f4763e3b38baffe01e728260ac801b1ebc73 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 11:47:10 +0200 Subject: [PATCH 336/404] Added command to untrack an account --- src/extensions/dynamic/TwitterTracker.py | 60 ++++++++++++++++++++++++ src/locale/TwitterTracker.toml | 9 ++++ 2 files changed, 69 insertions(+) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index 863dbe31..ea0247fd 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -152,6 +152,66 @@ async def track_account(self, interaction: Interaction, twitter_id: str, webhook self.add_account(db_item) await respond_or_followup(f"Now trackiing {twitter_id} in {webhook_id}", interaction, ephemeral=True) + @command(name=COG_STRINGS["twitter_untrack_account_name"], description=COG_STRINGS["twitter_untrack_account_description"]) + @describe( + twitter_id=COG_STRINGS["twitter_untrack_account_twitter_id_describe"], + webhook_id=COG_STRINGS["twitter_untrack_account_webhook_id_describe"], + all_webhooks=COG_STRINGS["twitter_untrack_account_all_webhooks_describe"] + ) + @rename( + twitter_id=COG_STRINGS["twitter_untrack_account_twitter_id_rename"], + webhook_id=COG_STRINGS["twitter_untrack_account_webhook_id_rename"], + all_webhooks=COG_STRINGS["twitter_untrack_account_all_webhooks_rename"] + ) + @autocomplete(webhook_id=TwitterWebhookIDTransformer.autocomplete) + async def untrack_account( + self, + interaction: Interaction, + twitter_id: str, + all_webhooks: bool = False, + webhook_id: str = None + ): + if not all_webhooks and not webhook_id: + await respond_or_followup( + "You must confirm to untrack from all webhooks or provide a webhook to untrack from", + interaction, + ephemeral=True + ) + + + if not twitter_id.isdigit(): + await respond_or_followup("The given twitter ID is not valid!", interaction, ephemeral=True) + return + + if all_webhooks: + entries = self.accounts.get(interaction.guild.id, {}).pop(int(twitter_id), []) + for entry in entries: + DBSession.delete(entry) + await respond_or_followup(f"No longer trakcing {twitter_id} in any webhook!", interaction, ephemeral=True) + return + + + if not webhook_id.isdigit(): + await respond_or_followup("The given webhook ID is not valid!", interaction, ephemeral=True) + return + + entries = self.accounts.get(interaction.guild.id, {}).get(int(twitter_id), []) + found = -1 + db_item = None + for idx, item in enumerate(entries): + if item.webhook_id == int(webhook_id): + found = idx + db_item = item + break + + if found >= 0: + self.accounts.get(interaction.guild.id).get(int(twitter_id)).pop(found) + + if db_item: + DBSession.delete(db_item) + + await respond_or_followup(f"No longer tracking {twitter_id} in {webhook_id}", interaction, ephemeral=True) + async def setup(bot: Bot): await bot.add_cog(TwitterTracker(bot)) diff --git a/src/locale/TwitterTracker.toml b/src/locale/TwitterTracker.toml index faa2bd06..0836795d 100644 --- a/src/locale/TwitterTracker.toml +++ b/src/locale/TwitterTracker.toml @@ -18,3 +18,12 @@ twitter_track_account_twitter_id_rename = "twitter-id" twitter_track_account_twitter_id_describe = "The ID of the twitter account to track." twitter_track_account_webhook_id_rename = "webhook" twitter_track_account_webhook_id_describe = "The webhook to send the tweets to." + +twitter_untrack_account_name = "untrack" +twitter_untrack_account_description = "Stop tracking the tweets of an account, globally or for a specific channel." +twitter_untrack_account_twitter_id_rename = "twitter-id" +twitter_untrack_account_twitter_id_describe = "The ID of the twitter account to untrack." +twitter_untrack_account_webhook_id_rename = "webhook" +twitter_untrack_account_webhook_id_describe = "The webhook to stop sending the tweets to." +twitter_untrack_account_all_webhooks_rename = "all-channels" +twitter_untrack_account_all_webhooks_describe = "If the account should be untracked for all webhooks." From 6833a2be5d20eea7da863beb3082fbb1be9b63a6 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 12:53:41 +0200 Subject: [PATCH 337/404] Added .venv to gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cc417615..b4e16c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ **/__pycache__ -test-file.py +.venv .vscode .idea secrets.env From cff1bebdc81b763c4a77867b85bc414858edd94a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 13:26:28 +0200 Subject: [PATCH 338/404] Removed old requirements --- requirements.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index a5aa0cda..9992c9c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,6 @@ sqlalchemy sqlalchemy-utils discord.py[voice] python-dotenv -emoji -lxml -PyNaCl -aiohttp[speedups] coloredlogs uvloop toml From bcbfe292dd95a4c910c2368430d1f25b481c6cb3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 13:26:44 +0200 Subject: [PATCH 339/404] Added TwitterTracker requirements --- requirements[twitter].txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements[twitter].txt diff --git a/requirements[twitter].txt b/requirements[twitter].txt new file mode 100644 index 00000000..a96e0b70 --- /dev/null +++ b/requirements[twitter].txt @@ -0,0 +1,2 @@ +tweepy==3.10.0 +aiohttp[speedups] \ No newline at end of file From d214adbb811649187358fa1426da8cc6d5174697 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 31 Mar 2023 13:27:08 +0200 Subject: [PATCH 340/404] Fixed incorrect kwarg for SongRequest --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 8071322a..b652bd27 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -1044,7 +1044,7 @@ async def add_modal_interaction_handler(self, interaction: Interaction) -> bool: multiple_request = item.get("components")[0].get("value") request_list = [ - SongRequest(raw_requst=x.strip(), + SongRequest(raw_request=x.strip(), request_type=parse_request_type(x.strip()), request_member=interaction.user) for x in multiple_request.split("\n") if x.strip() not in ('', ' ') From b4dc1ef69483b357add53ad720758cce110d0d0f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 2 Apr 2023 19:31:45 +0200 Subject: [PATCH 341/404] Changed all self.author to CURRENT_AUTHOR --- src/extensions/dynamic/VCMusic.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index b652bd27..bd3dd503 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -655,7 +655,7 @@ async def set_channel( if clear_messages: await channel.purge(before=interaction.created_at) - embed = create_music_embed(embed_color, self.author) + embed = create_music_embed(embed_color, CURRENT_AUTHOR) view = create_music_actionbar() message = await channel.send(embed=embed, view=view) @@ -1208,7 +1208,11 @@ async def resume_or_start_playback(self, interaction: Interaction) -> bool: await respond_or_followup(COG_STRINGS["music_resume_success"], interaction, ephemeral=True) return True - await respond_or_followup(COG_STRINGS["music_generic_error"].format(author=self.author), interaction, ephemeral=True) + await respond_or_followup( + COG_STRINGS["music_generic_error"].format(author=CURRENT_AUTHOR), + interaction, + ephemeral=True + ) return False async def pause_playback(self, interaction: Interaction) -> bool: @@ -1349,7 +1353,7 @@ async def update_embed(self, guild_id: int) -> bool: queue_length = COG_STRINGS["music_embed_queue_length"].format(length=len(self.active_players.get(guild_id).queue)) new_embed = create_music_embed( color=current_embed.color, - author=self.author, + author=CURRENT_AUTHOR, title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), description=f"{user}\n{volume}\n{queue_length}", image=current_song.thumbnail, @@ -1358,7 +1362,7 @@ async def update_embed(self, guild_id: int) -> bool: voice_client = self.active_players.get(guild_id).voice_client is_paused = True if voice_client is None else not voice_client.is_playing() else: - new_embed = create_music_embed(color=current_embed.color, author=self.author) + new_embed = create_music_embed(color=current_embed.color, author=CURRENT_AUTHOR) is_paused = True await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) From f0e4d2cf4ef0679cb1beeaec1be887f280e96b0f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 2 Apr 2023 20:18:24 +0200 Subject: [PATCH 342/404] Twitter API setup --- src/extensions/dynamic/TwitterTracker.py | 29 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index ea0247fd..ca698a77 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -1,8 +1,10 @@ import logging +import os from discord import Interaction, TextChannel from discord.app_commands import (autocomplete, command, default_permissions, describe, guild_only, rename) from discord.ext.commands import Bot, GroupCog +from tweepy import API, OAuthHandler, Stream, StreamListener from common.discord import TwitterWebhookIDTransformer, respond_or_followup from common.io import load_cog_toml @@ -12,22 +14,41 @@ COG_STRINGS = load_cog_toml(__name__) WEBHOOK_PREFIX = __name__ +CONSUMER_KEY = os.getenv("TWITTER_CONSUMER_KEY") +CONSUMER_SECRET = os.getenv("TWITTER_CONSUMER_SECRET") +ACCESS_TOKEN = os.getenv("TWITTER_ACCESS_TOKEN") +ACCESS_TOKEN_SECRET = os.getenv("TWITTER_ACCESS_TOKEN_SECRET") + def entry_primary_key(twitter_id: int, webhook_id: int) -> int: return int(f"{twitter_id % 1000}{webhook_id % 1000}") +class CustomStreamListener(StreamListener): + + def __init__(self, twitter_api: API): + super().__init__(twitter_api) + + @default_permissions(administrator=True) @guild_only() class TwitterTracker(GroupCog, name=COG_STRINGS["twitter_group_name"]): def __init__(self, bot: Bot): self.bot = bot - self.logger = logging.getLogger(__name__) - self.logger.info(f"{__name__} has been added as a Cog") self.webhooks = {} self.accounts = {} + twitter_auth = OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET) + twitter_auth.set_access_token(ACCESS_TOKEN, ACCESS_TOKEN_SECRET) + self.twitter_api = API(twitter_auth) + self.twitter_api.verify_credentials() + self.stream_listener = CustomStreamListener(self.twitter_api) + self.account_filter = Stream(self.twitter_api.auth, self.stream_listener) + + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + @GroupCog.listener() async def on_ready(self): webhook_count = 0 @@ -177,8 +198,7 @@ async def untrack_account( interaction, ephemeral=True ) - - + if not twitter_id.isdigit(): await respond_or_followup("The given twitter ID is not valid!", interaction, ephemeral=True) return @@ -190,7 +210,6 @@ async def untrack_account( await respond_or_followup(f"No longer trakcing {twitter_id} in any webhook!", interaction, ephemeral=True) return - if not webhook_id.isdigit(): await respond_or_followup("The given webhook ID is not valid!", interaction, ephemeral=True) return From b04f0a04100efafc5953011993e4b6eb9913f096 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 2 Apr 2023 20:27:17 +0200 Subject: [PATCH 343/404] Added stream data handlers --- src/extensions/dynamic/TwitterTracker.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py index ca698a77..bf776ece 100644 --- a/src/extensions/dynamic/TwitterTracker.py +++ b/src/extensions/dynamic/TwitterTracker.py @@ -29,6 +29,12 @@ class CustomStreamListener(StreamListener): def __init__(self, twitter_api: API): super().__init__(twitter_api) + def on_data(self, raw_data: str): + return super().on_data(raw_data) + + def on_error(self, status_code: int): + return super().on_error(status_code) + @default_permissions(administrator=True) @guild_only() From 35b66ec0161faaf9c910d8d98b460634d4df06a4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sat, 15 Apr 2023 18:50:44 +0200 Subject: [PATCH 344/404] Added DS_STORE to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cc417615..51fe744e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ **/__pycache__ test-file.py +.DS_STORE .vscode .idea secrets.env From 9489cf128487a0904d3987505aa38ed28c11eed7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 12 May 2023 14:32:27 +0200 Subject: [PATCH 345/404] Fixed VCMusic section --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8537f5f6..af3e2ac2 100644 --- a/README.md +++ b/README.md @@ -218,11 +218,12 @@ The user has control over the child Voice Channel name, and can limit how many/w #### /music stop +- Stop the current playback. + #### /music volume \<volume\> - Sets the volume percentage between 0-100 -- Stop the current playback. </details> # TODO From 375026d9450792bcd4f14c73f28ab28ec692697f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 12 May 2023 14:40:52 +0200 Subject: [PATCH 346/404] Added Google API credentials instructions --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index af3e2ac2..d6c500c8 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,18 @@ The user has control over the child Voice Channel name, and can limit how many/w ### Environment Variable: `ENABLE_VCMUSIC` +In order to function, a google API key with access to YouTube Data API v3 must be set to the `GOOGLE_API` environment variable. + +### To create your Google API credentials: + +1. Go to the [Google Cloud API]("https://console.cloud.google.com/apis/") site. +1. Create a new project and name it whatever you want. +1. In the [dashboard](https://console.cloud.google.com/apis/dashboard), click the `Enable APIs and Services` and search for `YouTube Data API v3`. +1. Click `Enable` to enable the use of the YouTube API. +1. Keep going back until at your [dashboard](https://console.cloud.google.com/apis/dashboard), and go to the [credentials](https://console.cloud.google.com/apis/credentials) section on the left. +1. Click on `Create Credentials` and then `API key`. +1. Copy the key given. For security, it is recommended that you "restrict key" and only enable `YouTube Data API v3`. + #### /music set-channel \<channel\> [optional: color] [optional: clear-channel] [optional: read-only] - Sets the channel to define as the music channel. From f22c46339b80e62a9ce7a8954ca8d812c1e5c136 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Fri, 12 May 2023 15:26:34 +0200 Subject: [PATCH 347/404] Updated TwitterCog development status --- README.md | 2 +- requirements[twitter].txt | 2 - src/extensions/dynamic/TwitterTracker.py | 242 ----------------------- 3 files changed, 1 insertion(+), 245 deletions(-) delete mode 100644 requirements[twitter].txt delete mode 100644 src/extensions/dynamic/TwitterTracker.py diff --git a/README.md b/README.md index d6c500c8..449003b4 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - [ ] PingableRolesCog ⏳ WIP implementation in UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ -- [ ] TwitterCog ⏳ WIP implementation in TwitterTracker +- [ ] TwitterCog ❌ Needs Twitter API v2 Bearer Token - [x] VoicemasterCog ✅ Implemented as VoiceAdmin - [ ] VotingCog ⚠️ diff --git a/requirements[twitter].txt b/requirements[twitter].txt deleted file mode 100644 index a96e0b70..00000000 --- a/requirements[twitter].txt +++ /dev/null @@ -1,2 +0,0 @@ -tweepy==3.10.0 -aiohttp[speedups] \ No newline at end of file diff --git a/src/extensions/dynamic/TwitterTracker.py b/src/extensions/dynamic/TwitterTracker.py deleted file mode 100644 index bf776ece..00000000 --- a/src/extensions/dynamic/TwitterTracker.py +++ /dev/null @@ -1,242 +0,0 @@ -import logging -import os - -from discord import Interaction, TextChannel -from discord.app_commands import (autocomplete, command, default_permissions, describe, guild_only, rename) -from discord.ext.commands import Bot, GroupCog -from tweepy import API, OAuthHandler, Stream, StreamListener - -from common.discord import TwitterWebhookIDTransformer, respond_or_followup -from common.io import load_cog_toml -from database.gateway import DBSession -from database.models import TwitterTrackerAccounts - -COG_STRINGS = load_cog_toml(__name__) -WEBHOOK_PREFIX = __name__ - -CONSUMER_KEY = os.getenv("TWITTER_CONSUMER_KEY") -CONSUMER_SECRET = os.getenv("TWITTER_CONSUMER_SECRET") -ACCESS_TOKEN = os.getenv("TWITTER_ACCESS_TOKEN") -ACCESS_TOKEN_SECRET = os.getenv("TWITTER_ACCESS_TOKEN_SECRET") - - -def entry_primary_key(twitter_id: int, webhook_id: int) -> int: - return int(f"{twitter_id % 1000}{webhook_id % 1000}") - - -class CustomStreamListener(StreamListener): - - def __init__(self, twitter_api: API): - super().__init__(twitter_api) - - def on_data(self, raw_data: str): - return super().on_data(raw_data) - - def on_error(self, status_code: int): - return super().on_error(status_code) - - -@default_permissions(administrator=True) -@guild_only() -class TwitterTracker(GroupCog, name=COG_STRINGS["twitter_group_name"]): - - def __init__(self, bot: Bot): - self.bot = bot - self.webhooks = {} - self.accounts = {} - - twitter_auth = OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET) - twitter_auth.set_access_token(ACCESS_TOKEN, ACCESS_TOKEN_SECRET) - self.twitter_api = API(twitter_auth) - self.twitter_api.verify_credentials() - self.stream_listener = CustomStreamListener(self.twitter_api) - self.account_filter = Stream(self.twitter_api.auth, self.stream_listener) - - self.logger = logging.getLogger(__name__) - self.logger.info(f"{__name__} has been added as a Cog") - - @GroupCog.listener() - async def on_ready(self): - webhook_count = 0 - for guild in self.bot.guilds: - guild_webhooks = await guild.webhooks() - self.webhooks[guild.id] = {} - for webhook in guild_webhooks: - if webhook.name.startswith(WEBHOOK_PREFIX): - self.webhooks[guild.id][webhook.id] = webhook - webhook_count += 1 - - all_accounts = DBSession.list(TwitterTrackerAccounts) - account_count = 0 - for entry in all_accounts: - account_count += self.add_account(entry) - - self.logger.info(f"Found {webhook_count} webhook(s) across {len(self.webhooks)} guild(s)") - self.logger.info(f"Found {account_count} account(s) across {len(self.webhooks)} guild(s)") - - def add_account(self, db_entry: TwitterTrackerAccounts): - ret_val = 0 - if db_entry.guild_id not in self.accounts: - self.accounts[db_entry.guild_id] = {} - - if db_entry.twitter_id not in self.accounts.get(db_entry.guild_id): - self.accounts[db_entry.guild_id][db_entry.twitter_id] = [] - ret_val = 1 - - self.accounts[db_entry.guild_id][db_entry.twitter_id].append(db_entry) - return ret_val - - @command(name=COG_STRINGS["twitter_create_webhook_name"], description=COG_STRINGS["twitter_create_webhook_description"]) - @describe( - name=COG_STRINGS["twitter_create_webhook_name_describe"], - channel=COG_STRINGS["twitter_create_webhook_channel_describe"] - ) - @rename( - name=COG_STRINGS["twitter_create_webhook_name_rename"], - channel=COG_STRINGS["twitter_create_webhook_channel_rename"] - ) - async def create_webhook(self, interaction: Interaction, name: str = None, channel: TextChannel = None): - await interaction.response.defer(ephemeral=True) - - if not channel: - channel = interaction.channel - - if not name: - name = channel.name - - name = f"{WEBHOOK_PREFIX}-{name}" - webhook = await channel.create_webhook(name=name) - if not interaction.guild.id in self.webhooks: - self.webhooks[interaction.guild.id] = {} - self.webhooks[interaction.guild.id][webhook.id] = webhook - await respond_or_followup(f"Created webhook: {webhook.url}", interaction, ephemeral=True, delete_after=None) - - @command(name=COG_STRINGS["twitter_delete_webhook_name"], description=COG_STRINGS["twitter_delete_webhook_description"]) - @describe(webhook_id=COG_STRINGS["twitter_delete_webhook_webhook_id_describe"]) - @rename(webhook_id=COG_STRINGS["twitter_delete_webhook_webhook_id_rename"]) - @autocomplete(webhook_id=TwitterWebhookIDTransformer.autocomplete) - async def delete_webhook(self, interaction: Interaction, webhook_id: str): - await interaction.response.defer(ephemeral=True) - - if not webhook_id.isdigit(): - await respond_or_followup("Invalid webhook ID provided!", interaction, ephemeral=True) - return - - webbhook_id_int = int(webhook_id) - guild_webhooks = self.webhooks.get(interaction.guild.id) - webhook = guild_webhooks.pop(webbhook_id_int, None) - if not webhook: - await respond_or_followup("The provided webhook ID is not a TwitterTracker webhook!", interaction, ephemeral=True) - return - - if not webhook.name.startswith(WEBHOOK_PREFIX): - await respond_or_followup( - "The provided webhook ID is not for a TwitterTracker webhook!", - interaction, - ephemeral=True - ) - return - - await webhook.delete() - await respond_or_followup("Succesfully deleted webhook!", interaction, ephemeral=True) - - @command(name=COG_STRINGS["twitter_track_account_name"], description=COG_STRINGS["twitter_track_account_description"]) - @describe( - twitter_id=COG_STRINGS["twitter_track_account_twitter_id_describe"], - webhook_id=COG_STRINGS["twitter_track_account_webhook_id_describe"] - ) - @rename( - twitter_id=COG_STRINGS["twitter_track_account_twitter_id_rename"], - webhook_id=COG_STRINGS["twitter_track_account_webhook_id_rename"] - ) - @autocomplete(webhook_id=TwitterWebhookIDTransformer.autocomplete) - async def track_account(self, interaction: Interaction, twitter_id: str, webhook_id: str): - await interaction.response.defer(ephemeral=True) - - if not twitter_id.isdigit(): - await respond_or_followup("The given twitter ID is not valid!", interaction, ephemeral=True) - return - - if not webhook_id.isdigit(): - await respond_or_followup("The given webhook ID is not valid!", interaction, ephemeral=True) - return - - current_webhooks = self.accounts.get(interaction.guild.id, {}).get(int(twitter_id), []) - exists = [x for x in current_webhooks if int(webhook_id) == x.webhook_id] - if exists: - await respond_or_followup("The account given is already tracked with that webhhook!", interaction, ephemeral=True) - return - - db_item = TwitterTrackerAccounts( - primary_key=entry_primary_key(int(twitter_id), - int(webhook_id)), - guild_id=interaction.guild.id, - twitter_id=int(twitter_id), - webhook_id=int(webhook_id), - twitter_name="TwitterHandle" - ) - DBSession.create(db_item) - self.add_account(db_item) - await respond_or_followup(f"Now trackiing {twitter_id} in {webhook_id}", interaction, ephemeral=True) - - @command(name=COG_STRINGS["twitter_untrack_account_name"], description=COG_STRINGS["twitter_untrack_account_description"]) - @describe( - twitter_id=COG_STRINGS["twitter_untrack_account_twitter_id_describe"], - webhook_id=COG_STRINGS["twitter_untrack_account_webhook_id_describe"], - all_webhooks=COG_STRINGS["twitter_untrack_account_all_webhooks_describe"] - ) - @rename( - twitter_id=COG_STRINGS["twitter_untrack_account_twitter_id_rename"], - webhook_id=COG_STRINGS["twitter_untrack_account_webhook_id_rename"], - all_webhooks=COG_STRINGS["twitter_untrack_account_all_webhooks_rename"] - ) - @autocomplete(webhook_id=TwitterWebhookIDTransformer.autocomplete) - async def untrack_account( - self, - interaction: Interaction, - twitter_id: str, - all_webhooks: bool = False, - webhook_id: str = None - ): - if not all_webhooks and not webhook_id: - await respond_or_followup( - "You must confirm to untrack from all webhooks or provide a webhook to untrack from", - interaction, - ephemeral=True - ) - - if not twitter_id.isdigit(): - await respond_or_followup("The given twitter ID is not valid!", interaction, ephemeral=True) - return - - if all_webhooks: - entries = self.accounts.get(interaction.guild.id, {}).pop(int(twitter_id), []) - for entry in entries: - DBSession.delete(entry) - await respond_or_followup(f"No longer trakcing {twitter_id} in any webhook!", interaction, ephemeral=True) - return - - if not webhook_id.isdigit(): - await respond_or_followup("The given webhook ID is not valid!", interaction, ephemeral=True) - return - - entries = self.accounts.get(interaction.guild.id, {}).get(int(twitter_id), []) - found = -1 - db_item = None - for idx, item in enumerate(entries): - if item.webhook_id == int(webhook_id): - found = idx - db_item = item - break - - if found >= 0: - self.accounts.get(interaction.guild.id).get(int(twitter_id)).pop(found) - - if db_item: - DBSession.delete(db_item) - - await respond_or_followup(f"No longer tracking {twitter_id} in {webhook_id}", interaction, ephemeral=True) - - -async def setup(bot: Bot): - await bot.add_cog(TwitterTracker(bot)) From 5651d8e95041eb28722e0c50f0c5dc31ae2748d3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 15 May 2023 10:21:55 +0200 Subject: [PATCH 348/404] Added support for sqlite autoincrement --- src/database/models.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/database/models.py b/src/database/models.py index 75d95adc..781a6f24 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import BigInteger, Boolean, Column, String +from sqlalchemy import BigInteger, Boolean, Column, Integer, String from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.declarative import declarative_base @@ -18,14 +18,14 @@ class VoiceAdminParent(base): __tablename__ = "voiceadmin_parents" - primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) channel_id = Column(BigInteger, nullable=False) class VoiceAdminChild(base): __tablename__ = "voiceadmin_children" - primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) channel_id = Column(BigInteger, nullable=False) owner_id = Column(BigInteger, nullable=False) @@ -36,14 +36,14 @@ class VoiceAdminChild(base): class AutoRolesConfig(base): __tablename__ = "autoroles_config" - primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) role_id = Column(BigInteger, nullable=False) class EventToolsEvents(base): __tablename__ = "eventtools_events" - primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) channel_id = Column(BigInteger, nullable=False) event_role_id = Column(BigInteger, nullable=False) @@ -62,14 +62,14 @@ class MusicChannels(base): class RoleReactMenus(base): __tablename__ = "rolereact_menus" - primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) message_id = Column(BigInteger, nullable=False) class TwitterTrackerAccounts(base): __tablename__ = "twittertracker_accounts" - primary_key = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) + primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) twitter_id = Column(BigInteger, nullable=False) twitter_name = Column(String, nullable=False) From 1ac75d2a0ff5447570966600b1c68fb965813ebd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 15 May 2023 10:26:17 +0200 Subject: [PATCH 349/404] Changed explicit primary keys to autoincrement --- src/extensions/default/RoleReact.py | 4 +--- src/extensions/dynamic/AutoRoles.py | 10 +++------- src/extensions/dynamic/EventTools.py | 8 +++----- src/extensions/dynamic/VoiceAdmin.py | 12 +++--------- 4 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 19c8fa50..14bdb3d2 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -12,7 +12,6 @@ EmojiTransformer, RoleReactMenuTransformer, RoleReactRoleTransformer, - primary_key_from_object, respond_or_followup ) from common.io import load_cog_toml @@ -301,8 +300,7 @@ async def create_menu(self, interaction: Interaction, color: Transform[Color, Co await interaction.response.defer() message = await interaction.channel.send("​") - db_primary_key = primary_key_from_object(message) - db_item = RoleReactMenus(primary_key=db_primary_key, guild_id=interaction.guild.id, message_id=message.id) + db_item = RoleReactMenus(guild_id=interaction.guild.id, message_id=message.id) DBSession.create(db_item) message_embeds = embeds_from_options([], menu_id=message.id, color=color) diff --git a/src/extensions/dynamic/AutoRoles.py b/src/extensions/dynamic/AutoRoles.py index 64107085..089d7cc1 100644 --- a/src/extensions/dynamic/AutoRoles.py +++ b/src/extensions/dynamic/AutoRoles.py @@ -6,7 +6,7 @@ from discord.ext.commands import Bot, GroupCog from client import EsportsBot -from common.discord import (RoleListTransformer, get_role, primary_key_from_object) +from common.discord import RoleListTransformer, get_role from common.io import load_cog_toml from database.gateway import DBSession from database.models import AutoRolesConfig @@ -61,11 +61,7 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ successful_roles = [] for role in roles: if role.is_assignable: - db_entry = AutoRolesConfig( - primary_key=primary_key_from_object(role), - guild_id=interaction.guild.id, - role_id=role.id - ) + db_entry = AutoRolesConfig(guild_id=interaction.guild.id, role_id=role.id) if db_entry in initial_entries: initial_entries.remove(db_entry) else: @@ -108,7 +104,7 @@ async def add_guild_role(self, interaction: Interaction, role: Role): await interaction.followup.send(COG_STRINGS["roles_add_role_warn_already_added"], ephemeral=True) return False - db_entry = AutoRolesConfig(primary_key=primary_key_from_object(role), guild_id=role.guild.id, role_id=role.id) + db_entry = AutoRolesConfig(guild_id=role.guild.id, role_id=role.id) DBSession.create(db_entry) await interaction.followup.send( COG_STRINGS["roles_add_role_success"].format(role=role.mention), diff --git a/src/extensions/dynamic/EventTools.py b/src/extensions/dynamic/EventTools.py index 4c8d24e3..0e46ab7e 100644 --- a/src/extensions/dynamic/EventTools.py +++ b/src/extensions/dynamic/EventTools.py @@ -34,12 +34,11 @@ from client import EsportsBot from common.discord import ( + ActiveEventTransformer, + ArchivedEventTransformer, ColourTransformer, DatetimeTransformer, - primary_key_from_object, - EventTransformer, - ActiveEventTransformer, - ArchivedEventTransformer + EventTransformer ) from common.io import load_cog_toml, load_timezones from database.gateway import DBSession @@ -517,7 +516,6 @@ async def create_event( await signin_channel.send(embed=signin_embed, view=signin_menu) db_entry = EventToolsEvents( - primary_key=primary_key_from_object(event), guild_id=interaction.guild.id, channel_id=signin_channel.id, event_role_id=event_role.id, diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 43595b57..01d12bd1 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -1,13 +1,12 @@ import logging -from discord import Interaction, Member, VoiceChannel, VoiceState, PermissionOverwrite +from discord import (Interaction, Member, PermissionOverwrite, VoiceChannel, VoiceState) from discord.app_commands import (command, default_permissions, describe, guild_only, rename) from discord.errors import Forbidden from discord.ext.commands import Bot, GroupCog from client import EsportsBot -from common.discord import primary_key_from_object -from common.io import load_cog_toml, load_banned_words +from common.io import load_banned_words, load_cog_toml from database.gateway import DBSession from database.models import VoiceAdminChild, VoiceAdminParent @@ -210,7 +209,6 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: name=f"{member.display_name}'s VC" ) db_entry: VoiceAdminChild = VoiceAdminChild( - primary_key=primary_key_from_object(new_child_channel), guild_id=new_child_channel.guild.id, channel_id=new_child_channel.id, owner_id=member.id, @@ -247,11 +245,7 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann await interaction.followup.send(COG_STRINGS["vc_set_parent_warn_already_child"], ephemeral=True) return False - db_entry: VoiceAdminParent = VoiceAdminParent( - primary_key=primary_key_from_object(channel), - guild_id=interaction.guild.id, - channel_id=channel.id - ) + db_entry: VoiceAdminParent = VoiceAdminParent(guild_id=interaction.guild.id, channel_id=channel.id) DBSession.create(db_entry) self.logger.info( f"Successfully added {channel.name} (guildid - {channel.guild.id} | channelid - {channel.id}) " From b2e61bf571725dcdcbc121f3fcb37eacbc896c2a Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Mon, 15 May 2023 10:26:57 +0200 Subject: [PATCH 350/404] Updated TODO list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 449003b4..a08ea805 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - ~~Implement AutoRoles cog~~ - Add back functionality of previous bot (eg. Music, PingableRoles, etc.) - Add game deal tracker (DealTracker(?) cog) -- Add proper support for SQLite auto increment primary keys +- ~~Add proper support for SQLite auto increment primary keys~~ - ~~Add proper use of command groups~~ ## Previous extensions to implement From 7ffe236c32eeb8753dd1d05ee169d69b2d7a347c Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 13:05:56 +0200 Subject: [PATCH 351/404] Removed TwitterTracker left-over files --- src/database/models.py | 12 +----------- src/locale/TwitterTracker.toml | 29 ----------------------------- 2 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 src/locale/TwitterTracker.toml diff --git a/src/database/models.py b/src/database/models.py index 781a6f24..fe57589f 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -11,8 +11,7 @@ "AutoRolesConfig", "EventToolsEvents", "MusicChannels", - "RoleReactMenus", - "TwitterTrackerAccounts" + "RoleReactMenus" ] @@ -65,12 +64,3 @@ class RoleReactMenus(base): primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) message_id = Column(BigInteger, nullable=False) - - -class TwitterTrackerAccounts(base): - __tablename__ = "twittertracker_accounts" - primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) - guild_id = Column(BigInteger, nullable=False) - twitter_id = Column(BigInteger, nullable=False) - twitter_name = Column(String, nullable=False) - webhook_id = Column(BigInteger, nullable=False) diff --git a/src/locale/TwitterTracker.toml b/src/locale/TwitterTracker.toml deleted file mode 100644 index 0836795d..00000000 --- a/src/locale/TwitterTracker.toml +++ /dev/null @@ -1,29 +0,0 @@ -twitter_group_name = "twitter" - -twitter_create_webhook_name = "add-channel" -twitter_create_webhook_description = "Adds a channel as a place to forward tweets to." -twitter_create_webhook_name_rename = "webhook-name" -twitter_create_webhook_name_describe = "The custom name of the webhook for the channel." -twitter_create_webhook_channel_rename = "channel" -twitter_create_webhook_channel_describe = "The text channel to forward the tweets to." - -twitter_delete_webhook_name = "delete-webhook" -twitter_delete_webhook_description = "Removes a given webhook." -twitter_delete_webhook_webhook_id_rename = "webhook" -twitter_delete_webhook_webhook_id_describe = "The webhook to delete." - -twitter_track_account_name = "track" -twitter_track_account_description = "Add an account to track the tweets of." -twitter_track_account_twitter_id_rename = "twitter-id" -twitter_track_account_twitter_id_describe = "The ID of the twitter account to track." -twitter_track_account_webhook_id_rename = "webhook" -twitter_track_account_webhook_id_describe = "The webhook to send the tweets to." - -twitter_untrack_account_name = "untrack" -twitter_untrack_account_description = "Stop tracking the tweets of an account, globally or for a specific channel." -twitter_untrack_account_twitter_id_rename = "twitter-id" -twitter_untrack_account_twitter_id_describe = "The ID of the twitter account to untrack." -twitter_untrack_account_webhook_id_rename = "webhook" -twitter_untrack_account_webhook_id_describe = "The webhook to stop sending the tweets to." -twitter_untrack_account_all_webhooks_rename = "all-channels" -twitter_untrack_account_all_webhooks_describe = "If the account should be untracked for all webhooks." From 814e8c06043c5dd78a5eb4aac716dce9666e89f3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 13:18:40 +0200 Subject: [PATCH 352/404] Added base for LogChannel implementation --- src/database/models.py | 9 ++++- src/extensions/default/LogChannel.py | 56 ++++++++++++++++++++++++++++ src/locale/LogChannel.toml | 1 + 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/extensions/default/LogChannel.py create mode 100644 src/locale/LogChannel.toml diff --git a/src/database/models.py b/src/database/models.py index fe57589f..840d69e2 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -11,7 +11,8 @@ "AutoRolesConfig", "EventToolsEvents", "MusicChannels", - "RoleReactMenus" + "RoleReactMenus", + "LogChannelChannels" ] @@ -64,3 +65,9 @@ class RoleReactMenus(base): primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) message_id = Column(BigInteger, nullable=False) + + +class LogChannelChannels(base): + __tablename__ = "logchannel_channels" + guild_id = Column(BigInteger, nullable=False) + channel_id = Column(BigInteger, nullable=False) \ No newline at end of file diff --git a/src/extensions/default/LogChannel.py b/src/extensions/default/LogChannel.py new file mode 100644 index 00000000..dee5967c --- /dev/null +++ b/src/extensions/default/LogChannel.py @@ -0,0 +1,56 @@ +import asyncio +import logging +import os +import re +from typing import Any, Coroutine + +from discord.app_commands import command, default_permissions +from discord.ext.commands import Bot, GroupCog + +from common.io import load_cog_toml + +COG_STRINGS = load_cog_toml(__name__) + + +class LogStreamCapture(logging.StreamHandler): + + def __init__(self, emit_handler: Coroutine, **kwargs: Any): + super().__init__(**kwargs) + self.emit_handler = emit_handler + + def emit(self, record): + try: + asyncio.create_task(self.emit_handler(record)) + message = self.format(record) + self.stream.write(message) + except: + self.handleError(record) + + +@default_permissions(administrator=True) +class LogChannel(GroupCog, name=COG_STRINGS["log_group_name"]): + + def __init__(self, bot: Bot): + self.bot = bot + self.root_logger = logging.getLogger() + self.custom_handler = LogStreamCapture(self.log_handler) + self.root_logger.addHandler(self.custom_handler) + self.prefix = os.getenv("LOGGING_PREFIX") + + async def log_handler(self, record: logging.LogRecord): + message_contents = record.message + if not message_contents.startswith(self.prefix): + return + + contents_no_prefix = record.message[record.message.index(self.prefix) + len(self.prefix):] + matches = re.search(r"^\[(?P<guild>[0-9]+)\]", contents_no_prefix) + if not matches: + return + + guild_id = matches.groupdict().get("guild") + if not guild_id or not guild_id.isdigit(): + return + + +async def setup(bot: Bot): + await bot.add_cog(LogChannel(bot)) \ No newline at end of file diff --git a/src/locale/LogChannel.toml b/src/locale/LogChannel.toml new file mode 100644 index 00000000..07ca0020 --- /dev/null +++ b/src/locale/LogChannel.toml @@ -0,0 +1 @@ +log_group_name="logging" \ No newline at end of file From 6843fc734f6a63d0fa2c3c68d1b4732ef1fb11e4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 13:19:20 +0200 Subject: [PATCH 353/404] Updated README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a08ea805..7c277f91 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - [x] AdminCog ✅ Implemented as AdminTools - [x] DefaultRoleCog ✅ Implemented as AutoRoles - [x] EventCategoriesCog ✅ Implemented as EventTools -- [ ] LogChannelCog ⚠️ +- [ ] LogChannelCog ⏳ WIP implementation in LogChannel - [x] MusicCog ✅ Implemented as VCMusic - [ ] PingableRolesCog ⏳ WIP implementation in UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact From cbe35f816a441ed41b7540942f423c23e8be4259 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 15:22:54 +0200 Subject: [PATCH 354/404] Updated LogChannelChannels model --- src/database/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/database/models.py b/src/database/models.py index 840d69e2..e2f4790c 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -69,5 +69,6 @@ class RoleReactMenus(base): class LogChannelChannels(base): __tablename__ = "logchannel_channels" - guild_id = Column(BigInteger, nullable=False) - channel_id = Column(BigInteger, nullable=False) \ No newline at end of file + guild_id = Column(BigInteger, nullable=False, primary_key=True) + channel_id = Column(BigInteger, nullable=False) + current_message_id = Column(BigInteger, nullable=False) \ No newline at end of file From 47ce89fd68994dcf158181587368be9e2ca1d5c1 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 15:23:14 +0200 Subject: [PATCH 355/404] Added LOG_CHANNEL environment variable --- secrets.template | 1 + 1 file changed, 1 insertion(+) diff --git a/secrets.template b/secrets.template index d1ca6ae4..985826bc 100644 --- a/secrets.template +++ b/secrets.template @@ -3,6 +3,7 @@ DISCORD_TOKEN= COMMAND_PREFIX=! UNKNOWN_COMMAND_EMOJI=⁉ +LOGGING_PREFIX="[+]" DEV_USER_IDS= DEV_GUILD_ID= ALL_MESSAGES_EPHEMERAL=FALSE From 2d68ca575433fb60849670025a3bfa98709185da Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 15:26:45 +0200 Subject: [PATCH 356/404] Implemented basic log forwarding to channel --- src/extensions/default/LogChannel.py | 77 ++++++++++++++++++++++++++-- src/locale/LogChannel.toml | 12 ++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/extensions/default/LogChannel.py b/src/extensions/default/LogChannel.py index dee5967c..67eaab55 100644 --- a/src/extensions/default/LogChannel.py +++ b/src/extensions/default/LogChannel.py @@ -4,12 +4,17 @@ import re from typing import Any, Coroutine -from discord.app_commands import command, default_permissions +from discord import Interaction, TextChannel +from discord.app_commands import command, default_permissions, describe, rename from discord.ext.commands import Bot, GroupCog +from common.discord import respond_or_followup from common.io import load_cog_toml +from database.gateway import DBSession +from database.models import LogChannelChannels COG_STRINGS = load_cog_toml(__name__) +DISCORD_MESSAGE_LIMIT = 2000 class LogStreamCapture(logging.StreamHandler): @@ -38,8 +43,7 @@ def __init__(self, bot: Bot): self.prefix = os.getenv("LOGGING_PREFIX") async def log_handler(self, record: logging.LogRecord): - message_contents = record.message - if not message_contents.startswith(self.prefix): + if not record.message.startswith(self.prefix): return contents_no_prefix = record.message[record.message.index(self.prefix) + len(self.prefix):] @@ -51,6 +55,73 @@ async def log_handler(self, record: logging.LogRecord): if not guild_id or not guild_id.isdigit(): return + guild = self.bot.get_guild(int(guild_id)) + if not guild: + return + + log_channel_entry = DBSession.get(LogChannelChannels, guild_id=guild.id) + if not log_channel_entry: + return + + channel = guild.get_channel(log_channel_entry.channel_id) + message = await channel.fetch_message(log_channel_entry.current_message_id) + + log_message = f"[<t:{int(record.created)}:f>] " + contents_no_prefix.replace(f"[{guild_id}]", "").strip() + + if len(message.content) + len(log_message) > DISCORD_MESSAGE_LIMIT - 5: + message = await channel.send(content=log_message) + log_channel_entry.current_message_id = message.id + DBSession.update(log_channel_entry) + else: + await message.edit(content=f"{message.content}\n{log_message}") + + @command(name=COG_STRINGS["log_set_channel_name"], description=COG_STRINGS["log_set_channel_description"]) + @describe(channel=COG_STRINGS["log_set_channel_channel_describe"]) + @rename(channel=COG_STRINGS["log_set_channel_channel_rename"]) + async def set_log_channel(self, interaction: Interaction, channel: TextChannel): + message = await channel.send("# Logging start") + current_channel = DBSession.get(LogChannelChannels, guild_id=interaction.guild.id) + if not current_channel: + db_item = LogChannelChannels(guild_id=interaction.guild.id, channel_id=channel.id, current_message_id=message.id) + DBSession.create(db_item) + else: + current_channel.channel_id = channel.id + current_channel.current_message_id = message.id + DBSession.update(current_channel) + + await respond_or_followup( + COG_STRINGS["log_set_channel_success"].format(channel=channel.mention), + interaction=interaction + ) + + @command(name=COG_STRINGS["log_remove_channel_name"], description=COG_STRINGS["log_remove_channel_description"]) + async def remove_log_channel(self, interaction: Interaction): + guild_id = interaction.guild.id + + db_item = DBSession.get(LogChannelChannels, guild_id=guild_id) + if not db_item: + await respond_or_followup( + COG_STRINGS["log_remove_channel_success"].format(channel="any channel"), + interaction=interaction + ) + return + + channel_id = db_item.channel_id + DBSession.delete(db_item) + + channel = interaction.guild.get_channel(channel_id) + if not channel: + await respond_or_followup( + COG_STRINGS["log_remove_channel_success"].format(channel="any channel"), + interaction=interaction + ) + return + + await respond_or_followup( + COG_STRINGS["log_remove_channel_success"].format(channel=channel.mention), + interaction=interaction + ) + async def setup(bot: Bot): await bot.add_cog(LogChannel(bot)) \ No newline at end of file diff --git a/src/locale/LogChannel.toml b/src/locale/LogChannel.toml index 07ca0020..8f0da62e 100644 --- a/src/locale/LogChannel.toml +++ b/src/locale/LogChannel.toml @@ -1 +1,11 @@ -log_group_name="logging" \ No newline at end of file +log_group_name="logging" + +log_set_channel_name="set-channel" +log_set_channel_description="Set the logging channel." +log_set_channel_channel_describe="The channel to set as the logging channel." +log_set_channel_channel_rename="channel" +log_set_channel_success="Now sending logs to {channel} ✅" + +log_remove_channel_name="remove-channel" +log_remove_channel_description="Stops sending logs to the logging channel." +log_remove_channel_success="No longer sending logs to {channel} ✅" \ No newline at end of file From 01031084620f95719ef66ca7772c1594c1c60f55 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 15:32:08 +0200 Subject: [PATCH 357/404] Added simple log level indicator --- src/extensions/default/LogChannel.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/extensions/default/LogChannel.py b/src/extensions/default/LogChannel.py index 67eaab55..4afb1266 100644 --- a/src/extensions/default/LogChannel.py +++ b/src/extensions/default/LogChannel.py @@ -66,7 +66,22 @@ async def log_handler(self, record: logging.LogRecord): channel = guild.get_channel(log_channel_entry.channel_id) message = await channel.fetch_message(log_channel_entry.current_message_id) - log_message = f"[<t:{int(record.created)}:f>] " + contents_no_prefix.replace(f"[{guild_id}]", "").strip() + log_level = "" + match record.levelno: + case logging.DEBUG: + log_level = "🤓" + case logging.INFO: + log_level = "✅" + case logging.WARNING: + log_level = "⚠️" + case logging.WARN: + log_level = "⚠️" + case logging.ERROR: + log_level = "❌" + case logging.CRITICAL: + log_level = "🔥🔥" + + log_message = f"[{log_level}][<t:{int(record.created)}:f>] " + contents_no_prefix.replace(f"[{guild_id}]", "").strip() if len(message.content) + len(log_message) > DISCORD_MESSAGE_LIMIT - 5: message = await channel.send(content=log_message) From 826ec8566f42cc1d9811759646d354ca518f0624 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 15:33:29 +0200 Subject: [PATCH 358/404] Updated log level indicators --- src/extensions/default/LogChannel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/default/LogChannel.py b/src/extensions/default/LogChannel.py index 4afb1266..28a4e0bb 100644 --- a/src/extensions/default/LogChannel.py +++ b/src/extensions/default/LogChannel.py @@ -69,7 +69,7 @@ async def log_handler(self, record: logging.LogRecord): log_level = "" match record.levelno: case logging.DEBUG: - log_level = "🤓" + log_level = "🐞" case logging.INFO: log_level = "✅" case logging.WARNING: @@ -77,9 +77,9 @@ async def log_handler(self, record: logging.LogRecord): case logging.WARN: log_level = "⚠️" case logging.ERROR: - log_level = "❌" + log_level = "❗️" case logging.CRITICAL: - log_level = "🔥🔥" + log_level = "❌" log_message = f"[{log_level}][<t:{int(record.created)}:f>] " + contents_no_prefix.replace(f"[{guild_id}]", "").strip() From a1a49b099738b87c38f1ac97519ee16c667192ac Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 30 May 2023 16:04:26 +0200 Subject: [PATCH 359/404] Fixed multiple log messages being generated --- src/client.py | 1 + src/extensions/default/LogChannel.py | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/client.py b/src/client.py index bd4e668c..c78cb258 100644 --- a/src/client.py +++ b/src/client.py @@ -20,6 +20,7 @@ def __init__(self, command_prefix: str, all_messages_ephemeral: bool, *args, **k super().__init__(command_prefix, *args, **kwargs) self.logger = logging.getLogger(__name__) self.only_ephemeral = all_messages_ephemeral + self.logging_prefix = os.getenv("LOGGING_PREFIX") def find_extensions(self): defaults = [] diff --git a/src/extensions/default/LogChannel.py b/src/extensions/default/LogChannel.py index 28a4e0bb..12be60e3 100644 --- a/src/extensions/default/LogChannel.py +++ b/src/extensions/default/LogChannel.py @@ -1,6 +1,5 @@ import asyncio import logging -import os import re from typing import Any, Coroutine @@ -26,8 +25,6 @@ def __init__(self, emit_handler: Coroutine, **kwargs: Any): def emit(self, record): try: asyncio.create_task(self.emit_handler(record)) - message = self.format(record) - self.stream.write(message) except: self.handleError(record) @@ -40,13 +37,16 @@ def __init__(self, bot: Bot): self.root_logger = logging.getLogger() self.custom_handler = LogStreamCapture(self.log_handler) self.root_logger.addHandler(self.custom_handler) - self.prefix = os.getenv("LOGGING_PREFIX") + self.logger = logging.getLogger(__name__) async def log_handler(self, record: logging.LogRecord): - if not record.message.startswith(self.prefix): + if not hasattr(record, "message"): return - contents_no_prefix = record.message[record.message.index(self.prefix) + len(self.prefix):] + if not record.message.startswith(self.bot.logging_prefix): + return + + contents_no_prefix = record.message[record.message.index(self.bot.logging_prefix) + len(self.bot.logging_prefix):] matches = re.search(r"^\[(?P<guild>[0-9]+)\]", contents_no_prefix) if not matches: return From 551e1cb9217d84e6e9cef7b251804e3433d12468 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 09:41:26 +0200 Subject: [PATCH 360/404] Changed logs to use an embed field per log item --- src/extensions/default/LogChannel.py | 46 ++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/extensions/default/LogChannel.py b/src/extensions/default/LogChannel.py index 12be60e3..e12e0d6b 100644 --- a/src/extensions/default/LogChannel.py +++ b/src/extensions/default/LogChannel.py @@ -3,7 +3,7 @@ import re from typing import Any, Coroutine -from discord import Interaction, TextChannel +from discord import Interaction, TextChannel, Embed, Color, NotFound from discord.app_commands import command, default_permissions, describe, rename from discord.ext.commands import Bot, GroupCog @@ -13,7 +13,6 @@ from database.models import LogChannelChannels COG_STRINGS = load_cog_toml(__name__) -DISCORD_MESSAGE_LIMIT = 2000 class LogStreamCapture(logging.StreamHandler): @@ -63,9 +62,6 @@ async def log_handler(self, record: logging.LogRecord): if not log_channel_entry: return - channel = guild.get_channel(log_channel_entry.channel_id) - message = await channel.fetch_message(log_channel_entry.current_message_id) - log_level = "" match record.levelno: case logging.DEBUG: @@ -81,27 +77,51 @@ async def log_handler(self, record: logging.LogRecord): case logging.CRITICAL: log_level = "❌" - log_message = f"[{log_level}][<t:{int(record.created)}:f>] " + contents_no_prefix.replace(f"[{guild_id}]", "").strip() + log_info = f"[{log_level}][<t:{int(record.created)}:f>] " + log_message = contents_no_prefix.replace(f"[{guild_id}]", "").strip() - if len(message.content) + len(log_message) > DISCORD_MESSAGE_LIMIT - 5: - message = await channel.send(content=log_message) + channel = guild.get_channel(log_channel_entry.channel_id) + try: + message = await channel.fetch_message(log_channel_entry.current_message_id) + message_embed = message.embeds[-1] + + if len(message_embed.fields) < 25: + message_embed.add_field(name=log_info, value=log_message, inline=False) + embeds = message.embeds + embeds[-1] = message_embed + await message.edit(embeds=embeds) + elif len(message.embeds < 10): + embed = Embed(title="​", description="", colour=Color.random()) + embed.add_field(name=log_info, value=log_message, inline=False) + embeds = message.embeds + embeds.append(embed) + await message.edit(embeds=embeds) + else: + embed = Embed(title="​", description="", colour=Color.random()) + embed.add_field(name=log_info, value=log_message, inline=False) + message = await channel.send(embed=embed) + log_channel_entry.current_message_id = message.id + DBSession.update(log_channel_entry) + except NotFound: + embed = Embed(title="​", description="", colour=Color.random()) + embed.add_field(name=log_info, value=log_message, inline=False) + message = await channel.send(embed=embed) log_channel_entry.current_message_id = message.id DBSession.update(log_channel_entry) - else: - await message.edit(content=f"{message.content}\n{log_message}") + return @command(name=COG_STRINGS["log_set_channel_name"], description=COG_STRINGS["log_set_channel_description"]) @describe(channel=COG_STRINGS["log_set_channel_channel_describe"]) @rename(channel=COG_STRINGS["log_set_channel_channel_rename"]) async def set_log_channel(self, interaction: Interaction, channel: TextChannel): - message = await channel.send("# Logging start") + await channel.send("# Logging Start") current_channel = DBSession.get(LogChannelChannels, guild_id=interaction.guild.id) if not current_channel: - db_item = LogChannelChannels(guild_id=interaction.guild.id, channel_id=channel.id, current_message_id=message.id) + db_item = LogChannelChannels(guild_id=interaction.guild.id, channel_id=channel.id, current_message_id=0) DBSession.create(db_item) else: current_channel.channel_id = channel.id - current_channel.current_message_id = message.id + current_channel.current_message_id = 0 DBSession.update(current_channel) await respond_or_followup( From d3a5b3b5deaed4949a39d77fa72ca308e439f7bd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 09:52:52 +0200 Subject: [PATCH 361/404] Added LogChannel compatible log messages to VoiceAdmin --- src/extensions/dynamic/VoiceAdmin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 01d12bd1..d447b101 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -248,8 +248,7 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann db_entry: VoiceAdminParent = VoiceAdminParent(guild_id=interaction.guild.id, channel_id=channel.id) DBSession.create(db_entry) self.logger.info( - f"Successfully added {channel.name} (guildid - {channel.guild.id} | channelid - {channel.id}) " - f"to Parent Voice Channel DB Table!" + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} made {channel.mention} into a Parent voice channel" ) await interaction.followup.send( COG_STRINGS["vc_set_parent_success"].format(channel=channel), @@ -277,6 +276,9 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh db_entry = DBSession.get(VoiceAdminParent, guild_id=channel.guild.id, channel_id=channel.id) DBSession.delete(db_entry) + self.logger.info( + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} removed {channel.mention} from being a Parent voice channel" + ) await interaction.followup.send( COG_STRINGS["vc_remove_parent_success"].format(channel=channel.name), ephemeral=self.bot.only_ephemeral @@ -355,6 +357,9 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): return False if not check_vc_name_allowed(new_name): + self.logger.warning( + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} attempted to rename a voice channel to a disallowed name: ||{new_name}||" + ) await interaction.followup.send(COG_STRINGS["vc_rename_warn_invalid_name"], ephemeral=True) return False From 292f0bfa0dc3af9e16d364253c13dd1f833c8338 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 09:53:28 +0200 Subject: [PATCH 362/404] Added LogChannel compatible log messages to AdminTools --- src/extensions/default/AdminTools.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extensions/default/AdminTools.py b/src/extensions/default/AdminTools.py index 9dfea3c3..00b17469 100644 --- a/src/extensions/default/AdminTools.py +++ b/src/extensions/default/AdminTools.py @@ -75,6 +75,10 @@ async def clear_messages(self, interaction: Interaction, count: int = 5): await interaction.response.defer(ephemeral=True) messages = await interaction.channel.purge(limit=count, before=interaction.created_at) + + self.logger.info( + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} cleared {len(messages)} from {interaction.channel.mention}" + ) await interaction.followup.send( COG_STRINGS["admin_clear_success"].format(count=len(messages)), ephemeral=self.bot.only_ephemeral From f0880d00ba471cb6f6b9d25c91f37755cbc6cecb Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 10:06:31 +0200 Subject: [PATCH 363/404] Added new utility file --- src/common/util.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/common/util.py diff --git a/src/common/util.py b/src/common/util.py new file mode 100644 index 00000000..a4df49fc --- /dev/null +++ b/src/common/util.py @@ -0,0 +1,13 @@ +def r_replace(string: str, _old: str, _new: str, count: int = 1) -> str: + """Replaces occurances of _old with _new but starting from the end of the string working to the start. + + Args: + string (str): The string to replace the characters in. + _old (str): The old string to replace. + _new (str): The new string to replace with. + count (int, optional): Limit how many occurances of _old to replace. Defaults to 1. + + Returns: + str: _description_ + """ + return _new.join(string.rsplit(_old, count)) From 34927e46a4dd270176fbd32447c18a1b3cdfceb0 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 10:07:03 +0200 Subject: [PATCH 364/404] Implemented VoiceAdmin channel status indicators --- src/extensions/dynamic/VoiceAdmin.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index d447b101..5bad08f3 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -7,6 +7,7 @@ from client import EsportsBot from common.io import load_banned_words, load_cog_toml +from common.util import r_replace from database.gateway import DBSession from database.models import VoiceAdminChild, VoiceAdminParent @@ -365,6 +366,12 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): name_set = new_name if new_name else f"{interaction.user.display_name}'s VC" + if db_entry.is_limited: + name_set += COG_STRINGS["vc_limited_icon_with_delimiter"] + + if db_entry.is_locked: + name_set += COG_STRINGS["vc_locked_icon_with_delimiter"] + if not new_name: if db_entry.has_custom_name: await voice_channel.edit(name=f"{interaction.user.display_name}'s VC") @@ -469,6 +476,7 @@ async def lock_channel(self, interaction: Interaction): db_entry.is_locked = True DBSession.update(db_entry) + await voice_channel.edit(name=f"{voice_channel.name}{COG_STRINGS['vc_locked_icon_with_delimiter']}") await interaction.followup.send(COG_STRINGS["vc_lock_success"], ephemeral=self.bot.only_ephemeral) return True @@ -509,7 +517,12 @@ async def unlock_channel(self, interaction: Interaction): db_entry.is_locked = False DBSession.update(db_entry) - await voice_channel.edit(sync_permissions=True) + await voice_channel.edit( + name=r_replace(voice_channel.name, + COG_STRINGS["vc_locked_icon_with_delimiter"], + ""), + sync_permissions=True + ) await voice_channel.set_permissions(voice_channel.guild.default_role, overwrite=None) await interaction.followup.send(COG_STRINGS["vc_unlock_success"], ephemeral=self.bot.only_ephemeral) @@ -558,6 +571,8 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): db_entry.is_limited = True DBSession.update(db_entry) + await voice_channel.edit(name=f"{voice_channel.name}{COG_STRINGS['vc_limited_icon_with_delimiter']}") + await interaction.followup.send( COG_STRINGS["vc_limit_success"].format(count=user_limit), ephemeral=self.bot.only_ephemeral @@ -597,7 +612,12 @@ async def unlimit_channel(self, interaction: Interaction): db_entry.is_limited = False DBSession.update(db_entry) - await voice_channel.edit(user_limit=None) + await voice_channel.edit( + name=r_replace(voice_channel.name, + COG_STRINGS["vc_limited_icon_with_delimiter"], + ""), + user_limit=None + ) await interaction.followup.send(COG_STRINGS["vc_unlimit_success"], ephemeral=self.bot.only_ephemeral) return True From 305561c8d5cc6133736b626ff961ece440543b2e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 10:07:20 +0200 Subject: [PATCH 365/404] Updated Voice admin status indicators --- src/locale/VoiceAdmin.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml index 7501bceb..a10dab55 100644 --- a/src/locale/VoiceAdmin.toml +++ b/src/locale/VoiceAdmin.toml @@ -2,8 +2,8 @@ vc_group_name = "voice" vc_admin_group_name = "voice-admin" default_vc_name = "{name}'s VC" -vc_limited_icon_with_delimited = "​📌​" -vc_locked_icon_with_delimiter = "​🔒​" +vc_limited_icon_with_delimited = "[​📌]​" +vc_locked_icon_with_delimiter = "​[🔒]​" vc_must_be_owner = "You must be the VC owner to do this." vc_set_parent_name = "set-parent" From 3279950523ba32b1bd6cae4320bab4b46b4cb81d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 12:42:37 +0200 Subject: [PATCH 366/404] Updated log messages to support LogChannel --- src/extensions/dynamic/VoiceAdmin.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 5bad08f3..3d222f75 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -145,18 +145,14 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: if not before.channel.category and not member.guild.me.guild_permissions.manage_channels: self.logger.error( - f"Missing permission `manage_channels` for category {before.channel.category.name} " - f"(channelid - {before.channel.category.id}) in guild {before.channel.guild.name} " - f"(guildid - {before.channel.guild.id})" + f"{self.bot.logging_prefix}[{before.channel.guild.id}] Missing permissions `Manage Channels` in this server!" ) return elif before.channel.category and not before.channel.category.permissions_for( before.channel.guild.me ).manage_channels: self.logger.error( - f"Missing permission `manage_channels` for category {before.channel.category.name} " - f"(channelid - {before.channel.category.id}) in guild {before.channel.guild.name} " - f"(guildid - {before.channel.guild.id})" + f"{self.bot.logging_prefix}[{before.channel.guild.id}] Missing permissions `Manage Channels` for the category {before.channel.category.name}({before.channel.category.id})" ) return @@ -188,16 +184,12 @@ async def on_voice_state_update(self, member: Member, before: VoiceState, after: if not after.channel.category and not member.guild.me.guild_permissions.manage_channels: self.logger.error( - f"Missing permission `manage_channels` for category {after.channel.category.name} " - f"(channelid - {after.channel.category.id}) in guild {after.channel.guild.name} " - f"(guildid - {after.channel.guild.id})" + f"{self.bot.logging_prefix}[{after.channel.guild.id}] Missing permissions `Manage Channels` in this server!" ) return elif after.channel.category and not after.channel.category.permissions_for(after.channel.guild.me).manage_channels: self.logger.error( - f"Missing permission `manage_channels` for category {after.channel.category.name} " - f"(channelid - {after.channel.category.id}) in guild {after.channel.guild.name} " - f"(guildid - {after.channel.guild.id})" + f"{self.bot.logging_prefix}[{after.channel.guild.id}] Missing permissions `Manage Channels` for the category {after.channel.category.name}({after.channel.category.id})" ) return From 9778057020f1a971f5a2a5286280999aca47f851 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 12:44:34 +0200 Subject: [PATCH 367/404] Decreased author update timer --- src/extensions/dynamic/VCMusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index bd3dd503..bef3f19b 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -600,7 +600,7 @@ def __init__(self, bot: Bot): async def on_ready(self): self.update_author.start() - @tasks.loop(hours=12) + @tasks.loop(hours=6) async def update_author(self): """Ensure that the author we acquired is still up to date From 756be6de32f5b4fa12413667dfe0ac43b2cc9c5f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 12:47:10 +0200 Subject: [PATCH 368/404] Added logging events --- src/extensions/dynamic/VCMusic.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index bef3f19b..aac614fd 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -688,6 +688,10 @@ async def set_channel( view_channel=True) ) + self.logger.info( + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} set {channel.mention} as the active music channel" + ) + @guild_only() class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): From 9f42c4db74246c43dfcefd3f3684a1403dd13f07 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 13:11:01 +0200 Subject: [PATCH 369/404] Added logging events --- src/extensions/dynamic/AutoRoles.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/AutoRoles.py b/src/extensions/dynamic/AutoRoles.py index 089d7cc1..dfd4005f 100644 --- a/src/extensions/dynamic/AutoRoles.py +++ b/src/extensions/dynamic/AutoRoles.py @@ -36,9 +36,14 @@ async def on_member_update(self, before: Member, after: Member): async def assign_roles(self, member: Member): guild_roles = DBSession.list(AutoRolesConfig, guild_id=member.guild.id) + parsed_roles = [] if guild_roles: - roles = [member.guild.get_role(x.role_id) for x in guild_roles] - await member.add_roles(*roles) + parsed_roles = [member.guild.get_role(x.role_id) for x in guild_roles] + await member.add_roles(*parsed_roles) + + self.logger.info( + f"{self.bot.logger_prefix}[{member.guild.id}] Member ({member.mention}) joined and received the following roles: [{','.join([x.mention for x in parsed_roles])}]" + ) @command(name=COG_STRINGS["roles_set_list_name"], description=COG_STRINGS["roles_set_list_description"]) @describe(roles=COG_STRINGS["roles_set_list_param_describe"]) @@ -83,6 +88,9 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ color=Color.random() ) + self.logger.info( + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} updated the list of automatically applied roles: [{','.join([x.mention for x in successful_roles])}]" + ) await interaction.followup.send(embed=response_embed, ephemeral=self.bot.only_ephemeral) return True @@ -106,6 +114,9 @@ async def add_guild_role(self, interaction: Interaction, role: Role): db_entry = AutoRolesConfig(guild_id=role.guild.id, role_id=role.id) DBSession.create(db_entry) + self.logger.info( + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} added {role.mention} to the list of automatically assigned roles" + ) await interaction.followup.send( COG_STRINGS["roles_add_role_success"].format(role=role.mention), ephemeral=self.bot.only_ephemeral @@ -131,6 +142,9 @@ async def remove_guild_role(self, interaction: Interaction, role: Role): return False DBSession.delete(db_entry) + self.logger.info( + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} removed {role.mention} from the list of automatically assigned roles" + ) await interaction.followup.send( COG_STRINGS["roles_remove_role_success"].format(role=role.mention), ephemeral=self.bot.only_ephemeral @@ -175,6 +189,9 @@ async def clear_guild_roles(self, interaction: Interaction): for item in db_items: DBSession.delete(item) + self.logger.info( + f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} cleared the list of automatically assigned roles" + ) await interaction.followup.send(COG_STRINGS["roles_clear_list_success"], ephemeral=self.bot.only_ephemeral) return True From 110b10fbb0cdfb8cd1dbe17c4dc68eefa7ba5d1d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 13:17:08 +0200 Subject: [PATCH 370/404] Added get-channel to LogChannel --- src/extensions/default/LogChannel.py | 25 +++++++++++++++++++++++++ src/locale/LogChannel.toml | 6 ++++++ 2 files changed, 31 insertions(+) diff --git a/src/extensions/default/LogChannel.py b/src/extensions/default/LogChannel.py index e12e0d6b..fa3c7dbb 100644 --- a/src/extensions/default/LogChannel.py +++ b/src/extensions/default/LogChannel.py @@ -129,6 +129,31 @@ async def set_log_channel(self, interaction: Interaction, channel: TextChannel): interaction=interaction ) + @command(name=COG_STRINGS["log_get_channel_name"], description=COG_STRINGS["log_get_channel_description"]) + async def get_log_channel(self, interaction: Interaction): + guild_id = interaction.guild.id + + db_item = DBSession.get(LogChannelChannels, guild_id=guild_id) + if not db_item: + await respond_or_followup(COG_STRINGS["log_warn_channel_not_set"], interaction=interaction) + return + + channel_id = db_item.channel_id + DBSession.delete(db_item) + + channel = interaction.guild.get_channel(channel_id) + if not channel: + await respond_or_followup( + COG_STRINGS["log_error_channel_deleted"].format(channel_id=channel_id), + interaction=interaction + ) + return + + await respond_or_followup( + COG_STRINGS["log_get_channel_success"].format(channel=channel.mention), + interaction=interaction + ) + @command(name=COG_STRINGS["log_remove_channel_name"], description=COG_STRINGS["log_remove_channel_description"]) async def remove_log_channel(self, interaction: Interaction): guild_id = interaction.guild.id diff --git a/src/locale/LogChannel.toml b/src/locale/LogChannel.toml index 8f0da62e..3adce124 100644 --- a/src/locale/LogChannel.toml +++ b/src/locale/LogChannel.toml @@ -6,6 +6,12 @@ log_set_channel_channel_describe="The channel to set as the logging channel." log_set_channel_channel_rename="channel" log_set_channel_success="Now sending logs to {channel} ✅" +log_get_channel_name="get-channel" +log_get_channel_description="Get the current logging channel." +log_warn_channel_not_set="The logging channel has not been set ⚠️" +log_error_channel_deleted="The log channel with ID `{channel_id}` has been deleted ❌" +log_get_channel_success="The log channel is currently set to {channel}" + log_remove_channel_name="remove-channel" log_remove_channel_description="Stops sending logs to the logging channel." log_remove_channel_success="No longer sending logs to {channel} ✅" \ No newline at end of file From 00a770c0a2a8f8c5e0c01d7bac596a59c34afde5 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 13:27:12 +0200 Subject: [PATCH 371/404] Updated README for LogChannel --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7c277f91..1f7bd81c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,25 @@ All commands in this cog require the user to have the administrator permissions </details> +<details> +<summary>LogChannel</summary> + +LogChannel is a cog used to send log messages to a specified discord channel per-guild. A standard logging message can be forwarded by prefixing the message with the `LOGGING_PREFIX` and the guild, eg. `"LOGGING_PREFIX[guild_id] Message...".` This will send the log message to the specified log channel for the given guild if it has been configured to do so. + +#### /logging set-channel \<Text Channel\> + +- Configures the given channel to be the logging channel. + +#### /logging get-channel + +- Gets the currently assigned logging channel. + +#### /logging remove-channel + +- Removes the currently configured logging channel. + +</details> + <details> <summary>VoiceAdmin</summary> @@ -263,7 +282,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - [x] AdminCog ✅ Implemented as AdminTools - [x] DefaultRoleCog ✅ Implemented as AutoRoles - [x] EventCategoriesCog ✅ Implemented as EventTools -- [ ] LogChannelCog ⏳ WIP implementation in LogChannel +- [x] LogChannelCog ✅ Implementation as LogChannel - [x] MusicCog ✅ Implemented as VCMusic - [ ] PingableRolesCog ⏳ WIP implementation in UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact From 4baf4ae1faaebf228ed88612b6745abc641cf991 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 13:36:35 +0200 Subject: [PATCH 372/404] Updated status of UserRoles --- README.md | 2 +- src/extensions/dynamic/UserRoles.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 1f7bd81c..7c971338 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - [x] EventCategoriesCog ✅ Implemented as EventTools - [x] LogChannelCog ✅ Implementation as LogChannel - [x] MusicCog ✅ Implemented as VCMusic -- [ ] PingableRolesCog ⏳ WIP implementation in UserRoles +- [ ] PingableRolesCog ⏳ Not being worked on, started in UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ - [ ] TwitterCog ❌ Needs Twitter API v2 Bearer Token diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 9272b9a4..3d0cff0f 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -16,7 +16,6 @@ def __init__(self, bot: Bot): self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") -@default_permissions(administrator=True) @guild_only() class UserRoles(GroupCog, name=COG_STRINGS["users_group_name"]): From 36569d90c1fc90838a3e2240ebd803c3d9ebfaba Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Wed, 31 May 2023 13:38:36 +0200 Subject: [PATCH 373/404] Updated client to publish commands when live --- src/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client.py b/src/client.py index c78cb258..20b7b999 100644 --- a/src/client.py +++ b/src/client.py @@ -65,7 +65,10 @@ async def setup_hook(self): DEV_GUILD = Object(id=os.getenv("DEV_GUILD_ID")) self.logger.warning(f"Using guild with id {DEV_GUILD.id} as Development guild!") self.tree.copy_global_to(guild=DEV_GUILD) - await self.tree.sync(guild=DEV_GUILD) + else: + DEV_GUILD = None + + await self.tree.sync(guild=DEV_GUILD) EsportsBot = __EsportsBot(command_prefix=os.getenv("COMMAND_PREFIX"), all_messages_ephemeral=False, intents=Intents.all()) From d412a5bc974fc8ca73a0fe9c6de1b780b7c3bc23 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 6 Aug 2023 13:02:34 +0200 Subject: [PATCH 374/404] Removed ONLY_EPHEMERAL flag --- secrets.template | 1 - src/client.py | 3 --- src/extensions/default/AdminTools.py | 12 +++------- src/extensions/default/RoleReact.py | 2 +- src/extensions/dynamic/AutoRoles.py | 16 +++++--------- src/extensions/dynamic/EventTools.py | 10 ++++----- src/extensions/dynamic/VoiceAdmin.py | 33 ++++++++-------------------- 7 files changed, 23 insertions(+), 54 deletions(-) diff --git a/secrets.template b/secrets.template index 985826bc..d93c6992 100644 --- a/secrets.template +++ b/secrets.template @@ -6,7 +6,6 @@ UNKNOWN_COMMAND_EMOJI=⁉ LOGGING_PREFIX="[+]" DEV_USER_IDS= DEV_GUILD_ID= -ALL_MESSAGES_EPHEMERAL=FALSE ################## ## Database Vars ## diff --git a/src/client.py b/src/client.py index 20b7b999..30c653e9 100644 --- a/src/client.py +++ b/src/client.py @@ -19,7 +19,6 @@ def __init__(self, command_prefix: str, all_messages_ephemeral: bool, *args, **k """ super().__init__(command_prefix, *args, **kwargs) self.logger = logging.getLogger(__name__) - self.only_ephemeral = all_messages_ephemeral self.logging_prefix = os.getenv("LOGGING_PREFIX") def find_extensions(self): @@ -42,8 +41,6 @@ def get_files(path): async def setup_hook(self): """The setup function that is called prior to the bot connecting to the Discord Gateway. """ - if not self.only_ephemeral: - self.only_ephemeral = os.getenv("ALL_MESSAGES_EPHEMERAL", "FALSE").upper() == "TRUE" default_extensions, dynamic_extensions = self.find_extensions() enabled_extensions = [] diff --git a/src/extensions/default/AdminTools.py b/src/extensions/default/AdminTools.py index 00b17469..648e6dc3 100644 --- a/src/extensions/default/AdminTools.py +++ b/src/extensions/default/AdminTools.py @@ -42,10 +42,7 @@ async def get_member_count(self, interaction: Interaction): await interaction.response.defer(ephemeral=True) member_count = interaction.guild.member_count - await interaction.followup.send( - COG_STRINGS["admin_members_format"].format(count=member_count), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["admin_members_format"].format(count=member_count), ephemeral=True) return True @command(name=COG_STRINGS["admin_version_name"], description=COG_STRINGS["admin_version_description"]) @@ -55,7 +52,7 @@ async def get_bot_version(self, interaction: Interaction): Args: interaction (Interaction): The interaction that triggered the command. """ - await interaction.response.send_message(self.version_string, ephemeral=self.bot.only_ephemeral) + await interaction.response.send_message(self.version_string, ephemeral=True) return True @command(name=COG_STRINGS["admin_clear_name"], description=COG_STRINGS["admin_clear_description"]) @@ -79,10 +76,7 @@ async def clear_messages(self, interaction: Interaction, count: int = 5): self.logger.info( f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} cleared {len(messages)} from {interaction.channel.mention}" ) - await interaction.followup.send( - COG_STRINGS["admin_clear_success"].format(count=len(messages)), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["admin_clear_success"].format(count=len(messages)), ephemeral=False) return True diff --git a/src/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py index 14bdb3d2..801b778c 100644 --- a/src/extensions/default/RoleReact.py +++ b/src/extensions/default/RoleReact.py @@ -306,7 +306,7 @@ async def create_menu(self, interaction: Interaction, color: Transform[Color, Co message_embeds = embeds_from_options([], menu_id=message.id, color=color) await message.edit(embeds=message_embeds) - await respond_or_followup(COG_STRINGS["react_create_menu_success"], interaction, ephemeral=self.bot.only_ephemeral) + await respond_or_followup(COG_STRINGS["react_create_menu_success"], interaction, ephemeral=True) @command(name=COG_STRINGS["react_delete_menu_name"], description=COG_STRINGS["react_delete_menu_description"]) @describe(menu_id=COG_STRINGS["react_delete_menu_message_id_describe"]) diff --git a/src/extensions/dynamic/AutoRoles.py b/src/extensions/dynamic/AutoRoles.py index dfd4005f..b2498fe5 100644 --- a/src/extensions/dynamic/AutoRoles.py +++ b/src/extensions/dynamic/AutoRoles.py @@ -91,7 +91,7 @@ async def set_guild_roles(self, interaction: Interaction, roles: Transform[List[ self.logger.info( f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} updated the list of automatically applied roles: [{','.join([x.mention for x in successful_roles])}]" ) - await interaction.followup.send(embed=response_embed, ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(embed=response_embed, ephemeral=False) return True @command(name=COG_STRINGS["roles_add_role_name"], description=COG_STRINGS["roles_add_role_description"]) @@ -117,10 +117,7 @@ async def add_guild_role(self, interaction: Interaction, role: Role): self.logger.info( f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} added {role.mention} to the list of automatically assigned roles" ) - await interaction.followup.send( - COG_STRINGS["roles_add_role_success"].format(role=role.mention), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["roles_add_role_success"].format(role=role.mention), ephemeral=True) return True @command(name=COG_STRINGS["roles_remove_role_name"], description=COG_STRINGS["roles_remove_role_description"]) @@ -145,10 +142,7 @@ async def remove_guild_role(self, interaction: Interaction, role: Role): self.logger.info( f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} removed {role.mention} from the list of automatically assigned roles" ) - await interaction.followup.send( - COG_STRINGS["roles_remove_role_success"].format(role=role.mention), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["roles_remove_role_success"].format(role=role.mention), ephemeral=True) return True @command(name=COG_STRINGS["roles_get_list_name"], description=COG_STRINGS["roles_get_list_description"]) @@ -174,7 +168,7 @@ async def list_guild_roles(self, interaction: Interaction): description=COG_STRINGS["roles_get_list_success_description"].format(roles=formatted_string), color=Color.random() ) - await interaction.followup.send(embed=response_embed, ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(embed=response_embed, ephemeral=True) return True @command(name=COG_STRINGS["roles_clear_list_name"], description=COG_STRINGS["roles_clear_list_description"]) @@ -192,7 +186,7 @@ async def clear_guild_roles(self, interaction: Interaction): self.logger.info( f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} cleared the list of automatically assigned roles" ) - await interaction.followup.send(COG_STRINGS["roles_clear_list_success"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["roles_clear_list_success"], ephemeral=True) return True diff --git a/src/extensions/dynamic/EventTools.py b/src/extensions/dynamic/EventTools.py index 0e46ab7e..9c38c07b 100644 --- a/src/extensions/dynamic/EventTools.py +++ b/src/extensions/dynamic/EventTools.py @@ -564,7 +564,7 @@ async def open_event(self, interaction: Interaction, event_id: str): await discord_event.start() await interaction.followup.send( content=COG_STRINGS["events_open_event_success"].format(event_name=event.name), - ephemeral=self.bot.only_ephemeral + ephemeral=True ) return True @@ -602,7 +602,7 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo if await self.delete_event(interaction.guild, event=event): await interaction.followup.send( content=COG_STRINGS["events_close_event_success_no_archive"].format(event_name=event.name), - ephemeral=self.bot.only_ephemeral + ephemeral=True ) else: await interaction.followup.send(content=COG_STRINGS[""], ephemeral=True) @@ -614,7 +614,7 @@ async def close_event(self, interaction: Interaction, event_id: str, archive: bo event_name=event.name, result="cleared" if clear_messages else "not changed" ), - ephemeral=self.bot.only_ephemeral + ephemeral=True ) discord_event = interaction.guild.get_scheduled_event(event.event_id) @@ -723,7 +723,7 @@ async def reschedule_event( await interaction.followup.send( content=COG_STRINGS["events_reschedule_event_success"].format(name=event.name, event_id=event.event_id), - ephemeral=self.bot.only_ephemeral + ephemeral=False ) return True @@ -757,7 +757,7 @@ async def remove_event(self, interaction: Interaction, event_id: str): await interaction.followup.send( content=COG_STRINGS["events_remove_event_success"].format(name=event.name), - ephemeral=self.bot.only_ephemeral + ephemeral=False ) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py index 3d222f75..0af3ae29 100644 --- a/src/extensions/dynamic/VoiceAdmin.py +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -243,10 +243,7 @@ async def set_parent_channel(self, interaction: Interaction, channel: VoiceChann self.logger.info( f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} made {channel.mention} into a Parent voice channel" ) - await interaction.followup.send( - COG_STRINGS["vc_set_parent_success"].format(channel=channel), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["vc_set_parent_success"].format(channel=channel), ephemeral=True) return True @command(name=COG_STRINGS["vc_remove_parent_name"], description=COG_STRINGS["vc_remove_parent_description"]) @@ -272,10 +269,7 @@ async def remove_parent_channel(self, interaction: Interaction, channel: VoiceCh self.logger.info( f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} removed {channel.mention} from being a Parent voice channel" ) - await interaction.followup.send( - COG_STRINGS["vc_remove_parent_success"].format(channel=channel.name), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["vc_remove_parent_success"].format(channel=channel.name), ephemeral=True) return True @@ -306,15 +300,12 @@ async def get_parent_channels(self, interaction: Interaction): fetched_channels = [await interaction.guild.fetch_channel(x.channel_id) for x in db_items] if len(fetched_channels) == 0: - await interaction.followup.send(COG_STRINGS["vc_get_parents_empty"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["vc_get_parents_empty"], ephemeral=True) return False response_string = "\n".join([f"- {x.name}" for x in fetched_channels]) - await interaction.followup.send( - COG_STRINGS["vc_get_parents_format"].format(channels=response_string), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["vc_get_parents_format"].format(channels=response_string), ephemeral=True) return True @command( @@ -379,10 +370,7 @@ async def rename_channel(self, interaction: Interaction, new_name: str = ""): f"Updated child Voice Channel of {interaction.user.display_name} " f"(guildid - {interaction.guild.id} | channelid - {voice_channel.id}) to {name_set}" ) - await interaction.followup.send( - COG_STRINGS["vc_rename_success"].format(name=name_set), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["vc_rename_success"].format(name=name_set), ephemeral=True) return True @command( @@ -469,7 +457,7 @@ async def lock_channel(self, interaction: Interaction): DBSession.update(db_entry) await voice_channel.edit(name=f"{voice_channel.name}{COG_STRINGS['vc_locked_icon_with_delimiter']}") - await interaction.followup.send(COG_STRINGS["vc_lock_success"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["vc_lock_success"], ephemeral=True) return True @@ -517,7 +505,7 @@ async def unlock_channel(self, interaction: Interaction): ) await voice_channel.set_permissions(voice_channel.guild.default_role, overwrite=None) - await interaction.followup.send(COG_STRINGS["vc_unlock_success"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["vc_unlock_success"], ephemeral=True) return True @command( @@ -565,10 +553,7 @@ async def limit_channel(self, interaction: Interaction, user_limit: int = 0): await voice_channel.edit(name=f"{voice_channel.name}{COG_STRINGS['vc_limited_icon_with_delimiter']}") - await interaction.followup.send( - COG_STRINGS["vc_limit_success"].format(count=user_limit), - ephemeral=self.bot.only_ephemeral - ) + await interaction.followup.send(COG_STRINGS["vc_limit_success"].format(count=user_limit), ephemeral=True) return True @command( @@ -611,7 +596,7 @@ async def unlimit_channel(self, interaction: Interaction): user_limit=None ) - await interaction.followup.send(COG_STRINGS["vc_unlimit_success"], ephemeral=self.bot.only_ephemeral) + await interaction.followup.send(COG_STRINGS["vc_unlimit_success"], ephemeral=True) return True From e1143793977acf3133d00a9169fa8de51cf30bf4 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 6 Aug 2023 13:17:46 +0200 Subject: [PATCH 375/404] Updated MUSIC_AUTHOR --- src/extensions/dynamic/VCMusic.py | 33 +++++++++++++------------------ src/locale/VCMusic.toml | 2 +- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index aac614fd..21a5649d 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -38,7 +38,8 @@ COG_STRINGS = load_cog_toml(__name__) AUTHOR_ID = 244050529271939073 -CURRENT_AUTHOR = "fuxticks#1809" +global MUSIC_AUTHOR +MUSIC_AUTHOR = "fluxticks" QUERY_RESULT_LIMIT = 15 INACTIVE_TIMEOUT = 60 EMBED_IMAGE_URL = os.getenv("MUSIC_DEFAULT_IMAGE") @@ -598,9 +599,10 @@ def __init__(self, bot: Bot): @GroupCog.listener() async def on_ready(self): - self.update_author.start() + if not self.update_author.is_running(): + self.update_author.start() - @tasks.loop(hours=6) + @tasks.loop(hours=2) async def update_author(self): """Ensure that the author we acquired is still up to date @@ -609,10 +611,11 @@ async def update_author(self): """ new_author = await self.bot.fetch_user(AUTHOR_ID) if new_author: - CURRENT_AUTHOR = new_author - self.logger.info(f"Found current discord tag of VCMusic: {CURRENT_AUTHOR}") + global MUSIC_AUTHOR + MUSIC_AUTHOR = new_author + self.logger.info(f"Found current discord tag of VCMusic: {MUSIC_AUTHOR}") return True - self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {CURRENT_AUTHOR}") + self.logger.info(f"Unable to find VCMusic author with id {AUTHOR_ID}, defaulting to {MUSIC_AUTHOR}") return False @command(name=COG_STRINGS["music_set_channel_name"], description=COG_STRINGS["music_set_channel_description"]) @@ -655,7 +658,7 @@ async def set_channel( if clear_messages: await channel.purge(before=interaction.created_at) - embed = create_music_embed(embed_color, CURRENT_AUTHOR) + embed = create_music_embed(embed_color, MUSIC_AUTHOR) view = create_music_actionbar() message = await channel.send(embed=embed, view=view) @@ -671,7 +674,7 @@ async def set_channel( await interaction.followup.send( content=COG_STRINGS["music_set_channel_success"].format(channel=channel.mention), - ephemeral=self.bot.only_ephemeral + ephemeral=False ) if read_only: @@ -688,10 +691,6 @@ async def set_channel( view_channel=True) ) - self.logger.info( - f"{self.bot.logging_prefix}[{interaction.guild.id}] {interaction.user.mention} set {channel.mention} as the active music channel" - ) - @guild_only() class VCMusic(GroupCog, name=COG_STRINGS["music_group_name"]): @@ -1212,11 +1211,7 @@ async def resume_or_start_playback(self, interaction: Interaction) -> bool: await respond_or_followup(COG_STRINGS["music_resume_success"], interaction, ephemeral=True) return True - await respond_or_followup( - COG_STRINGS["music_generic_error"].format(author=CURRENT_AUTHOR), - interaction, - ephemeral=True - ) + await respond_or_followup(COG_STRINGS["music_generic_error"].format(author=MUSIC_AUTHOR), interaction, ephemeral=True) return False async def pause_playback(self, interaction: Interaction) -> bool: @@ -1357,7 +1352,7 @@ async def update_embed(self, guild_id: int) -> bool: queue_length = COG_STRINGS["music_embed_queue_length"].format(length=len(self.active_players.get(guild_id).queue)) new_embed = create_music_embed( color=current_embed.color, - author=CURRENT_AUTHOR, + author=MUSIC_AUTHOR, title=COG_STRINGS["music_embed_title_playing"].format(song=current_song.title), description=f"{user}\n{volume}\n{queue_length}", image=current_song.thumbnail, @@ -1366,7 +1361,7 @@ async def update_embed(self, guild_id: int) -> bool: voice_client = self.active_players.get(guild_id).voice_client is_paused = True if voice_client is None else not voice_client.is_playing() else: - new_embed = create_music_embed(color=current_embed.color, author=CURRENT_AUTHOR) + new_embed = create_music_embed(color=current_embed.color, author=MUSIC_AUTHOR) is_paused = True await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml index ece2a2cd..4086f425 100644 --- a/src/locale/VCMusic.toml +++ b/src/locale/VCMusic.toml @@ -9,7 +9,7 @@ music_embed_title_playing = "Now playing — {song}" music_embed_current_volume = "Current Volume — `{value}%` 🎵" music_embed_request_user = "Requested by — {user}" music_embed_queue_length = "Songs in queue — `{length}`" -music_embed_footer = "Made by {author} 💖" +music_embed_footer = "Made by @{author} 💖" music_button_set_volume = "Set Volume" music_button_add_song = "Add Song" music_button_view_queue = "View Queue" From e32ffa5b92334ca4bfae99b6e14a1fb80dd37e86 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 6 Aug 2023 14:08:35 +0200 Subject: [PATCH 376/404] Implemented role-mention timeout --- src/extensions/dynamic/UserRoles.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 3d0cff0f..0f46d074 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -1,11 +1,23 @@ from discord.app_commands import guild_only, default_permissions from discord.ext.commands import GroupCog, Bot +from discord import Role from common.io import load_cog_toml +from asyncio import sleep as async_sleep, create_task import logging COG_STRINGS = load_cog_toml(__name__) +def timeout_role_mention(role: Role, duration: float): + + async def timeout(): + await role.edit(mentionable=False) + await async_sleep(duration) + await role.edit(mentionable=True) + + create_task(timeout()) + + @default_permissions(administrator=True) @guild_only() class UserRolesAdmin(GroupCog, name=COG_STRINGS["users_admin_group_name"]): From 687c3083efddb947534fe0c0240203e86c4323d9 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 6 Aug 2023 14:09:00 +0200 Subject: [PATCH 377/404] Sorted imports --- src/extensions/dynamic/UserRoles.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 0f46d074..d802b127 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -1,9 +1,12 @@ -from discord.app_commands import guild_only, default_permissions -from discord.ext.commands import GroupCog, Bot +import logging +from asyncio import create_task +from asyncio import sleep as async_sleep + from discord import Role +from discord.app_commands import default_permissions, guild_only +from discord.ext.commands import Bot, GroupCog + from common.io import load_cog_toml -from asyncio import sleep as async_sleep, create_task -import logging COG_STRINGS = load_cog_toml(__name__) From 0d3ec51fcb246e7b02b3d04603ae24ad7fbe8124 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 6 Aug 2023 14:18:13 +0200 Subject: [PATCH 378/404] Added UserRoles DB models --- src/database/models.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/database/models.py b/src/database/models.py index e2f4790c..9083cd50 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -12,7 +12,9 @@ "EventToolsEvents", "MusicChannels", "RoleReactMenus", - "LogChannelChannels" + "LogChannelChannels", + "UserRolesConfig", + "UserRolesRoles" ] @@ -71,4 +73,17 @@ class LogChannelChannels(base): __tablename__ = "logchannel_channels" guild_id = Column(BigInteger, nullable=False, primary_key=True) channel_id = Column(BigInteger, nullable=False) - current_message_id = Column(BigInteger, nullable=False) \ No newline at end of file + current_message_id = Column(BigInteger, nullable=False) + + +class UserRolesConfig(base): + __tablename__ = "userroles_config" + guild_id = Column(BigInteger, nullable=False, primary_key=True) + mention_cooldown = Column(BigInteger, default=60) + + +class UserRolesRoles(base): + __tablename__ = "userroles_roles" + primary_key = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True, nullable=False) + guild_id = Column(BigInteger, nullable=False) + role_id = Column(BigInteger, nullable=False) \ No newline at end of file From 839c32378cabde0da39252c1a779004683021e8e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Sun, 6 Aug 2023 14:25:08 +0200 Subject: [PATCH 379/404] UserRoles config load on start --- src/extensions/dynamic/UserRoles.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index d802b127..84777b17 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -7,6 +7,8 @@ from discord.ext.commands import Bot, GroupCog from common.io import load_cog_toml +from database.gateway import DBSession +from database.models import UserRolesConfig COG_STRINGS = load_cog_toml(__name__) @@ -29,6 +31,11 @@ def __init__(self, bot: Bot): self.bot = bot self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") + self.guild_configs = {} + + def load_config(self): + db_items = DBSession.list(UserRolesConfig) + self.guild_configs = {x.guild_id: x for x in db_items} @guild_only() From 5bb13d125aaaa020795116d740d6a40f8107ec19 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 20:15:37 +0200 Subject: [PATCH 380/404] Added extra DB fields to UserRolesConfig --- src/database/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/database/models.py b/src/database/models.py index 9083cd50..980858cf 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -80,6 +80,8 @@ class UserRolesConfig(base): __tablename__ = "userroles_config" guild_id = Column(BigInteger, nullable=False, primary_key=True) mention_cooldown = Column(BigInteger, default=60) + vote_length = Column(BigInteger, default=3600) + vote_threshold = Column(BigInteger, default=5) class UserRolesRoles(base): From 2d4576410ef5befb332aa069b81a182f346b6c7e Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 20:16:18 +0200 Subject: [PATCH 381/404] Create default configs for guilds not in DB --- src/extensions/dynamic/UserRoles.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 84777b17..bd831224 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -37,6 +37,12 @@ def load_config(self): db_items = DBSession.list(UserRolesConfig) self.guild_configs = {x.guild_id: x for x in db_items} + @GroupCog.listener() + async def on_ready(self): + for guild in self.bot.guilds: + if not DBSession.get(UserRolesConfig, guild_id=guild.id): + DBSession.create(UserRolesConfig(guild_id=guild.id)) + @guild_only() class UserRoles(GroupCog, name=COG_STRINGS["users_group_name"]): From cdb3f0b551936c8efa2c831607146d0baf3178f3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 20:17:05 +0200 Subject: [PATCH 382/404] Pass the instance of admin cog to regular cog --- src/extensions/dynamic/UserRoles.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index bd831224..db5e5e64 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -47,12 +47,14 @@ async def on_ready(self): @guild_only() class UserRoles(GroupCog, name=COG_STRINGS["users_group_name"]): - def __init__(self, bot: Bot): + def __init__(self, bot: Bot, admin_cog_instance: GroupCog): self.bot = bot + self.admin_cog = admin_cog_instance self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") async def setup(bot: Bot): - await bot.add_cog(UserRolesAdmin(bot)) - await bot.add_cog(UserRoles(bot)) + admin_cog = UserRolesAdmin(bot) + await bot.add_cog(admin_cog) + await bot.add_cog(UserRoles(bot, admin_cog)) From 39d0ee0ac0f883b0740e66a2f4f0415990a77387 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 20:23:11 +0200 Subject: [PATCH 383/404] Simple vote embed creation --- src/extensions/dynamic/UserRoles.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index db5e5e64..aac8901c 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -1,8 +1,9 @@ import logging from asyncio import create_task from asyncio import sleep as async_sleep +from datetime import datetime -from discord import Role +from discord import Color, Embed, Interaction, Role from discord.app_commands import default_permissions, guild_only from discord.ext.commands import Bot, GroupCog @@ -23,6 +24,21 @@ async def timeout(): create_task(timeout()) +def make_vote_embed(role_name: str, vote_duration: float, vote_threshold: int): + end = datetime.now().timestamp() + vote_duration + end_int = int(end) + end_time = f"<t:{end_int}:R>" + + embed = Embed( + title=COG_STRINGS["users_vote_menu_title"].format(name=role_name), + description=COG_STRINGS["users_vote_menu_description"].format(threshold=vote_threshold, + timestamp=end_time), + color=Color.random() + ) + + return embed + + @default_permissions(administrator=True) @guild_only() class UserRolesAdmin(GroupCog, name=COG_STRINGS["users_admin_group_name"]): From 59875c78ca0cfa2cda3124c4cb5f335a0f3114e3 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 20:28:01 +0200 Subject: [PATCH 384/404] Refactored checking of interaction prefix to function --- src/common/discord.py | 10 ++++++++++ src/extensions/dynamic/EventTools.py | 10 ++++------ src/extensions/dynamic/VCMusic.py | 9 +++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index a12d401a..3b481f4b 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -86,6 +86,16 @@ def primary_key_from_object(object: Union[Role, GuildChannel, ScheduledEvent, Me return int(f"{object.guild.id % 1000}{object.id % 1000}") +def check_interaction_prefix(interaction: Interaction, prefix: str): + if not interaction.data or not interaction.data.get("custom_id"): + return False + + if not interaction.data.get("custom_id").startswith(prefix): + return False + + return True + + class RoleListTransformer(Transformer): """The transformer class to transform a list of Roles given in a ccommand string to a list of discord.Role objects. diff --git a/src/extensions/dynamic/EventTools.py b/src/extensions/dynamic/EventTools.py index 9c38c07b..93819654 100644 --- a/src/extensions/dynamic/EventTools.py +++ b/src/extensions/dynamic/EventTools.py @@ -38,7 +38,8 @@ ArchivedEventTransformer, ColourTransformer, DatetimeTransformer, - EventTransformer + EventTransformer, + check_interaction_prefix ) from common.io import load_cog_toml, load_timezones from database.gateway import DBSession @@ -391,11 +392,8 @@ async def on_interaction(self, interaction: Interaction): Returns: bool: If the interaction was meaningfully handled. """ - if not interaction.data or not interaction.data.get("custom_id"): - return False - - if not interaction.data.get("custom_id").startswith(EVENT_INTERACTION_PREFIX): - return False + if not check_interaction_prefix(interaction, EVENT_INTERACTION_PREFIX): + return id_data = parse_custom_id(interaction.data.get("custom_id")) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py index 21a5649d..2ac4a476 100644 --- a/src/extensions/dynamic/VCMusic.py +++ b/src/extensions/dynamic/VCMusic.py @@ -31,7 +31,7 @@ from youtubesearchpython import VideosSearch from yt_dlp import YoutubeDL -from common.discord import ColourTransformer, respond_or_followup +from common.discord import check_interaction_prefix, ColourTransformer, respond_or_followup from common.io import load_cog_toml from database.gateway import DBSession from database.models import MusicChannels @@ -751,11 +751,8 @@ async def on_interaction(self, interaction: Interaction): Returns: bool: If the handling of the interaction was successful. """ - if not interaction.data or not interaction.data.get("custom_id"): - return False - - if not interaction.data.get("custom_id").startswith(MUSIC_INTERACTION_PREFIX): - return False + if not check_interaction_prefix(interaction, MUSIC_INTERACTION_PREFIX): + return try: user_action = UserActionType.from_string(interaction.data.get("custom_id")) From f83d2071ed0552904ae61fc18a8ff9853531f4bd Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 20:43:10 +0200 Subject: [PATCH 385/404] Added PollData dataclass --- src/extensions/dynamic/UserRoles.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index aac8901c..2ba1287d 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -1,6 +1,7 @@ import logging from asyncio import create_task from asyncio import sleep as async_sleep +from dataclasses import dataclass from datetime import datetime from discord import Color, Embed, Interaction, Role @@ -14,6 +15,15 @@ COG_STRINGS = load_cog_toml(__name__) +@dataclass() +class PollData: + guild_id: int + channel_id: int + message_id: int + user_votes: set + end_time: datetime + + def timeout_role_mention(role: Role, duration: float): async def timeout(): From fd7a1b1433380ee9e27a1f519e60e232c65f5e78 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 20:48:43 +0200 Subject: [PATCH 386/404] Added role name to PollData class --- src/extensions/dynamic/UserRoles.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 2ba1287d..3cc6c8ef 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -17,6 +17,7 @@ @dataclass() class PollData: + role_name: str guild_id: int channel_id: int message_id: int From ff7839dadc967a2a6b49170edf0e4985b54310f2 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 21:35:50 +0200 Subject: [PATCH 387/404] Updated embed creation --- src/extensions/dynamic/UserRoles.py | 14 ++++++++------ src/locale/UserRoles.toml | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 3cc6c8ef..df0ff328 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -35,15 +35,17 @@ async def timeout(): create_task(timeout()) -def make_vote_embed(role_name: str, vote_duration: float, vote_threshold: int): - end = datetime.now().timestamp() + vote_duration - end_int = int(end) +def make_vote_embed(poll_data: PollData, vote_threshold: int): + end_int = int(poll_data.end_time.timestamp()) end_time = f"<t:{end_int}:R>" + description = COG_STRINGS["users_vote_menu_description"].format(threshold=vote_threshold) + description += f"\n\n**Current Votes: `{len(poll_data.user_votes)}/{vote_threshold}`**" + description += f"\n\n**Voting Ends {end_time}**" + embed = Embed( - title=COG_STRINGS["users_vote_menu_title"].format(name=role_name), - description=COG_STRINGS["users_vote_menu_description"].format(threshold=vote_threshold, - timestamp=end_time), + title=COG_STRINGS["users_vote_menu_title"].format(name=poll_data.role_name), + description=description, color=Color.random() ) diff --git a/src/locale/UserRoles.toml b/src/locale/UserRoles.toml index 6240eb90..3f56c116 100644 --- a/src/locale/UserRoles.toml +++ b/src/locale/UserRoles.toml @@ -1,2 +1,5 @@ users_admin_group_name = "pingable-admin" -users_group_name = "pingable" \ No newline at end of file +users_group_name = "pingable" + +users_vote_menu_title = "Vote to create {name} Pingable Role" +users_vote_menu_description = "For the role to be created it must surpass `{threshold} vote(s)`" From c279f02a8cfe1469eb92daec0b309f774e95a2d7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 21:36:45 +0200 Subject: [PATCH 388/404] Actually load guild configs onready --- src/extensions/dynamic/UserRoles.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index df0ff328..cf6b8385 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -72,6 +72,8 @@ async def on_ready(self): if not DBSession.get(UserRolesConfig, guild_id=guild.id): DBSession.create(UserRolesConfig(guild_id=guild.id)) + self.load_config() + @guild_only() class UserRoles(GroupCog, name=COG_STRINGS["users_group_name"]): From 423ac38d12aa24bf4a49efebb066fb537e13ba91 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 22:19:39 +0200 Subject: [PATCH 389/404] Added InteractionType enum to track interactions --- src/extensions/dynamic/UserRoles.py | 47 ++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index cf6b8385..bc6c393d 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -2,7 +2,7 @@ from asyncio import create_task from asyncio import sleep as async_sleep from dataclasses import dataclass -from datetime import datetime +from enum import IntEnum from discord import Color, Embed, Interaction, Role from discord.app_commands import default_permissions, guild_only @@ -13,6 +13,8 @@ from database.models import UserRolesConfig COG_STRINGS = load_cog_toml(__name__) +INTERACTION_PREFIX = f"{__name__}.interaction" +INTERACTION_SPLIT_CHARACTER = "-" @dataclass() @@ -25,6 +27,49 @@ class PollData: end_time: datetime +class InteractionType(IntEnum): + VOTE_ADD = 0 + VOTE_REMOVE = 1 + ROLE_ADD = 2 + ROLE_REMOVE = 3 + + @property + def id(self) -> str: + base = f"{INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}" + match self: + case InteractionType.VOTE_ADD: + return f"{base}voteadd" + case InteractionType.VOTE_REMOVE: + return f"{base}voteremove" + case InteractionType.ROLE_ADD: + return f"{base}roleadd" + case InteractionType.ROLE_REMOVE: + return f"{base}roleremove" + case _: + raise ValueError(f"Missing ID for given enum - {self:s}") + + def __str__(self): + return self.id + + @classmethod + def from_string(self, string: str) -> "InteractionType": + if not string.startswith(INTERACTION_PREFIX): + raise ValueError("Invalid enum string ID given") + + enum_id = string.split(INTERACTION_SPLIT_CHARACTER)[-1] + match enum_id: + case "voteadd": + return InteractionType.VOTE_ADD + case "voteremove": + return InteractionType.VOTE_REMOVE + case "roleadd": + return InteractionType.ROLE_ADD + case "roleremove": + return InteractionType.ROLE_REMOVE + case _: + raise ValueError("Invalid enum string ID given") + + def timeout_role_mention(role: Role, duration: float): async def timeout(): From 972e04ebc60a821171affafd05ec8f0fc84b785f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 22:35:52 +0200 Subject: [PATCH 390/404] Added simple role menu creation --- src/extensions/dynamic/UserRoles.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index bc6c393d..71ee4fcb 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -97,6 +97,16 @@ def make_vote_embed(poll_data: PollData, vote_threshold: int): return embed +def make_role_embed(poll_data: PollData): + embed = Embed( + title=f"Get the {poll_data.role_name} (Pingable) Role", + description="This is a Pingable Role role menu.", + color=Color.random() + ) + + return embed + + @default_permissions(administrator=True) @guild_only() class UserRolesAdmin(GroupCog, name=COG_STRINGS["users_admin_group_name"]): From 30d161ce68a4c2f3d9fce7c438b5c75d1a25aab7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 22:38:30 +0200 Subject: [PATCH 391/404] Added new UserRoles env vars --- secrets.template | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/secrets.template b/secrets.template index d93c6992..b357e60c 100644 --- a/secrets.template +++ b/secrets.template @@ -35,9 +35,16 @@ TWITTER_ACCESS_TOKEN= TWITTER_ACCESS_TOKEN_SECRET= ################### +## UserRoles Vars ## +ROLE_SUFFIX=(Pingable) +INTERACTION_COOLDOWN=60 +################### + +## Extension Enable / Disable ## ENABLE_VOICEADMIN=FALSE ENABLE_EVENTTOOLS=FALSE ENABLE_AUTOROLES=FALSE ENABLE_VCMUSIC=FALSE -ENABLE_USERROLES=FALSE -ENABLE_TWITTERTRACKER=FALSE \ No newline at end of file +ENABLE_USERROLES=TRUE +ENABLE_TWITTERTRACKER=FALSE +################### \ No newline at end of file From b88334875d1c65c11970d974901a7a174fb554a7 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 23:36:35 +0200 Subject: [PATCH 392/404] Implemeted basic PingableRoles system --- src/extensions/dynamic/UserRoles.py | 263 +++++++++++++++++++++++++++- src/locale/UserRoles.toml | 14 ++ 2 files changed, 270 insertions(+), 7 deletions(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 71ee4fcb..cb96b371 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -1,20 +1,26 @@ import logging -from asyncio import create_task +import os +from asyncio import create_task, Task from asyncio import sleep as async_sleep from dataclasses import dataclass +from datetime import datetime, timedelta from enum import IntEnum -from discord import Color, Embed, Interaction, Role -from discord.app_commands import default_permissions, guild_only +from discord import Color, Embed, Interaction, Role, Message +from discord.app_commands import command, default_permissions, describe, guild_only, rename from discord.ext.commands import Bot, GroupCog +from discord.ui import View, Button +from common.discord import respond_or_followup, check_interaction_prefix from common.io import load_cog_toml from database.gateway import DBSession -from database.models import UserRolesConfig +from database.models import UserRolesConfig, UserRolesRoles COG_STRINGS = load_cog_toml(__name__) INTERACTION_PREFIX = f"{__name__}.interaction" INTERACTION_SPLIT_CHARACTER = "-" +USER_INTERACTION_COOLDOWN = int(os.getenv("INTERACTION_COOLDOWN", 60)) +ROLE_SUFFIX = os.getenv("ROLE_SUFFIX") @dataclass() @@ -56,7 +62,7 @@ def from_string(self, string: str) -> "InteractionType": if not string.startswith(INTERACTION_PREFIX): raise ValueError("Invalid enum string ID given") - enum_id = string.split(INTERACTION_SPLIT_CHARACTER)[-1] + enum_id = string.split(INTERACTION_SPLIT_CHARACTER)[1] match enum_id: case "voteadd": return InteractionType.VOTE_ADD @@ -97,10 +103,23 @@ def make_vote_embed(poll_data: PollData, vote_threshold: int): return embed +def make_vote_ended_embed(poll_data: PollData, vote_threshold: int): + end_int = int(poll_data.end_time.timestamp()) + end_time = f"<t:{end_int}:R>" + description = f"**Voting ended {end_time}**" + description += f"\n\nPoll finished with: `{len(poll_data.user_votes)}/{vote_threshold}` vote(s)" + embed = Embed( + title=COG_STRINGS["users_vote_menu_title"].format(name=poll_data.role_name), + description=description, + color=Color.random() + ) + return embed + + def make_role_embed(poll_data: PollData): embed = Embed( - title=f"Get the {poll_data.role_name} (Pingable) Role", - description="This is a Pingable Role role menu.", + title=f"Get the {poll_data.role_name} {ROLE_SUFFIX} Role", + description="This is a Pingable Role role menu. Use the buttons below to add/remove the role from yourself.", color=Color.random() ) @@ -138,6 +157,236 @@ def __init__(self, bot: Bot, admin_cog_instance: GroupCog): self.admin_cog = admin_cog_instance self.logger = logging.getLogger(__name__) self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") + self.current_polls = {} + self.poll_callbacks = [] + self.user_interaction_timeout = {} + self.tracked_role_ids = {} + self.load_roles() + + def load_roles(self): + db_items = DBSession.list(UserRolesRoles) + for item in db_items: + if item.guild_id not in self.tracked_role_ids: + self.tracked_role_ids[item.guild_id] = [] + self.tracked_role_ids[item.guild_id].append(item.role_id) + + @GroupCog.listener() + async def on_message(self, message: Message): + if message.author.bot: + return + + if message.author.guild_permissions.administrator: + return + + roles = self.tracked_role_ids.get(message.guild.id) + if not roles: + return + + guild_config = self.admin_cog.guild_configs.get(message.guild.id) + for role in message.role_mentions: + if role.id in roles: + timeout_role_mention(role, guild_config.mention_cooldown) + + @GroupCog.listener() + async def on_interaction(self, interaction: Interaction): + if not check_interaction_prefix(interaction, INTERACTION_PREFIX): + return + + print(interaction.data.get("custom_id")) + + if interaction.user.id in self.user_interaction_timeout: + time = f"{USER_INTERACTION_COOLDOWN}s" + await interaction.response.send_message( + COG_STRINGS["users_interaction_timeout"].format(time=time), + ephemeral=True, + delete_after=5 + ) + return + + current_guild_polls = self.current_polls.get(interaction.guild.id, {}) + poll_data: PollData = current_guild_polls.get(interaction.message.id, None) + + interaction_type = InteractionType.from_string(interaction.data.get("custom_id")) + guild_config = self.admin_cog.guild_configs.get(interaction.guild.id) + + match interaction_type: + case InteractionType.VOTE_ADD: + await self.user_add_vote(interaction, poll_data, guild_config) + + case InteractionType.VOTE_REMOVE: + await self.user_remove_vote(interaction, poll_data, guild_config) + + case InteractionType.ROLE_ADD: + await self.user_add_role(interaction) + + case InteractionType.ROLE_REMOVE: + await self.user_remove_role(interaction) + + def timeout_user_interaction(self, user_id: int): + cooldown_task = create_task(async_sleep(USER_INTERACTION_COOLDOWN)) + + def callback(task: Task): + self.user_interaction_timeout.pop(user_id) + + cooldown_task.add_done_callback(callback) + self.user_interaction_timeout[user_id] = cooldown_task + + async def user_add_role(self, interaction: Interaction): + interaction_id = interaction.data.get("custom_id") + role_id = int(interaction_id.split(INTERACTION_SPLIT_CHARACTER)[-1]) + role = interaction.guild.get_role(role_id) + if not role: + await interaction.response.send_message(COG_STRINGS["users_role_invalid"], ephemeral=True) + return + + await interaction.user.add_roles(role) + await interaction.response.send_message(COG_STRINGS["users_role_added"].format(role=role.name), ephemeral=True) + + async def user_remove_role(self, interaction: Interaction): + interaction_id = interaction.data.get("custom_id") + role_id = int(interaction_id.split(INTERACTION_SPLIT_CHARACTER)[-1]) + role = interaction.guild.get_role(role_id) + if not role: + await interaction.response.send_message(COG_STRINGS["users_role_invalid"], ephemeral=True) + return + + await interaction.user.remove_roles(role) + await interaction.response.send_message(COG_STRINGS["users_role_removed"].format(role=role.name), ephemeral=True) + + async def validate_user_vote(self, interaction: Interaction, poll_data: PollData): + if poll_data is None: + await interaction.message.edit(view=None) + await interaction.response.send_message( + COG_STRINGS["users_vote_ended"].format(name=poll_data.role_name), + ephemeral=True, + delete_after=10 + ) + return False + + # ngl in my mind this logic doesn't make sense, but it works... + if datetime.now() > poll_data.end_time: + await interaction.message.edit(view=None) + await interaction.response.send_message( + COG_STRINGS["users_vote_ended"].format(name=poll_data.role_name), + ephemeral=True, + delete_after=10 + ) + return False + + return True + + async def user_add_vote(self, interaction: Interaction, poll_data: PollData, guild_config: UserRolesConfig): + if not await self.validate_user_vote(interaction, poll_data): + return + + poll_data.user_votes.add(interaction.user.id) + await self.update_vote_count(poll_data, guild_config) + if len(poll_data.user_votes) < guild_config.vote_threshold: + await interaction.response.send_message( + COG_STRINGS["users_vote_added"].format(name=poll_data.role_name), + ephemeral=True, + delete_after=10 + ) + else: + await self.end_poll(poll_data) + + self.timeout_user_interaction(interaction.user.id) + + async def user_remove_vote(self, interaction: Interaction, poll_data: PollData, guild_config: UserRolesConfig): + if not await self.validate_user_vote(interaction, poll_data): + return + + poll_data.user_votes.discard(interaction.user.id) + await self.update_vote_count(poll_data, guild_config) + await interaction.response.send_message( + COG_STRINGS["users_vote_removed"].format(name=poll_data.role_name), + ephemeral=True, + delete_after=10 + ) + + self.timeout_user_interaction(interaction) + + async def update_vote_count(self, poll_data: PollData, guild_config: UserRolesConfig): + embed = make_vote_embed(poll_data, guild_config.vote_threshold) + + guild = self.bot.get_guild(poll_data.guild_id) + channel = guild.get_channel(poll_data.channel_id) + message = await channel.fetch_message(poll_data.message_id) + if not message: + return + + await message.edit(embed=embed) + + async def end_poll(self, poll_data: PollData): + if not self.current_polls.get(poll_data.guild_id, {}).get(poll_data.message_id): + return + + embed = make_role_embed(poll_data) + guild = self.bot.get_guild(poll_data.guild_id) + channel = guild.get_channel(poll_data.channel_id) + + view = View(timeout=None) + role = await guild.create_role(name=f"{poll_data.role_name} {ROLE_SUFFIX}", mentionable=True) + DBSession.create(UserRolesRoles(guild_id=guild.id, role_id=role.id)) + if not self.tracked_role_ids.get(guild.id): + self.tracked_role_ids[guild.id] = [] + self.tracked_role_ids[guild.id].append(role.id) + + add_button = Button(emoji="✅", custom_id=f"{InteractionType.ROLE_ADD.id}{INTERACTION_SPLIT_CHARACTER}{role.id}") + view.add_item(add_button) + remove_button = Button(emoji="❌", custom_id=f"{InteractionType.ROLE_REMOVE.id}{INTERACTION_SPLIT_CHARACTER}{role.id}") + view.add_item(remove_button) + + await channel.send(embed=embed, view=view) + + guild_config = self.admin_cog.guild_configs.get(poll_data.guild_id) + vote_ended_embed = make_vote_ended_embed(poll_data, guild_config.vote_threshold) + old_message = await channel.fetch_message(poll_data.message_id) + await old_message.edit(embed=vote_ended_embed, view=None) + self.current_polls.get(poll_data.guild_id).pop(poll_data.message_id) + + @command(name=COG_STRINGS["users_start_vote_name"], description=COG_STRINGS["users_start_vote_description"]) + @describe(role_name=COG_STRINGS["users_start_vote_role_name_describe"]) + @rename(role_name=COG_STRINGS["users_start_vote_role_name_rename"]) + async def start_vote(self, interaction: Interaction, role_name: str): + await interaction.response.defer(ephemeral=True) + + message = await interaction.channel.send("​") + guild_config = self.admin_cog.guild_configs.get(interaction.guild.id) + + end_datetime = datetime.now() + timedelta(seconds=guild_config.vote_length) + + poll_data = PollData( + role_name=role_name, + guild_id=interaction.guild.id, + channel_id=interaction.channel.id, + message_id=message.id, + user_votes=set(), + end_time=end_datetime + ) + + if not self.current_polls.get(interaction.guild.id): + self.current_polls[interaction.guild.id] = {} + self.current_polls[interaction.guild.id][message.id] = poll_data + + view = View(timeout=guild_config.vote_length) + + add_button = Button(emoji="✅", custom_id=InteractionType.VOTE_ADD.id) + view.add_item(add_button) + remove_button = Button(emoji="❌", custom_id=InteractionType.VOTE_REMOVE.id) + view.add_item(remove_button) + + poll_task = create_task(async_sleep(guild_config.vote_length)) + + def callback(task: Task): + self.poll_callbacks.remove(task) + create_task(self.end_poll(poll_data)) + + poll_task.add_done_callback(callback) + self.poll_callbacks.append(poll_task) + + await message.edit(embed=make_vote_embed(poll_data, guild_config.vote_threshold), view=view) + await respond_or_followup(COG_STRINGS["react_start_vote_success"].format(name=role_name), interaction, ephemeral=True) async def setup(bot: Bot): diff --git a/src/locale/UserRoles.toml b/src/locale/UserRoles.toml index 3f56c116..38ccae5d 100644 --- a/src/locale/UserRoles.toml +++ b/src/locale/UserRoles.toml @@ -1,5 +1,19 @@ users_admin_group_name = "pingable-admin" users_group_name = "pingable" +users_vote_ended = "Sorry, but this poll has already ended ⚠️" +users_vote_added = "You have voted for {name} Pingable Role ✅" +users_vote_removed = "Your vote for {name} Pingable Role has been removed ✅" +users_interaction_timeout = "You must wait {time} before interacting again ⚠️" +users_role_invalid = "The role you tried to receive is no longer valid, please let an admin know ❌" +users_role_added = "You have received the `{role}` role ✅" +users_role_removed = "You have removed `{role}` role from yourself ✅" + users_vote_menu_title = "Vote to create {name} Pingable Role" users_vote_menu_description = "For the role to be created it must surpass `{threshold} vote(s)`" + +users_start_vote_name = "create-role" +users_start_vote_description = "Start a poll to create a pingable role." +users_start_vote_role_name_rename = "role-name" +users_start_vote_role_name_describe = "The name of the role to create" +react_start_vote_success = "Vote started for {name} ✅" \ No newline at end of file From 9768c47e9f2ffb5da90b329926a4d817f71f1166 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 23:41:40 +0200 Subject: [PATCH 393/404] Updated UserRoels README --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7c971338..4e76ccf0 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,19 @@ In order to function, a google API key with access to YouTube Data API v3 must b </details> +<details> +<summary>UserRoles</summary> + +## UserRoles + +### Environment Variable: `ENABLE_USERROLES` + +#### /pingable create-role \<role-name\> + +- Start a poll to create a new user role. + +</details> + # TODO - ~~Implement unimplemented commands in VoiceAdmin and AdminTools cogs.~~ @@ -284,7 +297,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - [x] EventCategoriesCog ✅ Implemented as EventTools - [x] LogChannelCog ✅ Implementation as LogChannel - [x] MusicCog ✅ Implemented as VCMusic -- [ ] PingableRolesCog ⏳ Not being worked on, started in UserRoles +- [ ] PingableRolesCog ⏳ WIP - Mostly done, implementation in UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ - [ ] TwitterCog ❌ Needs Twitter API v2 Bearer Token From 638004baae03202971bf6d30cd95bdd4361c91c5 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 23:49:18 +0200 Subject: [PATCH 394/404] Moved interaction timeout to voting only --- src/extensions/dynamic/UserRoles.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index cb96b371..89a0034c 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -192,17 +192,6 @@ async def on_interaction(self, interaction: Interaction): if not check_interaction_prefix(interaction, INTERACTION_PREFIX): return - print(interaction.data.get("custom_id")) - - if interaction.user.id in self.user_interaction_timeout: - time = f"{USER_INTERACTION_COOLDOWN}s" - await interaction.response.send_message( - COG_STRINGS["users_interaction_timeout"].format(time=time), - ephemeral=True, - delete_after=5 - ) - return - current_guild_polls = self.current_polls.get(interaction.guild.id, {}) poll_data: PollData = current_guild_polls.get(interaction.message.id, None) @@ -253,6 +242,17 @@ async def user_remove_role(self, interaction: Interaction): await interaction.user.remove_roles(role) await interaction.response.send_message(COG_STRINGS["users_role_removed"].format(role=role.name), ephemeral=True) + async def check_for_timeout(self, interaction: Interaction): + if interaction.user.id in self.user_interaction_timeout: + time = f"{USER_INTERACTION_COOLDOWN}s" + await interaction.response.send_message( + COG_STRINGS["users_interaction_timeout"].format(time=time), + ephemeral=True, + delete_after=5 + ) + return False + return True + async def validate_user_vote(self, interaction: Interaction, poll_data: PollData): if poll_data is None: await interaction.message.edit(view=None) @@ -279,6 +279,9 @@ async def user_add_vote(self, interaction: Interaction, poll_data: PollData, gui if not await self.validate_user_vote(interaction, poll_data): return + if not await self.check_for_timeout(interaction): + return + poll_data.user_votes.add(interaction.user.id) await self.update_vote_count(poll_data, guild_config) if len(poll_data.user_votes) < guild_config.vote_threshold: @@ -296,6 +299,9 @@ async def user_remove_vote(self, interaction: Interaction, poll_data: PollData, if not await self.validate_user_vote(interaction, poll_data): return + if not await self.check_for_timeout(interaction): + return + poll_data.user_votes.discard(interaction.user.id) await self.update_vote_count(poll_data, guild_config) await interaction.response.send_message( From c0bf1b858497fba872fb4f4fbd6495cd936e8603 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Tue, 8 Aug 2023 23:49:49 +0200 Subject: [PATCH 395/404] Auto pin role-react message --- src/extensions/dynamic/UserRoles.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 89a0034c..148c373e 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -343,7 +343,8 @@ async def end_poll(self, poll_data: PollData): remove_button = Button(emoji="❌", custom_id=f"{InteractionType.ROLE_REMOVE.id}{INTERACTION_SPLIT_CHARACTER}{role.id}") view.add_item(remove_button) - await channel.send(embed=embed, view=view) + message = await channel.send(embed=embed, view=view) + await message.pin() guild_config = self.admin_cog.guild_configs.get(poll_data.guild_id) vote_ended_embed = make_vote_ended_embed(poll_data, guild_config.vote_threshold) From 2689043b26d32a456fc65ce81442880f48a1872b Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 10 Aug 2023 18:34:14 +0200 Subject: [PATCH 396/404] Added admin command to get current pingable roles config --- src/common/discord.py | 13 +++++++++- src/extensions/dynamic/UserRoles.py | 38 +++++++++++++++++++++++++++-- src/locale/UserRoles.toml | 11 ++++++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/common/discord.py b/src/common/discord.py index 3b481f4b..be684c13 100644 --- a/src/common/discord.py +++ b/src/common/discord.py @@ -7,7 +7,7 @@ from discord.app_commands import Choice, Transformer from discord.ui import View -from database.models import RoleReactMenus +from database.models import UserRolesConfig, RoleReactMenus from database.gateway import DBSession ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") @@ -366,3 +366,14 @@ async def autocomplete(self, interaction: Interaction, value: Union[int, str]) - choices = [Choice(name=f"{guild_webhooks.get(x).name} ({x})", value=str(x)) for x in guild_webhooks] return choices[:25] + + +class UserRolesConfigTransformer(Transformer): + + async def autocomplete(self, interaction: Interaction, value: str) -> List[Choice[str]]: + attributes = [x for x in UserRolesConfig.__dict__.keys() if not x.startswith("_") and "guild" not in x.lower()] + choices = [ + Choice(name=" ".join(j.capitalize() for j in x.split("_")), + value=x) for x in attributes if value.lower() in x.lower() + ] + return choices[:25] diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 148c373e..b794f67f 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -7,11 +7,11 @@ from enum import IntEnum from discord import Color, Embed, Interaction, Role, Message -from discord.app_commands import command, default_permissions, describe, guild_only, rename +from discord.app_commands import command, default_permissions, describe, guild_only, rename, autocomplete from discord.ext.commands import Bot, GroupCog from discord.ui import View, Button -from common.discord import respond_or_followup, check_interaction_prefix +from common.discord import respond_or_followup, check_interaction_prefix, UserRolesConfigTransformer from common.io import load_cog_toml from database.gateway import DBSession from database.models import UserRolesConfig, UserRolesRoles @@ -148,6 +148,40 @@ async def on_ready(self): self.load_config() + @command(name=COG_STRINGS["users_admin_get_config_name"], description=COG_STRINGS["users_admin_get_config_description"]) + @describe(setting=COG_STRINGS["users_admin_get_config_property_describe"]) + @rename(setting=COG_STRINGS["users_admin_get_config_property_rename"]) + @autocomplete(setting=UserRolesConfigTransformer.autocomplete) + async def get_config(self, interaction: Interaction, setting: str = None): + guild_config: UserRolesConfig = self.guild_configs.get(interaction.guild.id) + + if not setting: + config_title = COG_STRINGS["users_admin_get_config_title"] + config_description = COG_STRINGS["users_admin_get_config_subtext"] + settings = "\n".join( + f"• _{' '.join(x.capitalize() for x in x.split('_'))}_ – `{getattr(guild_config, x)}`" + for x in guild_config.__dict__ if not x.startswith("_") and "guild" not in x.lower() + ) + + message = f"{config_title}\n{config_description}\n\n{settings}" + await interaction.response.send_message(message, ephemeral=True) + return + + try: + value = getattr(guild_config, setting) + pretty_string = " ".join(x.capitalize() for x in setting.split("_")) + await interaction.response.send_message( + COG_STRINGS["users_admin_get_config_single"].format(setting=pretty_string, + value=value), + ephemeral=True, + ) + except AttributeError: + await interaction.response.send_message( + COG_STRINGS["users_admin_get_config_wrong_setting"].format(setting=setting), + ephemeral=True, + delete_after=15 + ) + @guild_only() class UserRoles(GroupCog, name=COG_STRINGS["users_group_name"]): diff --git a/src/locale/UserRoles.toml b/src/locale/UserRoles.toml index 38ccae5d..7aa696b8 100644 --- a/src/locale/UserRoles.toml +++ b/src/locale/UserRoles.toml @@ -16,4 +16,13 @@ users_start_vote_name = "create-role" users_start_vote_description = "Start a poll to create a pingable role." users_start_vote_role_name_rename = "role-name" users_start_vote_role_name_describe = "The name of the role to create" -react_start_vote_success = "Vote started for {name} ✅" \ No newline at end of file +react_start_vote_success = "Vote started for {name} ✅" + +users_admin_get_config_name = "current-settings" +users_admin_get_config_description = "Get the current Pingable Roles settings." +users_admin_get_config_property_describe = "The specific property to get." +users_admin_get_config_property_rename = "setting" +users_admin_get_config_title = "**__Current Pignable Role Config__**" +users_admin_get_config_subtext = "_Any time-related settings are in seconds._" +users_admin_get_config_single = "**{setting}** is currently set to `{value}`" +users_admin_get_config_wrong_setting = "There is no config setting called `{setting}` ⚠️" \ No newline at end of file From 87e3e77e8998b6811f837f37e24cc0a39f74d70d Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 10 Aug 2023 18:35:46 +0200 Subject: [PATCH 397/404] Fixed music-admin command name in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e76ccf0..5ea38db7 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b 1. Click on `Create Credentials` and then `API key`. 1. Copy the key given. For security, it is recommended that you "restrict key" and only enable `YouTube Data API v3`. -#### /music set-channel \<channel\> [optional: color] [optional: clear-channel] [optional: read-only] +#### /music-admin set-channel \<channel\> [optional: color] [optional: clear-channel] [optional: read-only] - Sets the channel to define as the music channel. From 2556e7205ed1c6b6a100a08829ae6f764f85ca41 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 10 Aug 2023 18:37:10 +0200 Subject: [PATCH 398/404] Added get-config to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5ea38db7..8772b19e 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,10 @@ In order to function, a google API key with access to YouTube Data API v3 must b ### Environment Variable: `ENABLE_USERROLES` +#### /pingable-admin get-config [optional: setting] + +- Get the current settings for UserRoles or a specific setting by providing the name. + #### /pingable create-role \<role-name\> - Start a poll to create a new user role. From 4bce2c44e8b3c213afab2ab6e114c88e76e1928f Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 10 Aug 2023 18:39:53 +0200 Subject: [PATCH 399/404] Removed typing of variable --- src/extensions/dynamic/UserRoles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index b794f67f..5aa8f992 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -153,7 +153,7 @@ async def on_ready(self): @rename(setting=COG_STRINGS["users_admin_get_config_property_rename"]) @autocomplete(setting=UserRolesConfigTransformer.autocomplete) async def get_config(self, interaction: Interaction, setting: str = None): - guild_config: UserRolesConfig = self.guild_configs.get(interaction.guild.id) + guild_config = self.guild_configs.get(interaction.guild.id) if not setting: config_title = COG_STRINGS["users_admin_get_config_title"] From 6b09353d8836b70443d7d86cd7830378032eae88 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 10 Aug 2023 18:54:01 +0200 Subject: [PATCH 400/404] Added pingable roles set-config command --- src/extensions/dynamic/UserRoles.py | 33 ++++++++++++++++++++++++++++- src/locale/UserRoles.toml | 11 +++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 5aa8f992..92f90a4a 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -7,7 +7,7 @@ from enum import IntEnum from discord import Color, Embed, Interaction, Role, Message -from discord.app_commands import command, default_permissions, describe, guild_only, rename, autocomplete +from discord.app_commands import command, default_permissions, describe, guild_only, Range, rename, autocomplete from discord.ext.commands import Bot, GroupCog from discord.ui import View, Button @@ -182,6 +182,37 @@ async def get_config(self, interaction: Interaction, setting: str = None): delete_after=15 ) + @command(name=COG_STRINGS["users_admin_set_config_name"], description=COG_STRINGS["users_admin_set_config_description"]) + @describe( + setting=COG_STRINGS["users_admin_set_config_property_describe"], + value=COG_STRINGS["users_admin_set_config_value_describe"] + ) + @rename( + setting=COG_STRINGS["users_admin_set_config_property_rename"], + value=COG_STRINGS["users_admin_set_config_value_rename"] + ) + @autocomplete(setting=UserRolesConfigTransformer.autocomplete) + async def set_config(self, interaction: Interaction, setting: str, value: Range[int, 1]): + guild_config = self.guild_configs.get(interaction.guild.id) + + try: + _ = getattr(guild_config, setting) + setattr(guild_config, setting, value) + self.guild_configs[interaction.guild.id] = guild_config + DBSession.update(guild_config) + await interaction.response.send_message( + COG_STRINGS["users_admin_set_config_success"].format( + setting=" ".join(x.capitalize() for x in setting.split("_")), + value=value + ) + ) + except AttributeError: + await interaction.response.send_message( + COG_STRINGS["users_admin_set_config_wrong_setting"].format(setting=setting), + ephemeral=True, + delete_after=15 + ) + @guild_only() class UserRoles(GroupCog, name=COG_STRINGS["users_group_name"]): diff --git a/src/locale/UserRoles.toml b/src/locale/UserRoles.toml index 7aa696b8..7b74eafc 100644 --- a/src/locale/UserRoles.toml +++ b/src/locale/UserRoles.toml @@ -25,4 +25,13 @@ users_admin_get_config_property_rename = "setting" users_admin_get_config_title = "**__Current Pignable Role Config__**" users_admin_get_config_subtext = "_Any time-related settings are in seconds._" users_admin_get_config_single = "**{setting}** is currently set to `{value}`" -users_admin_get_config_wrong_setting = "There is no config setting called `{setting}` ⚠️" \ No newline at end of file +users_admin_get_config_wrong_setting = "There is no config setting called `{setting}` ⚠️" + +users_admin_set_config_name = "set-config" +users_admin_set_config_description = "Set a config setting to a specific value." +users_admin_set_config_property_describe = "The setting to update." +users_admin_set_config_property_rename = "setting" +users_admin_set_config_wrong_setting = "There is no config setting called `{setting}` ⚠️" +users_admin_set_config_value_rename = "value" +users_admin_set_config_value_describe = "The value to set to." +users_admin_set_config_success = "Successfully set _{setting}_ to `{value}` ✅" \ No newline at end of file From 9760b536257b47f50f17590dbce0773bcf7ff345 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 10 Aug 2023 18:56:06 +0200 Subject: [PATCH 401/404] Added set-config to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 8772b19e..a03febc1 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,10 @@ In order to function, a google API key with access to YouTube Data API v3 must b - Get the current settings for UserRoles or a specific setting by providing the name. +#### /pingable-admin set-config \<setting\> \<value\> + +- Set a specific UserRoles setting to a given value. + #### /pingable create-role \<role-name\> - Start a poll to create a new user role. From 996f155678c4a0c19936ea5d87ef032580f2c419 Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 10 Aug 2023 19:11:53 +0200 Subject: [PATCH 402/404] Updated vote embed to use COG_STRINGS --- src/extensions/dynamic/UserRoles.py | 8 ++++---- src/locale/UserRoles.toml | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py index 92f90a4a..afae1036 100644 --- a/src/extensions/dynamic/UserRoles.py +++ b/src/extensions/dynamic/UserRoles.py @@ -116,10 +116,10 @@ def make_vote_ended_embed(poll_data: PollData, vote_threshold: int): return embed -def make_role_embed(poll_data: PollData): +def make_role_embed(poll_data: PollData, role_id: int): embed = Embed( - title=f"Get the {poll_data.role_name} {ROLE_SUFFIX} Role", - description="This is a Pingable Role role menu. Use the buttons below to add/remove the role from yourself.", + title=COG_STRINGS["users_vote_menu_end_title"].format(role_name=f"{poll_data.role_name} {ROLE_SUFFIX}"), + description=COG_STRINGS["users_vote_menu_end_description"].format(role_id=role_id), color=Color.random() ) @@ -392,7 +392,6 @@ async def end_poll(self, poll_data: PollData): if not self.current_polls.get(poll_data.guild_id, {}).get(poll_data.message_id): return - embed = make_role_embed(poll_data) guild = self.bot.get_guild(poll_data.guild_id) channel = guild.get_channel(poll_data.channel_id) @@ -408,6 +407,7 @@ async def end_poll(self, poll_data: PollData): remove_button = Button(emoji="❌", custom_id=f"{InteractionType.ROLE_REMOVE.id}{INTERACTION_SPLIT_CHARACTER}{role.id}") view.add_item(remove_button) + embed = make_role_embed(poll_data, role.id) message = await channel.send(embed=embed, view=view) await message.pin() diff --git a/src/locale/UserRoles.toml b/src/locale/UserRoles.toml index 7b74eafc..7f15285b 100644 --- a/src/locale/UserRoles.toml +++ b/src/locale/UserRoles.toml @@ -11,6 +11,8 @@ users_role_removed = "You have removed `{role}` role from yourself ✅" users_vote_menu_title = "Vote to create {name} Pingable Role" users_vote_menu_description = "For the role to be created it must surpass `{threshold} vote(s)`" +users_vote_menu_end_title = "Get the {role_name} Role" +users_vote_menu_end_description = "This is a Pingable Role role menu for <@&{role_id}>. Use the buttons below to add/remove the role for yourself." users_start_vote_name = "create-role" users_start_vote_description = "Start a poll to create a pingable role." From 0fdaa8b9cea4f53bf27a34871b5d4d73656dd0cf Mon Sep 17 00:00:00 2001 From: Fluxticks <benjigarment.appdev@gmail.com> Date: Thu, 10 Aug 2023 19:14:23 +0200 Subject: [PATCH 403/404] Updated README TODO --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a03febc1..cebefe09 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - ~~Implement unimplemented commands in VoiceAdmin and AdminTools cogs.~~ - ~~Implement EventTools cog~~ - ~~Implement AutoRoles cog~~ -- Add back functionality of previous bot (eg. Music, PingableRoles, etc.) +- ~~Add back functionality of previous bot (eg. Music, PingableRoles, etc.)~~ - Add game deal tracker (DealTracker(?) cog) - ~~Add proper support for SQLite auto increment primary keys~~ - ~~Add proper use of command groups~~ @@ -305,7 +305,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - [x] EventCategoriesCog ✅ Implemented as EventTools - [x] LogChannelCog ✅ Implementation as LogChannel - [x] MusicCog ✅ Implemented as VCMusic -- [ ] PingableRolesCog ⏳ WIP - Mostly done, implementation in UserRoles +- [x] PingableRolesCog ✅ Implementation as UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact - [ ] TwitchCog ⚠️ - [ ] TwitterCog ❌ Needs Twitter API v2 Bearer Token From 620e3d986026537eeed739c71427943b293af727 Mon Sep 17 00:00:00 2001 From: Becky Garment | Fluxticks <30944845+Fluxticks@users.noreply.github.com> Date: Mon, 14 Aug 2023 07:50:17 +0200 Subject: [PATCH 404/404] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cebefe09..c3f2bf71 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ In order to function, a google API key with access to YouTube Data API v3 must b - [x] MusicCog ✅ Implemented as VCMusic - [x] PingableRolesCog ✅ Implementation as UserRoles - [x] RoleReactCog ✅ Implemented as RoleReact -- [ ] TwitchCog ⚠️ +- [ ] TwitchCog ❌ Announcements precede most livestreams - [ ] TwitterCog ❌ Needs Twitter API v2 Bearer Token - [x] VoicemasterCog ✅ Implemented as VoiceAdmin - [ ] VotingCog ⚠️