Add subscribe command for automatic notifications of topics

Add a new 'subscribe' command which allows people to subscribe for
automatic notifications via direct message of topics which match the
subscription regex they provide.  With no regex argument it shows the
user's current subscription (if any).  Also add a new 'unsubscribe'
command for clearing the regex.

Example use cases:

1. I know that nova is planning to discuss $TOPIC some time tomorrow
   but they don't know exactly when, and I want to spend most of the
   day in another room whilst ensuring I don't miss that particular
   discussion on $TOPIC => "/msg ptgbot subscribe $TOPIC" will give me
   notifications when the PTL types "#nova next $TOPIC" and "#nova now

2. I'm interested in *all* discussion on Python 3.  I don't know which
   projects are planning to discuss it, let alone when, but that
   doesn't matter, because I can type "/msg ptgbot subscribe python ?3"
   and get notified of all Python 3 discussions.

As with the presence tracking commands, these commands can be used in
public channels by preceding them with a '#' character.

Change-Id: I3f51acc318ecf31d435768640cef6c46d8ca136c
Adam Spiers 2019-04-29 22:50:44 -06:00
parent 9cb30c28a9
commit e8f88b2ee1
4 changed files with 114 additions and 5 deletions

View File

@ -37,11 +37,25 @@ Anyone can privately message the bot with the following commands:
* ``seen NICK`` - asks the bot where the user with the given IRC nick
was last seen (if anywhere). The nick is case-insensitive.
* ``subscribe REGEXP`` - subscribes for a direct message notification
from the bot whenever a topic with a substring matching ``REGEXP``
is set via the ``now`` or ``next`` commands (see below). The exact
string the (case-insensitive) regular expression will be matched
against is of the form ``#track now topic`` (i.e. the same as the
full commands issued by track moderators). So for example
``subscribe #nova.*test|python *3`` would match any testing topics
in the nova track, and any Python 3 topics in any track.
* ``subscribe`` - shows your current subscription regular expression
(if any)
* ``unsubscribe`` - cancels your current subscription (if any)
The above commands also work in the channel when prefixed with ``#``,
for example ``#in the pub``. You can use the ``#`` prefix with
private messages to the bot too, in case you don't want to memorise
different syntax for these presence-tracking commands depending on
whether you are messaging the bot privately or in a channel.
different syntax for these commands depending on whether you are
messaging the bot privately or in a channel.
Track moderators commands

View File

@ -113,6 +113,15 @@
<a href="">(more help)</a>
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title">Worried about missing discussions on your favourite topic?</h3></div>
<div class="bot-help">
Message the bot with <code>subscribe REGEXP</code> to get a
notification message when any topic matching that REGEXP is being
discussed or up next.
<a href="">(more help)</a>
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title">Looking for someone, or want to be easy to find?</h3></div>
<div class="bot-help">

View File

@ -21,6 +21,7 @@ from ib3.connection import SSL
import json
import logging.config
import re
import os
import time
import textwrap
@ -107,9 +108,13 @@ class PTGBot(SASL, SSL,
self.check_out(nick, nick, words)
elif cmd == 'seen':
self.last_seen(nick, nick, words)
elif cmd == 'subscribe':
self.subscribe(nick, nick, msg.lstrip('#' + cmd).strip())
elif cmd == 'unsubscribe':
self.unsubscribe(nick, nick)
self.send_priv_or_pub(nick, None,
"Recognised commands: in, out, seen")
nick, None, "Recognised commands: in, out, seen, subscribe")
def check_in(self, reply_to, nick, words):
if len(words) == 0:
@ -190,6 +195,40 @@ class PTGBot(SASL, SSL,
(last_check_in['nick'], last_check_in['location'],
def subscribe(self, reply_to, nick, new_re):
existing_re =
if new_re == "":
if existing_re is None:
reply_to, nick,
"You don't have a subscription regex set yet"
reply_to, nick,
"Your current subscription regex is: " + existing_re)
else:, new_re)
reply_to, nick,
"Subscription set to " + new_re +
(" (was %s)" % existing_re if existing_re else "")
def unsubscribe(self, reply_to, nick):
existing_re =
if existing_re is None:
reply_to, nick,
"You don't have a subscription regex set yet"
else:, None)
reply_to, nick,
"Cancelled subscription %s" % existing_re
def on_pubmsg(self, c, e):
if not self.identify_msg_cap:
self.log.debug("Ignoring message because identify-msg "
@ -213,6 +252,13 @@ class PTGBot(SASL, SSL,
self.last_seen(chan, nick, words[1:])
elif cmd == '#subscribe':
self.subscribe(chan, nick, msg.lstrip('#' + cmd).strip())
elif cmd == '#unsubscribe':
self.unsubscribe(chan, nick)
if ( and not
(self.channels[chan].is_voiced(nick) or
@ -239,8 +285,10 @@ class PTGBot(SASL, SSL,
params = str.join(' ', words[2:])
if adverb == 'now':, params)
self.notify(track, adverb, params)
elif adverb == 'next':, params)
self.notify(track, adverb, params)
elif adverb == 'clean':[track])
elif adverb == 'color':
@ -318,6 +366,24 @@ class PTGBot(SASL, SSL,
self.send(chan, "%s: unknown command '%s'" % (nick, command))
def notify(self, track, adverb, params):
location =
track = '#' + track
trackloc = track
if location is not None:
trackloc = "%s (%s)" % (track, location)
for nick, regexp in
event_text = " ".join([track, adverb, params])
if, event_text, re.IGNORECASE):
message = "%s in %s: %s" % (adverb, trackloc, params)
# Note: there is no guarantee that nick will be online
# at this point. However if not, the bot will receive
# a 401 :No such nick/channel message which it will
# ignore due to the lack of a nosuchnick handler.
# Fortunately this is the behaviour we want.
self.send(nick, message)
def send_priv_or_pub(self, target, nick, msg):
if target.startswith('#') and nick is not None:
self.send(target, "%s: %s" % (nick, msg))

View File

@ -36,7 +36,8 @@ class PTGDataBase():
'links': OrderedDict(),
# Keys for last_check_in are lower-cased nicks;
# values are in the same format as BASE_CHECK_IN
'last_check_in': OrderedDict()}
'last_check_in': OrderedDict(),
'subscriptions': OrderedDict()}
'nick': None, # original case for use in output
@ -117,6 +118,9 @@ class PTGDataBase():['location'][track] = location
def get_location(self, track):
def add_next(self, track, session):
if track not in['next']:['next'][track] = []
@ -234,6 +238,22 @@ class PTGDataBase():
def get_subscription(self, nick):
if 'subscriptions' not in
return None
def get_subscriptions(self):
if 'subscriptions' not in
return {}
def set_subscription(self, nick, regexp):
if 'subscriptions' not in['subscriptions'] = OrderedDict()['subscriptions'][nick] = regexp
def save(self):
timestamp =['timestamp'] = self.serialise_timestamp(timestamp)