Merge "Add presence tracking commands"
This commit is contained in:
commit
9cb30c28a9
31
README.rst
31
README.rst
|
@ -12,6 +12,37 @@ with several sections of information:
|
||||||
* The tracks pre-scheduled for the day
|
* The tracks pre-scheduled for the day
|
||||||
* The tracks which booked available slots in the additional rooms
|
* 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
|
Track moderators commands
|
||||||
=========================
|
=========================
|
||||||
|
|
|
@ -113,6 +113,16 @@
|
||||||
<a href="https://opendev.org/openstack/ptgbot/src/branch/master/README.rst">(more help)</a>
|
<a href="https://opendev.org/openstack/ptgbot/src/branch/master/README.rst">(more help)</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
</script>
|
||||||
|
|
||||||
|
|
126
ptgbot/bot.py
126
ptgbot/bot.py
|
@ -87,6 +87,109 @@ class PTGBot(SASL, SSL, irc.bot.SingleServerIRCBot):
|
||||||
else:
|
else:
|
||||||
self.send(channel, "There are no active tracks defined yet")
|
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):
|
def on_pubmsg(self, c, e):
|
||||||
if not self.identify_msg_cap:
|
if not self.identify_msg_cap:
|
||||||
self.log.debug("Ignoring message because identify-msg "
|
self.log.debug("Ignoring message because identify-msg "
|
||||||
|
@ -97,15 +200,26 @@ class PTGBot(SASL, SSL, irc.bot.SingleServerIRCBot):
|
||||||
chan = e.target
|
chan = e.target
|
||||||
|
|
||||||
if msg.startswith('#'):
|
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
|
if (self.data.is_voice_required() and not
|
||||||
(self.channels[chan].is_voiced(nick) or
|
(self.channels[chan].is_voiced(nick) or
|
||||||
self.channels[chan].is_oper(nick))):
|
self.channels[chan].is_oper(nick))):
|
||||||
self.send(chan, "%s: Need voice to issue commands" % (nick,))
|
self.send(chan, "%s: Need voice to issue commands" % (nick,))
|
||||||
return
|
return
|
||||||
|
|
||||||
words = msg.split()
|
if cmd == '#help':
|
||||||
|
|
||||||
if words[0] == '#help':
|
|
||||||
self.usage(chan)
|
self.usage(chan)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -204,6 +318,12 @@ class PTGBot(SASL, SSL, irc.bot.SingleServerIRCBot):
|
||||||
self.send(chan, "%s: unknown command '%s'" % (nick, command))
|
self.send(chan, "%s: unknown command '%s'" % (nick, command))
|
||||||
return
|
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):
|
def send(self, channel, msg):
|
||||||
# 400 chars is an estimate of a safe line length (which can vary)
|
# 400 chars is an estimate of a safe line length (which can vary)
|
||||||
chunks = textwrap.wrap(msg, 400)
|
chunks = textwrap.wrap(msg, 400)
|
||||||
|
|
47
ptgbot/db.py
47
ptgbot/db.py
|
@ -33,7 +33,15 @@ class PTGDataBase():
|
||||||
'schedule': OrderedDict(),
|
'schedule': OrderedDict(),
|
||||||
'voice': 0,
|
'voice': 0,
|
||||||
'motd': {'message': '', 'level': 'info'},
|
'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):
|
def __init__(self, config):
|
||||||
self.filename = config['db_filename']
|
self.filename = config['db_filename']
|
||||||
|
@ -194,9 +202,44 @@ class PTGDataBase():
|
||||||
self.data['motd'] = {'level': '', 'message': ''}
|
self.data['motd'] = {'level': '', 'message': ''}
|
||||||
self.save()
|
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):
|
def save(self):
|
||||||
timestamp = datetime.datetime.now()
|
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'])
|
self.data['tracks'] = sorted(self.data['tracks'])
|
||||||
with open(self.filename, 'w') as fp:
|
with open(self.filename, 'w') as fp:
|
||||||
json.dump(self.data, fp)
|
json.dump(self.data, fp)
|
||||||
|
|
||||||
|
def serialise_timestamp(self, timestamp):
|
||||||
|
return '{:%Y-%m-%d %H:%M:%S}'.format(timestamp)
|
||||||
|
|
Loading…
Reference in New Issue