|
|
|
import configparser
|
|
|
|
import datetime
|
|
|
|
import functools
|
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import re
|
|
|
|
import unicodedata
|
|
|
|
|
|
|
|
import dateutil.parser
|
|
|
|
import irc.bot
|
|
|
|
import requests
|
|
|
|
import tempora.schedule
|
|
|
|
|
|
|
|
|
|
|
|
class ReplayBot(irc.bot.SingleServerIRCBot):
|
|
|
|
def __init__(self):
|
|
|
|
self.logger = logging.getLogger('irc.client')
|
|
|
|
handler = logging.StreamHandler()
|
|
|
|
handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
|
|
|
|
self.logger.addHandler(handler)
|
|
|
|
level = logging.DEBUG if bool(int(os.getenv('DEBUG', 0))) else logging.INFO
|
|
|
|
self.logger.setLevel(level)
|
|
|
|
self.config = configparser.ConfigParser()
|
|
|
|
self.config.read('settings.cfg')
|
|
|
|
self.supported_commands = [
|
|
|
|
(re.compile(r'^!load\s+(?P<id>\d+)$'), self._do_load),
|
|
|
|
(re.compile(r'^!start(\s+(?P<at>.+))?$'), self._do_start),
|
|
|
|
(re.compile(r'^!resync\s+(?P<offset>.+)$'), self._do_resync),
|
|
|
|
(re.compile(r'^!stop$'), self._do_stop),
|
|
|
|
]
|
|
|
|
self.messages = []
|
|
|
|
self.output_channel = '#{0}'.format(self.config['IRC'].get('output_channel'))
|
|
|
|
control_channel = self.config['IRC'].get('control_channel')
|
|
|
|
self.control_channel = '#{0}'.format(control_channel) if control_channel else None
|
|
|
|
self.control_users = self.config['IRC'].get('control_users').split(',')
|
|
|
|
server = self.config['IRC'].get('server')
|
|
|
|
port = self.config['IRC'].getint('port')
|
|
|
|
nickname = self.config['IRC'].get('nickname')
|
|
|
|
token = self.config['Twitch'].get('token')
|
|
|
|
self.logger.info('Connecting to %s:%d', server, port)
|
|
|
|
super(ReplayBot, self).__init__([(server, port, token)], nickname, nickname)
|
|
|
|
|
|
|
|
def on_welcome(self, connection, event):
|
|
|
|
connection.cap('REQ', ':twitch.tv/membership')
|
|
|
|
connection.cap('REQ', ':twitch.tv/tags')
|
|
|
|
connection.cap('REQ', ':twitch.tv/commands')
|
|
|
|
channels = set([self.output_channel])
|
|
|
|
if self.control_channel:
|
|
|
|
channels.add(self.control_channel)
|
|
|
|
for channel in channels:
|
|
|
|
self.logger.info('Joining %s', channel)
|
|
|
|
connection.join(channel)
|
|
|
|
|
|
|
|
def on_join(self, connection, event):
|
|
|
|
self.logger.info('Joined %s', event.target)
|
|
|
|
|
|
|
|
def on_pubmsg(self, connection, event):
|
|
|
|
self._process_message(connection, event)
|
|
|
|
|
|
|
|
def on_whisper(self, connection, event):
|
|
|
|
self._process_message(connection, event)
|
|
|
|
|
|
|
|
def _send_response(self, connection, event, msg):
|
|
|
|
if event.target.startswith('#'):
|
|
|
|
connection.privmsg(event.target, msg)
|
|
|
|
else:
|
|
|
|
connection.privmsg('#jtv', '/w {0} {1}'.format(event.source.nick, msg))
|
|
|
|
|
|
|
|
def _process_message(self, connection, event):
|
|
|
|
tags = {t['key']: t['value'] for t in event.tags}
|
|
|
|
message = ''.join([c for c in event.arguments[0] if not unicodedata.category(c).startswith('C')])
|
|
|
|
message = message.rstrip()
|
|
|
|
send_response = functools.partial(self._send_response, connection, event)
|
|
|
|
for pattern, action in self.supported_commands:
|
|
|
|
m = pattern.match(message)
|
|
|
|
if m:
|
|
|
|
action(tags, send_response, **m.groupdict())
|
|
|
|
|
|
|
|
def _do_load(self, tags, send_response, id, **kwargs):
|
|
|
|
if tags['user-id'] not in self.control_users:
|
|
|
|
send_response('Sorry @{0}, you are not allowed to do this'.format(tags['display-name']))
|
|
|
|
return
|
|
|
|
client_id = self.config['Twitch'].get('client_id')
|
|
|
|
def get_comments(cursor):
|
|
|
|
url = 'https://api.twitch.tv/v5/videos/{0}/comments'.format(id)
|
|
|
|
params = dict(client_id=client_id, cursor=cursor)
|
|
|
|
r = requests.get(url, params=params)
|
|
|
|
r.raise_for_status()
|
|
|
|
return r.json()
|
|
|
|
self.messages = []
|
|
|
|
cursor = ''
|
|
|
|
while True:
|
|
|
|
data = get_comments(cursor)
|
|
|
|
for comment in data.get('comments', []):
|
|
|
|
self.messages.append((
|
|
|
|
datetime.timedelta(seconds=float(comment['content_offset_seconds'])),
|
|
|
|
comment['commenter']['display_name'],
|
|
|
|
comment['message']['body']))
|
|
|
|
cursor = data.get('_next')
|
|
|
|
if not cursor:
|
|
|
|
break
|
|
|
|
if self.messages:
|
|
|
|
send_response('@{0}: loaded {1} messages, first message at {2}'.format(
|
|
|
|
tags['display-name'], len(self.messages), str(self.messages[0][0])))
|
|
|
|
else:
|
|
|
|
send_response('@{0}: failed to load messages'.format(tags['display-name']))
|
|
|
|
|
|
|
|
def _do_start(self, tags, send_response, at=None, **kwargs):
|
|
|
|
if tags['user-id'] not in self.control_users:
|
|
|
|
send_response('Sorry @{0}, you are not allowed to do this'.format(tags['display-name']))
|
|
|
|
return
|
|
|
|
if at is None:
|
|
|
|
t = datetime.datetime.now().astimezone(tz=None)
|
|
|
|
else:
|
|
|
|
t = dateutil.parser.parse(at).astimezone(tz=None)
|
|
|
|
for offset, user, msg in self.messages:
|
|
|
|
def cb(user=user, msg=msg):
|
|
|
|
return self._post_message(user, msg)
|
|
|
|
self.reactor.scheduler.execute_at(t + offset, cb)
|
|
|
|
with self.reactor.mutex:
|
|
|
|
while self.reactor.scheduler.queue:
|
|
|
|
if self.reactor.scheduler.queue[0].due():
|
|
|
|
self.reactor.scheduler.queue.pop(0)
|
|
|
|
else:
|
|
|
|
break
|
|
|
|
|
|
|
|
def _do_resync(self, tags, send_response, offset, **kwargs):
|
|
|
|
if tags['user-id'] not in self.control_users:
|
|
|
|
send_response('Sorry @{0}, you are not allowed to do this'.format(tags['display-name']))
|
|
|
|
return
|
|
|
|
offset = datetime.timedelta(seconds=float(offset))
|
|
|
|
with self.reactor.mutex:
|
|
|
|
for i, dc in enumerate(self.reactor.scheduler.queue):
|
|
|
|
ndc = tempora.schedule.DelayedCommand.from_datetime(dc + offset)
|
|
|
|
ndc.delay = dc.delay + offset
|
|
|
|
ndc.target = dc.target
|
|
|
|
self.reactor.scheduler.queue[i] = ndc
|
|
|
|
while self.reactor.scheduler.queue:
|
|
|
|
if self.reactor.scheduler.queue[0].due():
|
|
|
|
self.reactor.scheduler.queue.pop(0)
|
|
|
|
else:
|
|
|
|
break
|
|
|
|
|
|
|
|
def _do_stop(self, tags, send_response, **kwargs):
|
|
|
|
if tags['user-id'] not in self.control_users:
|
|
|
|
send_response('Sorry @{0}, you are not allowed to do this'.format(tags['display-name']))
|
|
|
|
return
|
|
|
|
with self.reactor.mutex:
|
|
|
|
self.reactor.scheduler.queue.clear()
|
|
|
|
|
|
|
|
def _post_message(self, user, msg):
|
|
|
|
self.connection.privmsg(self.output_channel, '[{0}]: {1}'.format(user, msg))
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
bot = ReplayBot()
|
|
|
|
bot.start()
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
main()
|