diff --git a/README.md b/README.md index 37777499..a367a688 100644 --- a/README.md +++ b/README.md @@ -224,28 +224,52 @@ In your `.env` file the `TWITCH_SUB_SECRET` should be a string that is 10-100 ch 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 [optional: channel_mention] [optional: hook name] -* 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. +#### !twitch createhook \ \ +* Aliases: `newhook, makehook, addhook` +* Creates a new Discord Webhook bound to the mentioned channel. + The name will be prefixed with the Twitch Cog Webhook prefix to distinguish Twitch hooks from other Webhooks. + The Webhooks created with the Twitch Cog do not need the prefix used in the name in order to reference them. +* *Requires `administrator` permission in Discord* #### !twitch deletehook \ * Deletes the given Discord Webhook. +* *Requires `administrator` permission in Discord* -#### !twitch add \ [optional: custom message] -* Adds a Twitch channel to be tracked in the current Discord server. -* *__If a custom message is given, it must be surrounded by double quotes__*: `!twitch add "custom_message"` +#### !twitch add \ \ [optional: custom message] +* Adds a Twitch channel to be tracked in the given Webhook. + This means when the channel goes live, its notification will be posted to the given Webhook. + A channel can be tied to more than one Webhook. + The custom message can be left empty, but when not, it will be used in the live notification. + A preview of what a notification looks like can be seen with the `!twitch preview ` command. +* *__If a custom message is given, it must be surrounded by double quotes__*: `!twitch add "custom_message"` +* *Requires `administrator` permission in Discord* -#### !twitch remove \ -* Removes a Twitch channel from being tracked in the current Discord server. +#### !twitch remove \ \ +* Removes a Twitch channel from being tracked in the given Webhook. +* *Requires `administrator` permission in Discord* -#### !twitch list -* Shows a list of all the currently tracked Twitch accounts and their custom messages. +#### !twitch list [optional: webhook name] +* Shows a list of all the currently tracked Twitch accounts and their custom messages for the given Webhook. +If no Webhook name is given, it shows the information for all Twitch Webhooks. +* *Requires `administrator` permission in Discord* -#### !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 webhooks +* Shows a list of the current Webhooks for the Twitch Cog. +* *Requires `administrator` permission in Discord* + +#### !twitch setmessage \ \ [optional: custom message] +* Sets the custom message of a Twitch channel for the given Webhook. + 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"` +* *Requires `administrator` permission in Discord* -#### !twitch getmessage \ -* Gets the currently set custom message for a Twitch channel. +#### !twitch getmessage \ [optional: webhook name] +* Gets the currently set custom message for a Twitch channel for the given Webhook. +If no Webhook name is given, it shows a list of all the custom messages for the Webhooks the channel is tracked in. +* *Requires `administrator` permission in Discord* + +#### !twitch preview \ \ +* Shows a preview of the live notification for a given channel for the given Webhook. diff --git a/docker-compose.yml b/docker-compose.yml index 870604dd..8b0e2e62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,8 @@ services: - ENABLE_TWITTER=False - ENABLE_TWITCH=False restart: unless-stopped + ports: + - 443:443 pg_admin: depends_on: diff --git a/src/esportsbot/cogs/TwitchCog.py b/src/esportsbot/cogs/TwitchCog.py new file mode 100644 index 00000000..8098d6ee --- /dev/null +++ b/src/esportsbot/cogs/TwitchCog.py @@ -0,0 +1,1014 @@ +""" +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 Twitch_info + +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): + 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): + 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): + 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): + 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": "../server.crt", "keyfile": "../server.key"}) + 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): + 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(Twitch_info) + 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(Twitch_info, 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(Twitch_info, 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(Twitch_info, hook_id=webhook_id) + embed = Embed( + title="**Currently Tracked Channels:**", + description=f"These are the currently tracked channels for the Webhook: \n`{webhook_name}`", + color=TWITCH_EMBED_COLOUR + ) + embed.set_author(name="Twitch Channels", icon_url=TWITCH_ICON) + if not db_items: + embed.add_field(name="No channels tracked", value="​", inline=False) + return embed + + for item in db_items: + custom_message = item.custom_message if item.custom_message else "" + embed.add_field(name=item.twitch_handle, value=custom_message, inline=False) + return embed + + @commands.group( + pass_context=True, + invoke_without_command=True, + help="Access the Twitch integration functions with this command" + ) + async def twitch(self, ctx): + """ + Empty command, purely used to organise subcommands to be under twitch instead of having to ensure name + uniqueness. + """ + + pass + + @twitch.command( + name="createhook", + aliases=["newhook", + "makehook", + "addhook"], + usage="<#channel> ", + help="Creates a new Discord Webhook bound to the mentioned channel. " + "The name will be prefixed with the Twitch Cog Webhook prefix to distinguish Twitch hooks from other Webhooks." + "The Webhooks created with the Twitch Cog do not need the prefix used in the name in order to reference them." + ) + @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", + usage="", + help="Deletes the given Discord Webhook. This will also stop tracking accounts that were tied to the given Webhook." + ) + @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(Twitch_info, 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", + usage=" [custom message]", + help="Adds a Twitch channel to be tracked in the given Webhook. " + "This means when the channel goes live, its notification will be posted to the given Webhook. " + "A channel can be tied to more than one Webhook. " + "The custom message can be left empty, but when not, it will be used in the live notification. " + "A preview of what a notification looks like can be seen with the " + "`!twitch preview ` command." + ) + @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 = Twitch_info( + 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 = Twitch_info( + 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", + usage=" ", + help="Removes a Twitch channel from being tracked in the given Webhook." + ) + @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", + usage="[webhook name]", + help="Shows a list of all the currently tracked Twitch accounts and their custom messages for the given Webhook. " + "If no Webhook name is given, it shows the information for all Twitch Webhooks." + ) + @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", help="Shows a list of the current Webhooks for the Twitch Cog.") + @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", + usage=" [message]", + help="Sets the custom message of a Twitch channel for the given Webhook. " + "Can be left empty if the custom message is to be removed. " + ) + @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(Twitch_info, guild_id=context.guild.id, channel_id=channel_id, hook_id=webhook_id) + if db_item: + db_item.custom_message = custom_message + self._db.update(db_item) + if not custom_message: + custom_message = "" + await context.send( + self.user_strings["set_custom_message"].format(channel=channel, + message=custom_message, + webhook=webhook_name) + ) + else: + await context.send(self.user_strings["channel_not_tracked"].format(name=channel, webhook=webhook_name)) + return + + @twitch.command( + name="getmessage", + usage=" [webhook name]", + help="Gets the currently set custom message for a Twitch channel for the given Webhook." + "If no Webhook name is given, it shows a list of all the custom messages for the Webhooks the channel is tracked in." + ) + async def get_channel_message(self, context, channel, webhook_name=None): + """ + Gets the custom channel message for a Webhook. If no Webhook name is given, get all the custom messages. + :param context: The context of the command. + :param channel: The channel to get the custom messages of. + :param webhook_name: The Webhook to get the custom message of. + """ + channel_id = await self.get_channel_id_from_command(channel) + + if channel_id not in self._twitch_app.tracked_channels: + # The requested channel is not tracked. + await context.send(self.user_strings["no_channel_error"].format(channel=channel)) + return + + if webhook_name: + webhook_id, webhook_info = get_webhook_by_name(self._twitch_app.hooks, webhook_name, context.guild.id, WEBHOOK_PREFIX) + custom_message = self._twitch_app.tracked_channels.get(channel_id).get(webhook_id) + if not custom_message: + custom_message = "" + await context.send( + self.user_strings["get_custom_message"].format(channel=channel, + webhook=webhook_name, + message=custom_message) + ) + return + + string = f"The custom messages for the channel `{channel}` are: \n" + for webhook_id in self._twitch_app.tracked_channels.get(channel_id): + message = self._twitch_app.tracked_channels.get(channel_id).get(webhook_id) + if not message: + message = "" + next_string = f"`{self._twitch_app.hooks.get(webhook_id).get('name')}` : '{message}'" + string += next_string + "\n" + + await context.send(string) + + @twitch.command( + name="preview", + usage=" ", + help="Shows a preview of the live notification for a given channel for the given Webhook." + ) + 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/lib/discordUtil.py b/src/esportsbot/lib/discordUtil.py index 4f42c556..68e498e7 100644 --- a/src/esportsbot/lib/discordUtil.py +++ b/src/esportsbot/lib/discordUtil.py @@ -1,10 +1,12 @@ import shlex -from discord import RawReactionActionEvent, Message, Member, User, Client, DMChannel, GroupChannel, TextChannel +from discord import Forbidden, RawReactionActionEvent, Message, Member, User, Client, DMChannel, GroupChannel, TextChannel from typing import List, Tuple, Union from . import emotes, exceptions # Link to an empty image, to allow for an author name in embeds without providing an icon. +from .stringTyping import strIsChannelMention + EMPTY_IMAGE = "https://i.imgur.com/sym17F7.png" @@ -82,6 +84,73 @@ async def send_timed_message(channel: TextChannel, *args, timer: int = 15, **kwa 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 + + +async def channel_from_mention(bot, c_id): + """ + Gets an instance of a channel when the channel was mentioned in the message. + :param bot: The instance of the bot to access discord with. + :param c_id: The mentioned channel. + :return: An instance of a channel or None if there is no channel with the given mention. + """ + + if not strIsChannelMention(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 = bot.get_channel(cleaned_id) + if channel is None: + try: + channel = await 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(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) diff --git a/src/esportsbot/lib/stringTyping.py b/src/esportsbot/lib/stringTyping.py index e7e99e5d..464d3a7b 100644 --- a/src/esportsbot/lib/stringTyping.py +++ b/src/esportsbot/lib/stringTyping.py @@ -64,7 +64,7 @@ def strIsChannelMention(mention: str) -> bool: :return: True if mention matches the formatting of a discord channel mention, False otherwise :rtype: bool """ - return mention[:2] == "<#" and mention[-1:] == ">" and strIsInt(mention[2:-1]) + return len(mention) == 21 and mention[:2] == "<#" and mention[-1:] == ">" and strIsInt(mention[2:-1]) # string extensions for numbers, e.g 11th, 1st, 23rd... diff --git a/src/esportsbot/models.py b/src/esportsbot/models.py index 588a443e..5ea8b12b 100644 --- a/src/esportsbot/models.py +++ b/src/esportsbot/models.py @@ -78,13 +78,11 @@ class Voicemaster_slave(base): class Twitch_info(base): __tablename__ = 'twitch_info' - id = Column(BigInteger, primary_key=True, autoincrement=True, nullable=False) guild_id = Column(BigInteger, nullable=False) - channel_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) - currently_live = Column(Boolean, nullable=False) - custom_message = Column(String, nullable=False) - # Will most likely change after Benji switch + custom_message = Column(String, nullable=True) class Twitter_info(base): diff --git a/src/esportsbot/user_strings.toml b/src/esportsbot/user_strings.toml index fa0102c1..98fd138f 100644 --- a/src/esportsbot/user_strings.toml +++ b/src/esportsbot/user_strings.toml @@ -1,5 +1,3 @@ -command_error_generic = "There was an internal error while performing your command! Please contact a developer!" - [logging] channel_set = "Logging channel has been set to <#{channel_id!s}>" channel_set_already = "Logging channel already set to this channel" @@ -27,41 +25,39 @@ default_role_removed = "Default role has been 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 = "The Music Channel has been bound to {music_channel}" +music_channel_set_log = "{author} has bound the Music Channel to {music_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 """ + use {bot_prefix}setmusicchannel -c """ -music_channel_get = "The Music Channel is currently set to {channel}" -music_channel_missing = "The Music Channel has not been set" +music_channel_get = "The Music Channel is currently set to {music_channel}" +music_channel_missing = "The Music Channel has not been bound" -music_channel_reset = "The Music Channel ({channel}) has been reset" +music_channel_reset = "The Music Channel ({music_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}" +music_channel_removed = "The Music Channel has been unbound from {music_channel}" +music_channel_removed_log = "{author} has unbound the Music Channel from {music_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}" +song_error = "There were errors while adding some songs to the queue" -music_channel_wrong_channel = "The command `{command}` must be sent in the Music Channel" +music_channel_wrong_channel = "That command {command_option} 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" +no_perms_voice_channel = "{author}, I need the permission `connect` to be able to join that Voice Channel" +no_voice_voice_channel = "{author}, You must be in a voice channel to request a song" +wrong_voice_voice_channel = "{author}, I am already in another voice channel 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_invalid_value = "The song number must be a value in the current queue." +song_remove_valid_options = "Valid options are from {start_index} 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!" @@ -75,46 +71,74 @@ 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}. +success_channel = "✅ <#{channel_id!s}> is now visible to **{role_name}**!" +success_event = """✅ New event category '{event_title}' created successfuly! +The event role is {event_role_mention}, and the signin menu is ID `{signin_menu_id!s}`, in {signin_channel_mention}. + +The event is currently **closed**, and invisible to your shared role, `{shared_role_name}`. Open the event when you're ready with `{command_prefix}open-event {event_name}`! +Feel free to customise the category, channels and roles, but do not synchronise the category permissions. + +You can create a new channel in the category with the correct permissions by either: +- Using the `+` icon next to the category, +- Dragging your channel into the category and synchronising **just that channel**'s permissions with the category, or +- Duplicating {event_general_mention}.""" +success_event_category ="✅ Event category '{event_name}' registered successfuly!" +success_event_deleted = "✅ {event_title} event category and role successfuly deleted." +success_event_role = "✅ The {event_name} event role is now **{role_name}**." +success_event_role_unregister = "✅ {event_title} event category successfuly unregistered." +success_menu = "✅ The {event_namme} event signin menu is now: {menu_url}" +success_shared_role = "✅ The shared role is now **{role_name}**." +success_event_closed = "Done!" +nothing_to_do = "Nothing to do!\n*(<#{channel_id!s}> already invisible to {shared_role}, no reactions on signin menu, no users with {event_role} role)*" + +menu_title = "Sign Into {event_name}" +menu_description = "If you're attending this event, react to this message to get the '{event_role}' role, and access to secret event channels!" + +react_delete_confirm = "React within 60 seconds: Are you sure you want to delete the '{event_title}' category{event_segment} and the {num_channels!s} event channels?" +react_error = "An error occurred when loading your reaction, please try the command again with a different emoji." +react_event_delete_cancel = "Event category deletion cancelled." +react_no_time = "Out of time, please try the command again." +react_start = "Please react to this message within 60 seconds, with the emoji which you would like users to react with to receive the event role:" + +request_event_name = ":x: Please give the name of the event!" +request_menu_id_event_name = ":x: Please provide a menu ID and event name!" +request_menu_role_name = ":x: Please provide a menu ID, followed by a role, and the name of your event!" +request_role_id = ":x: Please provide a role to set!" + +invalid_menu_id = ":x: Invalid menu ID!\nTo get a menu ID, enable discord's developer mode, right click on the menu, and click 'copy ID'" +invalid_role = ":x: Invalid role! Please give your role as either a mention or an ID." +unrecognised_event = ":x: Unrecognised event. The following events exist in this server: {events}" +unrecognised_menu_id = ":x: Unrecognised menu ID: {menu_id}" +unrecognised_role = ":x: Unrecognised role!" +invalid_signin_menu = ":x: The event signin menu must be a role menu granting the {role_name} role!" -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}**!" +channel_already_open = ":x: The {event_name} signin channel is already open! <#{channel_id!s}>" 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" +missing_shared_role = ":x: I can't find the server's shared role! Was it deleted?\nPlease use the `{command_prefix}set-shared-role` command to set a new one." +no_event_categories = ":x: This server doesn't have any event categories registered!" +no_shared_role = ":x: No shared role has been set for this server! Use the {command_prefix}set-shared-role` command to set one." + +no_channel_edit_perms = ":x: I don't have permission to edit the permissions in <#{channel_id!s}>!" +no_role_edit_perms = ":x: I don't have permission to assign roles!\nPlease give me the 'manage roles' permission." +role_edit_perms_bad_order = ":x: I don't have permission to unassign the **{event_role}** role!\nPlease move it below my {role_name} role." + +event_category_create_reason = "Creating new event category '{event_name}' requested via {command_prefix}create-event-category command" +event_category_delete_reason = "Event category '{event_name}' deletion requested via {command_prefix}delete-event-category command" +event_channel_close_reason = "{author} closed the {event_name} event via {command_prefix}close-event command" +event_channel_open_reason = "{author} opened the {event_name} event via the {command_prefix}open-event command" + +admin_channel_invisible = ["Event Channel made invisibile", "<#{channel_id!s}>"] +admin_event_category_deleted = ["Event Category Deleted", "Event name: {event_title}\nChannels deleted: {num_channels!s}"] +admin_event_category_unregistered = ["Event Category Unregistered", "Event name: {event_title}\nCategory/channels left undeleted."] +admin_event_category_updated = ["New Event Category Created", "Event name: {event_title}\nMenu ID: {menu_id!s}\nRole: <@&{role_id!s}>\n[Menu]({menu_url})"] +admin_event_closed = ["Event Closed", "Event name: {event_title}"] +admin_event_role_set = ["Event role set", "<@&{role_id}!s}>"] +admin_existing_event_registered = ["Existing Event Category Registered", "Event name: {event_title}\nMenu id: {menu_id}\nRole: <@&{role_id!s}>\n[Menu]({menu_url})"] +admin_menu_updated = ["Event signin menu updated", "Event name: {event_title}\nMenu ID: {menu_id}\nType: {menu_type}\n[Menu]({menu_url})"] +admin_role_menu_reset = ["Role menu reset", "id: {menu_id}\ntype: {menu_type}\n[Menu]({menu_url})"] +admin_role_removed = ["Event Role removed", "Users: {users!s}\n<@&{event_role_id!s}>"] +admin_shared_role_set = ["Shared role set", "<@&{role_id!s}>"] +admin_signin_visible = ["Event signin channel made visible", "<#{channel_id}>"] [voicemaster] success_slave_locked = "Your VM slave has been locked 🔒" @@ -144,6 +168,23 @@ log_vm_slaves_cleared = "{mention} has removed all VM slaves" log_slave_locked = "{mention} has locked their VM slave" log_slave_unlocked = "{mention} has unlocked their VM slave" +[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}" diff --git a/src/requirements.txt b/src/requirements.txt index 6f7de7ec..68dbe31b 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -11,4 +11,5 @@ youtube-search-python PyNaCl aiohttp[speedups] toml +tornado tweepy