diff --git a/countdown/__init__.py b/countdown/__init__.py index e69de29..005fc73 100644 --- a/countdown/__init__.py +++ b/countdown/__init__.py @@ -0,0 +1 @@ +"""Countdown supports denoting points in time, and sending reminders about them to IRC.""" diff --git a/countdown/ircplugin.py b/countdown/ircplugin.py index ec8b713..90c5091 100644 --- a/countdown/ircplugin.py +++ b/countdown/ircplugin.py @@ -1,43 +1,133 @@ """Access to countdown items through bot commands.""" import logging +import re +import threading +import time +import irc.client +import parsedatetime as pdt +from dateutil.parser import parse from dateutil.relativedelta import relativedelta - from django.utils import timezone -from ircbot.lib import Plugin from countdown.models import CountdownItem - +from ircbot.lib import Plugin, reply_destination_for_event log = logging.getLogger('countdown.ircplugin') class Countdown(Plugin): - """Report on countdown items.""" + new_reminder_regex = r'remind\s+([^\s]+)\s+(at|in|on)\s+(.*)\s+(to|that)\s+(.*)' + + def __init__(self, bot, connection, event): + """Initialize some stuff.""" + self.running_reminders = [] + self.send_reminders = True + + t = threading.Thread(target=self.reminder_thread) + t.daemon = True + t.start() + + super(Countdown, self).__init__(bot, connection, event) + def start(self): """Set up handlers.""" + self.running_reminders = [] + self.send_reminders = True self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+list$', self.handle_item_list, -20) self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+(\S+)$', self.handle_item_detail, -20) + # let this interrupt markov + self.connection.add_global_handler('pubmsg', self.handle_new_reminder, -50) + self.connection.add_global_handler('privmsg', self.handle_new_reminder, -50) + super(Countdown, self).start() def stop(self): """Tear down handlers.""" + self.running_reminders = [] + self.send_reminders = False self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_item_list) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_item_detail) + self.connection.remove_global_handler('pubmsg', self.handle_new_reminder) + self.connection.remove_global_handler('privmsg', self.handle_new_reminder) + super(Countdown, self).stop() + def reminder_thread(self): + """After IRC is started up, begin sending reminders.""" + time.sleep(30) + while self.send_reminders: + reminders = CountdownItem.objects.filter(is_reminder=True, sent_reminder=False) + + for reminder in reminders: + log.debug("%s @ %s", reminder.reminder_message, reminder.at_time) + if reminder.at_time <= timezone.now(): + log.info("sending %s to %s", reminder.reminder_message, reminder.reminder_target) + self.bot.reply(None, reminder.reminder_message, explicit_target=reminder.reminder_target) + reminder.sent_reminder = True + reminder.save() + time.sleep(1) + + def handle_new_reminder(self, connection, event): + """Watch IRC for requests to remind new things, create countdown items.""" + what = event.arguments[0] + my_nick = connection.get_nickname() + addressed_my_nick = r'^{0:s}[:,]\s+'.format(my_nick) + sender_nick = irc.client.NickMask(event.source).nick + sent_location = reply_destination_for_event(event) + + if re.search(addressed_my_nick, what, re.IGNORECASE) is not None: + # we were addressed, were we told to add a reminder? + trimmed_what = re.sub(addressed_my_nick, '', what) + log.debug(trimmed_what) + + match = re.match(self.new_reminder_regex, trimmed_what, re.IGNORECASE) + if match: + log.debug("%s is a new reminder request", trimmed_what) + who = match.group(1) + when_type = match.group(2) + when = match.group(3) + text = match.group(5) + log.debug("%s / %s / %s", who, when, text) + + item_name = '{0:s}-{1:s}'.format(sender_nick, timezone.now().strftime('%s')) + + # parse when to send the notification + if when_type == 'in': + # relative time + calendar = pdt.Calendar() + when_t = calendar.parseDT(when, timezone.localtime(timezone.now()), + tzinfo=timezone.get_current_timezone())[0] + else: + # absolute time + when_t = timezone.make_aware(parse(when)) + + # parse the person to address, if anyone, when sending the notification + if who == 'us' or who == sent_location: + message = text + elif who == 'me': + message = '{0:s}: {1:s}'.format(sender_nick, text) + else: + message = '{0:s}: {1:s}'.format(who, text) + log.debug("%s / %s / %s", item_name, when_t, message) + + countdown_item = CountdownItem.objects.create(name=item_name, at_time=when_t, is_reminder=True, + reminder_message=message, reminder_target=sent_location) + log.info("created countdown item %s", str(countdown_item)) + + return 'NO MORE' + def handle_item_detail(self, connection, event, match): """Provide the details of one countdown item.""" - name = match.group(1) if name != 'list': @@ -65,7 +155,6 @@ class Countdown(Plugin): def handle_item_list(self, connection, event, match): """List all countdown items.""" - items = CountdownItem.objects.all() if len(items) > 0: reply = "countdown items: {0:s}".format(", ".join([x.name for x in items])) diff --git a/countdown/migrations/0003_auto_20170222_2025.py b/countdown/migrations/0003_auto_20170222_2025.py new file mode 100644 index 0000000..af04f97 --- /dev/null +++ b/countdown/migrations/0003_auto_20170222_2025.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-23 02:25 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('countdown', '0002_remove_countdownitem_source'), + ] + + operations = [ + migrations.AddField( + model_name='countdownitem', + name='is_reminder', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='countdownitem', + name='reminder_message', + field=models.TextField(default=''), + ), + migrations.AddField( + model_name='countdownitem', + name='reminder_target', + field=models.CharField(default='', max_length=64), + ), + migrations.AddField( + model_name='countdownitem', + name='sent_reminder', + field=models.BooleanField(default=False), + ), + ] diff --git a/countdown/models.py b/countdown/models.py index 6622b90..1336657 100644 --- a/countdown/models.py +++ b/countdown/models.py @@ -10,15 +10,19 @@ log = logging.getLogger('countdown.models') class CountdownItem(models.Model): - """Track points in time.""" name = models.CharField(max_length=64, default='') at_time = models.DateTimeField() + is_reminder = models.BooleanField(default=False) + sent_reminder = models.BooleanField(default=False) + + reminder_message = models.TextField(default="") + reminder_target = models.CharField(max_length=64, default='') + created_time = models.DateTimeField(auto_now_add=True) def __str__(self): """String representation.""" - return "{0:s} @ {1:s}".format(self.name, timezone.localtime(self.at_time).strftime('%Y-%m-%d %H:%M:%S %Z')) diff --git a/ircbot/bot.py b/ircbot/bot.py index e01b82a..c6cb81c 100644 --- a/ircbot/bot.py +++ b/ircbot/bot.py @@ -144,51 +144,56 @@ class DrReactor(irc.client.Reactor): Also supports regex handlers. """ - log.debug("EVENT: e[%s] s[%s] t[%s] a[%s]", event.type, event.source, - event.target, event.arguments) + try: + log.debug("EVENT: e[%s] s[%s] t[%s] a[%s]", event.type, event.source, + event.target, event.arguments) - self.try_recursion(connection, event) + self.try_recursion(connection, event) - # only do aliasing for pubmsg/privmsg - if event.type in ['pubmsg', 'privmsg']: - what = event.arguments[0] - log.debug("checking for alias for %s", what) + # only do aliasing for pubmsg/privmsg + if event.type in ['pubmsg', 'privmsg']: + what = event.arguments[0] + log.debug("checking for alias for %s", what) - for alias in Alias.objects.all(): - repl = alias.replace(what) - if repl: - # we found an alias for our given string, doing a replace - event.arguments[0] = repl - # recurse, in case there's another alias in this one - return self._handle_event(connection, event) + for alias in Alias.objects.all(): + repl = alias.replace(what) + if repl: + # we found an alias for our given string, doing a replace + event.arguments[0] = repl + # recurse, in case there's another alias in this one + return self._handle_event(connection, event) - with self.mutex: - # doing regex version first as it has the potential to be more specific - log.debug("checking regex handlers for %s", event.type) - matching_handlers = sorted( - self.regex_handlers.get("all_events", []) + - self.regex_handlers.get(event.type, []) - ) - log.debug("got %d", len(matching_handlers)) - for handler in matching_handlers: - log.debug("checking %s vs. %s", handler, event.arguments) - for line in event.arguments: - match = re.search(handler.regex, line) - if match: - log.debug("match!") - result = handler.callback(connection, event, match) - if result == "NO MORE": - return + with self.mutex: + # doing regex version first as it has the potential to be more specific + log.debug("checking regex handlers for %s", event.type) + matching_handlers = sorted( + self.regex_handlers.get("all_events", []) + + self.regex_handlers.get(event.type, []) + ) + log.debug("got %d", len(matching_handlers)) + for handler in matching_handlers: + log.debug("checking %s vs. %s", handler, event.arguments) + for line in event.arguments: + match = re.search(handler.regex, line) + if match: + log.debug("match!") + result = handler.callback(connection, event, match) + if result == "NO MORE": + return - matching_handlers = sorted( - self.handlers.get("all_events", []) + - self.handlers.get(event.type, []) - ) - for handler in matching_handlers: - log.debug("not-match") - result = handler.callback(connection, event) - if result == "NO MORE": - return + matching_handlers = sorted( + self.handlers.get("all_events", []) + + self.handlers.get(event.type, []) + ) + for handler in matching_handlers: + log.debug("not-match") + result = handler.callback(connection, event) + if result == "NO MORE": + return + except Exception as ex: + log.error("caught exception!") + log.exception(ex) + connection.privmsg(event.target, str(ex)) def try_recursion(self, connection, event): """Scan message for subcommands to execute and use as part of this command. diff --git a/requirements-dev.txt b/requirements-dev.txt index fe3339f..0082d1d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,10 +11,11 @@ django-adminplus==0.5 django-bootstrap3==8.1.0 django-extensions==1.7.6 django-registration-redux==1.4 -Django==1.10.5 +django==1.10.5 djangorestframework==3.5.3 dodgy==0.1.9 # via prospector first==2.0.1 # via pip-tools +future==0.16.0 # via parsedatetime inflect==0.2.5 # via jaraco.itertools irc==15.0.6 isort==4.2.5 # via pylint @@ -31,6 +32,7 @@ mccabe==0.6.1 # via prospector, pylint more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools oauthlib==2.0.1 # via requests-oauthlib packaging==16.8 # via setuptools +parsedatetime==2.2 pep8-naming==0.4.1 # via prospector pip-tools==1.8.0 ply==3.10 diff --git a/requirements-server.txt b/requirements-server.txt index 6423a8f..2e124c3 100644 --- a/requirements-server.txt +++ b/requirements-server.txt @@ -8,8 +8,9 @@ django-adminplus==0.5 django-bootstrap3==8.1.0 django-extensions==1.7.6 django-registration-redux==1.4 -Django==1.10.5 +django==1.10.5 djangorestframework==3.5.3 +future==0.16.0 # via parsedatetime inflect==0.2.5 # via jaraco.itertools irc==15.0.6 jaraco.classes==1.4 # via jaraco.collections @@ -21,6 +22,7 @@ jaraco.stream==1.1.1 # via irc jaraco.text==1.9 # via irc, jaraco.collections more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools oauthlib==2.0.1 # via requests-oauthlib +parsedatetime==2.2 ply==3.10 psycopg2==2.6.2 python-dateutil==2.6.0 diff --git a/requirements.in b/requirements.in index afbcf18..511688d 100644 --- a/requirements.in +++ b/requirements.in @@ -5,6 +5,7 @@ django-extensions # more commands django-registration-redux # registration views/forms djangorestframework # dispatch WS API irc # core +parsedatetime # relative date stuff in countdown ply # dice lex/yacc compiler python-dateutil # countdown relative math python-gitlab # client for the gitlab bot diff --git a/requirements.txt b/requirements.txt index c18d544..46a053c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,8 +8,9 @@ django-adminplus==0.5 django-bootstrap3==8.1.0 django-extensions==1.7.6 django-registration-redux==1.4 -Django==1.10.5 +django==1.10.5 djangorestframework==3.5.3 +future==0.16.0 # via parsedatetime inflect==0.2.5 # via jaraco.itertools irc==15.0.6 jaraco.classes==1.4 # via jaraco.collections @@ -21,6 +22,7 @@ jaraco.stream==1.1.1 # via irc jaraco.text==1.9 # via irc, jaraco.collections more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools oauthlib==2.0.1 # via requests-oauthlib +parsedatetime==2.2 ply==3.10 python-dateutil==2.6.0 python-gitlab==0.18