From 946d69b140c89dc93141a8e68696cecf29c009dc Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Fri, 26 Apr 2019 17:00:23 +0100 Subject: [PATCH] Add presence tracking commands Add in/out/seen commands to let people voluntarily check in and out of tracks and other arbitrary locations, to make it easy for others to find them. Of course this is entirely optional. It's designed to cope gracefully with people forgetting to check out of locations they previously checked into. Change-Id: I0d88a540ad7a333841c208dd7f2a7247897eb238 --- README.rst | 31 +++++++++++++ html/ptg.html | 10 ++++ ptgbot/bot.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++++-- ptgbot/db.py | 47 ++++++++++++++++++- 4 files changed, 209 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index b1a7392..d134b70 100644 --- a/README.rst +++ b/README.rst @@ -12,6 +12,37 @@ with several sections of information: * The tracks pre-scheduled for the day * The tracks which booked available slots in the additional rooms +The bot also allows people to voluntarily check into (and out of) +tracks or other arbitrary locations, if they want to be found more +easily by other people. + + +User commands +============= + +Anyone can privately message the bot with the following commands: + +* ``in #TRACKNAME`` - tells the bot you are currently in the track + named ``TRACKNAME``. This must be one of the tracks it knows about, + for example: ``in #nova`` + +* ``in LOCATION`` - tells the bot you are currently in a location + which doesn't correspond to any track. This can be any freeform + text, for example: ``in the pub`` + +* ``out`` - tells the bot you've checked out of your current location. + However others will still be able to see when and where you checked + out. + +* ``seen NICK`` - asks the bot where the user with the given IRC nick + was last seen (if anywhere). The nick is case-insensitive. + +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. + Track moderators commands ========================= diff --git a/html/ptg.html b/html/ptg.html index 4bc93de..5775c9c 100644 --- a/html/ptg.html +++ b/html/ptg.html @@ -113,6 +113,16 @@ (more help) +
+

Looking for someone, or want to be easy to find?

+
+ Use #seen NICK to see if a user has checked in to a particular location. + Use #in LOCATION to check in somewhere, and #out to check out. +
+ Presence-tracking commands can also be sent privately to the bot. + (more help) +
+

Content on this page is being driven by room operators through the openstackptg bot on the #openstack-ptg IRC channel. It was last refreshed on {{timestamp}}.

diff --git a/ptgbot/bot.py b/ptgbot/bot.py index d324d55..171d014 100644 --- a/ptgbot/bot.py +++ b/ptgbot/bot.py @@ -87,6 +87,109 @@ class PTGBot(SASL, SSL, irc.bot.SingleServerIRCBot): else: self.send(channel, "There are no active tracks defined yet") + def on_privmsg(self, c, e): + if not self.identify_msg_cap: + self.log.debug("Ignoring message because identify-msg " + "cap not enabled") + return + nick = e.source.split('!')[0] + msg = e.arguments[0][1:] + words = msg.split() + cmd = words[0].lower() + words.pop(0) + + if cmd.startswith('#'): + cmd = cmd[1:] + + if cmd == 'in': + self.check_in(nick, nick, words) + elif cmd == 'out': + self.check_out(nick, nick, words) + elif cmd == 'seen': + self.last_seen(nick, nick, words) + else: + self.send_priv_or_pub(nick, None, + "Recognised commands: in, out, seen") + + def check_in(self, reply_to, nick, words): + if len(words) == 0: + self.send_priv_or_pub( + reply_to, nick, + "The 'in' command should be followed by a location.") + return + + location = " ".join(words) + + if location.startswith('#'): + track = location[1:].lower() + tracks = self.data.list_tracks() + if track not in tracks: + self.send_priv_or_pub( + reply_to, nick, "Unrecognised track #%s" % track) + return + + self.data.check_in(nick, location) + self.send_priv_or_pub( + reply_to, nick, + "OK, checked into %s - thanks for the update!" % location) + + def check_out(self, reply_to, nick, words): + if len(words) > 0: + self.send_priv_or_pub( + reply_to, nick, + "The 'out' command does not accept any extra parameters.") + return + + last_check_in = self.data.get_last_check_in(nick) + if last_check_in['location'] is None: + self.send_priv_or_pub( + reply_to, nick, "You weren't checked in anywhere yet!") + return + + if last_check_in['out'] is not None: + self.send_priv_or_pub( + reply_to, nick, + "You already checked out of %s at %s!" % + (last_check_in['location'], last_check_in['out'])) + return + + location = self.data.check_out(nick) + self.send_priv_or_pub( + reply_to, nick, + "OK, checked out of %s - thanks for the update!" % location) + + def last_seen(self, reply_to, nick, words): + if len(words) != 1: + self.send_priv_or_pub( + reply_to, nick, + "The 'seen' command needs a single nick argument.") + return + + seen_nick = words[0] + if nick.lower() == seen_nick.lower(): + self.send_priv_or_pub( + reply_to, nick, + "In case you hadn't noticed, you're right here.") + return + + last_check_in = self.data.get_last_check_in(seen_nick) + if last_check_in['location'] is None: + self.send_priv_or_pub( + reply_to, nick, + "%s never checked in anywhere" % seen_nick) + elif last_check_in['out'] is None: + self.send_priv_or_pub( + reply_to, nick, + "%s was last seen in %s at %s" % + (last_check_in['nick'], last_check_in['location'], + last_check_in['in'])) + else: + self.send_priv_or_pub( + reply_to, nick, + "%s checked out of %s at %s" % + (last_check_in['nick'], last_check_in['location'], + last_check_in['out'])) + def on_pubmsg(self, c, e): if not self.identify_msg_cap: self.log.debug("Ignoring message because identify-msg " @@ -97,15 +200,26 @@ class PTGBot(SASL, SSL, irc.bot.SingleServerIRCBot): chan = e.target if msg.startswith('#'): + words = msg.split() + cmd = words[0].lower() + + if cmd == '#in': + self.check_in(chan, nick, words[1:]) + return + elif cmd == '#out': + self.check_out(chan, nick, words[1:]) + return + elif cmd == '#seen': + self.last_seen(chan, nick, words[1:]) + return + if (self.data.is_voice_required() and not (self.channels[chan].is_voiced(nick) or self.channels[chan].is_oper(nick))): self.send(chan, "%s: Need voice to issue commands" % (nick,)) return - words = msg.split() - - if words[0] == '#help': + if cmd == '#help': self.usage(chan) return @@ -204,6 +318,12 @@ class PTGBot(SASL, SSL, irc.bot.SingleServerIRCBot): self.send(chan, "%s: unknown command '%s'" % (nick, command)) return + def send_priv_or_pub(self, target, nick, msg): + if target.startswith('#') and nick is not None: + self.send(target, "%s: %s" % (nick, msg)) + else: + self.send(target, msg) + def send(self, channel, msg): # 400 chars is an estimate of a safe line length (which can vary) chunks = textwrap.wrap(msg, 400) diff --git a/ptgbot/db.py b/ptgbot/db.py index 34fc725..599500c 100644 --- a/ptgbot/db.py +++ b/ptgbot/db.py @@ -33,7 +33,15 @@ class PTGDataBase(): 'schedule': OrderedDict(), 'voice': 0, 'motd': {'message': '', 'level': 'info'}, - 'links': OrderedDict()} + '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()} + + BASE_CHECK_IN = { + 'nick': None, # original case for use in output + 'location': None, 'in': None, 'out': None + } def __init__(self, config): self.filename = config['db_filename'] @@ -194,9 +202,44 @@ class PTGDataBase(): self.data['motd'] = {'level': '', 'message': ''} self.save() + def _blank_check_in(self): + # No need for a copy here + return OrderedDict(self.BASE_CHECK_IN) + + def get_last_check_in(self, nick): + if 'last_check_in' not in self.data: + return self._blank_check_in() + return self.data['last_check_in'].get( + nick.lower(), self._blank_check_in()) + + def check_in(self, nick, location): + if 'last_check_in' not in self.data: + self.data['last_check_in'] = OrderedDict() + self.data['last_check_in'][nick.lower()] = { + 'nick': nick, + 'location': location, + 'in': self.serialise_timestamp(datetime.datetime.now()), + 'out': None # no check-out yet + } + self.save() + + # Returns location if successfully checked out, otherwise None + def check_out(self, nick): + if 'last_check_in' not in self.data: + self.data['last_check_in'] = OrderedDict() + if nick.lower() not in self.data['last_check_in']: + return None + self.data['last_check_in'][nick.lower()]['out'] = \ + self.serialise_timestamp(datetime.datetime.now()) + self.save() + return self.data['last_check_in'][nick]['location'] + def save(self): timestamp = datetime.datetime.now() - self.data['timestamp'] = '{:%Y-%m-%d %H:%M:%S}'.format(timestamp) + self.data['timestamp'] = self.serialise_timestamp(timestamp) self.data['tracks'] = sorted(self.data['tracks']) with open(self.filename, 'w') as fp: json.dump(self.data, fp) + + def serialise_timestamp(self, timestamp): + return '{:%Y-%m-%d %H:%M:%S}'.format(timestamp)