def direct_message_channel_id(self, user_id): # This function will return the direct message channel ID for the given user ID try: # Query the "api/users/@me/channels" endpoint endpoint = "https://discordapp.com/api/users/@me/channels" # JSON data with the recipient_id (user_id) is required data = json.dumps({'recipient_id': user_id}) resp = requests.post(url=endpoint, headers=self.headers, data=data) if (resp.status_code != 200): # The expected response code is 200, anything else would indicate an issue get_logger().warning(resp.json(), exc_info=True) return (None) try: return (resp.json()['id']) except: # User ID is invalid or bot lacks permission return (None) except Exception as e: # Failed to retrieve direct message channel ID get_logger().error(e, exc_info=True) return (None)
def on_get(self, req, resp): try: # Retrieve the Discord channel ID and the YouTube channel URL from the headers disc_guild_id = req.get_header('discord_guild_id') disc_channel_id = req.get_header('discord-channel-id') yt_channel_url = req.get_header('youtube-channel-url') # If either of the two required headers are missing, return bad request if(disc_channel_id == None or yt_channel_url == None): raise falcon.HTTPBadRequest('Missing Requried Headers', 'Required headers: discord-channel-id and youtube-channel-url') # Using the youtube_management class add the subscription to the database message = youtube_management().unsubscribe(yt_channel_url, disc_channel_id) resp.status = falcon.get_http_status(message['code']) resp.content_type = ['application/json'] resp.body = json.dumps(message) except falcon.HTTPBadRequest as e: message = {"status":"error", "code":400, "message":e.description} resp.status = falcon.get_http_status(message['code']) resp.content_type = ['application/json'] resp.body = json.dumps(message) except Exception as e: get_logger().error(e, exc_info=True)
def on_get(self, req, resp): try: # Retrieve twitch_username and discrod_channel_id from headers twitch_username = req.get_header('twitch-username') discord_channel_id = req.get_header('discord-channel-id') discord_guild_id = req.get_header('discord-guild-id') # If twitch_username or discord_channel_id are not set then raise exception if (twitch_username is None or discord_channel_id is None or discord_guild_id is None): raise falcon.HTTPBadRequest( 'Missing Requried Headers', 'Required headers: twitch_username and discord_channel_id') # Using twitch_management class attempt to add new notifiaction message = twitch_management(twitch_username, discord_guild_id, discord_channel_id).delete() resp.status = falcon.get_http_status(message['code']) resp.content_type = ['application/json'] resp.body = json.dumps(message) except falcon.HTTPBadRequest as e: message = { "status": "error", "code": 400, "message": e.description } resp.status = falcon.get_http_status(message['code']) resp.content_type = ['application/json'] resp.body = json.dumps(message) except Exception as e: get_logger().error(e, exc_info=True)
def post_message(self, message, discord_channel_ids): # Used to send a message to an array of discord channel IDs # This is called for both Twitch and YouTube notifications # Convert the message dict into a JSON object json_data = json.dumps(message) for channel_id in discord_channel_ids: try: discord_message_url = "https://discordapp.com/api/channels/{}/messages".format( channel_id) r = requests.post(discord_message_url, headers=self.headers, data=json_data) if (r.status_code == 404 and r.json()['message'] == "Unknown Channel"): # Discord channel no longer exist, delete notifications associated # with the discord_channel_id notifications().delete_by_discord_channel_id(channel_id) if (r.status_code == 403 and r.json()['message'] == "Missing Permissions"): # The bot is missing permissions for the discord channel ID discord_management().alert_guild_owner_permissions( channel_id) except Exception as e: get_logger().error(e, exc_info=True)
def manage_databse_subscription(self, mode, yt_channel_id, yt_display_name, discord_channel_id, discord_guild_id = None): # This function will add(INSERT) or delete(DELETE) subscriptions to/from the database. try: if(mode == "subscribe"): # Insert notification information values = [yt_channel_id, discord_guild_id, discord_channel_id] sql = "INSERT INTO `youtube_notifications` VALUES (NULL, %s, %s, %s)" res = self.data_handler.insert(sql, values) # If the channel ID is new/unique to the database, insert the id # and display name into `youtube_channels` channel_res = self.data_handler.select("SELECT * FROM `youtube_channels` WHERE `yt_channel_id` = %s", [yt_channel_id]) if(len(channel_res) == 0): self.data_handler.insert("INSERT INTO `youtube_channels` VALUES (NULL, %s, %s)", [yt_channel_id, yt_display_name]) elif(mode == "unsubscribe"): values = [yt_channel_id, discord_channel_id] sql = "DELETE FROM `youtube_notifications` WHERE `yt_channel_id` = %s AND `discord_channel_id` = %s" res = self.data_handler.delete(sql, values) else: # "subscribe" and "unsubscribe" are hardcoded so this should never happen print("ERROR: Unknown mode in manage_databse_subscription") return(False) return(True) except Exception as e: get_logger().error(e, exc_info=True) return(False)
def manage_webhook_subscription(self, mode, yt_channel_id): # This function subscribe/unsubscribe to/from notifications for the youtube # channel id given with the YouTube appspot API try: # Google/YouTube utilizes appspot to send webhook based notifications url = "https://pubsubhubbub.appspot.com/subscribe" # callback, topic, verify, and mode must be sent as a "form" payload = self.get_form_data(mode, yt_channel_id) # Currently, no headers are required. headers= {} # NOTE: Figure out a better solution # timeout =1.xx is 100% a hack. Falcon only supports a single simultaneous # connection, the pubsubhubbub attemtps to reach /youtube/callback, however, # this current API request is blocking Falcon from responding. # Setting timeout to 1 sec will "ensure" that the payload POST data is sent, # and that the pubsubhubbub is able to reach the callback. response = requests.post(url, headers=headers, data = payload, timeout=1.0000000001) return(True) except requests.exceptions.ReadTimeout: # NOTE: See above return(True) except Exception as e: get_logger().error(e, exc_info=True) return(False)
def select(self, sql, input, values_only=False): try: conn = self.get_connection() with conn.cursor() as cursor: cursor.execute(sql, input) return (cursor.fetchall()) except Exception as e: get_logger().error(e, exc_info=True) return (None)
def on_post(self, req, resp, twitch_user_id): try: data = json.loads(req.bounded_stream.read().decode()) if 'display_name' in data['data'][0]: twitch_display_name = data['data'][0]['display_name'] twitch_management(twitch_display_name, 0, 0).update_user(twitch_user_id, twitch_display_name) except Exception as e: get_logger().error(e, exc_info=True)
def event(self, data): # This function is called when Twitch sends a webhook callback event = data["event"] try: # store/check event["id"]; Twitch creates a unique ID for each event # Sometimes the same event can be delivered multiple times. This prevents # the event from being processed multiple times. sql = "SELECT `event_id` FROM `twitch_event_ids` WHERE `event_id` = %s" res = data_handler().select(sql, [event["id"]]) if (len(res) != 0): # Event has already been published return # New event, add to the database sql = "INSERT INTO `twitch_event_ids` (`event_id`) VALUES (%s)" data_handler().insert(sql, [event["id"]]) if (event["type"] == "live"): # Channel is now live # Update live status in database sql = "UPDATE `twitch_channels` SET `streaming` = 1 WHERE `twitch_user_id` = %s" data_handler().update(sql, [event['broadcaster_user_id']]) # Format thumbnail URL twitch_thumbnail_url = "https://static-cdn.jtvnw.net/previews-ttv/live_user_" + event[ "broadcaster_user_name"] + "-640x360.jpg" # Prepare and Send discord message discord_post_obj = discord_post() message = discord_post_obj.prepare_twitch_message( event["broadcaster_user_name"], twitch_thumbnail_url) discord_channel_ids = data_handler().select( 'SELECT DISTINCT `discord_channel_id` FROM `twitch_notifications` WHERE `twitch_user_id`=%s', [event["broadcaster_user_id"]]) discord_channel_ids = list( map(lambda x: x['discord_channel_id'], discord_channel_ids)) # FLatten results into a list discord_post_obj.post_message(message, discord_channel_ids) elif (event["type"] == "offline"): # Set streaming status to false sql = "UPDATE `twitch_channels` SET `streaming` = 0 WHERE `twitch_user_id` = %s" data_handler().update(sql, [event['broadcaster_user_id']]) except Exception as e: get_logger().error(e, exc_info=True)
def delete(self, sql, input): try: conn = self.get_connection() with conn.cursor() as cursor: cursor.execute(sql, input) conn.commit() conn.close() return (True) except Exception as e: get_logger().error(e, exc_info=True) return (False)
def defined_update(self, key, input): try: conn = self.get_connection() with conn.cursor() as cursor: cursor.execute(self.defined_queries[key], input) conn.commit() conn.close() return (True) except Exception as e: get_logger().error(e, exc_info=True) return (False)
def find_parent_guild(self, discord_channel_id): # This function will return the parent guild ID of a given discord_channel_id # Typically, this relationship will be stored in the local database, however, # in the event that it is not found, a request will be made to the Discord API. try: # Search database for relationship # This relationship will be in either 'twitch_notifications' or 'youtube_notifications' # This query uses the 'UNION ALL' operator to combine the discord guild ID's from both tables. sql = ( "SELECT `discord_guild_id` FROM `twitch_notifications` WHERE `discord_channel_id` = %s " "UNION ALL SELECT `discord_guild_id` FROM `youtube_notifications` WHERE `discord_channel_id` = %s LIMIT 1" ) # Fetch the discord_guild_id discord_guild_id = self.db.select( sql, [discord_channel_id, discord_channel_id]) # Determine if a discord_guild_id was found in the database # Expected format: [{'discord_guild_id'}]. # If the value does not exist, the exception will trigger. try: return (discord_guild_id[0]['discord_guild_id']) except: # Discord Guild ID was not found in the databse discord_guild_id = None # Query the Discord API endpoint = "https://discordapp.com/api/channels/" + str( discord_channel_id) resp = requests.get(url=endpoint, headers=self.headers) if (resp.status_code != 200): # The expected response code is 200, anything else would indicate an issue get_logger().warning(resp.json(), exc_info=True) return (None) try: # Attempt to retreive the guild_id from the response return (resp.json()['guild_id']) except: # Either invalid channel ID or bot lacks access (should be caught in prior if statement) return (None) except Exception as e: # An exception occured while attempting to lookup the parent guild ID get_logger().error(e, exc_info=True) return (None)
def on_put(self, req, resp): try: yt_management = youtube_management() yt_management.update_display_names() yt_management.update_webhook_subs() resp.status = falcon.get_http_status(200) resp.content_type = ['application/json'] resp.body = json.dumps({"status": "success", "code": 200}) except Exception as e: get_logger().error(e, exc_info=True) resp.status = falcon.get_http_status(400) resp.content_type = ['application/json'] resp.body = json.dumps({"status": "error", "code": 400})
def on_get(self, req, resp): # Return a list of recent event entries. try: # Get level and limit variables from the headers # If the 'level' header is not provided, set it to 0 if req.get_header('min-log-level') is None: level = 0 else: # Will raise exception if cannot convert to int level = int(req.get_header('min-log-level')) # Create a soft limit of 100 events, this will prevent an excessive amount # of entries being returned. Also if limit is not provided, set it to 100 if req.get_header('limit') is None: limit = 100 else: # Get int value of limit, if limit is greater than 100: set to 100 # Will raise exception if cannot convert to int limit = int(req.get_header('limit')) if limit > 100: limit = 100 # Retrieve the requested entries from the database events = data_handler().select("SELECT * FROM `logging` WHERE `level` >= %s ORDER BY `id` DESC LIMIT %s", [level, limit]) resp.status = falcon.HTTP_200 resp.content_type = ['application/json'] # default=str resloves an issue with datetime.datetime not being # JSON serializable resp.body = json.dumps(events, default=str) except (TypeError, ValueError) as e: # Intended to capture value/type errors with header value int casting get_logger().warning(e, exc_info=True) resp.status = falcon.HTTP_400 resp.content_type = ['application/json'] resp.body = json.dumps({"status":"error", "code":400, "message":"Ensure that header values 'min-log-level' and 'limit' are integers."}) except Exception as e: get_logger().error(e, exc_info=True) resp.status = falcon.HTTP_400 resp.content_type = ['application/json'] resp.body = json.dumps({"status":"error", "code":400, "message":"Failed to retrieve entries."})
def insert_auth_token(self, data, code): try: db_obj = data_handler() input = [ None, None, code, data['access_token'], data['refresh_token'], data['scope'], data['token_type'] ] sql = "INSERT INTO `discord_auth` VALUES (%s,%s,%s,%s,%s,%s,%s)" db_insert = db_obj.insert(sql, input) return (db_insert) except Exception as e: get_logger().error(e, exc_info=True) return (False)
def on_post(self, req, resp): try: # Read data from POST data = req.bounded_stream.read().decode('UTF-8') # Parse the data using BeautifulSoup and XML parser bs_data = BeautifulSoup(data,"xml") # Select out the relavent data # video_url = bs_data.feed.entry.link['href'] channel_id = bs_data.feed.entry.findAll('yt:channelId')[0].contents[0] video_id = bs_data.feed.entry.findAll('yt:videoId')[0].contents[0] video_author = bs_data.feed.entry.author.findAll('name')[0].contents[0] video_title = bs_data.feed.entry.title.contents[0] youtube_notification().post_notification(channel_id, video_id, video_author, video_title) except Exception as e: get_logger().error(e, exc_info=True)
def on_post(self, req, resp, twitch_user_id=None): try: # Load the JSON data data = json.loads(req.bounded_stream.read().decode()) # Check if the request is a challenge if "challenge" in data: # Respond to the challenge resp.status = falcon.HTTP_200 resp.body = data["challenge"] # This request is not an actually notification, stop processing return if "event" in data: # Call the twitch_handler event function twitch_handler().event(data) except Exception as e: get_logger().error(e, exc_info=True)
def on_post(self, req, resp): # Create a new log entry, this function will manually trigger the # 'db_insert_log' and 'discord_post_log' functions. try: # Create an event_log object, this provides access to DB and Discord functions log = event_log() # Expects data to be in JSON format in POST data data = json.loads(req.bounded_stream.read().decode('UTF-8')) # Insert a log entry into the database. db_status = log.db_insert_log( data['log_level'], data['log_pathname'], data['log_class_name'], data['log_function_name'], data['log_exc_info'], data['log_message'] ) # If inserting the log into the database resulted in an error db_status = (false) if not db_status: raise # Send message to Discord, note: level must meet DISCORD_MIN_TO_LOG message = str(data['log_level']) + " | " + str(data['log_pathname']) + " | " + str(data['log_class_name']) + " | " + str(data['log_function_name']) + " | " + str(data['log_exc_info']) + " | " + str(data['log_message']) log.discord_post_log(int(data['log_level']), message) # Inserting entry was successful, return http_200 success resp.status = falcon.HTTP_200 resp.content_type = ['application/json'] resp.body = json.dumps({"status":"success", "code":200, "message":"Event logged to the database."}) except Exception as e: get_logger().error(e, exc_info=True) # Inserting the entry failed, return http_400 resp.status = falcon.HTTP_400 resp.content_type = ['application/json'] resp.body = json.dumps({"status":"error", "code":400, "message":"Failed to log event to the database"})
def on_delete(self, req, resp): # Used to delete notifications associated with either a discord_channel_id # or a discord_guild_id try: # Check if discord-channel-id or discord-guild-id is in the headers, else HTTPBadRequest if req.get_header('discord-channel-id') is not None: notifications().delete_by_discord_channel_id( req.get_header('discord-channel-id')) elif req.get_header('discord-guild-id') is not None: notifications().delete_by_discord_guild_id( req.get_header('discord-guild-id')) else: raise falcon.HTTPBadRequest( 'Missing Requried Headers', 'Required header(s): discord-channel-id or discord-guild-id' ) resp.status = falcon.get_http_status(200) resp.content_type = ['application/json'] resp.body = json.dumps({"status": "success", "code": 200}) except falcon.HTTPBadRequest as e: message = { "status": "error", "code": 400, "message": e.description } resp.status = falcon.get_http_status(message['code']) resp.content_type = ['application/json'] resp.body = json.dumps(message) except Exception as e: get_logger().error(e, exc_info=True) message = {"status": "error", "code": 400, "message": 'API ERROR'} resp.status = falcon.get_http_status(message['code']) resp.content_type = ['application/json'] resp.body = json.dumps(message)
def send_direct_message(self, user_id, message): # Sends direct message to the given user_id try: # Get the direct message channel ID direct_message_channel = self.direct_message_channel_id(user_id) endpoint = "https://discordapp.com/api/channels/" + str( direct_message_channel) + "/messages" data = json.dumps({'content': message}) resp = requests.post(url=endpoint, headers=self.headers, data=data) if (resp.status_code != 200): # Expected status_code is 200 get_logger().error(resp.json(), exc_info=True) return (False) return (True) except Exception as e: get_logger().error(e, exc_info=True) return (False)
def unsubscribe(self, yt_channel_url, discord_channel_id): # Use the youtube_channel_id class to determine the YouTube channel ID # for the given URL. # Convert the yt_channel_url into a YouTube channel ID. yt_channel_id = youtube_channel_id().get_yt_channel_id(yt_channel_url) # If the youtube_channel_id is unable to parse the URL, return the error. if yt_channel_id is None: return({"status":"error", "code":400, "message":"Error unable to parse the URL!"}) # Remove the yt_channel and discord_channel_id combination from the database database_status = self.manage_databse_subscription("unsubscribe", yt_channel_id, None, discord_channel_id, None) # Ignore unsubscribing from the YouTube webhook, the lease will automatically expire, # and unsubscribing could create unintended results if database_status == False: get_logger().error("Error removing notification from the database!", exc_info=True) return({"status":"error", "code":503, "message":"Error removing notification from the database!"}) else: return({"status":"success", "code":200, "message":"Notification successfully removed!"})
def get_override_value(self, platform, discord_guild_id): # This function will select the proper SQL statement based on the given # platform value, expected platform values: ['twitch', 'youtube'] # Returns override value if exist else returns None. try: platform = str(platform).lower() sql = "SELECT `override` FROM `notification_limit_overrides` WHERE `platform` = %s AND `discord_guild_id` = %s" res = self.db.select(sql, [platform, discord_guild_id]) return (int(res[0]['override'])) except (TypeError, IndexError) as e: # Expected exception if the override value does not exist return (None) except Exception as e: # Unexpected exception, log event get_logger().error(e, exc_info=True) return (None)
def subscribe(self, yt_channel_url, discord_guild_id, discord_channel_id): # Use the youtube_channel_id class to determine the YouTube channel ID # for the given URL. try: # Check if the discord_guild_id has reached the notification limit if(notification_limit().youtube_limit_reached(discord_guild_id)): # The limit has been reached return({"status":"error", "code":403, "message":"Notification limit has been reached!"}) # Convert the yt_channel_url into a YouTube channel ID. yt_channel_id = youtube_channel_id().get_yt_channel_id(yt_channel_url) # If the youtube_channel_id is unable to parse the URL, return the error. if yt_channel_id is None: return({"status":"error", "code":400, "message":"Error unable to parse the URL!"}) # Check if the yt_channel_id and discord_channel_id combination are unqiue. if(self.check_if_exist(yt_channel_id, discord_channel_id)): # The subscription to the YouTube channel in the Discord channel already exist. return({"status":"error", "code":400, "message":"Notification already exist!"}) # Add notifcation to local databse yt_display_name = self.get_display_name(yt_channel_id) database_status = self.manage_databse_subscription("subscribe", yt_channel_id, yt_display_name, discord_channel_id, discord_guild_id) # Check if the local database insertion if database_status is False: # Error with the database get_logger().error("Error inserting subscription", exc_info=True) return({"status":"error", "code":503, "message":"Error internal database failure!"}) # Subscribe to the webhook for the given channel subscribe_status = self.manage_webhook_subscription("subscribe", yt_channel_id) # Check if the YouTube API subscription was successful if subscribe_status is False: # Error with YouTube API (pubsubhubbub) get_logger().error("Error YouTube webhook subscription failed", exc_info=True) return({"status":"success", "code":200, "message":"Notification successfully added!"}) except Exception as e: get_logger().error(e, exc_info=True) return({"status":"error", "code":503, "message":"Error internal system failure!"})
def get_guild_data(self, discord_guild_id=None, discord_channel_id=None): # Fetches the guild object, this function can perform a lookup based on either the # guild ID or a channel ID. # https://discord.com/developers/docs/resources/guild#guild-object-guild-structure try: if discord_guild_id is None and discord_channel_id is None: # Neither value provided get_logger().warning( "Neither discord_guild_id nor discord_channel_id provided", exc_info=True) return (None) if discord_guild_id is None: # Use the find_parent_guild function to lookup the guild ID discord_guild_id = self.find_parent_guild(discord_channel_id) if discord_guild_id is None: # Failed to find Guild ID return (None) # Use the Discord API to retrieve the Guild Owners user ID endpoint = "https://discordapp.com/api/guilds/" + str( discord_guild_id) resp = requests.get(url=endpoint, headers=self.headers) if (resp.status_code != 200): # The expected response code is 200, anything else would indicate an issue get_logger().warning(resp.json(), exc_info=True) return (None) try: # Attempt to parse the response into a dict and return it return (resp.json()) except: # Either invalid guild_id or bot lacks access to guild return (None) except Exception as e: # Error retrieving guild owner_id get_logger().error(e, exc_info=True) return (None)
def alert_guild_owner_permissions(self, discord_channel_id): # This is used to notify a guild owner that the bot is missing the permissions # required to send a notification. try: # Create an event warning of the issue get_logger().warning(str("Missing permissions in " + str(discord_channel_id)), exc_info=True) # Retreive the guild data object, this contains the owner_id and guild name guild_data = self.get_guild_data( discord_channel_id=discord_channel_id) # Set the message message = "I've encountered an error in guild: `" + str( guild_data['name'] ) + "`! \n Please ensure that I'm able to read messages, send messages, and embed links in the channels you want me to operate in." # Send the message status = self.send_direct_message(guild_data['owner_id'], message) if status is False: # Failed to contact the guild owner # NOTE: determine if a notification should be removed get_logger().error( "Failed to contact guild owner for channel_id: " + str(discord_channel_id), exc_info=True) return (status) except Exception as e: # Failed to notify the guild owner get_logger().error(e, exc_info=True) return (False)
from wsgiref import simple_server # If API_PORT is set as a system variable use it, else use 8445 if(os.getenv('API_PORT') == None): port = 8445 else: port = int(os.getenv('API_PORT')) # create a simple_server object to serve the API self.httpd = simple_server.make_server('0.0.0.0', port, self.app) self.httpd.serve_forever() def get_app(self): # Getter function for the falcon API object. return(self.app) try: if __name__ == '__main__': # If the main.py is executed directly, the API will launch in "test" mode get_logger().info('API starting in test mode', exc_info=True) public_facing_api().start() else: # Guincorn will look for the variable "app" get_logger().info('API starting via Guincorn: ' + str(os.getpid()), exc_info=True) app = public_facing_api().get_app() except Exception as e: get_logger().critical(e, exc_info=True)
def on_get(self, req, resp): try: # Check if discord-channel-id is in headers, if not return HTTPBadRequest discord_channel_id = req.get_header('discord-channel-id') if discord_channel_id is None: raise falcon.HTTPBadRequest( 'Missing Requried Headers', 'Required header(s): discord-channel-id') # Get the parent guild of the discord_channel_id discord_guild_id = discord_management().find_parent_guild( discord_channel_id) # Create object for notifications class, get notification data for twitch and youtube # If no notifications are found, type None will be returned notifications_obj = notifications() # Create object for notification limit class, get notification limit and number of # notifications registered for each platform notification_limit_obj = notification_limit() # Create dict with notification data data = { discord_channel_id: { 'twitch': notifications_obj.get_twitch_notifications( discord_channel_id), 'youtube': notifications_obj.get_youtube_notifications( discord_channel_id), 'limits': { 'twitch': { 'limit': notification_limit_obj.get_twitch_limit( discord_guild_id), 'used': notification_limit_obj. get_twitch_notification_count(discord_guild_id) }, 'youtube': { 'limit': notification_limit_obj.get_youtube_limit( discord_guild_id), 'used': notification_limit_obj. get_youtube_notification_count(discord_guild_id) } } } } # Set http status to 200, set content_type to JSON, and convert the data dict into JSON resp.status = falcon.get_http_status(200) resp.content_type = ['application/json'] resp.body = json.dumps(data) except falcon.HTTPBadRequest as e: message = { "status": "error", "code": 400, "message": e.description } resp.status = falcon.get_http_status(message['code']) resp.content_type = ['application/json'] resp.body = json.dumps(message) except Exception as e: get_logger().error(e, exc_info=True) message = {"status": "error", "code": 400, "message": 'API ERROR'} resp.status = falcon.get_http_status(message['code']) resp.content_type = ['application/json'] resp.body = json.dumps(message)