Example #1
0
File: bot.py Project: jardg/pajbot
    def __init__(self, config, args=None):
        # Load various configuration variables from the given config object
        # The config object that should be passed through should
        # come from pajbot.utils.load_config
        self.load_config(config)

        # Update the database scheme if necessary using alembic
        # In case of errors, i.e. if the database is out of sync or the alembic
        # binary can't be called, we will shut down the bot.
        pajbot.utils.alembic_upgrade()

        # Actions in this queue are run in a separate thread.
        # This means actions should NOT access any database-related stuff.
        self.action_queue = ActionQueue()
        self.action_queue.start()

        self.reactor = irc.client.Reactor(self.on_connect)
        self.start_time = datetime.datetime.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self)
        self.stream_manager = StreamManager(self)

        StreamHelper.init_bot(self, self.stream_manager)
        ScheduleManager.init()

        self.users = UserManager()
        self.decks = DeckManager()
        self.module_manager = ModuleManager(self.socket_manager, bot=self).load()
        self.commands = CommandManager(
                socket_manager=self.socket_manager,
                module_manager=self.module_manager,
                bot=self).load()
        self.filters = FilterManager().reload()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()
        self.emotes = EmoteManager(self)
        self.twitter_manager = TwitterManager(self)

        HandlerManager.trigger('on_managers_loaded')

        # Reloadable managers
        self.reloadable = {
                'filters': self.filters,
                }

        # Commitable managers
        self.commitable = {
                'commands': self.commands,
                'filters': self.filters,
                'banphrases': self.banphrase_manager,
                }

        self.execute_every(10 * 60, self.commit_all)
        self.execute_every(1, self.do_tick)

        try:
            self.admin = self.config['main']['admin']
        except KeyError:
            log.warning('No admin user specified. See the [main] section in config.example.ini for its usage.')
        if self.admin:
            with self.users.get_user_context(self.admin) as user:
                user.level = 2000

        self.parse_version()

        relay_host = self.config['main'].get('relay_host', None)
        relay_password = self.config['main'].get('relay_password', None)
        if relay_host is None or relay_password is None:
            self.irc = MultiIRCManager(self)
        else:
            self.irc = SingleIRCManager(self)

        self.reactor.add_global_handler('all_events', self.irc._dispatcher, -10)

        twitch_client_id = None
        twitch_oauth = None
        if 'twitchapi' in self.config:
            twitch_client_id = self.config['twitchapi'].get('client_id', None)
            twitch_oauth = self.config['twitchapi'].get('oauth', None)

        # A client ID is required for the bot to work properly now, give an error for now
        if twitch_client_id is None:
            log.error('MISSING CLIENT ID, SET "client_id" VALUE UNDER [twitchapi] SECTION IN CONFIG FILE')

        self.twitchapi = TwitchAPI(twitch_client_id, twitch_oauth)

        self.data = {}
        self.data_cb = {}
        self.url_regex = re.compile(self.url_regex_str, re.IGNORECASE)

        self.data['broadcaster'] = self.streamer
        self.data['version'] = self.version
        self.data['version_brief'] = self.version_brief
        self.data['bot_name'] = self.nickname
        self.data_cb['status_length'] = self.c_status_length
        self.data_cb['stream_status'] = self.c_stream_status
        self.data_cb['bot_uptime'] = self.c_uptime
        self.data_cb['current_time'] = self.c_current_time

        self.silent = True if args.silent else self.silent

        if self.silent:
            log.info('Silent mode enabled')

        """
        For actions that need to access the main thread,
        we can use the mainthread_queue.
        """
        self.mainthread_queue = ActionQueue()
        self.execute_every(1, self.mainthread_queue.parse_action)

        self.websocket_manager = WebSocketManager(self)

        try:
            if self.config['twitchapi']['update_subscribers'] == '1':
                self.execute_every(30 * 60,
                                   self.action_queue.add,
                                   (self.update_subscribers_stage1, ))
        except:
            pass

        # XXX: TEMPORARY UGLY CODE
        HandlerManager.add_handler('on_user_gain_tokens', self.on_user_gain_tokens)
        HandlerManager.add_handler('send_whisper', self.whisper)
Example #2
0
File: bot.py Project: jardg/pajbot
class Bot:
    """
    Main class for the twitch bot
    """

    version = '1.30'
    date_fmt = '%H:%M'
    admin = None
    url_regex_str = r'\(?(?:(http|https):\/\/)?(?:((?:[^\W\s]|\.|-|[:]{1})+)@{1})?((?:www.)?(?:[^\W\s]|\.|-)+[\.][^\W\s]{2,4}|localhost(?=\/)|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::(\d*))?([\/]?[^\s\?]*[\/]{1})*(?:\/?([^\s\n\?\[\]\{\}\#]*(?:(?=\.)){1}|[^\s\n\?\[\]\{\}\.\#]*)?([\.]{1}[^\s\?\#]*)?)?(?:\?{1}([^\s\n\#\[\]]*))?([\#][^\s\n]*)?\)?'

    last_ping = datetime.datetime.now()
    last_pong = datetime.datetime.now()

    def parse_args():
        parser = argparse.ArgumentParser()
        parser.add_argument('--config', '-c',
                            default='config.ini',
                            help='Specify which config file to use '
                                    '(default: config.ini)')
        parser.add_argument('--silent',
                            action='count',
                            help='Decides whether the bot should be '
                            'silent or not')
        # TODO: Add a log level argument.

        return parser.parse_args()

    def load_config(self, config):
        self.config = config

        self.domain = config['web'].get('domain', 'localhost')

        self.nickname = config['main'].get('nickname', 'pajbot')
        self.password = config['main'].get('password', 'abcdef')

        self.timezone = config['main'].get('timezone', 'UTC')

        self.trusted_mods = config.getboolean('main', 'trusted_mods')

        TimeManager.init_timezone(self.timezone)

        if 'streamer' in config['main']:
            self.streamer = config['main']['streamer']
            self.channel = '#' + self.streamer
        elif 'target' in config['main']:
            self.channel = config['main']['target']
            self.streamer = self.channel[1:]

        self.wolfram = None
        if 'wolfram' in config['main']:
            try:
                import wolframalpha
                self.wolfram = wolframalpha.Client(config['main']['wolfram'])
            except ImportError:
                pass

        self.silent = False
        self.dev = False

        if 'flags' in config:
            self.silent = True if 'silent' in config['flags'] and config['flags']['silent'] == '1' else self.silent
            self.dev = True if 'dev' in config['flags'] and config['flags']['dev'] == '1' else self.dev

        DBManager.init(self.config['main']['db'])

        redis_options = {}
        if 'redis' in config:
            redis_options = config._sections['redis']

        RedisManager.init(**redis_options)

    def __init__(self, config, args=None):
        # Load various configuration variables from the given config object
        # The config object that should be passed through should
        # come from pajbot.utils.load_config
        self.load_config(config)

        # Update the database scheme if necessary using alembic
        # In case of errors, i.e. if the database is out of sync or the alembic
        # binary can't be called, we will shut down the bot.
        pajbot.utils.alembic_upgrade()

        # Actions in this queue are run in a separate thread.
        # This means actions should NOT access any database-related stuff.
        self.action_queue = ActionQueue()
        self.action_queue.start()

        self.reactor = irc.client.Reactor(self.on_connect)
        self.start_time = datetime.datetime.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self)
        self.stream_manager = StreamManager(self)

        StreamHelper.init_bot(self, self.stream_manager)
        ScheduleManager.init()

        self.users = UserManager()
        self.decks = DeckManager()
        self.module_manager = ModuleManager(self.socket_manager, bot=self).load()
        self.commands = CommandManager(
                socket_manager=self.socket_manager,
                module_manager=self.module_manager,
                bot=self).load()
        self.filters = FilterManager().reload()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()
        self.emotes = EmoteManager(self)
        self.twitter_manager = TwitterManager(self)

        HandlerManager.trigger('on_managers_loaded')

        # Reloadable managers
        self.reloadable = {
                'filters': self.filters,
                }

        # Commitable managers
        self.commitable = {
                'commands': self.commands,
                'filters': self.filters,
                'banphrases': self.banphrase_manager,
                }

        self.execute_every(10 * 60, self.commit_all)
        self.execute_every(1, self.do_tick)

        try:
            self.admin = self.config['main']['admin']
        except KeyError:
            log.warning('No admin user specified. See the [main] section in config.example.ini for its usage.')
        if self.admin:
            with self.users.get_user_context(self.admin) as user:
                user.level = 2000

        self.parse_version()

        relay_host = self.config['main'].get('relay_host', None)
        relay_password = self.config['main'].get('relay_password', None)
        if relay_host is None or relay_password is None:
            self.irc = MultiIRCManager(self)
        else:
            self.irc = SingleIRCManager(self)

        self.reactor.add_global_handler('all_events', self.irc._dispatcher, -10)

        twitch_client_id = None
        twitch_oauth = None
        if 'twitchapi' in self.config:
            twitch_client_id = self.config['twitchapi'].get('client_id', None)
            twitch_oauth = self.config['twitchapi'].get('oauth', None)

        # A client ID is required for the bot to work properly now, give an error for now
        if twitch_client_id is None:
            log.error('MISSING CLIENT ID, SET "client_id" VALUE UNDER [twitchapi] SECTION IN CONFIG FILE')

        self.twitchapi = TwitchAPI(twitch_client_id, twitch_oauth)

        self.data = {}
        self.data_cb = {}
        self.url_regex = re.compile(self.url_regex_str, re.IGNORECASE)

        self.data['broadcaster'] = self.streamer
        self.data['version'] = self.version
        self.data['version_brief'] = self.version_brief
        self.data['bot_name'] = self.nickname
        self.data_cb['status_length'] = self.c_status_length
        self.data_cb['stream_status'] = self.c_stream_status
        self.data_cb['bot_uptime'] = self.c_uptime
        self.data_cb['current_time'] = self.c_current_time

        self.silent = True if args.silent else self.silent

        if self.silent:
            log.info('Silent mode enabled')

        """
        For actions that need to access the main thread,
        we can use the mainthread_queue.
        """
        self.mainthread_queue = ActionQueue()
        self.execute_every(1, self.mainthread_queue.parse_action)

        self.websocket_manager = WebSocketManager(self)

        try:
            if self.config['twitchapi']['update_subscribers'] == '1':
                self.execute_every(30 * 60,
                                   self.action_queue.add,
                                   (self.update_subscribers_stage1, ))
        except:
            pass

        # XXX: TEMPORARY UGLY CODE
        HandlerManager.add_handler('on_user_gain_tokens', self.on_user_gain_tokens)
        HandlerManager.add_handler('send_whisper', self.whisper)

    def on_connect(self, sock):
        return self.irc.on_connect(sock)

    def on_user_gain_tokens(self, user, tokens_gained):
        self.whisper(user.username, 'You finished todays quest! You have been awarded with {} tokens.'.format(tokens_gained))

    def update_subscribers_stage1(self):
        limit = 100
        offset = 0
        subscribers = []
        log.info('Starting stage1 subscribers update')

        try:
            retry_same = 0
            while True:
                log.debug('Beginning sub request {0} {1}'.format(limit, offset))
                subs, retry_same, error = self.twitchapi.get_subscribers(self.streamer, limit, offset, 0 if retry_same is False else retry_same)
                log.debug('got em!')

                if error is True:
                    log.error('Too many attempts, aborting')
                    return False

                if retry_same is False:
                    offset += limit
                    if len(subs) == 0:
                        # We don't need to retry, and the last query finished propery
                        # Break out of the loop and start fiddling with the subs!
                        log.debug('Done!')
                        break
                    else:
                        log.debug('Fetched {0} subs'.format(len(subs)))
                        subscribers.extend(subs)

                if retry_same is not False:
                    # In case the next attempt is a retry, wait for 3 seconds
                    log.debug('waiting for 3 seconds...')
                    time.sleep(3)
                    log.debug('waited enough!')
            log.debug('Finished with the while True loop!')
        except:
            log.exception('Caught an exception while trying to get subscribers')
            return

        log.info('Ended stage1 subscribers update')
        if len(subscribers) > 0:
            log.info('Got some subscribers, so we are pushing them to stage 2!')
            self.mainthread_queue.add(self.update_subscribers_stage2,
                                      args=[subscribers])
            log.info('Pushed them now.')

    def update_subscribers_stage2(self, subscribers):
        self.kvi['active_subs'].set(len(subscribers) - 1)

        self.users.reset_subs()

        self.users.update_subs(subscribers)

    def start(self):
        """Start the IRC client."""
        self.reactor.process_forever()

    def get_kvi_value(self, key, extra={}):
        return self.kvi[key].get()

    def get_last_tweet(self, key, extra={}):
        return self.twitter_manager.get_last_tweet(key)

    def get_emote_tm(self, key, extra={}):
        val = self.emotes.get_emote_epm(key)
        if not val:
            return None
        return '{0:,d}'.format(val)

    def get_emote_count(self, key, extra={}):
        val = self.emotes.get_emote_count(key)
        if not val:
            return None
        return '{0:,d}'.format(val)

    def get_emote_tm_record(self, key, extra={}):
        val = self.emotes.get_emote_epmrecord(key)
        if not val:
            return None
        return '{0:,d}'.format(val)

    def get_source_value(self, key, extra={}):
        try:
            return getattr(extra['source'], key)
        except:
            log.exception('Caught exception in get_source_value')

        return None

    def get_user_value(self, key, extra={}):
        try:
            user = self.users.find(extra['argument'])
            if user:
                return getattr(user, key)
        except:
            log.exception('Caught exception in get_source_value')

        return None

    def get_command_value(self, key, extra={}):
        try:
            return getattr(extra['command'].data, key)
        except:
            log.exception('Caught exception in get_source_value')

        return None

    def get_usersource_value(self, key, extra={}):
        try:
            user = self.users.find(extra['argument'])
            if user:
                return getattr(user, key)
            else:
                return getattr(extra['source'], key)
        except:
            log.exception('Caught exception in get_source_value')

        return None

    def get_time_value(self, key, extra={}):
        try:
            tz = timezone(key)
            return datetime.datetime.now(tz).strftime(self.date_fmt)
        except:
            log.exception('Unhandled exception in get_time_value')

        return None

    def get_current_song_value(self, key, extra={}):
        if self.stream_manager.online:
            current_song = PleblistManager.get_current_song(self.stream_manager.current_stream.id)
            inner_keys = key.split('.')
            val = current_song
            for inner_key in inner_keys:
                val = getattr(val, inner_key, None)
                if val is None:
                    return None
            if val is not None:
                return val
        return None

    def get_strictargs_value(self, key, extra={}):
        ret = self.get_args_value(key, extra)

        if len(ret) == 0:
            return None
        return ret

    def get_args_value(self, key, extra={}):
        range = None
        try:
            msg_parts = extra['message'].split(' ')
        except (KeyError, AttributeError):
            msg_parts = ['']

        try:
            if '-' in key:
                range_str = key.split('-')
                if len(range_str) == 2:
                    range = (int(range_str[0]), int(range_str[1]))

            if range is None:
                range = (int(key), len(msg_parts))
        except (TypeError, ValueError):
            range = (0, len(msg_parts))

        try:
            return ' '.join(msg_parts[range[0]:range[1]])
        except AttributeError:
            return ''
        except:
            log.exception('UNHANDLED ERROR IN get_args_value')
            return ''

    def get_notify_value(self, key, extra={}):
        payload = {
                'message': extra['message'] or '',
                'trigger': extra['trigger'],
                'user': extra['source'].username_raw,
                }
        self.websocket_manager.emit('notify', payload)

        return ''

    def get_value(self, key, extra={}):
        if key in extra:
            return extra[key]
        elif key in self.data:
            return self.data[key]
        elif key in self.data_cb:
            return self.data_cb[key]()

        log.warning('Unknown key passed to get_value: {0}'.format(key))
        return None

    def privmsg_arr(self, arr):
        for msg in arr:
            self.privmsg(msg)

    def privmsg_from_file(self, url, per_chunk=35, chunk_delay=30):
        try:
            r = requests.get(url)
            lines = r.text.split('\n')
            i = 0
            while len(lines) > 0:
                if i == 0:
                    self.privmsg_arr(lines[:per_chunk])
                else:
                    self.execute_delayed(chunk_delay * i, self.privmsg_arr, (lines[:per_chunk],))

                del lines[:per_chunk]

                i = i + 1
        except:
            log.exception('error in privmsg_from_file')

    def privmsg(self, message, channel=None, increase_message=True):
        if channel is None:
            channel = self.channel

        return self.irc.privmsg(message, channel, increase_message=increase_message)

    def c_uptime(self):
        return time_since(datetime.datetime.now().timestamp(), self.start_time.timestamp())

    def c_current_time(self):
        return datetime.datetime.now()

    @property
    def is_online(self):
        return self.stream_manager.online

    def c_stream_status(self):
        return 'online' if self.stream_manager.online else 'offline'

    def c_status_length(self):
        if self.stream_manager.online:
            return time_since(time.time(), self.stream_manager.current_stream.stream_start.timestamp())
        else:
            if self.stream_manager.last_stream is not None:
                return time_since(time.time(), self.stream_manager.last_stream.stream_end.timestamp())
            else:
                return 'No recorded stream FeelsBadMan '

    def execute_at(self, at, function, arguments=()):
        self.reactor.scheduler.execute_at(at, lambda: function(*arguments))

    def execute_delayed(self, delay, function, arguments=()):
        self.reactor.scheduler.execute_after(delay, lambda: function(*arguments))

    def execute_every(self, period, function, arguments=()):
        self.reactor.scheduler.execute_every(period, lambda: function(*arguments))

    def _ban(self, username, reason=''):
        self.privmsg('.ban {0} {1}'.format(username, reason), increase_message=False)

    def ban(self, username, reason=''):
        log.debug('Banning {}'.format(username))
        self._timeout(username, 30, reason)
        self.execute_delayed(1, self._ban, (username, reason))

    def ban_user(self, user, reason=''):
        self._timeout(user.username, 30, reason)
        self.execute_delayed(1, self._ban, (user.username, reason))

    def unban(self, username):
        self.privmsg('.unban {0}'.format(username), increase_message=False)

    def _timeout(self, username, duration, reason=''):
        self.privmsg('.timeout {0} {1} {2}'.format(username, duration, reason), increase_message=False)

    def timeout(self, username, duration, reason=''):
        log.debug('Timing out {} for {} seconds'.format(username, duration))
        self._timeout(username, duration, reason)
        self.execute_delayed(1, self._timeout, (username, duration, reason))

    def timeout_warn(self, user, duration, reason=''):
        duration, punishment = user.timeout(duration, warning_module=self.module_manager['warning'])
        self.timeout(user.username, duration, reason)
        return (duration, punishment)

    def timeout_user(self, user, duration, reason=''):
        self._timeout(user.username, duration, reason)
        self.execute_delayed(1, self._timeout, (user.username, duration, reason))

    def whisper(self, username, *messages, separator='. '):
        """
        Takes a sequence of strings and concatenates them with separator.
        Then sends that string as a whisper to username
        """

        if len(messages) < 0:
            return False

        message = separator.join(messages)

        return self.irc.whisper(username, message)

    def send_message_to_user(self, user, message, separator='. ', method='say'):
        if method == 'say':
            self.say(user.username + ', ' + lowercase_first_letter(message), separator=separator)
        elif method == 'whisper':
            self.whisper(user.username, message, separator=separator)
        else:
            log.warning('Unknown send_message method: {}'.format(method))

    def say(self, *messages, channel=None, separator='. '):
        """
        Takes a sequence of strings and concatenates them with separator.
        Then sends that string to the given channel.
        """

        if len(messages) < 0:
            return False

        if not self.silent:
            message = separator.join(messages).strip()

            if len(message) >= 1:
                if (message[0] == '.' or message[0] == '/') and not message[1:3] == 'me':
                    log.warning('Message we attempted to send started with . or /, skipping.')
                    return

                log.info('Sending message: {0}'.format(message))

                self.privmsg(message[:510], channel)

    def me(self, message, channel=None):
        if not self.silent:
            message = message.strip()

            if len(message) >= 1:
                if message[0] == '.' or message[0] == '/':
                    log.warning('Message we attempted to send started with . or /, skipping.')
                    return

                log.info('Sending message: {0}'.format(message))

                self.privmsg('.me ' + message[:500], channel)

    def parse_version(self):
        self.version = self.version
        self.version_brief = self.version

        if self.dev:
            try:
                current_branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).decode('utf8').strip()
                latest_commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf8').strip()[:8]
                commit_number = subprocess.check_output(['git', 'rev-list', 'HEAD', '--count']).decode('utf8').strip()
                self.version = '{0} DEV ({1}, {2}, commit {3})'.format(self.version, current_branch, latest_commit, commit_number)
            except:
                pass

    def on_welcome(self, chatconn, event):
        return self.irc.on_welcome(chatconn, event)

    def connect(self):
        return self.irc.start()

    def on_disconnect(self, chatconn, event):
        self.irc.on_disconnect(chatconn, event)

    def parse_message(self, msg_raw, source, event, tags={}, whisper=False):
        msg_lower = msg_raw.lower()

        emote_tag = None

        for tag in tags:
            if tag['key'] == 'subscriber' and event.target == self.channel:
                source.subscriber = tag['value'] == '1'
            elif tag['key'] == 'emotes' and tag['value']:
                emote_tag = tag['value']
            elif tag['key'] == 'display-name' and tag['value']:
                source.username_raw = tag['value']
            elif tag['key'] == 'user-type':
                source.moderator = tag['value'] == 'mod' or source.username == self.streamer

        # source.num_lines += 1

        if source is None:
            log.error('No valid user passed to parse_message')
            return False

        if source.banned:
            self.ban(source.username)
            return False

        # If a user types when timed out, we assume he's been unbanned for a good reason and remove his flag.
        if source.timed_out is True:
            source.timed_out = False

        # Parse emotes in the message
        message_emotes = self.emotes.parse_message_twitch_emotes(source, msg_raw, emote_tag, whisper)

        urls = self.find_unique_urls(msg_raw)

        log.debug('{2}{0}: {1}'.format(source.username, msg_raw, '<w>' if whisper else ''))

        res = HandlerManager.trigger('on_message',
                source, msg_raw, message_emotes, whisper, urls, event,
                stop_on_false=True)
        if res is False:
            return False

        source.last_seen = datetime.datetime.now()
        source.last_active = datetime.datetime.now()

        """
        if self.streamer == 'forsenlol' and whisper is False:
            follow_time = self.twitchapi.get_follow_relationship2(source.username, self.streamer)

            if follow_time is False:
                self._timeout(source.username, 600, '2 years follow mode (or api is down?)')
                return

            follow_age = datetime.datetime.now() - follow_time
            log.debug(follow_age)

            if follow_age.days < 730:
                log.debug('followed less than 730 days LUL')
                self._timeout(source.username, 600, '2 years follow mode')
                """

        if source.ignored:
            return False

        if msg_lower[:1] == '!':
            msg_lower_parts = msg_lower.split(' ')
            trigger = msg_lower_parts[0][1:]
            msg_raw_parts = msg_raw.split(' ')
            remaining_message = ' '.join(msg_raw_parts[1:]) if len(msg_raw_parts) > 1 else None
            if trigger in self.commands:
                command = self.commands[trigger]
                extra_args = {
                        'emotes': message_emotes,
                        'trigger': trigger,
                        }
                command.run(self, source, remaining_message, event=event, args=extra_args, whisper=whisper)

    @time_method
    def redis_test(self, username):
        try:
            pipeline = RedisManager.get().pipeline()
            pipeline.hget('pajlada:users:points', username)
            pipeline.hget('pajlada:users:ignored', username)
            pipeline.hget('pajlada:users:banned', username)
            pipeline.hget('pajlada:users:last_active', username)
        except:
            pipeline.reset()
        finally:
            b = pipeline.execute()
            log.info(b)

    @time_method
    def redis_test2(self, username):
        redis = RedisManager.get()
        values = [
                redis.hget('pajlada:users:points', username),
                redis.hget('pajlada:users:ignored', username),
                redis.hget('pajlada:users:banned', username),
                redis.hget('pajlada:users:last_active', username),
                ]
        return values

    def on_whisper(self, chatconn, event):
        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        username = event.source.user.lower()

        with self.users.get_user_context(username) as source:
            self.parse_message(event.arguments[0], source, event, whisper=True, tags=event.tags)

    def on_ping(self, chatconn, event):
        # self.say('Received a ping. Last ping received {} ago'.format(time_since(datetime.datetime.now().timestamp(), self.last_ping.timestamp())))
        log.info('Received a ping. Last ping received {} ago'.format(time_since(datetime.datetime.now().timestamp(), self.last_ping.timestamp())))
        self.last_ping = datetime.datetime.now()

    def on_pong(self, chatconn, event):
        # self.say('Received a pong. Last pong received {} ago'.format(time_since(datetime.datetime.now().timestamp(), self.last_pong.timestamp())))
        log.info('Received a pong. Last pong received {} ago'.format(time_since(datetime.datetime.now().timestamp(), self.last_pong.timestamp())))
        self.last_pong = datetime.datetime.now()

    def on_pubnotice(self, chatconn, event):
        return
        type = 'whisper' if chatconn in self.whisper_manager else 'normal'
        log.debug('NOTICE {}@{}: {}'.format(type, event.target, event.arguments))

    def on_usernotice(self, chatconn, event):
        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        tags = {}
        for d in event.tags:
            tags[d['key']] = d['value']

        if 'login' not in tags:
            return

        username = tags['login']

        with self.users.get_user_context(username) as source:
            msg = ''
            if len(event.arguments) > 0:
                msg = event.arguments[0]
            HandlerManager.trigger('on_usernotice',
                    source, msg, tags)

    def on_action(self, chatconn, event):
        self.on_pubmsg(chatconn, event)

    def on_pubmsg(self, chatconn, event):
        if event.source.user == self.nickname:
            return False

        username = event.source.user.lower()


        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        with self.users.get_user_context(username) as source:
            res = HandlerManager.trigger('on_pubmsg',
                    source, event.arguments[0],
                    stop_on_false=True)
            if res is False:
                return False

            self.parse_message(event.arguments[0], source, event, tags=event.tags)

    @time_method
    def reload_all(self):
        log.info('Reloading all...')
        for key, manager in self.reloadable.items():
            log.debug('Reloading {0}'.format(key))
            manager.reload()
            log.debug('Done with {0}'.format(key))
        log.info('ok!')

    @time_method
    def commit_all(self):
        log.info('Commiting all...')
        for key, manager in self.commitable.items():
            log.info('Commiting {0}'.format(key))
            manager.commit()
            log.info('Done with {0}'.format(key))
        log.info('ok!')

        HandlerManager.trigger('on_commit', stop_on_false=False)

    def do_tick(self):
        HandlerManager.trigger('on_tick')

    def quit(self, message, event, **options):
        quit_chub = self.config['main'].get('control_hub', None)
        quit_delay = 0

        if quit_chub is not None and event.target == ('#{}'.format(quit_chub)):
            quit_delay_random = 300
            try:
                if message is not None and int(message.split()[0]) >= 1:
                    quit_delay_random = int(message.split()[0])
            except (IndexError, ValueError, TypeError):
                pass
            quit_delay = random.randint(0, quit_delay_random)
            log.info('{} is restarting in {} seconds.'.format(self.nickname, quit_delay))

        self.execute_delayed(quit_delay, self.quit_bot)

    def quit_bot(self, **options):
        self.commit_all()
        quit = '{nickname} {version} shutting down...'
        phrase_data = {
                'nickname': self.nickname,
                'version': self.version,
                }

        try:
            ScheduleManager.base_scheduler.print_jobs()
            ScheduleManager.base_scheduler.shutdown(wait=False)
        except:
            log.exception('Error while shutting down the apscheduler')

        try:
            self.say(quit.format(**phrase_data))
        except Exception:
            log.exception('Exception caught while trying to say quit phrase')

        self.twitter_manager.quit()
        self.socket_manager.quit()
        self.irc.quit()

        sys.exit(0)

    def apply_filter(self, resp, filter):
        available_filters = {
                'strftime': _filter_strftime,
                'lower': lambda var, args: var.lower(),
                'upper': lambda var, args: var.upper(),
                'time_since_minutes': lambda var, args: 'no time' if var == 0 else time_since(var * 60, 0, format='long'),
                'time_since': lambda var, args: 'no time' if var == 0 else time_since(var, 0, format='long'),
                'time_since_dt': _filter_time_since_dt,
                'urlencode': _filter_urlencode,
                'join': _filter_join,
                'number_format': _filter_number_format,
                }
        if filter.name in available_filters:
            return available_filters[filter.name](resp, filter.arguments)
        return resp

    def find_unique_urls(self, message):
        from pajbot.modules.linkchecker import find_unique_urls
        return find_unique_urls(self.url_regex, message)
Example #3
0
class Bot:
    """
    Main class for the twitch bot
    """

    version = '2.9.0'
    date_fmt = '%H:%M'
    admin = None
    url_regex_str = r'\(?(?:(http|https):\/\/)?(?:((?:[^\W\s]|\.|-|[:]{1})+)@{1})?((?:www.)?(?:[^\W\s]|\.|-)+[\.][^\W\s]{2,4}|localhost(?=\/)|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::(\d*))?([\/]?[^\s\?]*[\/]{1})*(?:\/?([^\s\n\?\[\]\{\}\#]*(?:(?=\.)){1}|[^\s\n\?\[\]\{\}\.\#]*)?([\.]{1}[^\s\?\#]*)?)?(?:\?{1}([^\s\n\#\[\]]*))?([\#][^\s\n]*)?\)?'

    last_ping = datetime.datetime.now()
    last_pong = datetime.datetime.now()

    def parse_args():
        parser = argparse.ArgumentParser()
        parser.add_argument('--config',
                            '-c',
                            default='config.ini',
                            help='Specify which config file to use '
                            '(default: config.ini)')
        parser.add_argument('--silent',
                            action='count',
                            help='Decides whether the bot should be '
                            'silent or not')
        # TODO: Add a log level argument.

        return parser.parse_args()

    def load_config(self, config):
        self.config = config

        self.domain = config['web'].get('domain', 'localhost')

        self.nickname = config['main'].get('nickname', 'pajbot')
        self.password = config['main'].get('password', 'abcdef')

        self.timezone = config['main'].get('timezone', 'UTC')

        self.trusted_mods = config.getboolean('main', 'trusted_mods')

        TimeManager.init_timezone(self.timezone)

        if 'streamer' in config['main']:
            self.streamer = config['main']['streamer']
            self.channel = '#' + self.streamer
        elif 'target' in config['main']:
            self.channel = config['main']['target']
            self.streamer = self.channel[1:]

        self.wolfram = None
        try:
            if 'wolfram' in config['main']:
                import wolframalpha
                self.wolfram = wolframalpha.Client(config['main']['wolfram'])
        except:
            pass

        self.silent = False
        self.dev = False

        if 'flags' in config:
            self.silent = True if 'silent' in config['flags'] and config[
                'flags']['silent'] == '1' else self.silent
            self.dev = True if 'dev' in config['flags'] and config['flags'][
                'dev'] == '1' else self.dev

        DBManager.init(self.config['main']['db'])

        redis_options = {}
        if 'redis' in config:
            redis_options = config._sections['redis']

        RedisManager.init(**redis_options)

    def __init__(self, config, args=None):
        # Load various configuration variables from the given config object
        # The config object that should be passed through should
        # come from pajbot.utils.load_config
        self.load_config(config)

        # Update the database scheme if necessary using alembic
        # In case of errors, i.e. if the database is out of sync or the alembic
        # binary can't be called, we will shut down the bot.
        pajbot.utils.alembic_upgrade()

        # Actions in this queue are run in a separate thread.
        # This means actions should NOT access any database-related stuff.
        self.action_queue = ActionQueue()
        self.action_queue.start()

        self.reactor = irc.client.Reactor(self.on_connect)
        self.start_time = datetime.datetime.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self)
        self.stream_manager = StreamManager(self)

        StreamHelper.init_bot(self, self.stream_manager)
        ScheduleManager.init()

        self.users = UserManager()
        self.decks = DeckManager()
        self.module_manager = ModuleManager(self.socket_manager,
                                            bot=self).load()
        self.commands = CommandManager(socket_manager=self.socket_manager,
                                       module_manager=self.module_manager,
                                       bot=self).load()
        self.filters = FilterManager().reload()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()
        self.emotes = EmoteManager(self)
        self.twitter_manager = TwitterManager(self)
        self.cachet_manager = CachetManager(self)

        HandlerManager.trigger('on_managers_loaded')

        # Reloadable managers
        self.reloadable = {
            'filters': self.filters,
        }

        # Commitable managers
        self.commitable = {
            'commands': self.commands,
            'filters': self.filters,
            'banphrases': self.banphrase_manager,
        }

        self.execute_every(10 * 60, self.commit_all)

        try:
            self.admin = self.config['main']['admin']
        except KeyError:
            log.warning(
                'No admin user specified. See the [main] section in config.example.ini for its usage.'
            )
        if self.admin:
            with self.users.get_user_context(self.admin) as user:
                user.level = 2000

        self.parse_version()

        relay_host = self.config['main'].get('relay_host', None)
        relay_password = self.config['main'].get('relay_password', None)
        if relay_host is None or relay_password is None:
            self.irc = MultiIRCManager(self)
        else:
            self.irc = SingleIRCManager(self)

        self.reactor.add_global_handler('all_events', self.irc._dispatcher,
                                        -10)

        twitch_client_id = None
        twitch_oauth = None
        if 'twitchapi' in self.config:
            twitch_client_id = self.config['twitchapi'].get('client_id', None)
            twitch_oauth = self.config['twitchapi'].get('oauth', None)

        # A client ID is required for the bot to work properly now, give an error for now
        if twitch_client_id is None:
            log.error(
                'MISSING CLIENT ID, SET "client_id" VALUE UNDER [twitchapi] SECTION IN CONFIG FILE'
            )

        self.twitchapi = TwitchAPI(twitch_client_id, twitch_oauth)

        self.data = {}
        self.data_cb = {}
        self.url_regex = re.compile(self.url_regex_str, re.IGNORECASE)

        self.data['broadcaster'] = self.streamer
        self.data['version'] = self.version
        self.data_cb['status_length'] = self.c_status_length
        self.data_cb['stream_status'] = self.c_stream_status
        self.data_cb['bot_uptime'] = self.c_uptime
        self.data_cb['current_time'] = self.c_current_time

        self.silent = True if args.silent else self.silent

        if self.silent:
            log.info('Silent mode enabled')
        """
        For actions that need to access the main thread,
        we can use the mainthread_queue.
        """
        self.mainthread_queue = ActionQueue()
        self.execute_every(1, self.mainthread_queue.parse_action)

        self.websocket_manager = WebSocketManager(self)

        try:
            if self.config['twitchapi']['update_subscribers'] == '1':
                self.execute_every(30 * 60, self.action_queue.add,
                                   (self.update_subscribers_stage1, ))
        except:
            pass

        # XXX: TEMPORARY UGLY CODE
        HandlerManager.add_handler('on_user_gain_tokens',
                                   self.on_user_gain_tokens)
        HandlerManager.add_handler('send_whisper', self.whisper)

    def on_connect(self, sock):
        return self.irc.on_connect(sock)

    def on_user_gain_tokens(self, user, tokens_gained):
        self.whisper(
            user.username,
            'You finished todays quest! You have been awarded with {} tokens.'.
            format(tokens_gained))

    def update_subscribers_stage1(self):
        limit = 100
        offset = 0
        subscribers = []
        log.info('Starting stage1 subscribers update')

        try:
            retry_same = 0
            while True:
                log.debug('Beginning sub request {0} {1}'.format(
                    limit, offset))
                subs, retry_same, error = self.twitchapi.get_subscribers(
                    self.streamer, limit, offset,
                    0 if retry_same is False else retry_same)
                log.debug('got em!')

                if error is True:
                    log.error('Too many attempts, aborting')
                    return False

                if retry_same is False:
                    offset += limit
                    if len(subs) == 0:
                        # We don't need to retry, and the last query finished propery
                        # Break out of the loop and start fiddling with the subs!
                        log.debug('Done!')
                        break
                    else:
                        log.debug('Fetched {0} subs'.format(len(subs)))
                        subscribers.extend(subs)

                if retry_same is not False:
                    # In case the next attempt is a retry, wait for 3 seconds
                    log.debug('waiting for 3 seconds...')
                    time.sleep(3)
                    log.debug('waited enough!')
            log.debug('Finished with the while True loop!')
        except:
            log.exception(
                'Caught an exception while trying to get subscribers')
            return

        log.info('Ended stage1 subscribers update')
        if len(subscribers) > 0:
            log.info(
                'Got some subscribers, so we are pushing them to stage 2!')
            self.mainthread_queue.add(self.update_subscribers_stage2,
                                      args=[subscribers])
            log.info('Pushed them now.')

    def update_subscribers_stage2(self, subscribers):
        self.kvi['active_subs'].set(len(subscribers) - 1)

        self.users.reset_subs()

        self.users.update_subs(subscribers)

    def start(self):
        """Start the IRC client."""
        self.reactor.process_forever()

    def get_kvi_value(self, key, extra={}):
        return self.kvi[key].get()

    def get_last_tweet(self, key, extra={}):
        return self.twitter_manager.get_last_tweet(key)

    def get_emote_tm(self, key, extra={}):
        val = self.emotes.get_emote_epm(key)
        if not val:
            return None
        return '{0:,d}'.format(val)

    def get_emote_count(self, key, extra={}):
        val = self.emotes.get_emote_count(key)
        if not val:
            return None
        return '{0:,d}'.format(val)

    def get_emote_tm_record(self, key, extra={}):
        val = self.emotes.get_emote_epmrecord(key)
        if not val:
            return None
        return '{0:,d}'.format(val)

    def get_source_value(self, key, extra={}):
        try:
            return getattr(extra['source'], key)
        except:
            log.exception('Caught exception in get_source_value')

        return None

    def get_user_value(self, key, extra={}):
        try:
            user = self.users.find(extra['argument'])
            if user:
                return getattr(user, key)
        except:
            log.exception('Caught exception in get_source_value')

        return None

    def get_command_value(self, key, extra={}):
        try:
            return getattr(extra['command'].data, key)
        except:
            log.exception('Caught exception in get_source_value')

        return None

    def get_usersource_value(self, key, extra={}):
        try:
            user = self.users.find(extra['argument'])
            if user:
                return getattr(user, key)
            else:
                return getattr(extra['source'], key)
        except:
            log.exception('Caught exception in get_source_value')

        return None

    def get_time_value(self, key, extra={}):
        try:
            tz = timezone(key)
            return datetime.datetime.now(tz).strftime(self.date_fmt)
        except:
            log.exception('Unhandled exception in get_time_value')

        return None

    def get_current_song_value(self, key, extra={}):
        if self.stream_manager.online:
            current_song = PleblistManager.get_current_song(
                self.stream_manager.current_stream.id)
            inner_keys = key.split('.')
            val = current_song
            for inner_key in inner_keys:
                val = getattr(val, inner_key, None)
                if val is None:
                    return None
            if val is not None:
                return val
        return None

    def get_strictargs_value(self, key, extra={}):
        ret = self.get_args_value(key, extra)

        if len(ret) == 0:
            return None
        return ret

    def get_args_value(self, key, extra={}):
        range = None
        try:
            msg_parts = extra['message'].split(' ')
        except (KeyError, AttributeError):
            msg_parts = ['']

        try:
            if '-' in key:
                range_str = key.split('-')
                if len(range_str) == 2:
                    range = (int(range_str[0]), int(range_str[1]))

            if range is None:
                range = (int(key), len(msg_parts))
        except (TypeError, ValueError):
            range = (0, len(msg_parts))

        try:
            return ' '.join(msg_parts[range[0]:range[1]])
        except AttributeError:
            return ''
        except:
            log.exception('UNHANDLED ERROR IN get_args_value')
            return ''

    def get_notify_value(self, key, extra={}):
        payload = {
            'message': extra['message'] or '',
            'trigger': extra['trigger'],
            'user': extra['source'].username_raw,
        }
        self.websocket_manager.emit('notify', payload)

        return ''

    def get_value(self, key, extra={}):
        if key in extra:
            return extra[key]
        elif key in self.data:
            return self.data[key]
        elif key in self.data_cb:
            return self.data_cb[key]()

        log.warning('Unknown key passed to get_value: {0}'.format(key))
        return None

    def privmsg(self, message, channel=None, increase_message=True):
        if channel is None:
            channel = self.channel

        return self.irc.privmsg(message,
                                channel,
                                increase_message=increase_message)

    def c_uptime(self):
        return time_since(datetime.datetime.now().timestamp(),
                          self.start_time.timestamp())

    def c_current_time(self):
        return datetime.datetime.now()

    @property
    def is_online(self):
        return self.stream_manager.online

    def c_stream_status(self):
        return 'online' if self.stream_manager.online else 'offline'

    def c_status_length(self):
        if self.stream_manager.online:
            return time_since(
                time.time(),
                self.stream_manager.current_stream.stream_start.timestamp())
        else:
            if self.stream_manager.last_stream is not None:
                return time_since(
                    time.time(),
                    self.stream_manager.last_stream.stream_end.timestamp())
            else:
                return 'No recorded stream FeelsBadMan '

    def execute_at(self, at, function, arguments=()):
        self.reactor.scheduler.execute_at(at, lambda: function(*arguments))

    def execute_delayed(self, delay, function, arguments=()):
        self.reactor.scheduler.execute_after(delay,
                                             lambda: function(*arguments))

    def execute_every(self, period, function, arguments=()):
        self.reactor.scheduler.execute_every(period,
                                             lambda: function(*arguments))

    def _ban(self, username, reason=''):
        self.privmsg('.ban {0} {1}'.format(username, reason),
                     increase_message=False)

    def ban(self, username, reason=''):
        log.debug('Banning {}'.format(username))
        self._timeout(username, 30, reason)
        self.execute_delayed(1, self._ban, (username, reason))

    def ban_user(self, user, reason=''):
        self._timeout(user.username, 30, reason)
        self.execute_delayed(1, self._ban, (user.username, reason))

    def unban(self, username):
        self.privmsg('.unban {0}'.format(username), increase_message=False)

    def _timeout(self, username, duration, reason=''):
        self.privmsg('.timeout {0} {1} {2}'.format(username, duration, reason),
                     increase_message=False)

    def timeout(self, username, duration, reason=''):
        log.debug('Timing out {} for {} seconds'.format(username, duration))
        self._timeout(username, duration, reason)
        self.execute_delayed(1, self._timeout, (username, duration, reason))

    def timeout_warn(self, user, duration, reason=''):
        duration, punishment = user.timeout(
            duration, warning_module=self.module_manager['warning'])
        self.timeout(user.username, duration, reason)
        return (duration, punishment)

    def timeout_user(self, user, duration, reason=''):
        self._timeout(user.username, duration, reason)
        self.execute_delayed(1, self._timeout,
                             (user.username, duration, reason))

    def whisper(self, username, *messages, separator='. '):
        """
        Takes a sequence of strings and concatenates them with separator.
        Then sends that string as a whisper to username
        """

        if len(messages) < 0:
            return False

        message = separator.join(messages)

        return self.irc.whisper(username, message)

    def send_message_to_user(self,
                             user,
                             message,
                             separator='. ',
                             method='say'):
        if method == 'say':
            self.say(user.username + ', ' + lowercase_first_letter(message),
                     separator=separator)
        elif method == 'whisper':
            self.whisper(user.username, message, separator=separator)
        else:
            log.warning('Unknown send_message method: {}'.format(method))

    def say(self, *messages, channel=None, separator='. '):
        """
        Takes a sequence of strings and concatenates them with separator.
        Then sends that string to the given channel.
        """

        if len(messages) < 0:
            return False

        if not self.silent:
            message = separator.join(messages).strip()

            if len(message) >= 1:
                if (message[0] == '.'
                        or message[0] == '/') and not message[1:3] == 'me':
                    log.warning(
                        'Message we attempted to send started with . or /, skipping.'
                    )
                    return

                log.info('Sending message: {0}'.format(message))

                self.privmsg(message[:510], channel)

    def me(self, message, channel=None):
        if not self.silent:
            message = message.strip()

            if len(message) >= 1:
                if message[0] == '.' or message[0] == '/':
                    log.warning(
                        'Message we attempted to send started with . or /, skipping.'
                    )
                    return

                log.info('Sending message: {0}'.format(message))

                self.privmsg('.me ' + message[:500], channel)

    def parse_version(self):
        self.version = self.version

        if self.dev:
            try:
                current_branch = subprocess.check_output(
                    ['git', 'rev-parse', '--abbrev-ref',
                     'HEAD']).decode('utf8').strip()
                latest_commit = subprocess.check_output(
                    ['git', 'rev-parse', 'HEAD']).decode('utf8').strip()[:8]
                commit_number = subprocess.check_output(
                    ['git', 'rev-list', 'HEAD',
                     '--count']).decode('utf8').strip()
                self.version = '{0} DEV ({1}, {2}, commit {3})'.format(
                    self.version, current_branch, latest_commit, commit_number)
            except:
                pass

    def on_welcome(self, chatconn, event):
        return self.irc.on_welcome(chatconn, event)

    def connect(self):
        return self.irc.start()

    def on_disconnect(self, chatconn, event):
        self.irc.on_disconnect(chatconn, event)

    def parse_message(self, msg_raw, source, event, tags={}, whisper=False):
        msg_lower = msg_raw.lower()

        emote_tag = None

        for tag in tags:
            if tag['key'] == 'subscriber' and event.target == self.channel:
                source.subscriber = tag['value'] == '1'
            elif tag['key'] == 'emotes' and tag['value']:
                emote_tag = tag['value']
            elif tag['key'] == 'display-name' and tag['value']:
                source.username_raw = tag['value']
            elif tag['key'] == 'user-type':
                source.moderator = tag[
                    'value'] == 'mod' or source.username == self.streamer

        # source.num_lines += 1

        if source is None:
            log.error('No valid user passed to parse_message')
            return False

        if source.banned:
            self.ban(source.username)
            return False

        # If a user types when timed out, we assume he's been unbanned for a good reason and remove his flag.
        if source.timed_out is True:
            source.timed_out = False

        # Parse emotes in the message
        message_emotes = self.emotes.parse_message_twitch_emotes(
            source, msg_raw, emote_tag, whisper)

        urls = self.find_unique_urls(msg_raw)

        log.debug('{2}{0}: {1}'.format(source.username, msg_raw,
                                       '<w>' if whisper else ''))

        res = HandlerManager.trigger('on_message',
                                     source,
                                     msg_raw,
                                     message_emotes,
                                     whisper,
                                     urls,
                                     event,
                                     stop_on_false=True)
        if res is False:
            return False

        source.last_seen = datetime.datetime.now()
        source.last_active = datetime.datetime.now()
        """
        if self.streamer == 'forsenlol' and whisper is False:
            follow_time = self.twitchapi.get_follow_relationship2(source.username, self.streamer)

            if follow_time is False:
                self._timeout(source.username, 600, '2 years follow mode (or api is down?)')
                return

            follow_age = datetime.datetime.now() - follow_time
            log.debug(follow_age)

            if follow_age.days < 730:
                log.debug('followed less than 730 days LUL')
                self._timeout(source.username, 600, '2 years follow mode')
                """

        if source.ignored:
            return False

        if msg_lower[:1] == '!':
            msg_lower_parts = msg_lower.split(' ')
            trigger = msg_lower_parts[0][1:]
            msg_raw_parts = msg_raw.split(' ')
            remaining_message = ' '.join(
                msg_raw_parts[1:]) if len(msg_raw_parts) > 1 else None
            if trigger in self.commands:
                command = self.commands[trigger]
                extra_args = {
                    'emotes': message_emotes,
                    'trigger': trigger,
                }
                command.run(self,
                            source,
                            remaining_message,
                            event=event,
                            args=extra_args,
                            whisper=whisper)

    @time_method
    def redis_test(self, username):
        try:
            pipeline = RedisManager.get().pipeline()
            pipeline.hget('pajlada:users:points', username)
            pipeline.hget('pajlada:users:ignored', username)
            pipeline.hget('pajlada:users:banned', username)
            pipeline.hget('pajlada:users:last_active', username)
        except:
            pipeline.reset()
        finally:
            b = pipeline.execute()
            log.info(b)

    @time_method
    def redis_test2(self, username):
        redis = RedisManager.get()
        values = [
            redis.hget('pajlada:users:points', username),
            redis.hget('pajlada:users:ignored', username),
            redis.hget('pajlada:users:banned', username),
            redis.hget('pajlada:users:last_active', username),
        ]
        return values

    def on_whisper(self, chatconn, event):
        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        username = event.source.user.lower()

        with self.users.get_user_context(username) as source:
            self.parse_message(event.arguments[0],
                               source,
                               event,
                               whisper=True,
                               tags=event.tags)

    def on_ping(self, chatconn, event):
        # self.say('Received a ping. Last ping received {} ago'.format(time_since(datetime.datetime.now().timestamp(), self.last_ping.timestamp())))
        log.info('Received a ping. Last ping received {} ago'.format(
            time_since(datetime.datetime.now().timestamp(),
                       self.last_ping.timestamp())))
        self.last_ping = datetime.datetime.now()

    def on_pong(self, chatconn, event):
        # self.say('Received a pong. Last pong received {} ago'.format(time_since(datetime.datetime.now().timestamp(), self.last_pong.timestamp())))
        log.info('Received a pong. Last pong received {} ago'.format(
            time_since(datetime.datetime.now().timestamp(),
                       self.last_pong.timestamp())))
        self.last_pong = datetime.datetime.now()

    def on_pubnotice(self, chatconn, event):
        return
        type = 'whisper' if chatconn in self.whisper_manager else 'normal'
        log.debug('NOTICE {}@{}: {}'.format(type, event.target,
                                            event.arguments))

    def on_usernotice(self, chatconn, event):
        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        tags = {}
        for d in event.tags:
            tags[d['key']] = d['value']

        if 'login' not in tags:
            return

        username = tags['login']

        with self.users.get_user_context(username) as source:
            msg = ''
            if len(event.arguments) > 0:
                msg = event.arguments[0]
            HandlerManager.trigger('on_usernotice', source, msg, tags)

    def on_action(self, chatconn, event):
        self.on_pubmsg(chatconn, event)

    def on_pubmsg(self, chatconn, event):
        if event.source.user == self.nickname:
            return False

        with MPTMetric(self.cachet_manager):
            username = event.source.user.lower()

            # We use .lower() in case twitch ever starts sending non-lowercased usernames
            with self.users.get_user_context(username) as source:
                res = HandlerManager.trigger('on_pubmsg',
                                             source,
                                             event.arguments[0],
                                             stop_on_false=True)
                if res is False:
                    return False

                self.parse_message(event.arguments[0],
                                   source,
                                   event,
                                   tags=event.tags)

    @time_method
    def reload_all(self):
        log.info('Reloading all...')
        for key, manager in self.reloadable.items():
            log.debug('Reloading {0}'.format(key))
            manager.reload()
            log.debug('Done with {0}'.format(key))
        log.info('ok!')

    @time_method
    def commit_all(self):
        log.info('Commiting all...')
        for key, manager in self.commitable.items():
            log.info('Commiting {0}'.format(key))
            manager.commit()
            log.info('Done with {0}'.format(key))
        log.info('ok!')

        HandlerManager.trigger('on_commit', stop_on_false=False)

    def quit(self, message, event, **options):
        quit_chub = self.config['main'].get('control_hub', None)
        quit_delay = 0

        if quit_chub is not None and event.target == ('#{}'.format(quit_chub)):
            quit_delay_random = 300
            try:
                if message is not None and int(message.split()[0]) >= 1:
                    quit_delay_random = int(message.split()[0])
            except (IndexError, ValueError, TypeError):
                pass
            quit_delay = random.randint(0, quit_delay_random)
            log.info('{} is restarting in {} seconds.'.format(
                self.nickname, quit_delay))

        self.execute_delayed(quit_delay, self.quit_bot)

    def quit_bot(self, **options):
        self.commit_all()
        quit = '{nickname} {version} shutting down...'
        phrase_data = {
            'nickname': self.nickname,
            'version': self.version,
        }

        try:
            ScheduleManager.base_scheduler.print_jobs()
            ScheduleManager.base_scheduler.shutdown(wait=False)
        except:
            log.exception('Error while shutting down the apscheduler')

        try:
            self.say(quit.format(**phrase_data))
        except Exception:
            log.exception('Exception caught while trying to say quit phrase')

        self.twitter_manager.quit()
        self.socket_manager.quit()
        self.irc.quit()

        sys.exit(0)

    def apply_filter(self, resp, filter):
        available_filters = {
            'strftime':
            _filter_strftime,
            'lower':
            lambda var, args: var.lower(),
            'upper':
            lambda var, args: var.upper(),
            'time_since_minutes':
            lambda var, args: 'no time'
            if var == 0 else time_since(var * 60, 0, format='long'),
            'time_since':
            lambda var, args: 'no time'
            if var == 0 else time_since(var, 0, format='long'),
            'time_since_dt':
            _filter_time_since_dt,
            'urlencode':
            _filter_urlencode,
            'join':
            _filter_join,
            'number_format':
            _filter_number_format,
        }
        if filter.name in available_filters:
            return available_filters[filter.name](resp, filter.arguments)
        return resp

    def find_unique_urls(self, message):
        from pajbot.modules.linkchecker import find_unique_urls
        return find_unique_urls(self.url_regex, message)
Example #4
0
class Bot:
    """
    Main class for the twitch bot
    """

    def __init__(self, config, args):
        self.config = config
        self.args = args

        ScheduleManager.init()

        DBManager.init(self.config["main"]["db"])

        # redis
        redis_options = {}
        if "redis" in config:
            redis_options = dict(config.items("redis"))
        RedisManager.init(**redis_options)
        utils.wait_for_redis_data_loaded(RedisManager.get())

        self.nickname = config["main"].get("nickname", "pajbot")

        if config["main"].getboolean("verified", False):
            self.tmi_rate_limits = TMIRateLimits.VERIFIED
        elif config["main"].getboolean("known", False):
            self.tmi_rate_limits = TMIRateLimits.KNOWN
        else:
            self.tmi_rate_limits = TMIRateLimits.BASE

        self.whisper_output_mode = WhisperOutputMode.from_config_value(
            config["main"].get("whisper_output_mode", "normal")
        )

        # phrases
        self.phrases = {
            "welcome": ["{nickname} {version} running! HeyGuys"],
            "quit": ["{nickname} {version} shutting down... BibleThump"],
        }
        if "phrases" in config:
            phrases = config["phrases"]
            if "welcome" in phrases:
                self.phrases["welcome"] = phrases["welcome"].splitlines()
            if "quit" in phrases:
                self.phrases["quit"] = phrases["quit"].splitlines()
        # Remembers whether the "welcome" phrases have already been said. We don't want to send the
        # welcome messages to chat again on a reconnect.
        self.welcome_messages_sent = False

        # streamer
        if "streamer" in config["main"]:
            self.streamer: str = config["main"]["streamer"]
            self.channel = "#" + self.streamer
        elif "target" in config["main"]:
            self.channel = config["main"]["target"]
            self.streamer: str = self.channel[1:]

        self.bot_domain = self.config["web"]["domain"]
        self.streamer_display = self.config["web"]["streamer_name"]

        log.debug("Loaded config")

        # do this earlier since schema upgrade can depend on the helix api
        self.api_client_credentials = ClientCredentials(
            self.config["twitchapi"]["client_id"],
            self.config["twitchapi"]["client_secret"],
            self.config["twitchapi"]["redirect_uri"],
        )

        self.twitch_id_api = TwitchIDAPI(self.api_client_credentials)
        self.twitch_tmi_api = TwitchTMIAPI()
        self.app_token_manager = AppAccessTokenManager(self.twitch_id_api, RedisManager.get())
        self.twitch_helix_api: TwitchHelixAPI = TwitchHelixAPI(RedisManager.get(), self.app_token_manager)
        self.twitch_v5_api = TwitchKrakenV5API(self.api_client_credentials, RedisManager.get())

        self.bot_user_id = self.twitch_helix_api.get_user_id(self.nickname)
        if self.bot_user_id is None:
            raise ValueError("The bot login name you entered under [main] does not exist on twitch.")

        self.broadcaster = self.twitch_helix_api.get_user_basics_by_login(self.streamer)
        if self.broadcaster is None:
            raise ValueError("The streamer login name you entered under [main] does not exist on twitch.")

        self.streamer_user_id = self.broadcaster.id

        self.streamer_access_token_manager = UserAccessTokenManager(
            api=self.twitch_id_api, redis=RedisManager.get(), username=self.streamer, user_id=self.streamer_user_id
        )

        StreamHelper.init_streamer(self.streamer, self.streamer_user_id, self.streamer_display)

        # SQL migrations
        with DBManager.create_dbapi_connection_scope() as sql_conn:
            sql_migratable = DatabaseMigratable(sql_conn)
            sql_migration = Migration(sql_migratable, pajbot.migration_revisions.db, self)
            sql_migration.run()

        # Redis migrations
        redis_migratable = RedisMigratable(redis_options=redis_options, namespace=self.streamer)
        redis_migration = Migration(redis_migratable, pajbot.migration_revisions.redis, self)
        redis_migration.run()

        # Thread pool executor for async actions
        self.action_queue = ActionQueue()

        # refresh points_rank and num_lines_rank regularly
        UserRanksRefreshManager.start(self.action_queue)

        self.reactor = irc.client.Reactor()
        # SafeDefaultScheduler makes the bot not exit on exception in the main thread
        # e.g. on actions via bot.execute_now, etc.
        self.reactor.scheduler_class = SafeDefaultScheduler
        self.reactor.scheduler = SafeDefaultScheduler()

        self.start_time = utils.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self.streamer, self.execute_now)
        self.stream_manager = StreamManager(self)
        StreamHelper.init_stream_manager(self.stream_manager)

        self.decks = DeckManager()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()

        # bot access token
        if "password" in self.config["main"]:
            log.warning(
                "DEPRECATED - Using bot password/oauth token from file. "
                "You should authenticate in web gui using route /bot_login "
                "and remove password from config file"
            )

            access_token = self.config["main"]["password"]

            if access_token.startswith("oauth:"):
                access_token = access_token[6:]

            self.bot_token_manager = UserAccessTokenManager(
                api=None,
                redis=None,
                username=self.nickname,
                user_id=self.bot_user_id,
                token=UserAccessToken.from_implicit_auth_flow_token(access_token),
            )
        else:
            self.bot_token_manager = UserAccessTokenManager(
                api=self.twitch_id_api, redis=RedisManager.get(), username=self.nickname, user_id=self.bot_user_id
            )

        self.emote_manager = EmoteManager(self.twitch_v5_api, self.action_queue)
        self.epm_manager = EpmManager()
        self.ecount_manager = EcountManager()
        if "twitter" in self.config and self.config["twitter"].get("streaming_type", "twitter") == "tweet-provider":
            self.twitter_manager = PBTwitterManager(self)
        else:
            self.twitter_manager = TwitterManager(self)
        self.module_manager = ModuleManager(self.socket_manager, bot=self).load()
        self.commands = CommandManager(
            socket_manager=self.socket_manager, module_manager=self.module_manager, bot=self
        ).load()
        self.websocket_manager = WebSocketManager(self)

        HandlerManager.trigger("on_managers_loaded")

        # Commitable managers
        self.commitable = {"commands": self.commands, "banphrases": self.banphrase_manager}

        self.execute_every(60, self.commit_all)
        self.execute_every(1, self.do_tick)

        # promote the admin to level 2000
        self.admin = self.config["main"].get("admin", None)
        if self.admin is None:
            log.warning("No admin user specified. See the [main] section in the example config for its usage.")
        else:
            with DBManager.create_session_scope() as db_session:
                admin_user = User.find_or_create_from_login(db_session, self.twitch_helix_api, self.admin)
                if admin_user is None:
                    log.warning(
                        "The login name you entered for the admin user does not exist on twitch. "
                        "No admin user has been created."
                    )
                else:
                    admin_user.level = 2000

        # silent mode
        self.silent = (
            "flags" in config and "silent" in config["flags"] and config["flags"]["silent"] == "1"
        ) or args.silent
        if self.silent:
            log.info("Silent mode enabled")

        # dev mode
        self.dev = "flags" in config and "dev" in config["flags"] and config["flags"]["dev"] == "1"
        if self.dev:
            self.version_long = utils.extend_version_if_possible(VERSION)
        else:
            self.version_long = VERSION

        self.irc = IRCManager(self)

        relay_host = self.config["main"].get("relay_host", None)
        relay_password = self.config["main"].get("relay_password", None)
        if relay_host is not None or relay_password is not None:
            log.warning(
                "DEPRECATED - Relaybroker support is no longer implemented. relay_host and relay_password are ignored"
            )

        self.data = {
            "broadcaster": self.streamer,
            "version": self.version_long,
            "version_brief": VERSION,
            "bot_name": self.nickname,
            "bot_domain": self.bot_domain,
            "streamer_display": self.streamer_display,
        }

        self.data_cb = {
            "status_length": self.c_status_length,
            "stream_status": self.c_stream_status,
            "bot_uptime": self.c_uptime,
            "current_time": self.c_current_time,
            "molly_age_in_years": self.c_molly_age_in_years,
        }

        self.user_agent = f"pajbot1/{VERSION} ({self.nickname})"

        self.thread_locals = threading.local()

    @property
    def password(self):
        return f"oauth:{self.bot_token_manager.token.access_token}"

    def start(self):
        """Start the IRC client."""
        self.reactor.process_forever()

    def get_kvi_value(self, key, extra={}):
        return self.kvi[key].get()

    def get_last_tweet(self, key, extra={}):
        return self.twitter_manager.get_last_tweet(key)

    def get_emote_epm(self, key, extra={}):
        epm = self.epm_manager.get_emote_epm(key)

        # maybe we simply haven't seen this emote yet (during the bot runtime) but it's a valid emote?
        if epm is None and self.emote_manager.match_word_to_emote(key) is not None:
            epm = 0

        if epm is None:
            return None

        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return f"{epm:,.0f}"

    def get_emote_epm_record(self, key, extra={}):
        val = self.epm_manager.get_emote_epm_record(key)
        if val is None:
            return None
        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return f"{val:,.0f}"

    def get_emote_count(self, key, extra={}):
        val = self.ecount_manager.get_emote_count(key)
        if val is None:
            return None
        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return f"{val:,.0f}"

    @staticmethod
    def get_source_value(key, extra={}):
        try:
            return getattr(extra["source"], key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_user_value(self, key, extra={}):
        try:
            with DBManager.create_session_scope() as db_session:
                user = User.find_by_user_input(db_session, extra["argument"])
                if user is not None:
                    return getattr(user, key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    @staticmethod
    def get_command_value(key, extra={}):
        try:
            return getattr(extra["command"].data, key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_broadcaster_value(self, key, extra={}):
        try:
            return getattr(self.broadcaster, key)
        except:
            log.exception("Caught exception in get_broadcaster_value")

        return None

    def get_usersource_value(self, key, extra={}):
        try:
            with DBManager.create_session_scope() as db_session:
                user = User.find_by_user_input(db_session, extra["argument"])
                if user is not None:
                    return getattr(user, key)

            return getattr(extra["source"], key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_time_value(self, key, extra={}):
        try:
            tz = timezone(key)
            return datetime.datetime.now(tz).strftime("%H:%M")
        except:
            log.exception("Unhandled exception in get_time_value")

        return None

    def get_date_value(self, key, extra={}):
        try:
            tz = timezone(key)
            return datetime.datetime.now(tz).strftime("%Y-%m-%d")
        except:
            log.exception("Unhandled exception in get_date_value")

    def get_datetimefromisoformat_value(self, key, extra={}):
        try:
            dt = datetime.datetime.fromisoformat(key)
            if dt.tzinfo is None:
                # The date format passed through in key did not contain a timezone, so we replace it with UTC
                dt = dt.replace(tzinfo=datetime.timezone.utc)

            return dt
        except:
            log.exception("Unhandled exception in get_datetimefromisoformat_value")

    def get_current_song_value(self, key, extra={}):
        if self.stream_manager.online:
            current_song = PleblistManager.get_current_song(self.stream_manager.current_stream.id)
            inner_keys = key.split(".")
            val = current_song
            for inner_key in inner_keys:
                val = getattr(val, inner_key, None)
                if val is None:
                    return None
            if val is not None:
                return val
        return None

    def get_strictargs_value(self, key, extra={}):
        ret = self.get_args_value(key, extra)

        if not ret:
            return None

        return ret

    @staticmethod
    def get_args_value(key, extra={}):
        r = None
        try:
            msg_parts = extra["message"].split(" ")
        except (KeyError, AttributeError):
            msg_parts = [""]

        try:
            if "-" in key:
                range_str = key.split("-")
                if len(range_str) == 2:
                    r = (int(range_str[0]), int(range_str[1]))

            if r is None:
                r = (int(key), len(msg_parts))
        except (TypeError, ValueError):
            r = (0, len(msg_parts))

        try:
            return " ".join(msg_parts[r[0] : r[1]])
        except AttributeError:
            return ""
        except:
            log.exception("UNHANDLED ERROR IN get_args_value")
            return ""

    def get_value(self, key, extra={}):
        if key in extra:
            return extra[key]

        if key in self.data:
            return self.data[key]

        if key in self.data_cb:
            return self.data_cb[key]()

        log.warning("Unknown key passed to get_value: %s", key)
        return None

    def privmsg_arr(self, arr, target=None):
        for msg in arr:
            self.privmsg(msg, target)

    def privmsg_arr_chunked(self, arr, per_chunk=35, chunk_delay=30, target=None):
        i = 0
        while arr:
            if i == 0:
                self.privmsg_arr(arr[:per_chunk], target)
            else:
                self.execute_delayed(chunk_delay * i, self.privmsg_arr, arr[:per_chunk], target)

            del arr[:per_chunk]

            i = i + 1

    def privmsg_from_file(self, url, per_chunk=35, chunk_delay=30, target=None):
        try:
            r = requests.get(url, headers={"User-Agent": self.user_agent})
            r.raise_for_status()

            content_type = r.headers["Content-Type"]
            if content_type is not None and cgi.parse_header(content_type)[0] != "text/plain":
                log.error("privmsg_from_file should be fed with a text/plain URL. Refusing to send.")
                return

            lines = r.text.splitlines()
            self.privmsg_arr_chunked(lines, per_chunk=per_chunk, chunk_delay=chunk_delay, target=target)
        except:
            log.exception("error in privmsg_from_file")

    # event is an event to clone and change the text from.
    # Usage: !eval bot.eval_from_file(event, 'https://pastebin.com/raw/LhCt8FLh')
    def eval_from_file(self, event, url):
        try:
            r = requests.get(url, headers={"User-Agent": self.user_agent})
            r.raise_for_status()

            content_type = r.headers["Content-Type"]
            if content_type is not None and cgi.parse_header(content_type)[0] != "text/plain":
                log.error("eval_from_file should be fed with a text/plain URL. Refusing to send.")
                return

            lines = r.text.splitlines()
            import copy

            for msg in lines:
                cloned_event = copy.deepcopy(event)
                cloned_event.arguments = [msg]
                # omit the source connection as None (since its not used)
                self.on_pubmsg(None, cloned_event)
            self.whisper_login(event.source.user.lower(), f"Successfully evaluated {len(lines)} lines")
        except:
            log.exception("BabyRage")
            self.whisper_login(event.source.user.lower(), "Exception BabyRage")

    def privmsg(self, message, channel=None):
        if channel is None:
            channel = self.channel

        self.irc.privmsg(channel, message, is_whisper=False)

    def c_uptime(self):
        return utils.time_ago(self.start_time)

    @staticmethod
    def c_current_time():
        return utils.now()

    @staticmethod
    def c_molly_age_in_years():
        molly_birth = datetime.datetime(2018, 10, 29, tzinfo=datetime.timezone.utc)
        now = utils.now()
        diff = now - molly_birth
        return diff.total_seconds() / 3600 / 24 / 365

    def get_datetime_value(self, key, extra=[]):
        try:
            tz = timezone(key)
            return datetime.datetime.now(tz)
        except:
            log.exception("Unhandled exception in get_datetime_value")

        return None

    @property
    def is_online(self):
        return self.stream_manager.online

    def c_stream_status(self):
        return "online" if self.stream_manager.online else "offline"

    def c_status_length(self):
        if self.stream_manager.online:
            return utils.time_ago(self.stream_manager.current_stream.stream_start)

        if self.stream_manager.last_stream is not None:
            return utils.time_ago(self.stream_manager.last_stream.stream_end)

        return "No recorded stream FeelsBadMan "

    def execute_now(self, function, *args, **kwargs):
        self.execute_delayed(0, function, *args, **kwargs)

    def execute_at(self, at, function, *args, **kwargs):
        self.reactor.scheduler.execute_at(at, lambda: function(*args, **kwargs))

    def execute_delayed(self, delay, function, *args, **kwargs):
        self.reactor.scheduler.execute_after(delay, lambda: function(*args, **kwargs))

    def execute_every(self, period, function, *args, **kwargs):
        self.reactor.scheduler.execute_every(period, lambda: function(*args, **kwargs))

    def _ban(self, login, reason=None):
        message = f"/ban {login}"
        if reason is not None:
            message += f" {reason}"
        self.privmsg(message)

    def ban(self, user, reason=None):
        self.ban_login(user.login, reason)

    def ban_login(self, login: str, reason=None):
        if self.thread_locals.moderation_actions is not None:
            self.thread_locals.moderation_actions.add(login, Ban(reason))
        else:
            self.timeout_login(login, 30, reason, once=True)
            self.execute_delayed(1, self._ban, login, reason)

    def unban(self, user):
        self.unban_login(user.login)

    def unban_login(self, login: str):
        if self.thread_locals.moderation_actions is not None:
            self.thread_locals.moderation_actions.add(login, Unban())
        else:
            self.privmsg(f"/unban {login}")

    def untimeout(self, user):
        self.untimeout_login(user.login)

    def untimeout_login(self, login: str):
        if self.thread_locals.moderation_actions is not None:
            self.thread_locals.moderation_actions.add(login, Untimeout())
        else:
            self.privmsg(f"/untimeout {login}")

    def _timeout(self, login: str, duration: int, reason=None):
        message = f"/timeout {login} {duration}"
        if reason is not None:
            message += f" {reason}"
        self.privmsg(message)

    def timeout(self, user, duration, reason=None, once=False):
        self.timeout_login(user.login, duration, reason, once)

    def timeout_login(self, login: str, duration: int, reason=None, once=False):
        if self.thread_locals.moderation_actions is not None:
            self.thread_locals.moderation_actions.add(login, Timeout(duration, reason, once))
        else:
            self._timeout(login, duration, reason)
            if not once:
                self.execute_delayed(1, self._timeout, login, duration, reason)

    def timeout_warn(self, user, duration: int, reason=None):
        duration, punishment = user.timeout(duration, warning_module=self.module_manager["warning"])
        self.timeout(user, duration, reason)
        return (duration, punishment)

    def delete_message(self, msg_id):
        self.privmsg(f"/delete {msg_id}")

    def whisper(self, user, message):
        if self.whisper_output_mode == WhisperOutputMode.NORMAL:
            self.irc.whisper(user.login, message)
        if self.whisper_output_mode == WhisperOutputMode.CHAT:
            self.privmsg(f"{user}, {message}")
        elif self.whisper_output_mode == WhisperOutputMode.DISABLED:
            log.debug(f'Whisper "{message}" to user "{user}" was not sent (due to config setting)')

    def whisper_login(self, login, message):
        if self.whisper_output_mode == WhisperOutputMode.NORMAL:
            self.irc.whisper(login, message)
        if self.whisper_output_mode == WhisperOutputMode.CHAT:
            self.privmsg(f"{login}, {message}")
        elif self.whisper_output_mode == WhisperOutputMode.DISABLED:
            log.debug(f'Whisper "{message}" to user "{login}" was not sent (due to config setting)')

    def send_message_to_user(self, user, message, event, method="say"):
        if method == "say":
            self.say(user.name + ", " + lowercase_first_letter(message))
        elif method == "whisper":
            self.whisper(user, message)
        elif method == "me":
            self.me(message)
        elif method == "reply":
            if event.type in ["action", "pubmsg"]:
                self.say(message)
            elif event.type == "whisper":
                self.whisper(user, message)
        else:
            log.warning("Unknown send_message method: %s", method)

    def say(self, message, channel=None):
        if message is None:
            log.warning("message=None passed to Bot::say()")
            return

        if self.silent:
            return

        message = utils.clean_up_message(message)
        self.privmsg(message[:510], channel)

    def is_bad_message(self, message):
        # Checks for banphrases
        return self.banphrase_manager.check_message(message, None) is not False

    def safe_privmsg(self, message, channel=None):
        if not self.is_bad_message(message):
            self.privmsg(message, channel)

    def safe_me(self, message, channel=None):
        if not self.is_bad_message(message):
            self.me(message, channel)

    def safe_say(self, message, channel=None):
        if not self.is_bad_message(message):
            self.say(message, channel)

    def me(self, message, channel=None):
        self.say("/me " + message[:500], channel=channel)

    def connect(self):
        self.irc.start()

    def parse_message(self, message, source, event, tags={}, whisper=False):
        msg_lower = message.lower()

        emote_tag = tags["emotes"]
        msg_id = tags.get("id", None)  # None on whispers!
        badges_string = tags.get("badges", "")
        badges = dict((badge.split("/") for badge in badges_string.split(",") if badge != ""))

        if not whisper and event.target == self.channel:
            # Moderator or broadcaster, both count
            source.moderator = tags["mod"] == "1" or source.id == self.streamer_user_id
            # Having the founder badge means that the subscriber tag is set to 0. Therefore it's more stable to just check badges
            source.subscriber = "founder" in badges or "subscriber" in badges
            # once they are a founder they are always be a founder, regardless if they are a sub or not.
            if not source.founder:
                source.founder = "founder" in badges
            source.vip = "vip" in badges

        if not whisper and source.banned:
            self.ban(
                source,
                reason=f"User is on the {self.nickname} banlist. Contact a moderator level 1000 or higher for unban.",
            )
            return False

        # Parse emotes in the message
        emote_instances, emote_counts = self.emote_manager.parse_all_emotes(message, emote_tag)

        now = utils.now()
        source.last_seen = now
        source.last_active = now

        if not whisper:
            # increment epm and ecount
            self.epm_manager.handle_emotes(emote_counts)
            self.ecount_manager.handle_emotes(emote_counts)

        urls = self.find_unique_urls(message)

        res = HandlerManager.trigger(
            "on_message",
            source=source,
            message=message,
            emote_instances=emote_instances,
            emote_counts=emote_counts,
            whisper=whisper,
            urls=urls,
            msg_id=msg_id,
            event=event,
        )
        if res is False:
            return False

        if source.ignored:
            return False

        if msg_lower[:1] == "!":
            msg_lower_parts = msg_lower.split(" ")
            trigger = msg_lower_parts[0][1:]
            msg_raw_parts = message.split(" ")
            remaining_message = " ".join(msg_raw_parts[1:]) if len(msg_raw_parts) > 1 else None
            if trigger in self.commands:
                command = self.commands[trigger]
                extra_args = {
                    "emote_instances": emote_instances,
                    "emote_counts": emote_counts,
                    "trigger": trigger,
                    "msg_id": msg_id,
                }
                command.run(self, source, remaining_message, event=event, args=extra_args, whisper=whisper)

    def on_whisper(self, chatconn, event):
        tags = {tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags}

        id = tags["user-id"]
        login = event.source.user
        name = tags["display-name"]

        with DBManager.create_session_scope(expire_on_commit=False) as db_session:
            source = User.from_basics(db_session, UserBasics(id, login, name))
            self.parse_message(event.arguments[0], source, event, tags, whisper=True)

    def on_usernotice(self, chatconn, event):
        tags = {tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags}

        id = tags["user-id"]
        login = tags["login"]
        name = tags["display-name"]

        with DBManager.create_session_scope(expire_on_commit=False) as db_session:
            source = User.from_basics(db_session, UserBasics(id, login, name))
            if event.arguments and len(event.arguments) > 0:
                msg = event.arguments[0]
            else:
                msg = None  # e.g. user didn't type an extra message to share with the streamer

            with new_message_processing_scope(self):
                HandlerManager.trigger("on_usernotice", source=source, message=msg, tags=tags)

                if msg is not None:
                    self.parse_message(msg, source, event, tags)

    def on_action(self, chatconn, event):
        self.on_pubmsg(chatconn, event)

    def on_pubmsg(self, chatconn, event):
        tags = {tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags}

        id = tags["user-id"]
        login = event.source.user
        name = tags["display-name"]

        if event.source.user == self.nickname:
            return False

        if self.streamer == "forsen":
            if "zonothene" in login:
                self._ban(login)
                return True

            raw_m = event.arguments[0].lower()
            if raw_m.startswith("!lastseen forsen"):
                if len(raw_m) > len("!lastseen forsen2"):
                    if raw_m[16] == " ":
                        return True
                else:
                    return True

            if raw_m.startswith("!lastseen @forsen"):
                if len(raw_m) > len("!lastseen @forsen2"):
                    if raw_m[17] == " ":
                        return True
                else:
                    return True

        if self.streamer == "nymn":
            if "hades_k" in login:
                self.timeout_login(login, 3600, reason="Bad username")
                return True

            if "hades_b" in login:
                self.timeout_login(login, 3600, reason="Bad username")
                return True

        with DBManager.create_session_scope(expire_on_commit=False) as db_session:
            source = User.from_basics(db_session, UserBasics(id, login, name))

            with new_message_processing_scope(self):
                res = HandlerManager.trigger("on_pubmsg", source=source, message=event.arguments[0], tags=tags)
                if res is False:
                    return False

                self.parse_message(event.arguments[0], source, event, tags=tags)

    def on_pubnotice(self, chatconn, event):
        tags = {tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags}
        HandlerManager.trigger(
            "on_pubnotice",
            stop_on_false=False,
            channel=event.target[1:],
            msg_id=tags["msg-id"],
            message=event.arguments[0],
        )

    def on_clearchat(self, chatconn, event):
        tags = {tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags}

        # Ignore "Chat has been cleared by a moderator" messages
        if "target-user-id" not in tags:
            return

        target_user_id = tags["target-user-id"]
        with DBManager.create_session_scope() as db_session:
            user = User.find_by_id(db_session, target_user_id)

            if user is None:
                # User is not otherwise known, we won't store their timeout (they need to type first)
                # We could theoretically also do an API call here to figure out everything about that user,
                # but that could easily overwhelm the bot when lots of unknown users are banned quickly (e.g. bots).
                return

            if "ban-duration" in tags:
                # timeout
                ban_duration = int(tags["ban-duration"])
                user.timeout_end = utils.now() + datetime.timedelta(seconds=ban_duration)
            else:
                # permaban
                # this sets timeout_end to None
                user.timed_out = False

    def on_welcome(self, _conn, _event):
        """Gets triggered on IRC welcome, i.e. when the login is successful."""
        if self.welcome_messages_sent:
            return

        for p in self.phrases["welcome"]:
            self.privmsg(p.format(nickname=self.nickname, version=self.version_long))

        self.welcome_messages_sent = True

    def commit_all(self):
        for key, manager in self.commitable.items():
            manager.commit()

        HandlerManager.trigger("on_commit", stop_on_false=False)

    @staticmethod
    def do_tick():
        HandlerManager.trigger("on_tick")

    def quit(self, message, event, **options):
        quit_chub = self.config["main"].get("control_hub", None)
        quit_delay = 0

        if quit_chub is not None and event.target == f"#{quit_chub}":
            quit_delay_random = 300
            try:
                if message is not None and int(message.split()[0]) >= 1:
                    quit_delay_random = int(message.split()[0])
            except (IndexError, ValueError, TypeError):
                pass
            quit_delay = random.randint(0, quit_delay_random)
            log.info("%s is restarting in %d seconds.", self.nickname, quit_delay)

        self.execute_delayed(quit_delay, self.quit_bot)

    def quit_bot(self, **options):
        self.commit_all()
        HandlerManager.trigger("on_quit")
        phrase_data = {"nickname": self.nickname, "version": self.version_long}

        try:
            ScheduleManager.base_scheduler.print_jobs()
            ScheduleManager.base_scheduler.shutdown(wait=False)
        except:
            log.exception("Error while shutting down the apscheduler")

        try:
            for p in self.phrases["quit"]:
                self.privmsg(p.format(**phrase_data))
        except Exception:
            log.exception("Exception caught while trying to say quit phrase")

        self.twitter_manager.quit()
        self.socket_manager.quit()

        sys.exit(0)

    def apply_filter(self, resp, f: SubstitutionFilter) -> Any:
        available_filters: dict[str, Callable[[Any, List[str]], Any]] = {
            "strftime": _filter_strftime,
            "lower": lambda var, args: var.lower(),
            "upper": lambda var, args: var.upper(),
            "title": lambda var, args: var.title(),
            "capitalize": lambda var, args: var.capitalize(),
            "swapcase": lambda var, args: var.swapcase(),
            "time_since_minutes": lambda var, args: "no time"
            if var == 0
            else utils.time_since(var * 60, 0, time_format="long"),
            "time_since": lambda var, args: "no time" if var == 0 else utils.time_since(var, 0, time_format="long"),
            "time_since_dt": _filter_time_since_dt,
            "timedelta_days": _filter_timedelta_days,
            "urlencode": _filter_urlencode,
            "join": _filter_join,
            "number_format": _filter_number_format,
            "add": _filter_add,
            "or_else": _filter_or_else,
            "or_broadcaster": self._filter_or_broadcaster,
            "or_streamer": self._filter_or_broadcaster,
            "slice": _filter_slice,
            "subtract": _filter_subtract,
            "multiply": _filter_multiply,
            "divide": _filter_divide,
            "floor": _filter_floor,
            "ceil": _filter_ceil,
        }
        if f.name in available_filters:
            return available_filters[f.name](resp, f.arguments)
        return resp

    def _filter_or_broadcaster(self, var: Any, args: List[str]) -> Any:
        return _filter_or_else(var, [self.streamer])

    def find_unique_urls(self, message):
        from pajbot.modules.linkchecker import find_unique_urls

        return find_unique_urls(message)
Example #5
0
    def __init__(self, config, args):
        self.config = config
        self.args = args

        self.last_ping = utils.now()
        self.last_pong = utils.now()

        DBManager.init(self.config["main"]["db"])

        # redis
        redis_options = {}
        if "redis" in config:
            redis_options = dict(config.items("redis"))
        RedisManager.init(**redis_options)
        wait_for_redis_data_loaded(RedisManager.get())

        # Pepega SE points sync
        pajbot.models.user.Config.se_sync_token = config["main"].get(
            "se_sync_token", None)
        pajbot.models.user.Config.se_channel = config["main"].get(
            "se_channel", None)

        self.nickname = config["main"].get("nickname", "pajbot")
        self.timezone = config["main"].get("timezone", "UTC")

        if config["main"].getboolean("verified", False):
            TMI.promote_to_verified()

        # phrases
        self.phrases = {
            "welcome": ["{nickname} {version} running!"],
            "quit": ["{nickname} {version} shutting down..."]
        }
        if "phrases" in config:
            phrases = config["phrases"]
            if "welcome" in phrases:
                self.phrases["welcome"] = phrases["welcome"].splitlines()
            if "quit" in phrases:
                self.phrases["quit"] = phrases["quit"].splitlines()

        TimeManager.init_timezone(self.timezone)

        # streamer
        if "streamer" in config["main"]:
            self.streamer = config["main"]["streamer"]
            self.channel = "#" + self.streamer
        elif "target" in config["main"]:
            self.channel = config["main"]["target"]
            self.streamer = self.channel[1:]
        StreamHelper.init_streamer(self.streamer)

        log.debug("Loaded config")

        # do this earlier since schema upgrade can depend on the helix api
        self.api_client_credentials = ClientCredentials(
            self.config["twitchapi"]["client_id"],
            self.config["twitchapi"]["client_secret"],
            self.config["twitchapi"]["redirect_uri"],
        )

        self.twitch_id_api = TwitchIDAPI(self.api_client_credentials)
        self.app_token_manager = AppAccessTokenManager(self.twitch_id_api,
                                                       RedisManager.get())
        self.twitch_helix_api = TwitchHelixAPI(RedisManager.get(),
                                               self.app_token_manager)
        self.twitch_v5_api = TwitchKrakenV5API(self.api_client_credentials,
                                               RedisManager.get())
        self.twitch_legacy_api = TwitchLegacyAPI(self.api_client_credentials,
                                                 RedisManager.get())
        self.twitch_tmi_api = TwitchTMIAPI()

        self.bot_user_id = self.twitch_helix_api.get_user_id(self.nickname)
        if self.bot_user_id is None:
            raise ValueError(
                "The bot login name you entered under [main] does not exist on twitch."
            )

        self.streamer_user_id = self.twitch_helix_api.get_user_id(
            self.streamer)
        if self.streamer_user_id is None:
            raise ValueError(
                "The streamer login name you entered under [main] does not exist on twitch."
            )

        # SQL migrations
        sql_conn = DBManager.engine.connect().connection
        sql_migratable = DatabaseMigratable(sql_conn)
        sql_migration = Migration(sql_migratable,
                                  pajbot.migration_revisions.db, self)
        sql_migration.run()

        # Redis migrations
        redis_migratable = RedisMigratable(redis_options=redis_options,
                                           namespace=self.streamer)
        redis_migration = Migration(redis_migratable,
                                    pajbot.migration_revisions.redis, self)
        redis_migration.run()

        # Actions in this queue are run in a separate thread.
        # This means actions should NOT access any database-related stuff.
        self.action_queue = ActionQueue()
        self.action_queue.start()

        self.reactor = irc.client.Reactor(self.on_connect)
        self.start_time = utils.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self.streamer, self.execute_now)
        self.stream_manager = StreamManager(self)

        StreamHelper.init_bot(self, self.stream_manager)
        ScheduleManager.init()

        self.users = UserManager()
        self.decks = DeckManager()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()

        # bot access token
        if "password" in self.config["main"]:
            log.warning(
                "DEPRECATED - Using bot password/oauth token from file. "
                "You should authenticate in web gui using route /bot_login "
                "and remove password from config file")

            access_token = self.config["main"]["password"]

            if access_token.startswith("oauth:"):
                access_token = access_token[6:]

            self.bot_token_manager = UserAccessTokenManager(
                api=None,
                redis=None,
                username=self.nickname,
                user_id=self.bot_user_id,
                token=UserAccessToken.from_implicit_auth_flow_token(
                    access_token),
            )
        else:
            self.bot_token_manager = UserAccessTokenManager(
                api=self.twitch_id_api,
                redis=RedisManager.get(),
                username=self.nickname,
                user_id=self.bot_user_id)

        self.emote_manager = EmoteManager(self.twitch_v5_api,
                                          self.twitch_legacy_api,
                                          self.action_queue)
        self.epm_manager = EpmManager()
        self.ecount_manager = EcountManager()
        self.twitter_manager = TwitterManager(self)
        self.module_manager = ModuleManager(self.socket_manager,
                                            bot=self).load()
        self.commands = CommandManager(socket_manager=self.socket_manager,
                                       module_manager=self.module_manager,
                                       bot=self).load()
        self.websocket_manager = WebSocketManager(self)

        HandlerManager.trigger("on_managers_loaded")

        # Commitable managers
        self.commitable = {
            "commands": self.commands,
            "banphrases": self.banphrase_manager
        }

        self.execute_every(10 * 60, self.commit_all)
        self.execute_every(1, self.do_tick)

        # promote the admin to level 2000
        admin = None
        try:
            admin = self.config["main"]["admin"]
        except KeyError:
            log.warning(
                "No admin user specified. See the [main] section in the example config for its usage."
            )
        if admin is not None:
            with self.users.get_user_context(admin) as user:
                user.level = 2000

        # silent mode
        self.silent = ("flags" in config and "silent" in config["flags"]
                       and config["flags"]["silent"] == "1") or args.silent
        if self.silent:
            log.info("Silent mode enabled")

        # dev mode
        self.dev = "flags" in config and "dev" in config["flags"] and config[
            "flags"]["dev"] == "1"
        if self.dev:
            self.version_long = extend_version_if_possible(VERSION)
        else:
            self.version_long = VERSION

        self.irc = IRCManager(self)

        relay_host = self.config["main"].get("relay_host", None)
        relay_password = self.config["main"].get("relay_password", None)
        if relay_host is not None or relay_password is not None:
            log.warning(
                "DEPRECATED - Relaybroker support is no longer implemented. relay_host and relay_password are ignored"
            )

        self.reactor.add_global_handler("all_events", self.irc._dispatcher,
                                        -10)

        self.data = {
            "broadcaster": self.streamer,
            "version": self.version_long,
            "version_brief": VERSION,
            "bot_name": self.nickname,
        }

        self.data_cb = {
            "status_length": self.c_status_length,
            "stream_status": self.c_stream_status,
            "bot_uptime": self.c_uptime,
            "current_time": self.c_current_time,
            "molly_age_in_years": self.c_molly_age_in_years,
        }
Example #6
0
    def __init__(self, config, args=None):
        # Load various configuration variables from the given config object
        # The config object that should be passed through should
        # come from pajbot.utils.load_config
        self.load_config(config)

        # Update the database scheme if necessary using alembic
        # In case of errors, i.e. if the database is out of sync or the alembic
        # binary can't be called, we will shut down the bot.
        pajbot.utils.alembic_upgrade()

        # Actions in this queue are run in a separate thread.
        # This means actions should NOT access any database-related stuff.
        self.action_queue = ActionQueue()
        self.action_queue.start()

        self.reactor = irc.client.Reactor(self.on_connect)
        self.start_time = datetime.datetime.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self)
        self.stream_manager = StreamManager(self)

        StreamHelper.init_bot(self, self.stream_manager)
        ScheduleManager.init()

        self.users = UserManager()
        self.decks = DeckManager()
        self.module_manager = ModuleManager(self.socket_manager,
                                            bot=self).load()
        self.commands = CommandManager(socket_manager=self.socket_manager,
                                       module_manager=self.module_manager,
                                       bot=self).load()
        self.filters = FilterManager().reload()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()
        self.emotes = EmoteManager(self)
        self.twitter_manager = TwitterManager(self)
        self.cachet_manager = CachetManager(self)

        HandlerManager.trigger('on_managers_loaded')

        # Reloadable managers
        self.reloadable = {
            'filters': self.filters,
        }

        # Commitable managers
        self.commitable = {
            'commands': self.commands,
            'filters': self.filters,
            'banphrases': self.banphrase_manager,
        }

        self.execute_every(10 * 60, self.commit_all)

        try:
            self.admin = self.config['main']['admin']
        except KeyError:
            log.warning(
                'No admin user specified. See the [main] section in config.example.ini for its usage.'
            )
        if self.admin:
            with self.users.get_user_context(self.admin) as user:
                user.level = 2000

        self.parse_version()

        relay_host = self.config['main'].get('relay_host', None)
        relay_password = self.config['main'].get('relay_password', None)
        if relay_host is None or relay_password is None:
            self.irc = MultiIRCManager(self)
        else:
            self.irc = SingleIRCManager(self)

        self.reactor.add_global_handler('all_events', self.irc._dispatcher,
                                        -10)

        twitch_client_id = None
        twitch_oauth = None
        if 'twitchapi' in self.config:
            twitch_client_id = self.config['twitchapi'].get('client_id', None)
            twitch_oauth = self.config['twitchapi'].get('oauth', None)

        # A client ID is required for the bot to work properly now, give an error for now
        if twitch_client_id is None:
            log.error(
                'MISSING CLIENT ID, SET "client_id" VALUE UNDER [twitchapi] SECTION IN CONFIG FILE'
            )

        self.twitchapi = TwitchAPI(twitch_client_id, twitch_oauth)

        self.data = {}
        self.data_cb = {}
        self.url_regex = re.compile(self.url_regex_str, re.IGNORECASE)

        self.data['broadcaster'] = self.streamer
        self.data['version'] = self.version
        self.data_cb['status_length'] = self.c_status_length
        self.data_cb['stream_status'] = self.c_stream_status
        self.data_cb['bot_uptime'] = self.c_uptime
        self.data_cb['current_time'] = self.c_current_time

        self.silent = True if args.silent else self.silent

        if self.silent:
            log.info('Silent mode enabled')
        """
        For actions that need to access the main thread,
        we can use the mainthread_queue.
        """
        self.mainthread_queue = ActionQueue()
        self.execute_every(1, self.mainthread_queue.parse_action)

        self.websocket_manager = WebSocketManager(self)

        try:
            if self.config['twitchapi']['update_subscribers'] == '1':
                self.execute_every(30 * 60, self.action_queue.add,
                                   (self.update_subscribers_stage1, ))
        except:
            pass

        # XXX: TEMPORARY UGLY CODE
        HandlerManager.add_handler('on_user_gain_tokens',
                                   self.on_user_gain_tokens)
        HandlerManager.add_handler('send_whisper', self.whisper)
Example #7
0
class Bot:
    """
    Main class for the twitch bot
    """
    def __init__(self, config, args):
        self.config = config
        self.args = args

        self.last_ping = utils.now()
        self.last_pong = utils.now()

        DBManager.init(self.config["main"]["db"])

        # redis
        redis_options = {}
        if "redis" in config:
            redis_options = dict(config.items("redis"))
        RedisManager.init(**redis_options)
        wait_for_redis_data_loaded(RedisManager.get())

        # Pepega SE points sync
        pajbot.models.user.Config.se_sync_token = config["main"].get(
            "se_sync_token", None)
        pajbot.models.user.Config.se_channel = config["main"].get(
            "se_channel", None)

        self.nickname = config["main"].get("nickname", "pajbot")
        self.timezone = config["main"].get("timezone", "UTC")

        if config["main"].getboolean("verified", False):
            TMI.promote_to_verified()

        # phrases
        self.phrases = {
            "welcome": ["{nickname} {version} running!"],
            "quit": ["{nickname} {version} shutting down..."]
        }
        if "phrases" in config:
            phrases = config["phrases"]
            if "welcome" in phrases:
                self.phrases["welcome"] = phrases["welcome"].splitlines()
            if "quit" in phrases:
                self.phrases["quit"] = phrases["quit"].splitlines()

        TimeManager.init_timezone(self.timezone)

        # streamer
        if "streamer" in config["main"]:
            self.streamer = config["main"]["streamer"]
            self.channel = "#" + self.streamer
        elif "target" in config["main"]:
            self.channel = config["main"]["target"]
            self.streamer = self.channel[1:]
        StreamHelper.init_streamer(self.streamer)

        log.debug("Loaded config")

        # do this earlier since schema upgrade can depend on the helix api
        self.api_client_credentials = ClientCredentials(
            self.config["twitchapi"]["client_id"],
            self.config["twitchapi"]["client_secret"],
            self.config["twitchapi"]["redirect_uri"],
        )

        self.twitch_id_api = TwitchIDAPI(self.api_client_credentials)
        self.app_token_manager = AppAccessTokenManager(self.twitch_id_api,
                                                       RedisManager.get())
        self.twitch_helix_api = TwitchHelixAPI(RedisManager.get(),
                                               self.app_token_manager)
        self.twitch_v5_api = TwitchKrakenV5API(self.api_client_credentials,
                                               RedisManager.get())
        self.twitch_legacy_api = TwitchLegacyAPI(self.api_client_credentials,
                                                 RedisManager.get())
        self.twitch_tmi_api = TwitchTMIAPI()

        self.bot_user_id = self.twitch_helix_api.get_user_id(self.nickname)
        if self.bot_user_id is None:
            raise ValueError(
                "The bot login name you entered under [main] does not exist on twitch."
            )

        self.streamer_user_id = self.twitch_helix_api.get_user_id(
            self.streamer)
        if self.streamer_user_id is None:
            raise ValueError(
                "The streamer login name you entered under [main] does not exist on twitch."
            )

        # SQL migrations
        sql_conn = DBManager.engine.connect().connection
        sql_migratable = DatabaseMigratable(sql_conn)
        sql_migration = Migration(sql_migratable,
                                  pajbot.migration_revisions.db, self)
        sql_migration.run()

        # Redis migrations
        redis_migratable = RedisMigratable(redis_options=redis_options,
                                           namespace=self.streamer)
        redis_migration = Migration(redis_migratable,
                                    pajbot.migration_revisions.redis, self)
        redis_migration.run()

        # Actions in this queue are run in a separate thread.
        # This means actions should NOT access any database-related stuff.
        self.action_queue = ActionQueue()
        self.action_queue.start()

        self.reactor = irc.client.Reactor(self.on_connect)
        self.start_time = utils.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self.streamer, self.execute_now)
        self.stream_manager = StreamManager(self)

        StreamHelper.init_bot(self, self.stream_manager)
        ScheduleManager.init()

        self.users = UserManager()
        self.decks = DeckManager()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()

        # bot access token
        if "password" in self.config["main"]:
            log.warning(
                "DEPRECATED - Using bot password/oauth token from file. "
                "You should authenticate in web gui using route /bot_login "
                "and remove password from config file")

            access_token = self.config["main"]["password"]

            if access_token.startswith("oauth:"):
                access_token = access_token[6:]

            self.bot_token_manager = UserAccessTokenManager(
                api=None,
                redis=None,
                username=self.nickname,
                user_id=self.bot_user_id,
                token=UserAccessToken.from_implicit_auth_flow_token(
                    access_token),
            )
        else:
            self.bot_token_manager = UserAccessTokenManager(
                api=self.twitch_id_api,
                redis=RedisManager.get(),
                username=self.nickname,
                user_id=self.bot_user_id)

        self.emote_manager = EmoteManager(self.twitch_v5_api,
                                          self.twitch_legacy_api,
                                          self.action_queue)
        self.epm_manager = EpmManager()
        self.ecount_manager = EcountManager()
        self.twitter_manager = TwitterManager(self)
        self.module_manager = ModuleManager(self.socket_manager,
                                            bot=self).load()
        self.commands = CommandManager(socket_manager=self.socket_manager,
                                       module_manager=self.module_manager,
                                       bot=self).load()
        self.websocket_manager = WebSocketManager(self)

        HandlerManager.trigger("on_managers_loaded")

        # Commitable managers
        self.commitable = {
            "commands": self.commands,
            "banphrases": self.banphrase_manager
        }

        self.execute_every(10 * 60, self.commit_all)
        self.execute_every(1, self.do_tick)

        # promote the admin to level 2000
        admin = None
        try:
            admin = self.config["main"]["admin"]
        except KeyError:
            log.warning(
                "No admin user specified. See the [main] section in the example config for its usage."
            )
        if admin is not None:
            with self.users.get_user_context(admin) as user:
                user.level = 2000

        # silent mode
        self.silent = ("flags" in config and "silent" in config["flags"]
                       and config["flags"]["silent"] == "1") or args.silent
        if self.silent:
            log.info("Silent mode enabled")

        # dev mode
        self.dev = "flags" in config and "dev" in config["flags"] and config[
            "flags"]["dev"] == "1"
        if self.dev:
            self.version_long = extend_version_if_possible(VERSION)
        else:
            self.version_long = VERSION

        self.irc = IRCManager(self)

        relay_host = self.config["main"].get("relay_host", None)
        relay_password = self.config["main"].get("relay_password", None)
        if relay_host is not None or relay_password is not None:
            log.warning(
                "DEPRECATED - Relaybroker support is no longer implemented. relay_host and relay_password are ignored"
            )

        self.reactor.add_global_handler("all_events", self.irc._dispatcher,
                                        -10)

        self.data = {
            "broadcaster": self.streamer,
            "version": self.version_long,
            "version_brief": VERSION,
            "bot_name": self.nickname,
        }

        self.data_cb = {
            "status_length": self.c_status_length,
            "stream_status": self.c_stream_status,
            "bot_uptime": self.c_uptime,
            "current_time": self.c_current_time,
            "molly_age_in_years": self.c_molly_age_in_years,
        }

    @property
    def password(self):
        return "oauth:{}".format(self.bot_token_manager.token.access_token)

    def on_connect(self, sock):
        return self.irc.on_connect(sock)

    def start(self):
        """Start the IRC client."""
        self.reactor.process_forever()

    def get_kvi_value(self, key, extra={}):
        return self.kvi[key].get()

    def get_last_tweet(self, key, extra={}):
        return self.twitter_manager.get_last_tweet(key)

    def get_emote_epm(self, key, extra={}):
        epm = self.epm_manager.get_emote_epm(key)

        # maybe we simply haven't seen this emote yet (during the bot runtime) but it's a valid emote?
        if epm is None and self.emote_manager.match_word_to_emote(
                key) is not None:
            epm = 0

        if epm is None:
            return None

        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return "{0:,.0f}".format(epm)

    def get_emote_epm_record(self, key, extra={}):
        val = self.epm_manager.get_emote_epm_record(key)
        if val is None:
            return None
        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return "{0:,.0f}".format(val)

    def get_emote_count(self, key, extra={}):
        val = self.ecount_manager.get_emote_count(key)
        if val is None:
            return None
        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return "{0:,.0f}".format(val)

    @staticmethod
    def get_source_value(key, extra={}):
        try:
            return getattr(extra["source"], key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_user_value(self, key, extra={}):
        try:
            user = self.users.find(extra["argument"])
            if user:
                return getattr(user, key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    @staticmethod
    def get_command_value(key, extra={}):
        try:
            return getattr(extra["command"].data, key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_usersource_value(self, key, extra={}):
        try:
            user = self.users.find(extra["argument"])
            if user:
                return getattr(user, key)

            return getattr(extra["source"], key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_time_value(self, key, extra={}):
        try:
            tz = timezone(key)
            return datetime.datetime.now(tz).strftime("%H:%M")
        except:
            log.exception("Unhandled exception in get_time_value")

        return None

    def get_current_song_value(self, key, extra={}):
        if self.stream_manager.online:
            current_song = PleblistManager.get_current_song(
                self.stream_manager.current_stream.id)
            inner_keys = key.split(".")
            val = current_song
            for inner_key in inner_keys:
                val = getattr(val, inner_key, None)
                if val is None:
                    return None
            if val is not None:
                return val
        return None

    def get_strictargs_value(self, key, extra={}):
        ret = self.get_args_value(key, extra)

        if not ret:
            return None

        return ret

    @staticmethod
    def get_args_value(key, extra={}):
        r = None
        try:
            msg_parts = extra["message"].split(" ")
        except (KeyError, AttributeError):
            msg_parts = [""]

        try:
            if "-" in key:
                range_str = key.split("-")
                if len(range_str) == 2:
                    r = (int(range_str[0]), int(range_str[1]))

            if r is None:
                r = (int(key), len(msg_parts))
        except (TypeError, ValueError):
            r = (0, len(msg_parts))

        try:
            return " ".join(msg_parts[r[0]:r[1]])
        except AttributeError:
            return ""
        except:
            log.exception("UNHANDLED ERROR IN get_args_value")
            return ""

    def get_notify_value(self, key, extra={}):
        payload = {
            "message": extra["message"] or "",
            "trigger": extra["trigger"],
            "user": extra["source"].username_raw
        }
        self.websocket_manager.emit("notify", payload)

        return ""

    def get_value(self, key, extra={}):
        if key in extra:
            return extra[key]

        if key in self.data:
            return self.data[key]

        if key in self.data_cb:
            return self.data_cb[key]()

        log.warning("Unknown key passed to get_value: %s", key)
        return None

    def privmsg_arr(self, arr, target=None):
        for msg in arr:
            self.privmsg(msg, target)

    def privmsg_from_file(self,
                          url,
                          per_chunk=35,
                          chunk_delay=30,
                          target=None):
        try:
            r = requests.get(url)
            r.raise_for_status()

            content_type = r.headers["Content-Type"]
            if content_type is not None and cgi.parse_header(
                    content_type)[0] != "text/plain":
                log.error(
                    "privmsg_from_file should be fed with a text/plain URL. Refusing to send."
                )
                return

            lines = r.text.splitlines()
            i = 0
            while lines:
                if i == 0:
                    self.privmsg_arr(lines[:per_chunk], target)
                else:
                    self.execute_delayed(chunk_delay * i, self.privmsg_arr,
                                         (lines[:per_chunk], target))

                del lines[:per_chunk]

                i = i + 1
        except:
            log.exception("error in privmsg_from_file")

    # event is an event to clone and change the text from.
    # Usage: !eval bot.eval_from_file(event, 'https://pastebin.com/raw/LhCt8FLh')
    def eval_from_file(self, event, url):
        try:
            r = requests.get(url)
            r.raise_for_status()

            content_type = r.headers["Content-Type"]
            if content_type is not None and cgi.parse_header(
                    content_type)[0] != "text/plain":
                log.error(
                    "eval_from_file should be fed with a text/plain URL. Refusing to send."
                )
                return

            lines = r.text.splitlines()
            import copy

            for msg in lines:
                cloned_event = copy.deepcopy(event)
                cloned_event.arguments = [msg]
                # omit the source connection as None (since its not used)
                self.on_pubmsg(None, cloned_event)
            self.whisper(event.source.user.lower(),
                         "Successfully evaluated {0} lines".format(len(lines)))
        except:
            log.exception("BabyRage")
            self.whisper(event.source.user.lower(), "Exception BabyRage")

    def privmsg(self, message, channel=None, increase_message=True):
        if channel is None:
            channel = self.channel

        return self.irc.privmsg(message,
                                channel,
                                increase_message=increase_message)

    def c_uptime(self):
        return utils.time_ago(self.start_time)

    @staticmethod
    def c_current_time():
        return utils.now()

    @staticmethod
    def c_molly_age_in_years():
        molly_birth = datetime.datetime(2018,
                                        10,
                                        29,
                                        tzinfo=datetime.timezone.utc)
        now = utils.now()
        diff = now - molly_birth
        return diff.total_seconds() / 3600 / 24 / 365

    @property
    def is_online(self):
        return self.stream_manager.online

    def c_stream_status(self):
        return "online" if self.stream_manager.online else "offline"

    def c_status_length(self):
        if self.stream_manager.online:
            return utils.time_ago(
                self.stream_manager.current_stream.stream_start)

        if self.stream_manager.last_stream is not None:
            return utils.time_ago(self.stream_manager.last_stream.stream_end)

        return "No recorded stream FeelsBadMan "

    def execute_now(self, function, arguments=()):
        self.execute_delayed(0, function, arguments)

    def execute_at(self, at, function, arguments=()):
        self.reactor.scheduler.execute_at(at, lambda: function(*arguments))

    def execute_delayed(self, delay, function, arguments=()):
        self.reactor.scheduler.execute_after(delay,
                                             lambda: function(*arguments))

    def execute_every(self, period, function, arguments=()):
        self.reactor.scheduler.execute_every(period,
                                             lambda: function(*arguments))

    def _ban(self, username, reason=""):
        self.privmsg(".ban {0} {1}".format(username, reason),
                     increase_message=False)

    def ban(self, username, reason=""):
        self._timeout(username, 30, reason)
        self.execute_delayed(1, self._ban, (username, reason))

    def ban_user(self, user, reason=""):
        self._timeout(user.username, 30, reason)
        self.execute_delayed(1, self._ban, (user.username, reason))

    def unban(self, username):
        self.privmsg(".unban {0}".format(username), increase_message=False)

    def _timeout(self, username, duration, reason=""):
        self.privmsg(".timeout {0} {1} {2}".format(username, duration, reason),
                     increase_message=False)

    def timeout(self, username, duration, reason=""):
        self._timeout(username, duration, reason)

    def timeout_warn(self, user, duration, reason=""):
        duration, punishment = user.timeout(
            duration, warning_module=self.module_manager["warning"])
        self.timeout(user.username, duration, reason)
        return (duration, punishment)

    def timeout_user(self, user, duration, reason=""):
        self._timeout(user.username, duration, reason)

    def timeout_user_once(self, user, duration, reason):
        self._timeout(user.username, duration, reason)

    def _timeout_user(self, user, duration, reason=""):
        self._timeout(user.username, duration, reason)

    def delete_message(self, msg_id):
        self.privmsg(".delete {0}".format(msg_id))

    def whisper(self, username, *messages, separator=". ", **rest):
        """
        Takes a sequence of strings and concatenates them with separator.
        Then sends that string as a whisper to username
        """

        if len(messages) < 0:
            return False

        message = separator.join(messages)

        return self.irc.whisper(username, message)

    def send_message_to_user(self,
                             user,
                             message,
                             event,
                             separator=". ",
                             method="say"):
        if method == "say":
            self.say(user.username + ", " + lowercase_first_letter(message),
                     separator=separator)
        elif method == "whisper":
            self.whisper(user.username, message, separator=separator)
        elif method == "me":
            self.me(message)
        elif method == "reply":
            if event.type in ["action", "pubmsg"]:
                self.say(message, separator=separator)
            elif event.type == "whisper":
                self.whisper(user.username, message, separator=separator)
        else:
            log.warning("Unknown send_message method: %s", method)

    def safe_privmsg(self, message, channel=None, increase_message=True):
        # Check for banphrases
        res = self.banphrase_manager.check_message(message, None)
        if res is not False:
            self.privmsg("filtered message ({})".format(res.id), channel,
                         increase_message)
            return

        self.privmsg(message, channel, increase_message)

    def say(self, *messages, channel=None, separator=". "):
        """
        Takes a sequence of strings and concatenates them with separator.
        Then sends that string to the given channel.
        """

        if len(messages) < 0:
            return False

        if not self.silent:
            message = separator.join(messages).strip()

            message = utils.clean_up_message(message)
            if not message:
                return False

            self.privmsg(message[:510], channel)

    def is_bad_message(self, message):
        return self.banphrase_manager.check_message(message, None) is not False

    def safe_me(self, message, channel=None):
        if not self.is_bad_message(message):
            self.me(message, channel)

    def me(self, message, channel=None):
        self.say(".me " + message[:500], channel=channel)

    def on_welcome(self, chatconn, event):
        return self.irc.on_welcome(chatconn, event)

    def connect(self):
        return self.irc.start()

    def on_disconnect(self, chatconn, event):
        self.irc.on_disconnect(chatconn, event)

    def parse_message(self, message, source, event, tags={}, whisper=False):
        msg_lower = message.lower()

        emote_tag = None
        msg_id = None

        for tag in tags:
            if tag["key"] == "subscriber" and event.target == self.channel:
                source.subscriber = tag["value"] == "1"
            elif tag["key"] == "emotes":
                emote_tag = tag["value"]
            elif tag["key"] == "display-name" and tag["value"]:
                source.username_raw = tag["value"]
            elif tag["key"] == "user-type":
                source.moderator = tag[
                    "value"] == "mod" or source.username == self.streamer
            elif tag["key"] == "id":
                msg_id = tag["value"]

        # source.num_lines += 1

        if source is None:
            log.error("No valid user passed to parse_message")
            return False

        if source.banned:
            self.ban(source.username)
            return False

        # If a user types when timed out, we assume he's been unbanned for a good reason and remove his flag.
        if source.timed_out is True:
            source.timed_out = False

        # Parse emotes in the message
        emote_instances, emote_counts = self.emote_manager.parse_all_emotes(
            message, emote_tag)

        if not whisper:
            # increment epm and ecount
            self.epm_manager.handle_emotes(emote_counts)
            self.ecount_manager.handle_emotes(emote_counts)

        urls = self.find_unique_urls(message)

        res = HandlerManager.trigger(
            "on_message",
            source=source,
            message=message,
            emote_instances=emote_instances,
            emote_counts=emote_counts,
            whisper=whisper,
            urls=urls,
            msg_id=msg_id,
            event=event,
        )
        if res is False:
            return False

        source.last_seen = utils.now()
        source.last_active = utils.now()

        if source.ignored:
            return False

        if whisper:
            self.whisper("datguy1",
                         "{} said: {}".format(source.username_raw, message))

        if msg_lower[:1] == "!":
            msg_lower_parts = msg_lower.split(" ")
            trigger = msg_lower_parts[0][1:]
            msg_raw_parts = message.split(" ")
            remaining_message = " ".join(
                msg_raw_parts[1:]) if len(msg_raw_parts) > 1 else None
            if trigger in self.commands:
                command = self.commands[trigger]
                extra_args = {
                    "emote_instances": emote_instances,
                    "emote_counts": emote_counts,
                    "trigger": trigger,
                    "msg_id": msg_id,
                }
                command.run(self,
                            source,
                            remaining_message,
                            event=event,
                            args=extra_args,
                            whisper=whisper)

    def on_whisper(self, chatconn, event):
        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        username = event.source.user.lower()

        with self.users.get_user_context(username) as source:
            self.parse_message(event.arguments[0],
                               source,
                               event,
                               whisper=True,
                               tags=event.tags)

    def on_ping(self, chatconn, event):
        self.last_ping = utils.now()

    def on_pong(self, chatconn, event):
        self.last_pong = utils.now()

    def on_usernotice(self, chatconn, event):
        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        tags = {}
        for d in event.tags:
            tags[d["key"]] = d["value"]

        if "login" not in tags:
            return

        username = tags["login"]

        with self.users.get_user_context(username) as source:
            msg = ""
            if event.arguments:
                msg = event.arguments[0]
            HandlerManager.trigger("on_usernotice",
                                   source=source,
                                   message=msg,
                                   tags=tags)

    def on_action(self, chatconn, event):
        self.on_pubmsg(chatconn, event)

    def on_pubmsg(self, chatconn, event):
        if event.source.user == self.nickname:
            return False

        username = event.source.user.lower()

        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        with self.users.get_user_context(username) as source:
            res = HandlerManager.trigger("on_pubmsg",
                                         source=source,
                                         message=event.arguments[0])
            if res is False:
                return False

            self.parse_message(event.arguments[0],
                               source,
                               event,
                               tags=event.tags)

    @time_method
    def commit_all(self):
        for key, manager in self.commitable.items():
            manager.commit()

        HandlerManager.trigger("on_commit", stop_on_false=False)

    @staticmethod
    def do_tick():
        HandlerManager.trigger("on_tick")

    def quit(self, message, event, **options):
        quit_chub = self.config["main"].get("control_hub", None)
        quit_delay = 0

        if quit_chub is not None and event.target == ("#{}".format(quit_chub)):
            quit_delay_random = 300
            try:
                if message is not None and int(message.split()[0]) >= 1:
                    quit_delay_random = int(message.split()[0])
            except (IndexError, ValueError, TypeError):
                pass
            quit_delay = random.randint(0, quit_delay_random)
            log.info("%s is restarting in %d seconds.", self.nickname,
                     quit_delay)

        self.execute_delayed(quit_delay, self.quit_bot)

    def quit_bot(self, **options):
        self.commit_all()
        HandlerManager.trigger("on_quit")
        phrase_data = {"nickname": self.nickname, "version": self.version_long}

        try:
            ScheduleManager.base_scheduler.print_jobs()
            ScheduleManager.base_scheduler.shutdown(wait=False)
        except:
            log.exception("Error while shutting down the apscheduler")

        try:
            for p in self.phrases["quit"]:
                self.privmsg(p.format(**phrase_data))
        except Exception:
            log.exception("Exception caught while trying to say quit phrase")

        self.twitter_manager.quit()
        self.socket_manager.quit()

        sys.exit(0)

    def apply_filter(self, resp, f):
        available_filters = {
            "strftime":
            _filter_strftime,
            "lower":
            lambda var, args: var.lower(),
            "upper":
            lambda var, args: var.upper(),
            "time_since_minutes":
            lambda var, args: "no time"
            if var == 0 else utils.time_since(var * 60, 0, time_format="long"),
            "time_since":
            lambda var, args: "no time"
            if var == 0 else utils.time_since(var, 0, time_format="long"),
            "time_since_dt":
            _filter_time_since_dt,
            "urlencode":
            _filter_urlencode,
            "join":
            _filter_join,
            "number_format":
            _filter_number_format,
            "add":
            _filter_add,
            "or_else":
            _filter_or_else,
            "or_broadcaster":
            self._filter_or_broadcaster,
            "or_streamer":
            self._filter_or_broadcaster,
        }
        if f.name in available_filters:
            return available_filters[f.name](resp, f.arguments)
        return resp

    def _filter_or_broadcaster(self, var, args):
        return _filter_or_else(var, self.streamer)

    def find_unique_urls(self, message):
        from pajbot.modules.linkchecker import find_unique_urls

        return find_unique_urls(URL_REGEX, message)
Example #8
0
    def __init__(self, config, args):
        self.config = config
        self.args = args

        self.last_ping = utils.now()
        self.last_pong = utils.now()

        ScheduleManager.init()

        DBManager.init(self.config["main"]["db"])

        # redis
        redis_options = {}
        if "redis" in config:
            redis_options = dict(config.items("redis"))
        RedisManager.init(**redis_options)
        wait_for_redis_data_loaded(RedisManager.get())

        self.nickname = config["main"].get("nickname", "pajbot")

        if config["main"].getboolean("verified", False):
            TMI.promote_to_verified()

        # phrases
        self.phrases = {
            "welcome": ["{nickname} {version} running! HeyGuys"],
            "quit": ["{nickname} {version} shutting down... BibleThump"],
        }
        if "phrases" in config:
            phrases = config["phrases"]
            if "welcome" in phrases:
                self.phrases["welcome"] = phrases["welcome"].splitlines()
            if "quit" in phrases:
                self.phrases["quit"] = phrases["quit"].splitlines()

        # streamer
        if "streamer" in config["main"]:
            self.streamer = config["main"]["streamer"]
            self.channel = "#" + self.streamer
        elif "target" in config["main"]:
            self.channel = config["main"]["target"]
            self.streamer = self.channel[1:]

        self.bot_domain = self.config["web"]["domain"]
        log.debug("Loaded config")

        # do this earlier since schema upgrade can depend on the helix api
        self.api_client_credentials = ClientCredentials(
            self.config["twitchapi"]["client_id"],
            self.config["twitchapi"]["client_secret"],
            self.config["twitchapi"]["redirect_uri"],
        )

        HandlerManager.init_handlers()

        self.twitch_id_api = TwitchIDAPI(self.api_client_credentials)
        self.twitch_tmi_api = TwitchTMIAPI()
        self.app_token_manager = AppAccessTokenManager(self.twitch_id_api,
                                                       RedisManager.get())

        SongRequestQueueManager.init(self.streamer)

        self.twitch_helix_api = TwitchHelixAPI(RedisManager.get(),
                                               self.app_token_manager)
        self.twitch_v5_api = TwitchKrakenV5API(self.api_client_credentials,
                                               RedisManager.get())
        self.spotify_api = None
        self.spotify_token_manager = None
        if ("client_id" in config["spotify"]
                and "client_secret" in config["spotify"]
                and "redirect_uri" in config["spotify"]
                and "user_id" in config["spotify"]):
            if (config["spotify"]["client_id"] != ""
                    and config["spotify"]["client_secret"] != ""
                    and config["spotify"]["redirect_uri"] != ""
                    and config["spotify"]["user_id"] != ""):
                self.spotify_api = SpotifyApi(
                    RedisManager.get(),
                    config["spotify"]["client_id"],
                    config["spotify"]["client_secret"],
                    config["spotify"]["redirect_uri"],
                )
                self.spotify_token_manager = SpotifyAccessTokenManager(
                    self.spotify_api, RedisManager.get(),
                    config["spotify"]["user_id"])
                log.info("Spotify Loaded")

        self.streamlabs_api = None
        if "socket_access_token" in config["streamlabs"]:
            socket_access_token = config["streamlabs"]["socket_access_token"]
            if socket_access_token is not None and socket_access_token != "":
                self.streamlabs_api = StreamLabsAPI(socket_access_token)

        self.bot_user_id = self.twitch_helix_api.get_user_id(self.nickname)
        if self.bot_user_id is None:
            raise ValueError(
                "The bot login name you entered under [main] does not exist on twitch."
            )

        self.streamer_user_id = self.twitch_helix_api.get_user_id(
            self.streamer)
        if self.streamer_user_id is None:
            raise ValueError(
                "The streamer login name you entered under [main] does not exist on twitch."
            )

        self.streamer_access_token_manager = UserAccessTokenManager(
            api=self.twitch_id_api,
            redis=RedisManager.get(),
            username=self.streamer,
            user_id=self.streamer_user_id)

        self.pubsub_api = PubSubAPI(
            self,
            self.streamer_access_token_manager,
        )

        StreamHelper.init_streamer(self.streamer, self.streamer_user_id)

        # SQL migrations
        with DBManager.create_dbapi_connection_scope() as sql_conn:
            sql_migratable = DatabaseMigratable(sql_conn)
            sql_migration = Migration(sql_migratable,
                                      pajbot.migration_revisions.db, self)
            sql_migration.run()

        # Redis migrations
        redis_migratable = RedisMigratable(redis_options=redis_options,
                                           namespace=self.streamer)
        redis_migration = Migration(redis_migratable,
                                    pajbot.migration_revisions.redis, self)
        redis_migration.run()

        # Thread pool executor for async actions
        self.action_queue = ActionQueue()

        # refresh points_rank and num_lines_rank regularly
        UserRanksRefreshManager.start(self.action_queue)

        self.reactor = irc.client.Reactor(self.on_connect)
        # SafeDefaultScheduler makes the bot not exit on exception in the main thread
        # e.g. on actions via bot.execute_now, etc.
        self.reactor.scheduler_class = SafeDefaultScheduler
        self.reactor.scheduler = SafeDefaultScheduler()

        self.start_time = utils.now()
        ActionParser.bot = self

        self.socket_manager = SocketManager(self.streamer, self.execute_now)
        self.stream_manager = StreamManager(self)
        StreamHelper.init_stream_manager(self.stream_manager)

        self.decks = DeckManager()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()

        self.websocket_manager = WebSocketManager(self)

        # bot access token
        if "password" in self.config["main"]:
            log.warning(
                "DEPRECATED - Using bot password/oauth token from file. "
                "You should authenticate in web gui using route /bot_login "
                "and remove password from config file")

            access_token = self.config["main"]["password"]

            if access_token.startswith("oauth:"):
                access_token = access_token[6:]

            self.bot_token_manager = UserAccessTokenManager(
                api=None,
                redis=None,
                username=self.nickname,
                user_id=self.bot_user_id,
                token=UserAccessToken.from_implicit_auth_flow_token(
                    access_token),
            )
        else:
            self.bot_token_manager = UserAccessTokenManager(
                api=self.twitch_id_api,
                redis=RedisManager.get(),
                username=self.nickname,
                user_id=self.bot_user_id)

        self.songrequest_manager = SongrequestManager(self)
        self.songrequest_websocket_manager = SongRequestWebSocketManager(self)
        self.emote_manager = EmoteManager(self.twitch_v5_api,
                                          self.action_queue)
        self.epm_manager = EpmManager()
        self.ecount_manager = EcountManager()
        if "twitter" in self.config and self.config["twitter"].get(
                "streaming_type", "twitter") == "tweet-provider":
            self.twitter_manager = PBTwitterManager(self)
        else:
            self.twitter_manager = TwitterManager(self)
        self.module_manager = ModuleManager(self.socket_manager,
                                            bot=self).load()
        self.commands = CommandManager(socket_manager=self.socket_manager,
                                       module_manager=self.module_manager,
                                       bot=self).load()
        self.spotify_streamlabs_manager = SpotifyStreamLabsManager(self)

        HandlerManager.trigger("on_managers_loaded")

        # Commitable managers
        self.commitable = {
            "commands": self.commands,
            "banphrases": self.banphrase_manager
        }

        self.execute_every(60, self.commit_all)
        self.execute_every(1, self.do_tick)

        # promote the admin to level 2000
        self.admin = self.config["main"].get("admin", None)
        if self.admin is None:
            log.warning(
                "No admin user specified. See the [main] section in the example config for its usage."
            )
        else:
            with DBManager.create_session_scope() as db_session:
                admin_user = User.find_or_create_from_login(
                    db_session, self.twitch_helix_api, self.admin)
                if admin_user is None:
                    log.warning(
                        "The login name you entered for the admin user does not exist on twitch. "
                        "No admin user has been created.")
                else:
                    admin_user.level = 2000

        # silent mode
        self.silent = ("flags" in config and "silent" in config["flags"]
                       and config["flags"]["silent"] == "1") or args.silent
        if self.silent:
            log.info("Silent mode enabled")

        # dev mode
        self.dev = "flags" in config and "dev" in config["flags"] and config[
            "flags"]["dev"] == "1"
        if self.dev:
            self.version_long = extend_version_if_possible(VERSION)
        else:
            self.version_long = VERSION

        self.irc = IRCManager(self)

        relay_host = self.config["main"].get("relay_host", None)
        relay_password = self.config["main"].get("relay_password", None)
        if relay_host is not None or relay_password is not None:
            log.warning(
                "DEPRECATED - Relaybroker support is no longer implemented. relay_host and relay_password are ignored"
            )

        self.reactor.add_global_handler("all_events", self.irc._dispatcher,
                                        -10)

        self.data = {
            "broadcaster": self.streamer,
            "version": self.version_long,
            "version_brief": VERSION,
            "bot_name": self.nickname,
            "bot_domain": self.bot_domain,
        }

        self.data_cb = {
            "status_length": self.c_status_length,
            "stream_status": self.c_stream_status,
            "bot_uptime": self.c_uptime,
            "current_time": self.c_current_time,
            "molly_age_in_years": self.c_molly_age_in_years,
        }
        self.user_agent = f"pajbot1/{VERSION} ({self.nickname})"
Example #9
0
class Bot:
    """
    Main class for the twitch bot
    """

    version = "1.35"
    date_fmt = "%H:%M"
    admin = None
    url_regex_str = r"\(?(?:(http|https):\/\/)?(?:((?:[^\W\s]|\.|-|[:]{1})+)@{1})?((?:www.)?(?:[^\W\s]|\.|-)+[\.][^\W\s]{2,4}|localhost(?=\/)|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::(\d*))?([\/]?[^\s\?]*[\/]{1})*(?:\/?([^\s\n\?\[\]\{\}\#]*(?:(?=\.)){1}|[^\s\n\?\[\]\{\}\.\#]*)?([\.]{1}[^\s\?\#]*)?)?(?:\?{1}([^\s\n\#\[\]]*))?([\#][^\s\n]*)?\)?"

    last_ping = pajbot.utils.now()
    last_pong = pajbot.utils.now()

    @staticmethod
    def parse_args():
        parser = argparse.ArgumentParser()
        parser.add_argument(
            "--config",
            "-c",
            default="config.ini",
            help="Specify which config file to use (default: config.ini)")
        parser.add_argument(
            "--silent",
            action="count",
            help="Decides whether the bot should be silent or not")
        # TODO: Add a log level argument.

        return parser.parse_args()

    bot_token = None

    @property
    def password(self):
        if "password" in self.config["main"]:
            log.warning(
                "DEPRECATED - Using bot password/oauth token from file. "
                "You should authenticate in web gui using route /bot_login "
                "and remove password from config file")
            return self.config["main"]["password"]

        if self.bot_token is None:
            self.bot_token = BotToken(self.config)

        t = self.bot_token.access_token()
        return "oauth:{}".format(t)

    def load_config(self, config):
        self.config = config

        DBManager.init(self.config["main"]["db"])

        redis_options = {}
        if "redis" in config:
            redis_options = dict(config.items("redis"))

        RedisManager.init(**redis_options)

        pajbot.models.user.Config.se_sync_token = config["main"].get(
            "se_sync_token", None)
        pajbot.models.user.Config.se_channel = config["main"].get(
            "se_channel", None)

        self.domain = config["web"].get("domain", "localhost")

        self.nickname = config["main"].get("nickname", "pajbot")

        self.timezone = config["main"].get("timezone", "UTC")

        if config["main"].getboolean("verified", False):
            TMI.promote_to_verified()

        self.trusted_mods = config.getboolean("main", "trusted_mods")

        self.phrases = {
            "welcome": ["{nickname} {version} running!"],
            "quit": ["{nickname} {version} shutting down..."]
        }

        if "phrases" in config:
            phrases = config["phrases"]
            if "welcome" in phrases:
                self.phrases["welcome"] = phrases["welcome"].splitlines()
            if "quit" in phrases:
                self.phrases["quit"] = phrases["quit"].splitlines()

        TimeManager.init_timezone(self.timezone)

        if "streamer" in config["main"]:
            self.streamer = config["main"]["streamer"]
            self.channel = "#" + self.streamer
        elif "target" in config["main"]:
            self.channel = config["main"]["target"]
            self.streamer = self.channel[1:]

        StreamHelper.init_streamer(self.streamer)

        self.silent = False
        self.dev = False

        if "flags" in config:
            self.silent = True if "silent" in config["flags"] and config[
                "flags"]["silent"] == "1" else self.silent
            self.dev = True if "dev" in config["flags"] and config["flags"][
                "dev"] == "1" else self.dev

    def __init__(self, config, args=None):
        # Load various configuration variables from the given config object
        # The config object that should be passed through should
        # come from pajbot.utils.load_config
        self.load_config(config)
        log.debug("Loaded config")

        # streamer is additionally initialized here so streamer can be accessed by the DB migrations
        # before StreamHelper.init_bot() is called later (which depends on an upgraded DB because
        # StreamManager accesses the DB)
        StreamHelper.init_streamer(self.streamer)

        # Update the database (and partially redis) scheme if necessary using alembic
        # In case of errors, i.e. if the database is out of sync or the alembic
        # binary can't be called, we will shut down the bot.
        pajbot.utils.alembic_upgrade()
        log.debug("ran db upgrade")

        # Actions in this queue are run in a separate thread.
        # This means actions should NOT access any database-related stuff.
        self.action_queue = ActionQueue()
        self.action_queue.start()

        self.reactor = irc.client.Reactor(self.on_connect)
        self.start_time = pajbot.utils.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self.streamer)
        self.stream_manager = StreamManager(self)

        StreamHelper.init_bot(self, self.stream_manager)
        ScheduleManager.init()

        self.users = UserManager()
        self.decks = DeckManager()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()

        twitch_client_id = None
        twitch_oauth = None
        if "twitchapi" in self.config:
            twitch_client_id = self.config["twitchapi"].get("client_id", None)
            twitch_oauth = self.config["twitchapi"].get("oauth", None)

        # A client ID is required for the bot to work properly now, give an error for now
        if twitch_client_id is None:
            log.error(
                'MISSING CLIENT ID, SET "client_id" VALUE UNDER [twitchapi] SECTION IN CONFIG FILE'
            )

        self.twitchapi = TwitchAPI(twitch_client_id, twitch_oauth)
        self.emote_manager = EmoteManager(twitch_client_id)
        self.epm_manager = EpmManager()
        self.ecount_manager = EcountManager()
        self.twitter_manager = TwitterManager(self)

        self.module_manager = ModuleManager(self.socket_manager,
                                            bot=self).load()
        self.commands = CommandManager(socket_manager=self.socket_manager,
                                       module_manager=self.module_manager,
                                       bot=self).load()

        HandlerManager.trigger("on_managers_loaded")

        # Reloadable managers
        self.reloadable = {}

        # Commitable managers
        self.commitable = {
            "commands": self.commands,
            "banphrases": self.banphrase_manager
        }

        self.execute_every(10 * 60, self.commit_all)
        self.execute_every(1, self.do_tick)

        try:
            self.admin = self.config["main"]["admin"]
        except KeyError:
            log.warning(
                "No admin user specified. See the [main] section in config.example.ini for its usage."
            )
        if self.admin:
            with self.users.get_user_context(self.admin) as user:
                user.level = 2000

        self.parse_version()

        relay_host = self.config["main"].get("relay_host", None)
        relay_password = self.config["main"].get("relay_password", None)
        if relay_host is None or relay_password is None:
            self.irc = MultiIRCManager(self)
        else:
            self.irc = SingleIRCManager(self)

        self.reactor.add_global_handler("all_events", self.irc._dispatcher,
                                        -10)

        self.data = {}
        self.data_cb = {}
        self.url_regex = re.compile(self.url_regex_str, re.IGNORECASE)

        self.data["broadcaster"] = self.streamer
        self.data["version"] = self.version
        self.data["version_brief"] = self.version_brief
        self.data["bot_name"] = self.nickname
        self.data_cb["status_length"] = self.c_status_length
        self.data_cb["stream_status"] = self.c_stream_status
        self.data_cb["bot_uptime"] = self.c_uptime
        self.data_cb["current_time"] = self.c_current_time

        self.silent = True if args.silent else self.silent

        if self.silent:
            log.info("Silent mode enabled")
        """
        For actions that need to access the main thread,
        we can use the mainthread_queue.
        """
        self.mainthread_queue = ActionQueue()
        self.execute_every(1, self.mainthread_queue.parse_action)

        self.websocket_manager = WebSocketManager(self)

        try:
            if self.config["twitchapi"]["update_subscribers"] == "1":
                self.execute_every(30 * 60, self.action_queue.add,
                                   (self.update_subscribers_stage1, ))
        except:
            pass

    def on_connect(self, sock):
        return self.irc.on_connect(sock)

    def update_subscribers_stage1(self):
        limit = 25
        offset = 0
        subscribers = []
        log.info("Starting stage1 subscribers update")

        try:
            retry_same = 0
            while True:
                log.debug("Beginning sub request limit=%s offset=%s", limit,
                          offset)
                subs, retry_same, error = self.twitchapi.get_subscribers(
                    self.streamer, 0, offset,
                    0 if retry_same is False else retry_same)
                log.debug("got em!")

                if error is True:
                    log.error("Too many attempts, aborting")
                    return False

                if retry_same is False:
                    offset += limit
                    if not subs:
                        # We don't need to retry, and the last query finished propery
                        # Break out of the loop and start fiddling with the subs!
                        log.debug("Done!")
                        break

                    log.debug("Fetched %d subs", len(subs))
                    subscribers.extend(subs)

                if retry_same is not False:
                    # In case the next attempt is a retry, wait for 3 seconds
                    log.debug("waiting for 3 seconds...")
                    time.sleep(3)
                    log.debug("waited enough!")
            log.debug("Finished with the while True loop!")
        except:
            log.exception(
                "Caught an exception while trying to get subscribers")
            return None

        log.info("Ended stage1 subscribers update")
        if subscribers:
            log.info(
                "Got some subscribers, so we are pushing them to stage 2!")
            self.mainthread_queue.add(self.update_subscribers_stage2,
                                      args=[subscribers])
            log.info("Pushed them now.")

    def update_subscribers_stage2(self, subscribers):
        self.kvi["active_subs"].set(len(subscribers) - 1)

        self.users.reset_subs()

        self.users.update_subs(subscribers)

    def start(self):
        """Start the IRC client."""
        self.reactor.process_forever()

    def get_kvi_value(self, key, extra={}):
        return self.kvi[key].get()

    def get_last_tweet(self, key, extra={}):
        return self.twitter_manager.get_last_tweet(key)

    def get_emote_epm(self, key, extra={}):
        val = self.epm_manager.get_emote_epm(key)
        if val is None:
            return None
        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return "{0:,.0f}".format(val)

    def get_emote_epm_record(self, key, extra={}):
        val = self.epm_manager.get_emote_epm_record(key)
        if val is None:
            return None
        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return "{0:,.0f}".format(val)

    def get_emote_count(self, key, extra={}):
        val = self.ecount_manager.get_emote_count(key)
        if val is None:
            return None
        # formats the number with grouping (e.g. 112,556) and zero decimal places
        return "{0:,.0f}".format(val)

    @staticmethod
    def get_source_value(key, extra={}):
        try:
            return getattr(extra["source"], key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_user_value(self, key, extra={}):
        try:
            user = self.users.find(extra["argument"])
            if user:
                return getattr(user, key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    @staticmethod
    def get_command_value(key, extra={}):
        try:
            return getattr(extra["command"].data, key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_usersource_value(self, key, extra={}):
        try:
            user = self.users.find(extra["argument"])
            if user:
                return getattr(user, key)

            return getattr(extra["source"], key)
        except:
            log.exception("Caught exception in get_source_value")

        return None

    def get_time_value(self, key, extra={}):
        try:
            tz = timezone(key)
            return datetime.datetime.now(tz).strftime(self.date_fmt)
        except:
            log.exception("Unhandled exception in get_time_value")

        return None

    def get_current_song_value(self, key, extra={}):
        if self.stream_manager.online:
            current_song = PleblistManager.get_current_song(
                self.stream_manager.current_stream.id)
            inner_keys = key.split(".")
            val = current_song
            for inner_key in inner_keys:
                val = getattr(val, inner_key, None)
                if val is None:
                    return None
            if val is not None:
                return val
        return None

    def get_strictargs_value(self, key, extra={}):
        ret = self.get_args_value(key, extra)

        if not ret:
            return None

        return ret

    @staticmethod
    def get_args_value(key, extra={}):
        r = None
        try:
            msg_parts = extra["message"].split(" ")
        except (KeyError, AttributeError):
            msg_parts = [""]

        try:
            if "-" in key:
                range_str = key.split("-")
                if len(range_str) == 2:
                    r = (int(range_str[0]), int(range_str[1]))

            if r is None:
                r = (int(key), len(msg_parts))
        except (TypeError, ValueError):
            r = (0, len(msg_parts))

        try:
            return " ".join(msg_parts[r[0]:r[1]])
        except AttributeError:
            return ""
        except:
            log.exception("UNHANDLED ERROR IN get_args_value")
            return ""

    def get_notify_value(self, key, extra={}):
        payload = {
            "message": extra["message"] or "",
            "trigger": extra["trigger"],
            "user": extra["source"].username_raw
        }
        self.websocket_manager.emit("notify", payload)

        return ""

    def get_value(self, key, extra={}):
        if key in extra:
            return extra[key]

        if key in self.data:
            return self.data[key]

        if key in self.data_cb:
            return self.data_cb[key]()

        log.warning("Unknown key passed to get_value: %s", key)
        return None

    def privmsg_arr(self, arr, target=None):
        for msg in arr:
            self.privmsg(msg, target)

    def privmsg_from_file(self,
                          url,
                          per_chunk=35,
                          chunk_delay=30,
                          target=None):
        try:
            r = requests.get(url)
            r.raise_for_status()

            content_type = r.headers["Content-Type"]
            if content_type is not None and cgi.parse_header(
                    content_type)[0] != "text/plain":
                log.error(
                    "privmsg_from_file should be fed with a text/plain URL. Refusing to send."
                )
                return

            lines = r.text.splitlines()
            i = 0
            while lines:
                if i == 0:
                    self.privmsg_arr(lines[:per_chunk], target)
                else:
                    self.execute_delayed(chunk_delay * i, self.privmsg_arr,
                                         (lines[:per_chunk], target))

                del lines[:per_chunk]

                i = i + 1
        except:
            log.exception("error in privmsg_from_file")

    # event is an event to clone and change the text from.
    # Usage: !eval bot.eval_from_file(event, 'https://pastebin.com/raw/LhCt8FLh')
    def eval_from_file(self, event, url):
        try:
            r = requests.get(url)
            r.raise_for_status()

            content_type = r.headers["Content-Type"]
            if content_type is not None and cgi.parse_header(
                    content_type)[0] != "text/plain":
                log.error(
                    "privmsg_from_file should be fed with a text/plain URL. Refusing to send."
                )
                return

            lines = r.text.splitlines()
            import copy

            for msg in lines:
                cloned_event = copy.deepcopy(event)
                cloned_event.arguments = [msg]
                # omit the source connection as None (since its not used)
                self.on_pubmsg(None, cloned_event)
            self.whisper(event.source.user.lower(),
                         "Successfully evaluated {0} lines".format(len(lines)))
        except:
            log.exception("BabyRage")
            self.whisper(event.source.user.lower(), "Exception BabyRage")

    def privmsg(self, message, channel=None, increase_message=True):
        if channel is None:
            channel = self.channel

        return self.irc.privmsg(message,
                                channel,
                                increase_message=increase_message)

    def c_uptime(self):
        return time_ago(self.start_time)

    @staticmethod
    def c_current_time():
        return pajbot.utils.now()

    @property
    def is_online(self):
        return self.stream_manager.online

    def c_stream_status(self):
        return "online" if self.stream_manager.online else "offline"

    def c_status_length(self):
        if self.stream_manager.online:
            return time_ago(self.stream_manager.current_stream.stream_start)

        if self.stream_manager.last_stream is not None:
            return time_ago(self.stream_manager.last_stream.stream_end)

        return "No recorded stream FeelsBadMan "

    def execute_at(self, at, function, arguments=()):
        self.reactor.scheduler.execute_at(at, lambda: function(*arguments))

    def execute_delayed(self, delay, function, arguments=()):
        self.reactor.scheduler.execute_after(delay,
                                             lambda: function(*arguments))

    def execute_every(self, period, function, arguments=()):
        self.reactor.scheduler.execute_every(period,
                                             lambda: function(*arguments))

    def _ban(self, username, reason=""):
        self.privmsg(".ban {0} {1}".format(username, reason),
                     increase_message=False)

    def ban(self, username, reason=""):
        log.debug("Banning %s", username)
        self._timeout(username, 30, reason)
        self.execute_delayed(1, self._ban, (username, reason))

    def ban_user(self, user, reason=""):
        self._timeout(user.username, 30, reason)
        self.execute_delayed(1, self._ban, (user.username, reason))

    def unban(self, username):
        self.privmsg(".unban {0}".format(username), increase_message=False)

    def _timeout(self, username, duration, reason=""):
        self.privmsg(".timeout {0} {1} {2}".format(username, duration, reason),
                     increase_message=False)

    def timeout(self, username, duration, reason=""):
        # log.debug("Timing out %s for %d seconds", username, duration)
        self._timeout(username, duration, reason)

    def timeout_warn(self, user, duration, reason=""):
        duration, punishment = user.timeout(
            duration, warning_module=self.module_manager["warning"])
        self.timeout(user.username, duration, reason)
        return (duration, punishment)

    def timeout_user(self, user, duration, reason=""):
        self._timeout(user.username, duration, reason)

    def _timeout_user(self, user, duration, reason=""):
        self._timeout(user.username, duration, reason)

    def whisper(self, username, *messages, separator=". ", **rest):
        """
        Takes a sequence of strings and concatenates them with separator.
        Then sends that string as a whisper to username
        """

        if len(messages) < 0:
            return False

        message = separator.join(messages)

        return self.irc.whisper(username, message)

    def send_message_to_user(self,
                             user,
                             message,
                             event,
                             separator=". ",
                             method="say"):
        if method == "say":
            self.say(user.username + ", " + lowercase_first_letter(message),
                     separator=separator)
        elif method == "whisper":
            self.whisper(user.username, message, separator=separator)
        elif method == "me":
            self.me(message)
        elif method == "reply":
            if event.type in ["action", "pubmsg"]:
                self.say(message, separator=separator)
            elif event.type == "whisper":
                self.whisper(user.username, message, separator=separator)
        else:
            log.warning("Unknown send_message method: %s", method)

    def safe_privmsg(self, message, channel=None, increase_message=True):
        # Check for banphrases
        res = self.banphrase_manager.check_message(message, None)
        if res is not False:
            self.privmsg("filtered message ({})".format(res.id), channel,
                         increase_message)
            return

        self.privmsg(message, channel, increase_message)

    def say(self, *messages, channel=None, separator=". "):
        """
        Takes a sequence of strings and concatenates them with separator.
        Then sends that string to the given channel.
        """

        if len(messages) < 0:
            return False

        if not self.silent:
            message = separator.join(messages).strip()

            message = clean_up_message(message)
            if not message:
                return False

            # log.info("Sending message: %s", message)

            self.privmsg(message[:510], channel)

    def is_bad_message(self, message):
        return self.banphrase_manager.check_message(message, None) is not False

    def safe_me(self, message, channel=None):
        if not self.is_bad_message(message):
            self.me(message, channel)

    def me(self, message, channel=None):
        self.say(".me " + message[:500], channel=channel)

    def parse_version(self):
        self.version = self.version
        self.version_brief = self.version

        if self.dev:
            try:
                current_branch = (subprocess.check_output(
                    ["git", "rev-parse", "--abbrev-ref",
                     "HEAD"]).decode("utf8").strip())
                latest_commit = subprocess.check_output(
                    ["git", "rev-parse", "HEAD"]).decode("utf8").strip()[:8]
                commit_number = subprocess.check_output(
                    ["git", "rev-list", "HEAD",
                     "--count"]).decode("utf8").strip()
                self.version = "{0} DEV ({1}, {2}, commit {3})".format(
                    self.version, current_branch, latest_commit, commit_number)
            except:
                log.exception("hmm")

    def on_welcome(self, chatconn, event):
        return self.irc.on_welcome(chatconn, event)

    def connect(self):
        return self.irc.start()

    def on_disconnect(self, chatconn, event):
        self.irc.on_disconnect(chatconn, event)

    def parse_message(self, message, source, event, tags={}, whisper=False):
        msg_lower = message.lower()

        emote_tag = None

        for tag in tags:
            if tag["key"] == "subscriber" and event.target == self.channel:
                source.subscriber = tag["value"] == "1"
            elif tag["key"] == "emotes":
                emote_tag = tag["value"]
            elif tag["key"] == "display-name" and tag["value"]:
                source.username_raw = tag["value"]
            elif tag["key"] == "user-type":
                source.moderator = tag[
                    "value"] == "mod" or source.username == self.streamer

        # source.num_lines += 1

        if source is None:
            log.error("No valid user passed to parse_message")
            return False

        if source.banned:
            self.ban(source.username)
            return False

        # If a user types when timed out, we assume he's been unbanned for a good reason and remove his flag.
        if source.timed_out is True:
            source.timed_out = False

        # Parse emotes in the message
        emote_instances, emote_counts = self.emote_manager.parse_all_emotes(
            message, emote_tag)

        self.epm_manager.handle_emotes(emote_counts)
        self.ecount_manager.handle_emotes(emote_counts)

        urls = self.find_unique_urls(message)

        # log.debug("{2}{0}: {1}".format(source.username, message, "<w>" if whisper else ""))

        res = HandlerManager.trigger(
            "on_message",
            source=source,
            message=message,
            emote_instances=emote_instances,
            emote_counts=emote_counts,
            whisper=whisper,
            urls=urls,
            event=event,
        )
        if res is False:
            return False

        source.last_seen = pajbot.utils.now()
        source.last_active = pajbot.utils.now()

        if source.ignored:
            return False

        if whisper:
            self.whisper('datguy1',
                         '{} said: {}'.format(source.username_raw, message))

        if msg_lower[:1] == "!":
            msg_lower_parts = msg_lower.split(" ")
            trigger = msg_lower_parts[0][1:]
            msg_raw_parts = message.split(" ")
            remaining_message = " ".join(
                msg_raw_parts[1:]) if len(msg_raw_parts) > 1 else None
            if trigger in self.commands:
                command = self.commands[trigger]
                extra_args = {
                    "emote_instances": emote_instances,
                    "emote_counts": emote_counts,
                    "trigger": trigger
                }
                command.run(self,
                            source,
                            remaining_message,
                            event=event,
                            args=extra_args,
                            whisper=whisper)

    def on_whisper(self, chatconn, event):
        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        username = event.source.user.lower()

        with self.users.get_user_context(username) as source:
            self.parse_message(event.arguments[0],
                               source,
                               event,
                               whisper=True,
                               tags=event.tags)

    def on_ping(self, chatconn, event):
        # self.say('Received a ping. Last ping received {} ago'.format(time_since(pajbot.utils.now().timestamp(), self.last_ping.timestamp())))
        log.info("Received a ping. Last ping received %s ago",
                 time_ago(self.last_ping))
        self.last_ping = pajbot.utils.now()

    def on_pong(self, chatconn, event):
        # self.say('Received a pong. Last pong received {} ago'.format(time_since(pajbot.utils.now().timestamp(), self.last_pong.timestamp())))
        log.info("Received a pong. Last pong received %s ago",
                 time_ago(self.last_pong))
        self.last_pong = pajbot.utils.now()

    def on_pubnotice(self, chatconn, event):
        return
        type = "whisper" if chatconn in self.whisper_manager else "normal"
        log.debug("NOTICE {}@{}: {}".format(type, event.target,
                                            event.arguments))

    def on_usernotice(self, chatconn, event):
        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        tags = {}
        for d in event.tags:
            tags[d["key"]] = d["value"]

        if "login" not in tags:
            return

        username = tags["login"]

        with self.users.get_user_context(username) as source:
            msg = ""
            if event.arguments:
                msg = event.arguments[0]
            HandlerManager.trigger("on_usernotice",
                                   source=source,
                                   message=msg,
                                   tags=tags)

    def on_action(self, chatconn, event):
        self.on_pubmsg(chatconn, event)

    def on_pubmsg(self, chatconn, event):
        if event.source.user == self.nickname:
            return False

        username = event.source.user.lower()

        # We use .lower() in case twitch ever starts sending non-lowercased usernames
        with self.users.get_user_context(username) as source:
            res = HandlerManager.trigger("on_pubmsg",
                                         source=source,
                                         message=event.arguments[0])
            if res is False:
                return False

            self.parse_message(event.arguments[0],
                               source,
                               event,
                               tags=event.tags)

    @time_method
    def reload_all(self):
        log.info("Reloading all...")
        for key, manager in self.reloadable.items():
            log.debug("Reloading %s", key)
            manager.reload()
            log.debug("Done with %s", key)
        log.info("ok!")

    @time_method
    def commit_all(self):
        # log.info("Commiting all...")
        for key, manager in self.commitable.items():
            # log.info("Commiting %s", key)
            manager.commit()
            # log.info("Done with %s", key)
        # log.info("ok!")

        HandlerManager.trigger("on_commit", stop_on_false=False)

    @staticmethod
    def do_tick():
        HandlerManager.trigger("on_tick")

    def quit(self, message, event, **options):
        quit_chub = self.config["main"].get("control_hub", None)
        quit_delay = 0

        if quit_chub is not None and event.target == ("#{}".format(quit_chub)):
            quit_delay_random = 300
            try:
                if message is not None and int(message.split()[0]) >= 1:
                    quit_delay_random = int(message.split()[0])
            except (IndexError, ValueError, TypeError):
                pass
            quit_delay = random.randint(0, quit_delay_random)
            log.info("%s is restarting in %d seconds.", self.nickname,
                     quit_delay)

        self.execute_delayed(quit_delay, self.quit_bot)

    def quit_bot(self, **options):
        HandlerManager.trigger("on_quit")
        self.commit_all()
        phrase_data = {"nickname": self.nickname, "version": self.version}

        try:
            ScheduleManager.base_scheduler.print_jobs()
            ScheduleManager.base_scheduler.shutdown(wait=False)
        except:
            log.exception("Error while shutting down the apscheduler")

        try:
            for p in self.phrases["quit"]:
                if not self.silent:
                    self.privmsg(p.format(**phrase_data))
        except Exception:
            log.exception("Exception caught while trying to say quit phrase")

        self.twitter_manager.quit()
        self.socket_manager.quit()
        self.irc.quit()

        sys.exit(0)

    @staticmethod
    def apply_filter(resp, f):
        available_filters = {
            "strftime":
            _filter_strftime,
            "lower":
            lambda var, args: var.lower(),
            "upper":
            lambda var, args: var.upper(),
            "time_since_minutes":
            lambda var, args: "no time"
            if var == 0 else time_since(var * 60, 0, time_format="long"),
            "time_since":
            lambda var, args: "no time"
            if var == 0 else time_since(var, 0, time_format="long"),
            "time_since_dt":
            _filter_time_since_dt,
            "urlencode":
            _filter_urlencode,
            "join":
            _filter_join,
            "number_format":
            _filter_number_format,
            "add":
            _filter_add,
        }
        if f.name in available_filters:
            return available_filters[f.name](resp, f.arguments)
        return resp

    def find_unique_urls(self, message):
        from pajbot.modules.linkchecker import find_unique_urls

        return find_unique_urls(self.url_regex, message)
Example #10
0
    def __init__(self, config, args=None):
        # Load various configuration variables from the given config object
        # The config object that should be passed through should
        # come from pajbot.utils.load_config
        self.load_config(config)
        log.debug("Loaded config")

        # streamer is additionally initialized here so streamer can be accessed by the DB migrations
        # before StreamHelper.init_bot() is called later (which depends on an upgraded DB because
        # StreamManager accesses the DB)
        StreamHelper.init_streamer(self.streamer)

        # Update the database (and partially redis) scheme if necessary using alembic
        # In case of errors, i.e. if the database is out of sync or the alembic
        # binary can't be called, we will shut down the bot.
        pajbot.utils.alembic_upgrade()
        log.debug("ran db upgrade")

        # Actions in this queue are run in a separate thread.
        # This means actions should NOT access any database-related stuff.
        self.action_queue = ActionQueue()
        self.action_queue.start()

        self.reactor = irc.client.Reactor(self.on_connect)
        self.start_time = pajbot.utils.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self.streamer)
        self.stream_manager = StreamManager(self)

        StreamHelper.init_bot(self, self.stream_manager)
        ScheduleManager.init()

        self.users = UserManager()
        self.decks = DeckManager()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()

        twitch_client_id = None
        twitch_oauth = None
        if "twitchapi" in self.config:
            twitch_client_id = self.config["twitchapi"].get("client_id", None)
            twitch_oauth = self.config["twitchapi"].get("oauth", None)

        # A client ID is required for the bot to work properly now, give an error for now
        if twitch_client_id is None:
            log.error(
                'MISSING CLIENT ID, SET "client_id" VALUE UNDER [twitchapi] SECTION IN CONFIG FILE'
            )

        self.twitchapi = TwitchAPI(twitch_client_id, twitch_oauth)
        self.emote_manager = EmoteManager(twitch_client_id)
        self.epm_manager = EpmManager()
        self.ecount_manager = EcountManager()
        self.twitter_manager = TwitterManager(self)

        self.module_manager = ModuleManager(self.socket_manager,
                                            bot=self).load()
        self.commands = CommandManager(socket_manager=self.socket_manager,
                                       module_manager=self.module_manager,
                                       bot=self).load()

        HandlerManager.trigger("on_managers_loaded")

        # Reloadable managers
        self.reloadable = {}

        # Commitable managers
        self.commitable = {
            "commands": self.commands,
            "banphrases": self.banphrase_manager
        }

        self.execute_every(10 * 60, self.commit_all)
        self.execute_every(1, self.do_tick)

        try:
            self.admin = self.config["main"]["admin"]
        except KeyError:
            log.warning(
                "No admin user specified. See the [main] section in config.example.ini for its usage."
            )
        if self.admin:
            with self.users.get_user_context(self.admin) as user:
                user.level = 2000

        self.parse_version()

        relay_host = self.config["main"].get("relay_host", None)
        relay_password = self.config["main"].get("relay_password", None)
        if relay_host is None or relay_password is None:
            self.irc = MultiIRCManager(self)
        else:
            self.irc = SingleIRCManager(self)

        self.reactor.add_global_handler("all_events", self.irc._dispatcher,
                                        -10)

        self.data = {}
        self.data_cb = {}
        self.url_regex = re.compile(self.url_regex_str, re.IGNORECASE)

        self.data["broadcaster"] = self.streamer
        self.data["version"] = self.version
        self.data["version_brief"] = self.version_brief
        self.data["bot_name"] = self.nickname
        self.data_cb["status_length"] = self.c_status_length
        self.data_cb["stream_status"] = self.c_stream_status
        self.data_cb["bot_uptime"] = self.c_uptime
        self.data_cb["current_time"] = self.c_current_time

        self.silent = True if args.silent else self.silent

        if self.silent:
            log.info("Silent mode enabled")
        """
        For actions that need to access the main thread,
        we can use the mainthread_queue.
        """
        self.mainthread_queue = ActionQueue()
        self.execute_every(1, self.mainthread_queue.parse_action)

        self.websocket_manager = WebSocketManager(self)

        try:
            if self.config["twitchapi"]["update_subscribers"] == "1":
                self.execute_every(30 * 60, self.action_queue.add,
                                   (self.update_subscribers_stage1, ))
        except:
            pass
Example #11
0
    def __init__(self, config: cfg.Config, args: argparse.Namespace) -> None:
        self.args = args
        self.config = config

        ScheduleManager.init()

        DBManager.init(config["main"]["db"])

        # redis
        redis_options = config.get("redis", {})
        RedisManager.init(redis_options)
        utils.wait_for_redis_data_loaded(RedisManager.get())

        if cfg.get_boolean(config["main"], "verified", False):
            self.tmi_rate_limits = TMIRateLimits.VERIFIED
        elif cfg.get_boolean(config["main"], "known", False):
            self.tmi_rate_limits = TMIRateLimits.KNOWN
        else:
            self.tmi_rate_limits = TMIRateLimits.BASE

        self.whisper_output_mode = WhisperOutputMode.from_config_value(
            config["main"].get("whisper_output_mode", "normal"))

        # phrases
        self.phrases = {
            "welcome": ["{nickname} {version} running! HeyGuys"],
            "quit": ["{nickname} {version} shutting down... BibleThump"],
        }
        if "phrases" in config:
            phrases = config["phrases"]
            if "welcome" in phrases:
                self.phrases["welcome"] = phrases["welcome"].splitlines()
            if "quit" in phrases:
                self.phrases["quit"] = phrases["quit"].splitlines()
        # Remembers whether the "welcome" phrases have already been said. We don't want to send the
        # welcome messages to chat again on a reconnect.
        self.welcome_messages_sent = False

        self.bot_domain = config["web"]["domain"]

        # do this earlier since schema upgrade can depend on the helix api
        self.api_client_credentials = ClientCredentials(
            config["twitchapi"]["client_id"],
            config["twitchapi"]["client_secret"],
            config["twitchapi"].get(
                "redirect_uri",
                f"https://{config['web']['domain']}/login/authorized"),
        )

        self.twitch_id_api = TwitchIDAPI(self.api_client_credentials)
        self.twitch_tmi_api = TwitchTMIAPI()
        self.app_token_manager = AppAccessTokenManager(self.twitch_id_api,
                                                       RedisManager.get())
        self.twitch_helix_api: TwitchHelixAPI = TwitchHelixAPI(
            RedisManager.get(), self.app_token_manager)

        self.streamer: UserBasics = cfg.load_streamer(config,
                                                      self.twitch_helix_api)
        self.channel = f"#{self.streamer.login}"

        self.streamer_display: str = self.streamer.name
        if "streamer_name" in config["web"]:
            # Override the streamer display name
            self.streamer_display = config["web"]["streamer_name"]

        self.bot_user: UserBasics = cfg.load_bot(config, self.twitch_helix_api)

        self.control_hub_user: Optional[UserBasics] = self._load_control_hub(
            config)
        self.control_hub_channel: Optional[str] = None
        if self.control_hub_user:
            self.control_hub_channel = f"#{self.control_hub_user.login}"

        log.debug("Loaded config")

        self.streamer_access_token_manager = UserAccessTokenManager(
            api=self.twitch_id_api,
            redis=RedisManager.get(),
            username=self.streamer.login,
            user_id=self.streamer.id,
        )

        StreamHelper.init_streamer(self.streamer.login, self.streamer.id,
                                   self.streamer.name)

        # SQL migrations
        with DBManager.create_dbapi_connection_scope() as sql_conn:
            sql_migratable = DatabaseMigratable(sql_conn)
            sql_migration = Migration(sql_migratable,
                                      pajbot.migration_revisions.db, self)
            sql_migration.run()

        # Redis migrations
        redis_migratable = RedisMigratable(redis_options=redis_options,
                                           namespace=self.streamer.login)
        redis_migration = Migration(redis_migratable,
                                    pajbot.migration_revisions.redis, self)
        redis_migration.run()

        # Thread pool executor for async actions
        self.action_queue = ActionQueue()

        # refresh points_rank and num_lines_rank regularly
        UserRanksRefreshManager.start(self.action_queue)

        self.reactor = irc.client.Reactor()
        # SafeDefaultScheduler makes the bot not exit on exception in the main thread
        # e.g. on actions via bot.execute_now, etc.
        self.reactor.scheduler_class = SafeDefaultScheduler
        self.reactor.scheduler = SafeDefaultScheduler()

        self.start_time = utils.now()
        ActionParser.bot = self

        HandlerManager.init_handlers()

        self.socket_manager = SocketManager(self.streamer.login,
                                            self.execute_now)
        self.stream_manager = StreamManager(self)
        StreamHelper.init_stream_manager(self.stream_manager)

        self.decks = DeckManager()
        self.banphrase_manager = BanphraseManager(self).load()
        self.timer_manager = TimerManager(self).load()
        self.kvi = KVIManager()

        # bot access token
        if "password" in config["main"]:
            log.warning(
                "DEPRECATED - Using bot password/oauth token from file. "
                "You should authenticate in web gui using route /bot_login "
                "and remove password from config file")

            access_token = config["main"]["password"]

            if access_token.startswith("oauth:"):
                access_token = access_token[6:]

            self.bot_token_manager = UserAccessTokenManager(
                api=None,
                redis=None,
                username=self.bot_user.login,
                user_id=self.bot_user.id,
                token=UserAccessToken.from_implicit_auth_flow_token(
                    access_token),
            )
        else:
            self.bot_token_manager = UserAccessTokenManager(
                api=self.twitch_id_api,
                redis=RedisManager.get(),
                username=self.bot_user.login,
                user_id=self.bot_user.id,
            )

        self.emote_manager = EmoteManager(self.twitch_helix_api,
                                          self.action_queue)
        self.epm_manager = EpmManager()
        self.ecount_manager = EcountManager()
        self.twitter_manager = cfg.load_twitter_manager(config)(self)
        self.module_manager = ModuleManager(self.socket_manager,
                                            bot=self).load()
        self.commands = CommandManager(socket_manager=self.socket_manager,
                                       module_manager=self.module_manager,
                                       bot=self).load()
        self.websocket_manager = WebSocketManager(self)

        HandlerManager.trigger("on_managers_loaded")

        # Commitable managers
        self.commitable = {
            "commands": self.commands,
            "banphrases": self.banphrase_manager
        }

        self.execute_every(60, self.commit_all)
        self.execute_every(1, self.do_tick)

        admin: Optional[UserBasics] = self._load_admin(config)

        if admin is None:
            log.warning(
                "No admin user specified. See the [main] section in the example config for its usage."
            )
        else:
            with DBManager.create_session_scope() as db_session:
                admin_user = User.from_basics(db_session, admin)
                admin_user.level = 2000

        # silent mode
        self.silent = ("flags" in config and "silent" in config["flags"]
                       and config["flags"]["silent"] == "1") or args.silent
        if self.silent:
            log.info("Silent mode enabled")

        # dev mode
        self.dev = "flags" in config and "dev" in config["flags"] and config[
            "flags"]["dev"] == "1"
        if self.dev:
            self.version_long = utils.extend_version_if_possible(VERSION)
        else:
            self.version_long = VERSION

        self.irc = IRCManager(self)

        relay_host = config["main"].get("relay_host", None)
        relay_password = config["main"].get("relay_password", None)
        if relay_host is not None or relay_password is not None:
            log.warning(
                "DEPRECATED - Relaybroker support is no longer implemented. relay_host and relay_password are ignored"
            )

        self.data = {
            "broadcaster": self.streamer.login,
            "version": self.version_long,
            "version_brief": VERSION,
            "bot_name": self.bot_user.login,
            "bot_domain": self.bot_domain,
            "streamer_display": self.streamer_display,
        }

        self.data_cb = {
            "status_length": self.c_status_length,
            "stream_status": self.c_stream_status,
            "bot_uptime": self.c_uptime,
            "current_time": self.c_current_time,
            "molly_age_in_years": self.c_molly_age_in_years,
        }

        self.user_agent = f"pajbot1/{VERSION} ({self.bot_user.login})"

        self.thread_locals = threading.local()

        self.subs_only = False