diff --git a/.gitignore b/.gitignore index 176d046c..d7627d43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ **/__pycache__ -test-file.py -/src/cogs/TestCog.py -/src/esportsbot/version.txt +.DS_STORE +.venv .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/README.md b/README.md index f537d9d1..c3f2bf71 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ Dependency Versions: +
- + @@ -16,463 +17,197 @@ 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: - -- 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-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 -$ source secrets.env -``` - -4. Change into the bot directory: -```bash -$ cd src -``` -5. Install all the requirements for python: -```bash -pip install -r requirements.txt -``` -6. Run the bot: -```bash -python3 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.
-Voicemaster +AdminTools -### Voicemaster - #### !voice setparent -* Aliases: `setvmparent` -* Make the given ID a Voicemaster parent voice channel. +## AdminTools -#### !voice getparents -* Aliases: `setvmparent` -* Get all the Voicemaster parent voice channels in the server. +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. -#### !voice removeparent -* Aliases: `removevmparent` -* Remove the given ID as a Voicemaster parent voice channel. +### Current Commands: -#### !voice removeallparents -* Remove all Voicemaster parents from the server. +#### /admin member-count -#### !voice removeallchildren -* Delete all the Voicemaster child channels in the server. +- Get the current member count of the server. -#### !voice lock -* Aliases: `lockvm` -* Locks the Voicemaster child you're currently in to the number of current members. +#### /admin clear-messages [optional: message-count] -#### !voice unlock -* Aliases: `unlockvm` -* Unlocks the Voicemaster child you're currently in. +- Delete a specific number of messages in the given channel. + Defaults to 5 messages, with a maximum of 100 messages. -#### !voice rename -* Aliases: `renamevm` -* Renames your current Voicemaster voice channel. -
- -
-Default Role +#### /admin get-version -### Default role - #### !setdefaultroles -* Sets the roles that the server gives to members when they join the server. +- Get the current version of the Bot. -#### !getdefaultroles -* Gets the current default roles set for the server. - -#### !removedefaultroles -* Removes the current default roles for the server.
-Log Channel +RoleReact -### Log Channel - #### !setlogchannel -* Set the log channel to the #'ed channel or given role ID. +## RoleReact -#### !getlogchannel -* Gets the current log channel value. +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. -#### !removelogchannel -* Removes the current log channel value. -
+### Current Commands: -
-Administrator Tools - -### Administrator Tools - Adds a few commands useful for admin operations. +#### /reactroles create [optional: color] -#### !admin clear -* Aliases: `cls, purge, delete` -* Clear the specified number of messages from the current text channel. +- Creates a new menu to add roles to. -#### !admin version -* Get the current version of the bot. +#### /reactroles delete \ -#### !admin members -* List the current number of members in the server. +- Deletes a given menu and it's message. -#### !dev remove-cog \ -* Unloads the given cog. -* *This command requires your user ID to be defined in the env file under `DEV_IDS`* +#### /reactroles add-role \ \ [optional: emoji] [optional: description] -#### !dev add-cog \ -* Loads the given cog. -* *This command requires your user ID to be defined in the env file under `DEV_IDS`* +- Add a role to a given menu. Optionally give the role an emoji and/or description. -#### !dev reload-cog \ -* Reloads the given cog. -* *This command requires your user ID to be defined in the env file under `DEV_IDS`* +#### /reactroles remove-role \ \ -#### !admin set-rep \ \ -* Sets the permissions for a user in the channels/categories given. -* *Requires `administrator` permission in Discord* - -#### !admin user-info \ -* Get basic information about a users profile. -* *Requires `administrator` permission in Discord* +- Remove a role from a given menu.
-Twitter Integration - -### Twitter Integration +LogChannel -Enables forwarding tweets when they are tweeted to a discord channel for specific Twitter accounts. +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. -Requires the `ENABLE_TWITTER` variable to be set to `TRUE` in order to function. +#### /logging set-channel \ -#### !twitter add \ +- Configures the given channel to be the logging channel. -* Add a Twitter handle to notify when they tweet or quote retweet. +#### /logging get-channel -#### !twitter remove \ +- Gets the currently assigned logging channel. -* Remove the given Twitter handle from notifications. +#### /logging remove-channel -#### !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 \ - -* 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. +- Removes the currently configured logging channel.
-Event Channel Management +VoiceAdmin -### Event Category Management +## VoiceAdmin -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. +### Environment Variable: `ENABLE_VOICEADMIN` -#### !events create-event \ \ +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 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. +### Current Commands: -#### !events open-event \ +#### /voice-admin set-parent \ -* 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. +- Set a Voice Channel to be a parent Voice Channel. -#### !events close-event \ +#### /voice-admin remove-parent \ -* Stops any member who is not an administrator from being able to see any of the event channels. +- Remove a Voice Channel from being a parent Voice Channel. -#### !events delete-event \ +#### /voice get-parents -* Deletes all the channels in the category for the event and deletes the role created by the bot for the event. +- Get the list of current parent Voice Channels. -
- -
-Twitch Integration +#### /voice rename \ -### Twitch Integration +- Rename your current Voice Channel -Enables sending notifications to a Discord channel whenever a tracked channel goes live. +#### /voice lock -Requires the `ENABLE_TWITCH` variable to be set to `TRUE` in order to function. +- Only allow current members to (re)join your Voice Channel. -Set the `TEMP_BEARER_FILE` to anything you like, this will be the file where your bearer token is stored for reuse. +#### /voice unlock -### Creating your self-signed SSL keys: +- Allow anyone to join your Voice Channel again. -1. Create the Certificate Authority (CA) private key: -```console -$ openssl genrsa -des3 -out servercakey.pem -``` +#### /voice limit -2. Create the CA public certificate: -```console -$ openssl req -new -x509 -key servercakey.pem -out root.crt -``` +- Set the member count limit of your Voice Channel. -3. Create the server's private key file: -```console -$ openssl genrsa -out server.key -``` +#### /voice remove-limit -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. - -#### !twitch createhook \ \ - -* Creates a Discord Webhook bound to the channel given and with the name given, but prefixed with the Twitch Webhook prefix. - -#### !twitch deletehook \ - -* Deletes the given Discord Webhook. - -#### !twitch add \ \ [optional: custom message] - -* 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 "custom_message"` - -#### !twitch remove \ \ - -* 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 \ \ [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 "custom_message"` - -#### !twitch getmessage \ [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 \ \ -* Get a preview of the live notification for the given Twitch channel in the given Webhook. +- Remove the member count limit of your Voice Channel.
-Role Reaction Menus - -### Role Reaction Menus. - -Role reaction menus allow admins to create reactable menus that when reacted to grant defined roles to the user. - -For devs: - -* 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`. +AutoRoles -#### !roles make-menu \ \<description> [\<mentioned role> \<emoji>] +## AutoRoles -* 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} ... ...` +### Environment Variable: `ENABLE_AUTOROLES` -#### !roles add-option [optional: menu id] [\<mentioned role> \<emoji>] +#### /autoroles set-list \<One or many roles mentioned\> -* 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} ... ...` +- 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 remove-option \<emoji> [optional: menu id] +#### /autoroles add-role \<role\> -* 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* +- Adds a role to the list of roles without overriding the currently configured roles. -#### !roles disable-menu [optional: menu id] +#### /autoroles remove-role \<role\> -* 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* +- Removes a role from the list of currently configured roles. -#### !roles enable-menu [optional: menu id] +#### /autoroles get-list -* 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* +- Gets the list of currently configured AutoRoles. -#### !roles delete-menu \<menu id> +#### /autoroles clear-list -* Deletes the given role reaction menu. __Does not__ delete any of the roles in the menu, just the message. -* *Requires `administrator` permission in Discord* - -#### !roles toggle-ids - -* 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* +- Clears all roles from the list of configured AutoRoles. </details> <details> -<summary>Poll Reaction Menus</summary> - -### Poll Reaction menus. - -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. +<summary>EventTools</summary> -For devs: +## EventTools -* 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`. +### Environment Variable: `ENABLE_EVENTTOOLS` -#### !votes make-poll \<title> [\<emoji> \<description>] +#### /events create-event \<name\> \<physical location\> \<start time\> \<end time\> \<timezone\> \<common member role\> \<role color\> -* 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] - ``` +- Creates a new event. -#### !votes add-option \<menu id> \<emoji> \<description> +#### /events open-event \<event name or ID\> -* 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}` +- Opens the given event. This will show the sign-in menu to members. -#### !votes remove-option \<menu id> \<emoji> +#### /events close-event \<event name or ID\> [optional: keep-event?] [optional: clear-messages?] -* 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* +- 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. -#### !votes delete-poll \<menu id> +#### /events reschedule-event \<physical location\> \<start time\> \<end time\> \<timezone\> -* Aliases: `delete, del` -* Deletes the poll with the menu id given. -* *You must be the owner of the poll or be an administrator* +- If an event has been archived, it can be reused and rescheduled for a new date using this command. -#### !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* +#### /events remove-event \<event name or ID\> +- Entirely deletes either an active or archived event. </details> <details> -<summary>Music Bot</summary> - -### Music Bot +<summary>VCMusic</summary> -A basic music bot that functions similarly to the popular 'Hydra Bot'. +## VCMusic -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. +### Environment Variable: `ENABLE_VCMUSIC` -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: +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: @@ -484,178 +219,148 @@ For this cog to work, the `GOOGLE_API` env var must also be set, and instruction 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 - -* 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* - -#### !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* - -#### !music queue - -* Aliases: `songqueue, songs, songlist, songslist` -* Gets the current list of songs in the queue. +#### /music-admin set-channel \<channel\> [optional: color] [optional: clear-channel] [optional: read-only] -#### !music join [optional: -f | force] +- Sets the channel to define as the music channel. -* 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 play -#### !music kick [optional: -f | force] +- Resumes or starts playback. -* 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 pause -#### !music play [optional: song request] +- Pauses playback. -* 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 skip-song -#### !music pause +- Skips the current song. Stops playback if the last song in the queue. -* Pauses the current song. +#### /music shuffle-queue -#### !music shuffle +- Shuffles the current queue. -* Shuffles the current queue of songs. +#### /music add-music -#### !music volume \<volume level> +- Opens the dialogue to add one or many songs to the queue. -* Sets the volume of the bot for everyone to the level given. +#### /music view-queue -#### !music clear +- Shows the current queue. -* Clears the queue entirely, does not stop the current song from playing. +#### /music stop -#### !music skip [optional: skip to position] +- Stop the current playback. -* 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 volume \<volume\> -#### !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. +- Sets the volume percentage between 0-100 </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. +<summary>UserRoles</summary> -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. +## UserRoles -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. +### Environment Variable: `ENABLE_USERROLES` -#### !pingme settings get-settings +#### /pingable-admin get-config [optional: setting] -* Returns an embed of the current default settings for the server. -* *Requires `administrator` permission in Discord* +- Get the current settings for UserRoles or a specific setting by providing the name. -#### !pingme settings default-settings +#### /pingable-admin set-config \<setting\> \<value\> -* Resets all settings for this guild to the bot-defined defaults defined in the `.env` file. -* *Requires `administrator` permission in Discord* +- Set a specific UserRoles setting to a given value. -#### !pingme settings poll-length \<poll length in seconds> +#### /pingable create-role \<role-name\> -* 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* +- Start a poll to create a new user role. -#### !pingme settings poll-threshold \<number of votes threshold> +</details> -* Sets the number of votes required in a poll for the role to be created. -* *Requires `administrator` permission in Discord* +# TODO -#### !pingme settings ping-cooldown \<cooldown in seconds> +- ~~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) +- ~~Add proper support for SQLite auto increment primary keys~~ +- ~~Add proper use of command groups~~ -* 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* +## Previous extensions to implement -#### !pingme settings poll-emoji \<emoji> +<pre> +✅ Extension implemented either partially or fully. -* Sets the emoji to be used when creating a poll to vote in. -* *Requires `administrator` permission in Discord* +⏳ Extension currently being implemented not yet ready. -#### !pingme settings role-emoji \<emoji> +❌ High priority extension not yet implemented. -* 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* +⚠️ Low priority extension not yet implemented. +</pre> -#### !pingme disable-role \<one or many role mentions> +- [x] AdminCog ✅ Implemented as AdminTools +- [x] DefaultRoleCog ✅ Implemented as AutoRoles +- [x] EventCategoriesCog ✅ Implemented as EventTools +- [x] LogChannelCog ✅ Implementation as LogChannel +- [x] MusicCog ✅ Implemented as VCMusic +- [x] PingableRolesCog ✅ Implementation as UserRoles +- [x] RoleReactCog ✅ Implemented as RoleReact +- [ ] TwitchCog ❌ Announcements precede most livestreams +- [ ] TwitterCog ❌ Needs Twitter API v2 Bearer Token +- [x] VoicemasterCog ✅ Implemented as VoiceAdmin +- [ ] VotingCog ⚠️ -* 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* +# Quick Setup Guide -#### !pingme enable-role \<one or many role mentions> +Requirements needed to run: -* 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* +- 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/). -#### !pingme create-role \<role name> [optional: poll length in seconds] +1. Clone this repository: -* 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. +```console +$ git clone https://github.com/FragSoc/esports-bot.git +``` -#### !pingme delete-role \<one or many role mentions> +2. Change into the repo directory: -* Deletes the mentioned roles from the server. -* The roles provided __must__ be pingable roles created with this cog. -* *Requires `administrator` permission in Discord* +```console +$ cd esports-bot +``` -#### !pingme convert-role \<one or many role mentions> +3. Rename the `secrets.template` to `secrets.env` and set all the variables. -* 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* +```console +$ nano secrets.env +$ source secrets.env +``` -#### !pingme convert-pingable \<one or many role mentions> +4. Install all the requirements for python: -* 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* +```bash +pip install -r requirements.txt +``` -#### !pingme role-cooldown \<role mention | role ID> \<cooldown in seconds> +5. Run the bot: -* 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* +```bash +python3 src/main.py +``` -#### !pingme role-emoji \<role mention | role ID> \<emoji> +# Contributing Guide -* 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* +If you wish to contribute to this bot please use the following paradigms: -</details> +- 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/`. + - 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` diff --git a/src/esportsbot/banned_words.txt b/banned_words.txt similarity index 100% rename from src/esportsbot/banned_words.txt rename to banned_words.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..9992c9c6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +psycopg2-binary>=2.8 +sqlalchemy +sqlalchemy-utils +discord.py[voice] +python-dotenv +coloredlogs +uvloop +toml diff --git a/requirements[music].txt b/requirements[music].txt new file mode 100644 index 00000000..3af9877d --- /dev/null +++ b/requirements[music].txt @@ -0,0 +1,3 @@ +youtube-search-python +yt-dlp +google-api-python-client \ No newline at end of file diff --git a/secrets.template b/secrets.template index 48839241..b357e60c 100644 --- a/secrets.template +++ b/secrets.template @@ -3,7 +3,9 @@ DISCORD_TOKEN= COMMAND_PREFIX=! UNKNOWN_COMMAND_EMOJI=⁉ -DEV_IDS= +LOGGING_PREFIX="[+]" +DEV_USER_IDS= +DEV_GUILD_ID= ################## ## Database Vars ## @@ -15,63 +17,34 @@ 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 +## 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= -################ - -## 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 +## TwitterTracker Vars ## +# These variables are used in the TwitterTracker extension. 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 +################### +## 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=TRUE +ENABLE_TWITTERTRACKER=FALSE +################### \ No newline at end of file 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..45033df7 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,34 @@ +import logging +import os +from time import sleep + +from client import EsportsBot + +__all__ = ["start_bot"] +logger = logging.getLogger(__name__) + + +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"): + 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..30c653e9 --- /dev/null +++ b/src/client.py @@ -0,0 +1,71 @@ +import logging +import os + +from discord import Intents, Object +from discord.ext.commands import Bot + +import glob + +__all__ = ["EsportsBot"] + + +class __EsportsBot(Bot): + + def __init__(self, command_prefix: str, all_messages_ephemeral: bool, *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__) + self.logging_prefix = os.getenv("LOGGING_PREFIX") + + 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. + """ + + 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 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.dynamic.{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) + 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()) diff --git a/src/esportsbot/__init__.py b/src/common/__init__.py similarity index 100% rename from src/esportsbot/__init__.py rename to src/common/__init__.py diff --git a/src/common/discord.py b/src/common/discord.py new file mode 100644 index 00000000..be684c13 --- /dev/null +++ b/src/common/discord.py @@ -0,0 +1,379 @@ +import re +from datetime import datetime +from typing import List, Union + +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 UserRolesConfig, RoleReactMenus +from database.gateway import DBSession + +ROLE_REGEX = re.compile(r"(?<=\<\@\&)(\d)+(?=\>)") + + +async def respond_or_followup( + message: str, + interaction: Interaction, + ephemeral: bool = False, + delete_after: float = 10, + **kwargs +): + if interaction.response.is_done(): + message = await interaction.followup.send(content=message, ephemeral=ephemeral, **kwargs) + 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) + return True + + +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: + return 0 + + try: + return int(role_found.group()) + except ValueError: + return 0 + + +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 + + +def primary_key_from_object(object: Union[Role, GuildChannel, ScheduledEvent, Message]): + 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. + + 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) + parsed_roles = [] + for _, role_match in enumerate(roles_found): + role_id = role_match.group() + try: + role = await get_role(interaction.guild, int(role_id)) + parsed_roles.append(role) + except ValueError: + continue + + return parsed_roles + + +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}):" + 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) + + 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" + else: + hour_format = "%-I" if len(time_values.get("Hour")) == 1 else "%I" + + 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 = "" + + 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}" + + return datetime.strptime(date_string, full_format) + + +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 [ + 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() + + +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] + 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 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) + + +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 [] + + roles = [] + 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 + + +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: + 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): + """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) + 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] + + +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) + if not DBSession.get(RoleReactMenus, guild_id=interaction.guild.id, message_id=menu_id): + return [] + + message = await interaction.channel.fetch_message(menu_id) + view = View.from_message(message) + menu_roles = get_roles_from_view(view, interaction.guild) + 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] + + +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] + + +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/common/io.py b/src/common/io.py new file mode 100644 index 00000000..a82316da --- /dev/null +++ b/src/common/io.py @@ -0,0 +1,74 @@ +import json +import logging +import os +from typing import Dict + +import toml + +logger = logging.getLogger(__name__) + + +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: + return toml.load(path) + except FileNotFoundError: + logger.warning(f"Unable to load TOML file for {cog_path}") + return {} + + +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: + return file.readline() + except FileNotFoundError: + return None + + +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: + data = json.load(file) + zones = data.get("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.path.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 [] 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)) diff --git a/src/esportsbot/cogs/__init__.py b/src/database/__init__.py similarity index 100% rename from src/esportsbot/cogs/__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..b62f30cb --- /dev/null +++ b/src/database/gateway.py @@ -0,0 +1,127 @@ +import logging +import os +from typing import Any + +from sqlalchemy import Table, create_engine +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): + """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: + 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): + """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 + 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): + """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() + 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): + """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() + 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): + """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() + 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..980858cf --- /dev/null +++ b/src/database/models.py @@ -0,0 +1,91 @@ +from sqlalchemy import BigInteger, Boolean, Column, Integer, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declarative_base + +base = declarative_base() + +__all__ = [ + "base", + "VoiceAdminParent", + "VoiceAdminChild", + "AutoRolesConfig", + "EventToolsEvents", + "MusicChannels", + "RoleReactMenus", + "LogChannelChannels", + "UserRolesConfig", + "UserRolesRoles" +] + + +class VoiceAdminParent(base): + __tablename__ = "voiceadmin_parents" + 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().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) + 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().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().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) + 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) + + +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) + + +class RoleReactMenus(base): + __tablename__ = "rolereact_menus" + 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, primary_key=True) + channel_id = Column(BigInteger, nullable=False) + 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) + vote_length = Column(BigInteger, default=3600) + vote_threshold = Column(BigInteger, default=5) + + +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 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 = "<a:" in emoji_str - first_colon_index = emoji_str.index(":") - second_colon_index = emoji_str.index(":", first_colon_index + 1) - - name = emoji_str[first_colon_index + 1:second_colon_index] - emoji_id = emoji_str[second_colon_index + 1:-1] - - return {"name": name, "id": emoji_id, "animated": animated} - - -def partial_from_string(emoji_str: str) -> 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/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="<channel_id> 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 "<empty>" - 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 <command> 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 = "<empty>" - 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 = "<empty>" - 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 = "<empty>" - 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 = <prefix><fully qualified 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/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 = "<volume level %>" -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 = "<from position> <to position>" -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 = "<channel mention> [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 = "<poll length in seconds>" -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 = "<number of votes required>" -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 = "<ping cooldown in seconds>" -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 = "<emoji>" -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 = "<emoji>" -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="<role name> [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 = "<one or many role mentions>" -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 = "<one or many role mentions>" -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 = "<one or many role mentions>" -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 = "<role mention | role ID> <cooldown in seconds>" -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 = "<role mention | role ID> <emoji>" -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 = "<one or many role mentions>" -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 = "<one or many role mentions>" -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 = "<voice channel ID>" -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 = "<voice channel ID>" -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 = "<number of messages>" -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 = "<cog name>" -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 ="<cog name>" -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 = "<cog name>" -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 = "<user mention> <channel or category ids>" -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 = "<user mention>" -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 = "<channel mention | channel ID>" -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 = "<hook name>" -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 = "<account handle>" -readme_url = "https://github.com/FragSoc/esports-bot#twitter-add-twitter-handle" - -[help.twitter_remove] -help_string = "Stops tracking a Twitter account." -usage = "<account handle>" -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 = "<one or many role mentions>" -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 = "<event name> [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 = "<event name>" -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 = "<event name>" -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 = "<event name>" -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 = "<title> <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/lib/__init__.py b/src/extensions/__init__.py similarity index 100% rename from src/esportsbot/lib/__init__.py rename to src/extensions/__init__.py diff --git a/src/extensions/default/AdminTools.py b/src/extensions/default/AdminTools.py new file mode 100644 index 00000000..648e6dc3 --- /dev/null +++ b/src/extensions/default/AdminTools.py @@ -0,0 +1,84 @@ +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 client import EsportsBot +from common.io import load_bot_version, load_cog_toml + +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): + """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() + 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") + + @command(name=COG_STRINGS["admin_members_name"], description=COG_STRINGS["admin_members_description"]) + 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. + """ + 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=True) + return True + + @command(name=COG_STRINGS["admin_version_name"], description=COG_STRINGS["admin_version_description"]) + 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. + """ + 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"]) + @describe(count=COG_STRINGS["admin_clear_param_describe"]) + @rename(count=COG_STRINGS["admin_clear_param_rename"]) + 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. + """ + if count > 100: + await interaction.response.send_message(COG_STRINGS["admin_clear_warn_too_many"], ephemeral=True) + return False + + 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=False) + return True + + +async def setup(bot: Bot): + await bot.add_cog(AdminTools(bot)) diff --git a/src/extensions/default/LogChannel.py b/src/extensions/default/LogChannel.py new file mode 100644 index 00000000..fa3c7dbb --- /dev/null +++ b/src/extensions/default/LogChannel.py @@ -0,0 +1,187 @@ +import asyncio +import logging +import re +from typing import Any, Coroutine + +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 + +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__) + + +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)) + 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.logger = logging.getLogger(__name__) + + async def log_handler(self, record: logging.LogRecord): + if not hasattr(record, "message"): + return + + 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 + + guild_id = matches.groupdict().get("guild") + 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 + + 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_info = f"[{log_level}][<t:{int(record.created)}:f>] " + log_message = contents_no_prefix.replace(f"[{guild_id}]", "").strip() + + 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) + 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): + 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=0) + DBSession.create(db_item) + else: + current_channel.channel_id = channel.id + current_channel.current_message_id = 0 + 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_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 + + 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/extensions/default/RoleReact.py b/src/extensions/default/RoleReact.py new file mode 100644 index 00000000..801b778c --- /dev/null +++ b/src/extensions/default/RoleReact.py @@ -0,0 +1,450 @@ +import logging +from dataclasses import dataclass +from typing import Union + +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, + RoleReactRoleTransformer, + respond_or_followup +) +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__}." +MAX_VIEW_ITEM_COUNT = 25 + + +@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]: + """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: + 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( + COG_STRINGS["react_warn_invalid_message_found"].format(message_id=message_id), + interaction, + ephemeral=True + ) + return None + + return message + + +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 [] + + 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_roles.get(option.value) + else: + 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: + """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. " + f"Option count exceeds {MAX_VIEW_ITEM_COUNT} * {MAX_VIEW_ITEM_COUNT} ({len(options)})" + ) + + view = View(timeout=None) + 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}", + value=str(option.role_id), + description=option.description, + emoji=option.emoji + ) + child_select.max_values += 1 + + if (idx + 1) % MAX_VIEW_ITEM_COUNT == 0: + view.add_item(child_select) + 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) + + return 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 + ) + 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"]) + + return embed + + +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. " + 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)] + + embeds = [] + embed_item = Embed(title=COG_STRINGS["react_embed_title"], description="**__Active Roles__**", color=color) + + for idx, option in enumerate(options): + embed_item.description += f"\n{option!s}" + if (idx + 1) % MAX_VIEW_ITEM_COUNT == 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: + footer_text = COG_STRINGS["react_footer_no_id"] + + embeds[-1].set_footer(text=footer_text) + + return embeds + + +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: + 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"]): + + 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): + """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 + + 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) + 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 role in select_roles: + 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(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()): + """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("​") + 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) + await message.edit(embeds=message_embeds) + + 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"]) + @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) + 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( + 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( + 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(menu_id=RoleReactMenuTransformer.autocomplete) + async def add_role( + self, + interaction: Interaction, + menu_id: str, + role: Role, + emoji: Transform[PartialEmoji, + 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) + 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 + ) + + @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): + """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) + 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 + ) + + +async def setup(bot: Bot): + await bot.add_cog(RoleReact(bot)) 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/dynamic/AutoRoles.py b/src/extensions/dynamic/AutoRoles.py new file mode 100644 index 00000000..b2498fe5 --- /dev/null +++ b/src/extensions/dynamic/AutoRoles.py @@ -0,0 +1,194 @@ +import logging +from typing import List + +from discord import Color, Embed, Interaction, Member, Role +from discord.app_commands import (Transform, command, default_permissions, describe, guild_only, rename) +from discord.ext.commands import Bot, GroupCog + +from client import EsportsBot +from common.discord import RoleListTransformer, get_role +from common.io import load_cog_toml +from database.gateway import DBSession +from database.models import AutoRolesConfig + +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): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__} has been added as a Cog") + + @GroupCog.listener() + async def on_member_join(self, member: Member): + if not member.pending: + await self.assign_roles(member) + + @GroupCog.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): + guild_roles = DBSession.list(AutoRolesConfig, guild_id=member.guild.id) + + parsed_roles = [] + if guild_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"]) + @rename(roles=COG_STRINGS["roles_set_list_param_rename"]) + 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(ephemeral=True) + + initial_entries = DBSession.list(AutoRolesConfig, guild_id=interaction.guild.id) + + successful_roles = [] + for role in roles: + if role.is_assignable: + db_entry = AutoRolesConfig(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) + + if len(successful_roles) == 0: + await interaction.followup.send(COG_STRINGS["roles_set_warn_empty"], ephemeral=True) + 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() + ) + + 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=False) + 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"]) + 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(ephemeral=True) + + 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=True) + return False + + 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=True) + 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"]) + 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(ephemeral=True) + + 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) + 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=True) + return True + + @command(name=COG_STRINGS["roles_get_list_name"], description=COG_STRINGS["roles_get_list_description"]) + 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(ephemeral=True) + + 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"], ephemeral=True) + 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, ephemeral=True) + return True + + @command(name=COG_STRINGS["roles_clear_list_name"], description=COG_STRINGS["roles_clear_list_description"]) + 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) + + 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=True) + return True + + +async def setup(bot: Bot): + await bot.add_cog(AutoRoles(bot)) diff --git a/src/extensions/dynamic/EventTools.py b/src/extensions/dynamic/EventTools.py new file mode 100644 index 00000000..93819654 --- /dev/null +++ b/src/extensions/dynamic/EventTools.py @@ -0,0 +1,763 @@ +import logging +from dataclasses import dataclass, field +from datetime import datetime +from enum import IntEnum +from zoneinfo import ZoneInfo + +from discord import ( + Colour, + Embed, + EntityType, + EventStatus, + Guild, + Interaction, + PermissionOverwrite, + PrivacyLevel, + Role, + ScheduledEvent, + SelectOption, + TextChannel +) +from discord.app_commands import ( + Choice, + Transform, + autocomplete, + choices, + command, + default_permissions, + describe, + guild_only, + rename +) +from discord.ext.commands import Bot, GroupCog +from discord.ui import Select, View + +from client import EsportsBot +from common.discord import ( + ActiveEventTransformer, + ArchivedEventTransformer, + ColourTransformer, + DatetimeTransformer, + EventTransformer, + check_interaction_prefix +) +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" +TIMEZONES = load_timezones() + +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) +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) +class Event: + name: str + guild_id: int = field(compare=True) + channel_id: int = field(compare=True) + 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(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: + 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 + + +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) + + +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 + + +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 + + +@default_permissions(administrator=True) +@guild_only() +class EventTools(GroupCog, name=COG_STRINGS["events_group_name"]): + + def __init__(self, bot: EsportsBot): + self.bot = bot + 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, but split into active events and archived events. + + Returns: + tuple[dict, dict]: Each dictionary in the tuples mapps event ID to an Event object containing the event data. + """ + db_entries = DBSession.list(EventToolsEvents) + active_events = {} + archived_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 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 + 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, + 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) + 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 + 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): + if event is None: + event = self.events.get(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) + if event_store.is_archived: + self.archived_events.pop(event_id, None) + 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: + 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.get(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.events.pop(event.event_id) + self.archived_events[event.event_id] = event + event_store.is_archived = True + 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 + + @GroupCog.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 + + # 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: + 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.archive_event(after.guild, event_id=after.id) + + @GroupCog.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 check_interaction_prefix(interaction, EVENT_INTERACTION_PREFIX): + return + + 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( + 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"], + common_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"], + 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"], + common_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) + 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], + common_role: Role, + 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(ephemeral=True) + 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) + + 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 schedule_event( + interaction.guild, + event_name, + event_start_aware, + event_end_aware, + event_location, + signin_channel + ) + + event_store = Event( + name=event_name, + 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 + ) + + 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( + 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.id] = event_store + + 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=ActiveEventTransformer.autocomplete) + async def open_event(self, interaction: Interaction, event_id: str): + 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), + 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=True + ) + 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=ActiveEventTransformer.autocomplete) + 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(): + 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.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), + ephemeral=True + ) + return False + + if not archive: + 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=True + ) + 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, clear_messages=clear_messages): + 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=True + ) + + discord_event = interaction.guild.get_scheduled_event(event.event_id) + if discord_event is not None: + if discord_event.status.active: + await discord_event.end() + else: + await discord_event.cancel() + + 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] + ) + @autocomplete(event_id=ArchivedEventTransformer.autocomplete) + 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] + ): + await interaction.response.defer(ephemeral=True) + + 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_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), + ephemeral=False + ) + 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) + async def remove_event(self, interaction: Interaction, event_id: str): + await interaction.response.defer(ephemeral=True) + + 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.get(event_id_int, None) + if event is None: + event = self.archived_events.get(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 + + await self.delete_event(interaction.guild, event=event) + + await interaction.followup.send( + content=COG_STRINGS["events_remove_event_success"].format(name=event.name), + ephemeral=False + ) + + +async def setup(bot: Bot): + await bot.add_cog(EventTools(bot)) diff --git a/src/extensions/dynamic/UserRoles.py b/src/extensions/dynamic/UserRoles.py new file mode 100644 index 00000000..afae1036 --- /dev/null +++ b/src/extensions/dynamic/UserRoles.py @@ -0,0 +1,467 @@ +import logging +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, Message +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 + +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 + +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() +class PollData: + role_name: str + guild_id: int + channel_id: int + message_id: int + user_votes: set + 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(): + await role.edit(mentionable=False) + await async_sleep(duration) + await role.edit(mentionable=True) + + create_task(timeout()) + + +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=poll_data.role_name), + description=description, + color=Color.random() + ) + + 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, role_id: int): + embed = Embed( + 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() + ) + + return embed + + +@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") + self.guild_configs = {} + + 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)) + + 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 = 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 + ) + + @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"]): + + 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") + 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 + + 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 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) + 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 + + 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: + 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 + + 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( + 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 + + 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) + + embed = make_role_embed(poll_data, role.id) + 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) + 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): + admin_cog = UserRolesAdmin(bot) + await bot.add_cog(admin_cog) + await bot.add_cog(UserRoles(bot, admin_cog)) diff --git a/src/extensions/dynamic/VCMusic.py b/src/extensions/dynamic/VCMusic.py new file mode 100644 index 00000000..2ac4a476 --- /dev/null +++ b/src/extensions/dynamic/VCMusic.py @@ -0,0 +1,1483 @@ +import logging +import os +import re +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 + +import googleapiclient.discovery +from discord import ( + ButtonStyle, + Color, + Embed, + FFmpegPCMAudio, + Guild, + Interaction, + Member, + PCMVolumeTransformer, + PermissionOverwrite, + TextChannel, + TextStyle, + VoiceClient, + VoiceState +) +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 +from youtubesearchpython import VideosSearch +from yt_dlp import YoutubeDL + +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 + +COG_STRINGS = load_cog_toml(__name__) +AUTHOR_ID = 244050529271939073 +global MUSIC_AUTHOR +MUSIC_AUTHOR = "fluxticks" +QUERY_RESULT_LIMIT = 15 +INACTIVE_TIMEOUT = 60 +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) + + +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 + SKIP = 10 + VOLUME = 11 + VOLUME_MODAL_SUBMIT = 12 + VOLUME_MODAL_VALUE = 13 + SHUFFLE = 14 + + @property + def id(self) -> str: + base = f"{MUSIC_INTERACTION_PREFIX}{INTERACTION_SPLIT_CHARACTER}" + match self: + case UserActionType.PLAY: + return f"{base}actionplay" + case UserActionType.PAUSE: + return f"{base}actionpause" + case UserActionType.STOP: + return f"{base}actionstop" + case UserActionType.ADD_SONG: + return f"{base}actionadd" + case UserActionType.VIEW_QUEUE: + return f"{base}actionview" + case UserActionType.EDIT_QUEUE: + return f"{base}actionedit" + case UserActionType.ADD_SONG_MODAL_SUBMIT: + return f"{base}submitadd" + case UserActionType.EDIT_QUEUE_MODAL_SUBMIT: + return f"{base}submitedit" + case UserActionType.ADD_SONG_MODAL_SINGLE: + return f"{base}addmodalsingle" + case UserActionType.ADD_SONG_MODAL_MULTIPLE: + return f"{base}addmodalmultiple" + case UserActionType.SKIP: + 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 UserActionType.SHUFFLE: + return f"{base}shuffle" + case _: + 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 "skipsong": + return UserActionType.SKIP + case "volume": + return UserActionType.VOLUME + case "submitvolume": + 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__}") + + 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: + """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 + url: str = None + title: str = None + thumbnail: str = None + 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) + 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 = self.get_stream_data() + return self + case SongRequestType.YOUTUBE_PLAYLIST: + if not GOOGLE_API_KEY: + return None + playlist_items = get_playlist_items(self.raw_request) + song_requests = parse_playlist_response(self.raw_request, self.request_member, playlist_items) + return song_requests + case _: + raise ValueError("Invalid SongRequestType given!") + + 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 + + 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 + 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 + + if self.title is None: + self.title = escape_discord_characters(self.stream_data.get("title")) + + if self.thumbnail is None: + self.thumbnail = self.stream_data.get("thumbnail") + + return info + + +@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) + 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: + """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\/" + 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.STRING + + +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 + 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: + """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: + 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] + + preferred_keywords = ["official", "music"] + alternate_keywords = ["lyric", "audio"] + + for result in video_results: + video_title = result.get("title").lower() + for keyword in preferred_keywords: + if keyword in video_title: + return result + + for keyword in alternate_keywords: + if keyword in video_title: + return result + + return video_results[0] + + +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. + + 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 + video_thumbnail = None + + video_url = result.get("link") + 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_request_type(video_thumbnail) != SongRequestType.YOUTUBE_THUMBNAIL: + video_thumbnail = EMBED_IMAGE_URL + + return {"title": video_title, "url": video_url, "thumbnail": video_thumbnail} + + +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: + escaped_title = escaped_title.replace(character, f"\\{character}") + return escaped_title + + +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: + 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, 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) + song = SongRequest( + raw_request=original_request, + request_type=SongRequestType.YOUTUBE_VIDEO, + request_member=original_member, + title=title, + url=url, + thumbnail=thumbnail + ) + formatted_requests.append(song) + return formatted_requests + + +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 + 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, + title: str = COG_STRINGS["music_embed_title_idle"], + description: str = None, + 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)) + return 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) + pause_button = Button(style=ButtonStyle.secondary, emoji="⏸️", custom_id=UserActionType.PAUSE.id) + playback_button = play_button if is_paused else pause_button + + skip_button = Button( + style=ButtonStyle.secondary, + label=COG_STRINGS["music_button_skip_song"], + 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"], + emoji="🔊", + custom_id=UserActionType.VOLUME.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(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) + # TOOD: Implement queue editing + # view.add_item(edit_button) + view.add_item(stop_button) + + return view + + +@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.logger = logging.getLogger(__name__) + self.logger.info(f"{__name__}.{__class__.__name__} has been added as a Cog") + + @GroupCog.listener() + async def on_ready(self): + if not self.update_author.is_running(): + self.update_author.start() + + @tasks.loop(hours=2) + 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: + 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 {MUSIC_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, MUSIC_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=False + ) + + 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 + 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: + 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): + """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 check_interaction_prefix(interaction, MUSIC_INTERACTION_PREFIX): + return + + try: + user_action = UserActionType.from_string(interaction.data.get("custom_id")) + except ValueError: + return False + + match user_action: + case UserActionType.PLAY: + return await self.resume_or_start_playback(interaction) + case UserActionType.PAUSE: + 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: + return await self.add_interaction_hanlder(interaction) + case UserActionType.VIEW_QUEUE: + return await self.get_current_queue(interaction) + case UserActionType.EDIT_QUEUE: + 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: + pass + case _: + 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: 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) + 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): + """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() + return + + no_longer_active = [] + + 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.end_playback(guild_id) + 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): + """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() + 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: + await self.active_players.get(guild).voice_client.disconnect() + + 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 + + if not user.voice.channel: + return False + + if guild.id not in self.active_players: + return True + + 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 + + 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: + """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 + + 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: + """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 + + 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: + """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 + + 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 + + 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) + return False + + await respond_or_followup(COG_STRINGS["music_thinking"], interaction, ephemeral=True, delete_after=20) + + 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(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 ('', + ' ') + ] + if single_request.strip() not in ('', ' '): + request_list = [ + SongRequest( + single_request.strip(), + parse_request_type(single_request.strip()), + request_member=interaction.user + ) + ] + request_list + + first_success = 0 + if request_list: + first_request = request_list.pop(0) + song = await first_request.get_song() + 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 = [] + 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 respond_or_followup( + COG_STRINGS["music_added_song_count"].format(count=len(request_list) - len(failed_requests) + first_success), + interaction, + 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 = []) -> 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 + + if interaction.guild.id not in self.active_players: + voice_client = await interaction.user.voice.channel.connect() + 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() + 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() + 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): + 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): + self.run_tasks() + 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 + + 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: + 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=float(self.active_players.get(guild_id).volume) / float(100) + ) + + self.active_players[guild_id].voice_client.play(voice_source) + self.playing.append(guild_id) + + return True + + 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 + + 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 + + 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() + 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 + + 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: + """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 + + if interaction.guild.id not 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() + 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 + + 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 + + if interaction.guild.id not 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): + 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 + + 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) + return False + + # 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) + 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 = "__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: + 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) + 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() + + self.active_players.get(guild_id).queue = [] + 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) -> 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 + 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 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) + 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=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, + 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=MUSIC_AUTHOR) + is_paused = True + + await embed_message.edit(embed=new_embed, view=create_music_actionbar(is_paused)) + return True + + 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() + 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): + await respond_or_followup(COG_STRINGS["music_invalid_voice"], interaction, ephemeral=True) + return False + + 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) + return True + + @command(name=COG_STRINGS["music_play_name"], description=COG_STRINGS["music_play_description"]) + 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"]) + 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"]) + 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"]) + 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"]) + 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"]) + 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"]) + @describe(volume=COG_STRINGS["music_volume_volume_describe"]) + @rename(volume=COG_STRINGS["music_volume_volume_rename"]) + 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 + + 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 + + @command(name=COG_STRINGS["music_shuffle_name"], description=COG_STRINGS["music_shuffle_description"]) + 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) + + +async def setup(bot: Bot): + await bot.add_cog(VCMusicAdmin(bot)) + await bot.add_cog(VCMusic(bot)) diff --git a/src/extensions/dynamic/VoiceAdmin.py b/src/extensions/dynamic/VoiceAdmin.py new file mode 100644 index 00000000..0af3ae29 --- /dev/null +++ b/src/extensions/dynamic/VoiceAdmin.py @@ -0,0 +1,605 @@ +import logging + +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.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 + +COG_STRINGS = load_cog_toml(__name__) +BANNED_WORDS = load_banned_words() + + +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: + return False + return db_entry.owner_id == member.id + + +def check_vc_name_allowed(new_name: str) -> bool: + # 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 + + +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 + + +@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, + 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__}.{__class__.__name__} has been added as a Cog") + + @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. + + 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 + + if not before.channel and not after.channel: + return + + if before.channel: + if not channel_is_child(before.channel): + return + + if not before.channel.category and not member.guild.me.guild_permissions.manage_channels: + self.logger.error( + 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"{self.bot.logging_prefix}[{before.channel.guild.id}] Missing permissions `Manage Channels` for the category {before.channel.category.name}({before.channel.category.id})" + ) + 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, db_entry): + 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: + if not channel_is_parent(after.channel): + return + + if not after.channel.category and not member.guild.me.guild_permissions.manage_channels: + self.logger.error( + 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"{self.bot.logging_prefix}[{after.channel.guild.id}] Missing permissions `Manage Channels` for the category {after.channel.category.name}({after.channel.category.id})" + ) + return + + 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( + 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) + 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"]) + @describe(channel=COG_STRINGS["vc_set_parent_param_describe"]) + @rename(channel=COG_STRINGS["vc_set_parent_param_rename"]) + 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. + """ + 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) + return False + + if channel_is_child(channel): + await interaction.followup.send(COG_STRINGS["vc_set_parent_warn_already_child"], ephemeral=True) + return False + + db_entry: VoiceAdminParent = VoiceAdminParent(guild_id=interaction.guild.id, channel_id=channel.id) + DBSession.create(db_entry) + 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=True) + 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"]) + @rename(channel=COG_STRINGS["vc_remove_parent_param_rename"]) + 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. + """ + 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) + return False + + 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=True) + 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"]) + 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. + """ + await interaction.response.defer(ephemeral=True) + + 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.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=True) + return True + + @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"]) + 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. + """ + await interaction.response.defer(ephemeral=True) + + voice_state = interaction.user.voice + + if voice_state is None: + 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.followup.send(COG_STRINGS["vc_rename_warn_not_owner"], ephemeral=True) + 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 + + 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") + 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.followup.send(COG_STRINGS["vc_rename_success"].format(name=name_set), ephemeral=True) + return True + + @command( + name=COG_STRINGS["vc_lock_name"], + description=f"{COG_STRINGS['vc_lock_description']} {COG_STRINGS['vc_must_be_owner']}" + ) + 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. + """ + await interaction.response.defer(ephemeral=True) + + voice_state = interaction.user.voice + + if voice_state is None: + 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.followup.send(COG_STRINGS["vc_lock_warn_not_owner"], ephemeral=True) + 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}" + ) + + 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: + 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 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=True) + + return True + + @command( + name=COG_STRINGS["vc_unlock_name"], + description=f"{COG_STRINGS['vc_unlock_description']} {COG_STRINGS['vc_must_be_owner']}" + ) + 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. + """ + await interaction.response.defer(ephemeral=True) + + voice_state = interaction.user.voice + + if voice_state is None: + 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.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 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( + 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=True) + return True + + @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"]) + 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. + """ + await interaction.response.defer(ephemeral=True) + + voice_state = interaction.user.voice + + if not voice_state: + 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.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.followup.send(COG_STRINGS["vc_limit_warn_too_many"], ephemeral=True) + 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 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=True) + return True + + @command( + name=COG_STRINGS["vc_unlimit_name"], + description=f"{COG_STRINGS['vc_unlimit_description']} {COG_STRINGS['vc_must_be_owner']}" + ) + 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. + """ + await interaction.response.defer(ephemeral=True) + + voice_state = interaction.user.voice + + if not voice_state: + 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.followup.send(COG_STRINGS["vc_unlimit_warn_not_owner"], ephemeral=True) + return False + + if not db_entry.is_limited: + 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( + 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=True) + return True + + +async def setup(bot: Bot): + await bot.add_cog(VoiceAdmin(bot)) + await bot.add_cog(VoiceAdminUser(bot)) diff --git a/src/extensions/dynamic/__init__.py b/src/extensions/dynamic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/locale/AdminTools.toml b/src/locale/AdminTools.toml new file mode 100644 index 00000000..68e1199b --- /dev/null +++ b/src/locale/AdminTools.toml @@ -0,0 +1,21 @@ +admin_group_name = "admin" + +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 = "user-info" +admin_user_info_title = "{user} — User Info" +admin_user_info_description = "Showing the user info for {mention} \n" + +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 = "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 diff --git a/src/locale/AutoRoles.toml b/src/locale/AutoRoles.toml new file mode 100644 index 00000000..95f28881 --- /dev/null +++ b/src/locale/AutoRoles.toml @@ -0,0 +1,33 @@ +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." +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}" + +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 = "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 = "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 = "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 diff --git a/src/locale/EventTools.toml b/src/locale/EventTools.toml new file mode 100644 index 00000000..778f4f8d --- /dev/null +++ b/src/locale/EventTools.toml @@ -0,0 +1,72 @@ +events_group_name = "events" + +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" +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" +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}" +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 ⚠️" + +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" +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 = "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 ⚠️" + +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." +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" +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_error_missing_role = "Unable to find associated role for {name} event. Please contact an admin if this is an error ❌" + +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" +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 diff --git a/src/locale/LogChannel.toml b/src/locale/LogChannel.toml new file mode 100644 index 00000000..3adce124 --- /dev/null +++ b/src/locale/LogChannel.toml @@ -0,0 +1,17 @@ +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_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 diff --git a/src/locale/RoleReact.toml b/src/locale/RoleReact.toml new file mode 100644 index 00000000..c7be7102 --- /dev/null +++ b/src/locale/RoleReact.toml @@ -0,0 +1,44 @@ +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__**" +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." +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_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." +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_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" +react_delete_menu_success = "Successfully deleted menu with ID `{menu_id}` ✅" \ No newline at end of file diff --git a/src/locale/UserRoles.toml b/src/locale/UserRoles.toml new file mode 100644 index 00000000..7f15285b --- /dev/null +++ b/src/locale/UserRoles.toml @@ -0,0 +1,39 @@ +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_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." +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} ✅" + +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}` ⚠️" + +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 diff --git a/src/locale/VCMusic.toml b/src/locale/VCMusic.toml new file mode 100644 index 00000000..4086f425 --- /dev/null +++ b/src/locale/VCMusic.toml @@ -0,0 +1,85 @@ +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} ❌" + +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" +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" +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" +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." +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_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 ⚠️" +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 ✅" + +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_" + +music_stopped_success = "Playback stopped ✅" + +music_play_name = "play" +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." + +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}%` ✅" + +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 diff --git a/src/locale/VoiceAdmin.toml b/src/locale/VoiceAdmin.toml new file mode 100644 index 00000000..a10dab55 --- /dev/null +++ b/src/locale/VoiceAdmin.toml @@ -0,0 +1,66 @@ +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_must_be_owner = "You must be the VC owner to do this." + +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" +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 = "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 = "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 = "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_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." +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 = "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 = "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_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 = "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 ⚠️" +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 diff --git a/src/main.py b/src/main.py index 85288171..d9567471 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,21 @@ -from esportsbot import bot -import coloredlogs import logging +import os +import sys + +import coloredlogs -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() + from bot import start_bot + start_bot() diff --git a/src/requirements.txt b/src/requirements.txt deleted file mode 100644 index 0fa3a5f3..00000000 --- a/src/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -psycopg2-binary>=2.8 -sqlalchemy -sqlalchemy-utils -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 diff --git a/timezone.json b/timezone.json new file mode 100644 index 00000000..215d69b6 --- /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" + }, + "hkhkg": { + "_description": "Hong Kong SAR China", + "_alias": "Asia/Hong_Kong" + }, + "jptyo": { + "_description": "Tokyo, Japan", + "_alias": "Asia/Tokyo" + }, + "krsel": { + "_description": "Seoul, South Korea", + "_alias": "Asia/Seoul" + }, + "sgsin": { + "_description": "Singapore", + "_alias": "Asia/Singapore" + }, + "uslax": { + "_description": "Los Angeles, United States", + "_alias": "US/Pacific" + }, + "usnyc": { + "_description": "New York, United States", + "_alias": "US/Eastern" + }, + "usden": { + "_description": "Denver, United States", + "_alias": "US/Mountain" + } + } +} \ No newline at end of file