From c81d0519a04e72fc7d1da3ef5ad84dab2213a66e Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Wed, 22 Feb 2017 20:32:05 -0600 Subject: [PATCH 1/6] countdown: fields for sending reminders extending countdown items in order to have them also store reminders, which are the same idea (an event at a time) but these are watched by the irc bot and sent to the specified destination when the time is reached this adds support for configuring this, code in support of it is coming in later commits bss/dr.botzo#11 --- .../migrations/0003_auto_20170222_2025.py | 35 +++++++++++++++++++ countdown/models.py | 6 ++++ 2 files changed, 41 insertions(+) create mode 100644 countdown/migrations/0003_auto_20170222_2025.py 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..cc56e7a 100644 --- a/countdown/models.py +++ b/countdown/models.py @@ -16,6 +16,12 @@ class CountdownItem(models.Model): 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): From beff6638d070de32e6d18dc11e2dc1b8e87b0ef4 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Wed, 22 Feb 2017 20:34:03 -0600 Subject: [PATCH 2/6] countdown: poll db for reminders, send to target every second (!?) a new thread checks the database for reminder countdown items that haven't been reminded about yet. when the at time is reached, the thread sends the reminder and notes that a reminder was sent bss/dr.botzo#11 --- countdown/ircplugin.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/countdown/ircplugin.py b/countdown/ircplugin.py index ec8b713..53cb2bc 100644 --- a/countdown/ircplugin.py +++ b/countdown/ircplugin.py @@ -1,6 +1,8 @@ """Access to countdown items through bot commands.""" import logging +import threading +import time from dateutil.relativedelta import relativedelta @@ -17,8 +19,21 @@ class Countdown(Plugin): """Report on countdown items.""" + 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) @@ -29,12 +44,29 @@ class Countdown(Plugin): 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) 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_item_detail(self, connection, event, match): """Provide the details of one countdown item.""" From ba5369bd31442859fdd62574b84c980b2b9766aa Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Wed, 22 Feb 2017 20:39:52 -0600 Subject: [PATCH 3/6] countdown: prospector docstring stuff bss/dr.botzo#17 --- countdown/__init__.py | 1 + countdown/ircplugin.py | 3 --- countdown/models.py | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) 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 53cb2bc..8765f63 100644 --- a/countdown/ircplugin.py +++ b/countdown/ircplugin.py @@ -16,7 +16,6 @@ log = logging.getLogger('countdown.ircplugin') class Countdown(Plugin): - """Report on countdown items.""" def __init__(self, bot, connection, event): @@ -69,7 +68,6 @@ class Countdown(Plugin): def handle_item_detail(self, connection, event, match): """Provide the details of one countdown item.""" - name = match.group(1) if name != 'list': @@ -97,7 +95,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/models.py b/countdown/models.py index cc56e7a..1336657 100644 --- a/countdown/models.py +++ b/countdown/models.py @@ -10,7 +10,6 @@ log = logging.getLogger('countdown.models') class CountdownItem(models.Model): - """Track points in time.""" name = models.CharField(max_length=64, default='') @@ -26,5 +25,4 @@ class CountdownItem(models.Model): 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')) From a6c2f91dc0e1ae686b7c174633ba72bc9eb2345b Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Wed, 22 Feb 2017 22:06:00 -0600 Subject: [PATCH 4/6] add parsedatetime dep, for countdown reminders bss/dr.botzo#11 --- requirements-dev.txt | 4 +++- requirements-server.txt | 4 +++- requirements.in | 1 + requirements.txt | 4 +++- 4 files changed, 10 insertions(+), 3 deletions(-) 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 From 9c4e0fe782753cd1d364102f6dbe14f88937b213 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Wed, 22 Feb 2017 22:07:39 -0600 Subject: [PATCH 5/6] countdown: receive reminder countdowns over IRC bss/dr.botzo#11 --- countdown/ircplugin.py | 66 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/countdown/ircplugin.py b/countdown/ircplugin.py index 8765f63..90c5091 100644 --- a/countdown/ircplugin.py +++ b/countdown/ircplugin.py @@ -1,16 +1,18 @@ """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') @@ -18,6 +20,8 @@ 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 = [] @@ -39,6 +43,10 @@ class Countdown(Plugin): 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): @@ -49,6 +57,9 @@ class Countdown(Plugin): 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): @@ -66,6 +77,55 @@ class Countdown(Plugin): 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) From 1bbb64618dd7f33f8432d5dea618601d49cec7b0 Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Wed, 22 Feb 2017 22:08:04 -0600 Subject: [PATCH 6/6] bot: capture exceptions, try to report on them it beats crashing, probably --- ircbot/bot.py | 85 +++++++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 40 deletions(-) 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.