Browse Source

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
changes/86/655986/1
Adam Spiers 8 months ago
parent
commit
946d69b140
4 changed files with 209 additions and 5 deletions
  1. +31
    -0
      README.rst
  2. +10
    -0
      html/ptg.html
  3. +123
    -3
      ptgbot/bot.py
  4. +45
    -2
      ptgbot/db.py

+ 31
- 0
README.rst View File

@@ -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
=========================

+ 10
- 0
html/ptg.html View File

@@ -113,6 +113,16 @@
<a href="https://opendev.org/openstack/ptgbot/src/branch/master/README.rst">(more help)</a>
</div>
</div>
<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">
Use <code>#seen NICK</code> to see if a user has checked in to a particular location.
Use <code>#in LOCATION</code> to check in somewhere, and <code>#out</code> to check out.
<br />
Presence-tracking commands can also be sent privately to the bot.
<a href="https://opendev.org/openstack/ptgbot/src/branch/master/README.rst">(more help)</a>
</div>
</div>
<p class="text-muted">Content on this page is being driven by room operators through the <a href="https://opendev.org/openstack/ptgbot/src/branch/master/README.rst">openstackptg bot</a> on the <a href="http://eavesdrop.openstack.org/irclogs/%23openstack-ptg/">#openstack-ptg IRC channel</a>. It was last refreshed on {{timestamp}}.</p>
</script>


+ 123
- 3
ptgbot/bot.py View File

@@ -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)

+ 45
- 2
ptgbot/db.py View File

@@ -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)

Loading…
Cancel
Save