Source code for the PTG event scheduling bot
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

465 lines
17 KiB

# Copyright 2011, 2013 OpenStack Foundation
# Copyright 2012 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import collections
import daemon
from ib3.auth import SASL
from ib3.connection import SSL
import irc.bot
import json
import logging.config
import re
import os
import time
import textwrap
import ptgbot.db
try:
import daemon.pidlockfile as pid_file_module
except ImportError:
# as of python-daemon 1.6 it doesn't bundle pidlockfile anymore
# instead it depends on lockfile-0.9.1
import daemon.pidfile as pid_file_module
# https://bitbucket.org/jaraco/irc/issue/34/
# irc-client-should-not-crash-on-failed
# ^ This is why pep8 is a bad idea.
irc.client.ServerConnection.buffer_class.errors = 'replace'
# If a long message is split, how long to sleep between sending parts
# of a message. This is lower than the general recommended interval,
# but in practice freenode allows short bursts at a higher rate.
MESSAGE_CONTINUATION_SLEEP = 0.5
# The amount of time to sleep between messages.
ANTI_FLOOD_SLEEP = 2
DOC_URL = 'https://opendev.org/openstack/ptgbot/src/branch/master/README.rst'
class PTGBot(SASL, SSL, irc.bot.SingleServerIRCBot):
log = logging.getLogger("ptgbot.bot")
def __init__(self, nickname, password, server, port, channel, db):
super(PTGBot, self).__init__(
server_list=[(server, port)],
nickname=nickname,
realname=nickname,
ident_password=password,
channels=[channel])
self.nickname = nickname
self.password = password
self.channel = channel
self.identify_msg_cap = False
self.data = db
def on_welcome(self, c, e):
self.identify_msg_cap = False
self.log.debug("Requesting identify-msg capability")
c.cap('REQ', 'identify-msg')
c.cap('END')
def on_cap(self, c, e):
self.log.debug("Received cap response %s" % repr(e.arguments))
if e.arguments[0] == 'ACK' and 'identify-msg' in e.arguments[1]:
self.log.debug("identify-msg cap acked")
self.identify_msg_cap = True
def usage(self, channel):
self.send(channel, "I accept commands in the following format: "
"'#TRACK COMMAND [PARAMETERS]'")
self.send(channel, "See doc at: " + DOC_URL)
def send_track_list(self, channel):
tracks = self.data.list_tracks()
if tracks:
self.send(channel, "Active tracks: %s" % str.join(' ', tracks))
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)
elif cmd == 'subscribe':
self.subscribe(nick, nick, msg.lstrip('#' + cmd).strip())
elif cmd == 'unsubscribe':
self.unsubscribe(nick, nick)
else:
self.send_priv_or_pub(
nick, None, "Recognised commands: in, out, seen, subscribe")
# Checks location against known tracks. If prefixed with # then
# insists it must match a known track. If not #-prefixed but
# matches a known track then the # prefix is added. Returns the
# normalized location to check into, or None if a valid one was
# not established. When matching/matched against a known track,
# it will be lower-cased. This assumes that all registered tracks
# are lower-case.
def normalize_location(self, reply_to, nick, location):
tracks = self.data.list_tracks()
if location.startswith('#'):
track = location[1:].lower()
if track in tracks:
return location.lower()
else:
self.send_priv_or_pub(
reply_to, nick, "Unrecognised track #%s" % track)
return None
else:
if location.lower() in tracks:
return '#' + location.lower()
else:
# Free-form location
return location
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)
location = self.normalize_location(reply_to, nick, location)
if location is None:
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]
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 subscribe(self, reply_to, nick, new_re):
existing_re = self.data.get_subscription(nick)
if new_re == "":
if existing_re is None:
self.send_priv_or_pub(
reply_to, nick,
"You don't have a subscription regex set yet"
)
else:
self.send_priv_or_pub(
reply_to, nick,
"Your current subscription regex is: " + existing_re)
else:
try:
re.compile(new_re)
except Exception as e:
self.send_priv_or_pub(reply_to, nick, "Invalid regex: %s" % e)
else:
self.data.set_subscription(nick, new_re)
self.send_priv_or_pub(
reply_to, nick,
"Subscription set to " + new_re +
(" (was %s)" % existing_re if existing_re else "")
)
def unsubscribe(self, reply_to, nick):
existing_re = self.data.get_subscription(nick)
if existing_re is None:
self.send_priv_or_pub(
reply_to, nick,
"You don't have a subscription regex set yet"
)
else:
self.data.set_subscription(nick, None)
self.send_priv_or_pub(
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 "
"cap not enabled")
return
nick = e.source.split('!')[0]
msg = e.arguments[0][1:]
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
elif cmd == '#subscribe':
self.subscribe(chan, nick, msg.lstrip('#' + cmd).strip())
return
elif cmd == '#unsubscribe':
self.unsubscribe(chan, nick)
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
if cmd == '#help':
self.usage(chan)
return
if ((len(words) < 2) or
(len(words) == 2 and words[1].lower() != 'clean')):
self.send(chan, "%s: Incorrect arguments" % (nick,))
self.usage(chan)
return
track = words[0][1:].lower()
if not self.data.is_track_valid(track):
self.send(chan, "%s: unknown track '%s'" % (nick, track))
self.send_track_list(chan)
return
adverb = words[1].lower()
params = str.join(' ', words[2:])
if adverb == 'now':
self.data.add_now(track, params)
self.notify(track, adverb, params)
elif adverb == 'next':
self.data.add_next(track, params)
self.notify(track, adverb, params)
elif adverb == 'clean':
self.data.clean_tracks([track])
elif adverb == 'color':
self.data.add_color(track, params)
elif adverb == 'location':
self.data.add_location(track, params)
elif adverb == 'book':
room, sep, timeslot = params.partition('-')
if self.data.is_slot_valid_and_empty(room, timeslot):
self.data.book(track, room, timeslot)
self.send(chan, "%s: Room %s is now booked on %s for %s" %
(nick, room, timeslot, track))
else:
self.send(chan, "%s: slot '%s' is invalid (or booked)" %
(nick, params))
elif adverb == 'unbook':
room, sep, timeslot = params.partition('-')
if self.data.is_slot_booked_for_track(track, room, timeslot):
self.data.unbook(room, timeslot)
self.send(chan, "%s: Room %s (previously booked for %s) "
"is now free on %s" %
(nick, room, track, timeslot))
else:
self.send(chan, "%s: slot '%s' is invalid "
"(or not booked for %s)" %
(nick, params, track))
else:
self.send(chan, "%s: unknown directive '%s'. "
"Did you mean: %s now %s... ?" %
(nick, adverb, track, adverb))
return
if adverb in ['now', 'next']:
if not self.data.get_track_room(track):
self.send(chan, "%s: message added, but please note that "
"track '%s' does not appear to have a room "
"scheduled today." % (nick, track))
if msg.startswith('~'):
if not self.channels[chan].is_oper(nick):
self.send(chan, "%s: Need op for admin commands" % (nick,))
return
words = msg.split()
command = words[0][1:].lower()
if command == 'emptydb':
self.data.empty()
elif command == 'fetchdb':
url = words[1]
self.send(chan, "Loading DB from %s ..." % url)
try:
self.data.import_json(url)
self.send(chan, "Done.")
except Exception as e:
self.send(chan, "Error loading DB: %s" % e)
elif command == 'newday':
self.data.new_day_cleanup()
elif command == 'motd':
if len(words) < 3:
self.send(chan, "Not enough params (~motd LEVEL MESSAGE)")
return
self.data.motd(words[1], str.join(' ', words[2:]))
elif command == 'cleanmotd':
self.data.clean_motd()
elif command == 'requirevoice':
self.data.require_voice()
elif command == 'alloweveryone':
self.data.allow_everyone()
elif command == 'list':
self.send_track_list(chan)
elif command in ('clean', 'add', 'del'):
if len(words) < 2:
self.send(chan, "this command takes one or more arguments")
return
getattr(self.data, command + '_tracks')(words[1:])
else:
self.send(chan, "%s: unknown command '%s'" % (nick, command))
return
def notify(self, track, adverb, params):
location = self.data.get_location(track)
track = '#' + track
trackloc = track
if location is not None:
trackloc = "%s (%s)" % (track, location)
for nick, regexp in self.data.get_subscriptions().items():
event_text = " ".join([track, adverb, params])
if re.search(regexp, 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))
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)
if len(chunks) > 10:
raise Exception("Unusually large message: %s" % (msg,))
for count, chunk in enumerate(chunks):
self.connection.privmsg(channel, chunk)
if count:
time.sleep(MESSAGE_CONTINUATION_SLEEP)
time.sleep(ANTI_FLOOD_SLEEP)
def start(configpath):
with open(configpath, 'r') as fp:
config = json.load(fp, object_pairs_hook=collections.OrderedDict)
if 'log_config' in config:
log_config = config['log_config']
fp = os.path.expanduser(log_config)
if not os.path.exists(fp):
raise Exception("Unable to read logging config file at %s" % fp)
logging.config.fileConfig(fp)
else:
logging.basicConfig(level=logging.DEBUG)
db = ptgbot.db.PTGDataBase(config)
bot = PTGBot(config['irc_nick'],
config.get('irc_pass', ''),
config['irc_server'],
config['irc_port'],
config['irc_channel'],
db)
bot.start()
def main():
parser = argparse.ArgumentParser(description='PTG bot.')
parser.add_argument('configfile', help='specify the config file')
parser.add_argument('-d', dest='nodaemon', action='store_true',
help='do not run as a daemon')
args = parser.parse_args()
if not args.nodaemon:
pid = pid_file_module.TimeoutPIDLockFile(
"/var/run/ptgbot/ptgbot.pid", 10)
with daemon.DaemonContext(pidfile=pid):
start(args.configfile)
start(args.configfile)
if __name__ == "__main__":
main()