From cc2d2c299294900451a05238c35cbae89a5075a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Forr=C3=B3?= Date: Thu, 22 Aug 2019 16:51:53 +0200 Subject: [PATCH] Implement monitoring Twitch events and periodic commands --- Dockerfile | 2 +- bot.py | 41 +++++++++++++++++++++----- clients/twitch.py | 69 +++++++++++++++++++++++++++++++++++++++++++- config.py | 32 ++++++++++++++++++++ requirements.txt | 2 ++ settings.cfg.example | 13 +++++++++ 6 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 config.py diff --git a/Dockerfile b/Dockerfile index a9966f8..1958790 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /bot COPY . . RUN touch settings.cfg -RUN apk add --no-cache gcc libc-dev libxml2-dev libxslt-dev && \ +RUN apk add --no-cache gcc git libc-dev libxml2-dev libxslt-dev && \ rm -rf /var/cache/apk/* RUN pip install --no-cache-dir --requirement requirements.txt diff --git a/bot.py b/bot.py index e5ba9cc..15eb963 100644 --- a/bot.py +++ b/bot.py @@ -1,8 +1,11 @@ import asyncio -import configparser +import json import logging import os +import websockets + +from config import Config from commands import Commands from extracommands import ExtraCommands from clients.discord import DiscordClient @@ -13,9 +16,6 @@ TIMEOUT = 0.2 def main(): - config = configparser.ConfigParser() - config.read('settings.cfg') - level = logging.DEBUG if bool(int(os.getenv('DEBUG', 0))) else logging.INFO handler = logging.StreamHandler() @@ -36,12 +36,23 @@ def main(): twitch_logger.addHandler(handler) twitch_logger.setLevel(level) + config = Config() + config.reload() + commands = Commands(config, commands_logger) extra_commands = ExtraCommands() + extra_commands.reload() - async def monitor_extra_commands(): + twitch_client = TwitchClient(config, twitch_logger, commands, extra_commands) + discord_client = DiscordClient(config, discord_logger, commands, extra_commands) + + async def monitor_config_files(): while True: + try: + config.reload_if_needed() + except Exception as e: + commands_logger.info('Exception', exc_info=e) try: extra_commands.reload_if_needed() except Exception as e: @@ -49,7 +60,6 @@ def main(): await asyncio.sleep(TIMEOUT) async def run_twitch_client(): - twitch_client = TwitchClient(config, twitch_logger, commands, extra_commands) twitch_client.connect_() while True: try: @@ -60,18 +70,33 @@ def main(): async def run_discord_client(): while True: - discord_client = DiscordClient(config, discord_logger, commands, extra_commands) try: await discord_client.start_() except Exception as e: discord_logger.info('Exception', exc_info=e) await discord_client.logout() + async def run_periodic_commands(): + interval = config['PeriodicCommands'].getint('interval') + while True: + await asyncio.sleep(interval) + twitch_client.run_periodic_command() + + async def monitor_twitch_events(): + url = config['TwitchEvents'].get('wss_url') + key = config['TwitchEvents'].get('wss_key') + async with websockets.connect(url, extra_headers={'X-Events-Key': key}) as ws: + while True: + event = json.loads(await ws.recv()) + twitch_client.process_twitch_event(event) + loop = asyncio.get_event_loop() try: - asyncio.ensure_future(monitor_extra_commands()) + asyncio.ensure_future(monitor_config_files()) asyncio.ensure_future(run_twitch_client()) asyncio.ensure_future(run_discord_client()) + asyncio.ensure_future(run_periodic_commands()) + asyncio.ensure_future(monitor_twitch_events()) loop.run_forever() finally: loop.close() diff --git a/clients/twitch.py b/clients/twitch.py index 421219f..4c80648 100644 --- a/clients/twitch.py +++ b/clients/twitch.py @@ -1,15 +1,21 @@ +import collections import datetime import functools +import random import re import time import unicodedata import dateutil.parser import irc.bot +import nostril +import requests from commands import CommandError +MAX_EVENTS = 100 + # $username --> Sweet! Thanks for the quote! #$id: $response QUOTE_ADDED_PATTERN = re.compile(r'''^(.\s)? (?P.+)\s+-->\s+ @@ -83,6 +89,9 @@ class TwitchClient(irc.bot.SingleServerIRCBot): self.logger = logger self.commands = commands self.extra_commands = extra_commands + self.last_periodic_command = None + self.streaming = False + self.events = collections.deque([], MAX_EVENTS) self.giveaway = None self.patterns = [ (QUOTE_ADDED_PATTERN, self._add_quote), @@ -126,6 +135,56 @@ class TwitchClient(irc.bot.SingleServerIRCBot): def process_data(self): self.reactor.process_once() + def process_twitch_event(self, event): + def is_nonsensical(e): + try: + nonsense = nostril.nonsense(e['data'][0]['from_name']) + except (KeyError, IndexError, ValueError): + return False + else: + return nonsense + def get_time(e): + try: + time = dateutil.parser.parse(e['data'][0]['followed_at']) + time = time.astimezone(tz=datetime.timezone.utc).replace(tzinfo=None) + except (KeyError, IndexError, ValueError): + return datetime.datetime(1, 1, 1) + else: + return time + if event.get('topic') == 'streams': + self.streaming = bool(event.get('data')) + self.events.appendleft(event) + follows = [e for e in self.events if e.get('topic') == 'followers'] + if not follows: + return + nonsensical = [e for e in follows if is_nonsensical(e)] + if not nonsensical: + return + self.logger.info('Nonsensical followers detected: %s', str(nonsensical)) # FIXME: remove this + enabled = self.config['TwitchEvents'].getboolean('antispam_enabled') + interval = self.config['TwitchEvents'].getint('antispam_interval') + count = self.config['TwitchEvents'].getint('antispam_count') + command = self.config['TwitchEvents'].get('antispam_command') + times = sorted([get_time(e) for e in nonsensical], reverse=True) + if len([t for t in times if (times[0] - t).total_seconds() <= interval]) >= count: + if enabled: + for channel in self.config['IRC'].get('channels').split(','): + self.connection.privmsg('#{0}'.format(channel), command) + else: + self.logger.info('Potential SPAM detected!') + + def run_periodic_command(self): + if not self.streaming: + return + commands = {k: v for k, v in self.extra_commands.items() if k.endswith('@')} + if not commands: + return + if self.last_periodic_command in commands and len(commands) > 1: + del commands[self.last_periodic_command] + self.last_periodic_command, resp = random.choice(list(commands.items())) + for channel in self.config['IRC'].get('channels').split(','): + self.connection.privmsg('#{0}'.format(channel), resp.format(user='?')) + def on_welcome(self, connection, event): connection.cap('REQ', ':twitch.tv/membership') connection.cap('REQ', ':twitch.tv/tags') @@ -137,6 +196,12 @@ class TwitchClient(irc.bot.SingleServerIRCBot): def on_join(self, connection, event): self.logger.info('Joined %s', event.target) + client_id = self.config['Twitch'].get('client_id') + channel_id = self.config['Twitch'].get('channel_id') + params = dict(client_id=client_id) + r = requests.get('https://api.twitch.tv/v5/streams/{0}'.format(channel_id), params=params) + if r.ok: + self.streaming = bool(r.json().get('stream')) def on_pubmsg(self, connection, event): self._process_message(connection, event) @@ -166,7 +231,9 @@ class TwitchClient(irc.bot.SingleServerIRCBot): if m: action(tags, send_response, **m.groupdict()) for cmd, resp in self.extra_commands.items(): - if cmd == message: + if cmd.rstrip('@') == message: + if cmd.endswith('@'): + self.last_periodic_command = message send_response(resp.format(user=tags['display-name'])) def _format_quote(self, quote): diff --git a/config.py b/config.py new file mode 100644 index 0000000..ad7483c --- /dev/null +++ b/config.py @@ -0,0 +1,32 @@ +import configparser +import os + + +FILENAME = 'settings.cfg' + + +class Config(object): + + def __init__(self): + self.last_mtime = 0 + self.config = None + + def __contains__(self, item): + return item in self.config + + def __getitem__(self, key): + return self.config[key] + + def __getattr__(self, name): + return getattr(self.config, name) + + def reload(self): + config = configparser.ConfigParser() + config.read(FILENAME) + self.config = config + + def reload_if_needed(self): + mtime = os.stat(FILENAME).st_mtime + if mtime > self.last_mtime: + self.last_mtime = mtime + self.reload() diff --git a/requirements.txt b/requirements.txt index 04d5cfa..316cd3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ pyquery python-dateutil python-twitter requests +websockets +git+https://github.com/casics/nostril.git@1.1.1#egg=nostril diff --git a/settings.cfg.example b/settings.cfg.example index 5b9f57c..9f3ea3a 100644 --- a/settings.cfg.example +++ b/settings.cfg.example @@ -33,6 +33,8 @@ announcement_pattern = Live now! Playing {game} - Bring Cheese! {url} [Twitch] cache_api_url = https://ladylilia.com/twitch-cache/api token = oauth:__TWITCH_OAUTH_TOKEN__ +client_id = __TWITCH_CLIENT_ID__ +channel_id = 92737529 [Youtube] api_key = __GOOGLE_API_KEY__ @@ -51,3 +53,14 @@ act_as_proxy = true [TwitchSubs] api_url = https://ladylilia.com/twitch-subs/api api_key = __TWITCH_SUBS_API_KEY__ + +[TwitchEvents] +wss_url = wss://ladylilia.com/twitch-events/ +wss_key = __TWITCH_EVENTS_KEY__ +antispam_enabled = False +antispam_interval = 30 +antispam_count = 4 +antispam_command = /followers 60m + +[PeriodicCommands] +Interval = 1800