statusbot/statusbot/bot.py

689 lines
23 KiB
Python
Executable File

#! /usr/bin/env python
# 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.
# The configuration file should look like:
"""
[ircbot]
nick=NICKNAME
pass=PASSWORD
server=irc.oftc.net
port=6697
use_sasl=False
channels=foo,bar
nicks=alice,bob
backend=wiki
[wiki]
username=StatusBot
password=password
url=https://wiki.example.com/w/api.php
pageid=1781
successpageid=2434
successpageurl=https://wiki.example.com/w/Success
thankspageid=37700
thankspageurl=https://wiki.example.com/w/Thanks
# when using etherpad, set the ircbot.backend value to etherpad
[etherpad]
url=https://etherpad.example.com/p
pad=infra-status
successpad=success
thankspad=thanks
[irclogs]
url=http://eavesdrop.example.com/irclogs/%(chan)s/%(chan)s.%(date)s.log.html
[twitter]
consumer_key=consumer_key
consumer_secret=consumer_secret
access_token_key=access_token_key
access_token_secret=access_token_secret
[mastodon]
host=https://example.com
token='mastodon_bearer_token'
"""
import argparse
import configparser
import daemon
from ib3.auth import SASL
from ib3.connection import SSL
import irc.bot
import json
import logging.config
import os
import tempfile
import time
import simplemediawiki
import datetime
import re
import requests
import tweepy
import textwrap
import urllib
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'
ANTI_FLOOD_SLEEP = 2
class BackendInterface(object):
def __init__(self, config):
pass
def login(self):
pass
def load(self) -> str:
pass
def save(self, data: str):
pass
class WikiPage(BackendInterface):
def __init__(self, config):
self.url = config.get('wiki', 'url')
self.pageid = config.get('wiki', 'pageid')
self.username = config.get('wiki', 'username')
self.password = config.get('wiki', 'password')
def login(self):
self.wiki = simplemediawiki.MediaWiki(self.url)
self.wiki.login(self.username, self.password)
def load(self):
data = self.wiki.call(dict(action='query',
prop='revisions',
rvprop='content',
pageids=self.pageid,
format='json'))
return data['query']['pages'][str(self.pageid)]['revisions'][0]['*']
def save(self, text):
data = self.wiki.call(dict(action='query',
prop='info',
pageids=self.pageid,
intoken='edit'))
token = data['query']['pages'][str(self.pageid)]['edittoken']
data = self.wiki.call(dict(action='edit',
pageid=self.pageid,
bot=True,
text=text,
token=token))
class EtherPage(BackendInterface):
def __init__(self, config):
self.url = config.get('etherpad', 'url')
self.pad = config.get('etherpad', 'pad')
@property
def pageurl(self):
return '/'.join([self.url, self.pad or 'undefined'])
def load(self):
return requests.get('/'.join([self.url, self.pad, 'export/txt'])).text
def save(self, text):
requests.post('/'.join([self.url, self.pad, 'import']),
files=dict(file=text))
def timestamp(ts=None):
if not ts:
ts = datetime.datetime.now()
return ts.strftime("%Y-%m-%d %H:%M:%S UTC")
class SuccessPage(object):
def __init__(self, config, backend):
self.backend = backend(config)
self.ready = False
if isinstance(self.backend, WikiPage):
self.backend.pageid = config.get('wiki', 'successpageid',
fallback=None)
self.backend.pageurl = config.get('wiki', 'successpageurl',
fallback=None)
self.ready = self.backend.pageid is not None
elif isinstance(self.backend, EtherPage):
self.backend.pad = config.get('etherpad', 'successpad',
fallback=None)
self.ready = self.backend.pad is not None
if config.has_option('irclogs', 'url'):
self.irclogs_url = config.get('irclogs', 'url')
else:
self.irclogs_url = None
def log(self, channel, nick, msg):
if self.ready:
self.backend.login()
ts = timestamp()
if self.irclogs_url:
url = self.irclogs_url % {
'chan': urllib.parse.quote(channel),
'date': ts[0:10]}
onchan = "[%s %s]" % (url, channel)
else:
onchan = channel
data = self.backend.load()
current = data.split("\n")
newtext = "%s\n|-\n| %s || %s (on %s) || %s\n%s" % (
current[0], ts, nick, onchan, msg, '\n'.join(current[1:]))
self.backend.save(newtext)
class ThanksPage(object):
def __init__(self, config, backend):
self.backend = backend(config)
if isinstance(self.backend, WikiPage):
self.backend.pageid = config.get('wiki', 'thankspageid',
fallback=None)
self.backend.pageurl = config.get('wiki', 'thankspageurl',
fallback=None)
self.ready = self.backend.pageid is not None
elif isinstance(self.backend, EtherPage):
self.backend.pad = config.get('etherpad', 'thankspad',
fallback=None)
self.ready = self.backend.pad is not None
if config.has_option('irclogs', 'url'):
self.irclogs_url = config.get('irclogs', 'url')
else:
self.irclogs_url = None
def log(self, channel, nick, msg):
if self.ready:
self.backend.login()
ts = timestamp()
if self.irclogs_url:
url = self.irclogs_url % {
'chan': urllib.parse.quote(channel),
'date': ts[0:10]}
onchan = "[%s %s]" % (url, channel)
else:
onchan = channel
data = self.backend.load()
current = data.split("\n")
newtext = "%s\n|-\n| %s || %s (on %s) || %s\n%s" % (
current[0], ts, nick, onchan, msg, '\n'.join(current[1:]))
self.backend.save(newtext)
class UpdateInterface(object):
def alert(self, msg=None):
pass
def notice(self, msg=None):
pass
def log(self, msg=None):
pass
def ok(self, msg=None):
pass
class Tweet(UpdateInterface):
logger = logging.getLogger("statusbot.tweet")
def __init__(self, config):
self.consumer_key = config.get('twitter', 'consumer_key')
self.consumer_secret = config.get('twitter', 'consumer_secret')
self.access_token_key = config.get('twitter', 'access_token_key')
self.access_token_secret = config.get('twitter', 'access_token_secret')
auth = tweepy.OAuthHandler(self.consumer_key, self.consumer_secret)
auth.set_access_token(self.access_token_key, self.access_token_secret)
self.api = tweepy.API(auth)
def update(self, msg):
# NOTE(ianw) python-twitter, used originally, used to do some
# automated splitting. It didn't account for emoji,
# link-shortening, etc. Ergo, this could be better, but KISS
# for now
tweets = textwrap.wrap(msg, 270)
if len(tweets) > 1:
for i in range(0, len(tweets)):
tweet = tweets[i]
tweets[i] = tweet + " %d/%d" % (i + 1, len(tweets))
try:
prior_tweet = None
for tweet in tweets:
if not prior_tweet:
prior_tweet = self.api.update_status(status=tweet)
else:
prior_tweet = self.api.update_status(
status=tweet,
in_reply_to_status_id=prior_tweet.id,
auto_populate_reply_metadata=True)
except tweepy.TweepyException:
self.logger.exception("Failed to tweet")
def alert(self, msg=None):
# warning sign
self.update('\u26A0\ufe0f ' + msg)
def notice(self, msg=None):
# pushpin (notice board)
self.update('\U0001f4cc ' + msg)
def log(self, msg=None):
# wood (log)
self.update('\U0001fab5 ' + msg)
def ok(self, msg=None):
if msg:
self.update('\u2705\ufe0f ' + msg)
else:
self.update('\u2705\ufe0f Everything back to normal')
class Toot(UpdateInterface):
logger = logging.getLogger("statusbot.toot")
def __init__(self, config):
self.host = config.get('mastodon', 'host')
self.token = config.get('mastodon', 'token')
def update(self, msg):
# NOTE(ianw) Toots are 500 characters, which should be enough.
# Figure out chaining some other time
url = '%s/api/v1/statuses' % (self.host)
data = {'status': msg}
r = requests.post(url, data=data,
headers={
'Authorization': 'Bearer %s' % (self.token)})
# not sure what to check for...
if r.status_code == 200:
json_data = r.json()
self.logger.debug('Tooted id: %s' % json_data['id'])
else:
self.logger.error('Toot failed: %s' % r.status_code)
def alert(self, msg=None):
# warning sign
self.update('\u26A0\ufe0f ' + msg)
def notice(self, msg=None):
# pushpin (notice board)
self.update('\U0001f4cc ' + msg)
def log(self, msg=None):
# wood (log)
self.update('\U0001fab5 ' + msg)
def ok(self, msg=None):
if msg:
self.update('\u2705\ufe0f ' + msg)
else:
self.update('\u2705\ufe0f Everything back to normal')
class StatusPage(UpdateInterface):
alert_re = re.compile(r'{{CI Alert\|(.*?)}}')
item_re = re.compile(r'^\* (.*)$')
def __init__(self, config, backend):
self.backend = backend(config)
self.current_alert = None
self.items = []
def alert(self, msg):
self.update(set_alert=True, msg=msg)
def notice(self, msg):
self.update(msg=msg)
def log(self, msg):
self.update(msg=msg)
def ok(self, msg):
self.update(clear_alert=True, msg=msg)
def update(self, set_alert=None, clear_alert=None, msg=None):
self.backend.login()
self.loadItems()
if set_alert:
self.setAlert(msg)
if clear_alert:
self.setAlert(None)
if msg:
self.addItem(msg)
self.saveItems()
def loadItems(self):
self.current_alert = None
self.items = []
text = self.backend.load()
for line in text.split('\n'):
m = self.alert_re.match(line)
if m:
self.current_alert = m.group(1)
m = self.item_re.match(line)
if m:
self.items.append(m.group(1))
def saveItems(self):
text = ''
if self.current_alert:
text += '{{CI Alert|%s}}\n\n' % self.current_alert
for item in self.items:
text += '* %s\n' % item
self.backend.save(text)
def addItem(self, item, ts=None):
text = '%s %s' % (timestamp(ts=ts), item)
self.items.insert(0, text)
def setAlert(self, current_alert):
self.current_alert = current_alert
class AlertFile(UpdateInterface):
def __init__(self, config):
if config.has_section('alertfile'):
self.dir = config.get('alertfile', 'dir')
self.path = os.path.join(self.dir, 'alert.json')
else:
self.path = None
self.ok()
def write(self, msg):
if not self.path:
return
f, path = tempfile.mkstemp(dir=self.dir)
os.write(f, json.dumps(dict(alert=msg)).encode('utf-8'))
os.close(f)
os.chmod(path, 0o644)
os.rename(path, self.path)
def alert(self, msg=None):
self.write(msg)
def ok(self, msg=None):
self.write(None)
class BaseStatusBot(SSL, irc.bot.SingleServerIRCBot):
log = logging.getLogger("statusbot.bot")
def on_pubmsg(self, c, e):
nick = e.source.split('!')[0]
msg = e.arguments[0]
# Unprivileged commands
try:
if msg.startswith('#success'):
self.handle_success_command(e.target, nick, msg)
return
if msg.startswith('#thanks'):
self.handle_thanks_command(e.target, nick, msg)
return
except Exception:
self.log.exception("Exception handling command %s" % msg)
return
# Privileged commands
if not msg.startswith('#status'):
return
if nick not in self.nicks:
self.log.debug("Ignoring message from untrusted user %s" % nick)
return
try:
self.handle_status_command(e.target, nick, msg)
except Exception:
self.log.exception("Exception handling command %s" % msg)
def handle_success_command(self, channel, nick, msg):
parts = msg.split()
text = ' '.join(parts[1:])
self.log.info("Processing success from %s: %s" % (nick, text))
self.successlog.log(channel, nick, text)
self.send(channel, "%s: Added success to Success page "
"(%s)"
% (nick, self.successlog.backend.pageurl))
def handle_thanks_command(self, channel, nick, msg):
parts = msg.split()
text = ' '.join(parts[1:])
self.log.info("Processing thanks from %s: %s" % (nick, text))
self.thankslog.log(channel, nick, text)
self.send(channel, "%s: Added your thanks to Thanks page "
"(%s)"
% (nick, self.thankslog.backend.pageurl))
def handle_status_command(self, channel, nick, msg):
parts = msg.split()
command = parts[1].lower()
text = ' '.join(parts[2:])
if command == 'alert':
self.log.info("Processing alert from %s: %s" % (nick, text))
self.send(channel, "%s: sending alert" % (nick,))
self.broadcast('NOTICE: ', text, set_topic=True)
for p in self.publishers:
p.alert(text)
self.send(channel, "%s: finished sending alert" % (nick,))
elif command == 'notice':
self.log.info("Processing notice from %s: %s" % (nick, text))
self.send(channel, "%s: sending notice" % (nick,))
self.broadcast('NOTICE: ', text)
for p in self.publishers:
p.notice(text)
self.send(channel, "%s: finished sending notice" % (nick,))
elif command == 'log':
self.log.info("Processing log from %s: %s" % (nick, text))
for p in self.publishers:
p.log(text)
self.send(channel, "%s: finished logging" % (nick,))
elif command == 'ok':
self.log.info("Processing ok from %s: %s" % (nick, text))
self.send(channel, "%s: sending ok" % (nick,))
self.broadcast('NOTICE: ', text, restore_topic=True)
for p in self.publishers:
p.ok(text)
self.send(channel, "%s: finished sending ok" % (nick,))
else:
self.send(channel, "%s: unknown command" % (nick,))
self.log.info(
"Unknown command %s from %s: %s" % (command, nick, msg))
def broadcast(self, prefix, msg, set_topic=False, restore_topic=False):
if set_topic:
self.current_topic = msg
for channel in self.channel_list:
if restore_topic:
# Set to the saved topic or just the channel name if
# we don't have a saved topic (to avoid leaving it as
# the alert).
t = self.topics.get(channel, channel)
self.set_topic(channel, t)
if msg:
self.notice(channel, prefix + msg)
if set_topic:
self.set_topic(channel, msg)
def on_currenttopic(self, c, e):
channel, topic = (e.arguments[0], e.arguments[1])
self.update_saved_topic(channel, topic)
def on_topic(self, c, e):
channel, topic = (e.target, e.arguments[0])
self.update_saved_topic(channel, topic)
def update_saved_topic(self, channel, topic):
if topic == self.current_topic:
return
self.log.info("Current topic on %s is %s" % (channel, topic))
self.topics[channel] = topic
def notice(self, channel, msg):
self.connection.notice(channel, msg)
time.sleep(ANTI_FLOOD_SLEEP)
def send(self, channel, msg):
self.connection.privmsg(channel, msg)
time.sleep(ANTI_FLOOD_SLEEP)
def set_topic(self, channel, topic):
self.log.info("Setting topic on %s to %s" % (channel, topic))
self.connection.privmsg('ChanServ', 'topic %s %s' % (channel, topic))
time.sleep(ANTI_FLOOD_SLEEP)
class NoSASLStatusBot(BaseStatusBot):
def __init__(self, channels, nicks, publishers, successlog, thankslog,
nickname, password, server, port=6697):
super(NoSASLStatusBot, self).__init__(
server_list=[(server, port)],
nickname=nickname,
realname=nickname)
self.channel_list = channels
self.nicks = nicks
self.nickname = nickname
self.password = password
self.topics = {}
self.current_topic = None
self.publishers = publishers
self.successlog = successlog
self.thankslog = thankslog
def on_welcome(self, c, e):
self.log.debug("Identifying to nickserv")
c.privmsg("nickserv", "identify %s " % self.password)
for channel in self.channel_list:
self.log.info("Joining %s" % channel)
c.join(channel)
time.sleep(ANTI_FLOOD_SLEEP)
def on_nicknameinuse(self, c, e):
self.log.debug("Nickname in use, releasing")
c.nick(c.get_nickname() + "_")
c.privmsg("nickserv", "identify %s " % self.password)
c.privmsg("nickserv", "ghost %s %s" % (self.nickname, self.password))
c.privmsg("nickserv", "release %s %s" % (self.nickname, self.password))
time.sleep(ANTI_FLOOD_SLEEP)
c.nick(self.nickname)
class SASLStatusBot(SASL, BaseStatusBot):
def __init__(self, channels, nicks, publishers, successlog, thankslog,
nickname, password, server, port=6697):
super(SASLStatusBot, self).__init__(
server_list=[(server, port)],
nickname=nickname,
realname=nickname,
ident_password=password,
channels=channels)
self.channel_list = channels
self.nicks = nicks
self.nickname = nickname
self.password = password
self.topics = {}
self.current_topic = None
self.publishers = publishers
self.successlog = successlog
self.thankslog = thankslog
def _main(configpath):
config = configparser.RawConfigParser()
config.read(configpath)
setup_logging(config)
if config.get('ircbot', 'backend', fallback=None) == "etherpad":
backend = EtherPage
else:
backend = WikiPage
channels = ['#' + name.strip() for name in
config.get('ircbot', 'channels').split(',')]
nicks = [name.strip() for name in
config.get('ircbot', 'nicks').split(',')]
publishers = [StatusPage(config, backend),
AlertFile(config)]
successlog = SuccessPage(config, backend)
thankslog = ThanksPage(config, backend)
if config.has_section('twitter'):
publishers.append(Tweet(config))
if config.has_section('mastodon'):
publishers.append(Toot(config))
if config.has_option('ircbot', 'use_sasl'):
use_sasl = config.getboolean('ircbot', 'use_sasl')
else:
use_sasl = False
if use_sasl:
bot = SASLStatusBot(channels, nicks, publishers, successlog,
thankslog,
config.get('ircbot', 'nick'),
config.get('ircbot', 'pass'),
config.get('ircbot', 'server'),
config.getint('ircbot', 'port'))
else:
bot = NoSASLStatusBot(channels, nicks, publishers, successlog,
thankslog,
config.get('ircbot', 'nick'),
config.get('ircbot', 'pass'),
config.get('ircbot', 'server'),
config.getint('ircbot', 'port'))
bot.start()
def main():
parser = argparse.ArgumentParser(description='Status bot.')
parser.add_argument('-c', dest='config', nargs=1,
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/statusbot/statusbot.pid", 10)
with daemon.DaemonContext(pidfile=pid):
_main(args.config)
_main(args.config)
def setup_logging(config):
if config.has_option('ircbot', 'log_config'):
log_config = config.get('ircbot', '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)
if __name__ == "__main__":
main()