BotLogMauve/log.py

272 lines
9.7 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
import logging
import os
from datetime import datetime
from getpass import getpass
from argparse import ArgumentParser
from configparser import ConfigParser
from time import mktime, localtime
import slixmpp
import asyncio
import aiohttp
import feedparser
NS = 'https://linkmauve.fr/protocol/feed-state'
class FeedStateStorage(slixmpp.xmlstream.ElementBase):
namespace = NS
name = 'feed-state'
plugin_attrib = 'feed_state'
interfaces = set()
def set_state(self, url, latest):
state = FeedState()
state['url'] = url
state['latest'] = str(latest)
self.append(state)
class FeedState(slixmpp.xmlstream.ElementBase):
namespace = NS
name = 'feed'
plugin_attrib = 'feed'
plugin_multi_attrib = 'feeds'
interfaces = {'url', 'latest'}
slixmpp.xmlstream.register_stanza_plugin(FeedStateStorage, FeedState, iterable=True)
class Feed:
def __init__(self, bot, url, last_updated):
self.bot = bot
self.url = url
self.rooms = []
self.last_updated = last_updated
asyncio.ensure_future(self.http_loop())
async def http_loop(self):
try:
async with aiohttp.ClientSession() as client:
while True:
text = await self.fetch(client)
await self.handle_http(text)
await asyncio.sleep(10)
except slixmpp.xmlstream.xmlstream.NotConnectedError:
# THIS is a hack.
import sys
sys.exit(2)
async def handle_http(self, text):
data = feedparser.parse(text)
feed = data['feed']
# The global updated_parsed field can be buggy, use the most recent entrys instead.
updated = max(entry['updated_parsed'] for entry in data['entries'])
if updated == self.last_updated:
return
self.last_updated = updated
title = feed['title']
entries = data['entries']
for entry in reversed(entries):
updated = entry['updated_parsed']
if updated < self.last_updated:
continue
body = self.bot.text_template.format(feed=title, title=entry['title'], link=entry['link'])
xhtml_im = self.bot.xhtml_template.format(feed=title, title=entry['title'], link=entry['link'])
for room in self.rooms:
self.bot.send_message(room, body, mhtml=xhtml_im, mtype='groupchat')
# Save the state of this feed.
timestamp = int(mktime(self.last_updated))
self.bot.stored_feeds[self.url] = timestamp
storage = FeedStateStorage()
for url, time in self.bot.stored_feeds.items():
storage.set_state(url, time)
self.bot.plugin['xep_0223'].store(storage, NS, id='current')
async def fetch(self, client):
async with client.get(self.url) as response:
return await response.text()
def add_room(self, jid):
self.rooms.append(jid)
logging.info('Adding room %s to feed %s.', jid, self.url)
def __repr__(self):
return 'Feed(%s)' % self.url
class MUCBot(slixmpp.ClientXMPP):
def __init__(self, config):
slixmpp.ClientXMPP.__init__(self, config.jid, config.password)
self.rooms = config.rooms
self.nick = config.nick
self.feeds = config.feeds
self.text_template = config.text_template
self.xhtml_template = config.xhtml_template
self.stored_feeds = {}
self.add_event_handler("session_start", self.start)
self.add_event_handler("disconnected", self.on_disconnected)
self.add_event_handler("groupchat_message", self.muc_message)
for room in self.rooms:
self.add_event_handler("muc::%s::got_online" % room, self.muc_online)
self.add_event_handler("muc::%s::got_offline" % room, self.muc_offline)
async def start(self, event):
for room in self.rooms:
self.plugin['xep_0045'].join_muc(room, self.nick)
await self.retrieve_stored_feed_state()
feeds = {}
for muc, url in self.feeds:
if url not in feeds:
last_updated = localtime(self.stored_feeds.get(url))
feeds[url] = Feed(self, url, last_updated)
feed = feeds[url]
feed.add_room(muc)
self.feeds = feeds
def on_disconnected(self, event):
self.xmpp.connect()
async def retrieve_stored_feed_state(self):
try:
iq = await self.plugin['xep_0223'].retrieve(NS)
except slixmpp.exceptions.IqError:
logging.info('No feeds had been stored in PEP yet.')
else:
payload = iq['pubsub']['items']['item']['payload']
storage = FeedStateStorage(payload)
self.stored_feeds = {}
for feed in storage['feeds']:
url = feed['url']
time = int(feed['latest'])
self.stored_feeds[url] = time
def muc_message(self, message):
room = message['from'].bare
nick = message['from'].resource
self.log(room, 'R', '<%s> %s' % (message['from'].resource, message['body']))
reponse = "/me se frotte sur les jambes de"
if message['mucnick'] != self.nick and self.nick+"?" in message['body']:
self.send_message(mto=message['from'].bare,
mbody="%s %s." % (reponse, message['mucnick']),
mtype='groupchat')
def muc_online(self, presence):
room = presence['muc']['room']
nick = presence['muc']['nick']
status = ' (%s)' % presence['status'] if presence['status'] else ''
self.log(room, 'I', '---> %s joined the room%s' % (nick, status))
def muc_offline(self, presence):
room = presence['muc']['room']
nick = presence['muc']['nick']
status = ' (%s)' % presence['status'] if presence['status'] else ''
self.log(room, 'I', '<--- %s left the room%s' % (nick, status))
def log(self, room, typ, message):
now = datetime.utcnow()
day = now.strftime('%Y-%m-%d')
filename = '%s/%s.log' % (room, day)
os.makedirs(room, exist_ok=True)
with open(filename, 'a') as out:
timestamp = now.isoformat() + 'Z'
split_message = message.split('\n')
nb_lines = len(split_message) - 1
final_message = '\n '.join(split_message)
lines = 'M%s %s %03d %s\n' % (typ, timestamp, nb_lines, final_message)
out.write(lines)
class Config:
__slots__ = ['_parser', '_filename', 'jid', 'password', 'rooms', 'nick', 'feeds', 'text_template', 'xhtml_template']
def __init__(self, filename):
if filename is not None:
self._filename = filename
else:
xdg_config_home = os.environ.get('XDG_CONFIG_HOME')
if xdg_config_home is None or xdg_config_home[0] != '/':
xdg_config_home = os.path.join(os.environ.get('HOME'), '.config')
self._filename = os.path.join(xdg_config_home, 'botlogmauve', 'bot.cfg')
self.restart()
def restart(self):
logging.info('Reading configuration from “%s”.', self._filename)
self._parser = ConfigParser()
with open(self._filename) as fp:
self._parser.read_file(fp, self._filename)
default_section = self._parser['DEFAULT']
self.jid = default_section['jid']
self.password = default_section['password']
self.nick = default_section['nick']
self.text_template = default_section['text_template']
self.xhtml_template = default_section['xhtml_template']
self.rooms = []
self.feeds = []
for room in self._parser.sections():
self.rooms.append(room)
section = self._parser[room]
try:
feeds = section['feeds']
except KeyError:
pass
else:
for feed in feeds.split():
self.feeds.append((room, feed))
def main():
# Setup the command line arguments.
parser = ArgumentParser()
# Output verbosity options.
parser.add_argument("-q", "--quiet", help="set logging to ERROR",
action="store_const", dest="loglevel",
const=logging.ERROR, default=logging.INFO)
parser.add_argument("-d", "--debug", help="set logging to DEBUG",
action="store_const", dest="loglevel",
const=logging.DEBUG, default=logging.INFO)
# Configuration file override.
parser.add_argument("CONFIG", nargs='?', default='bot.cfg', help="overrides the configuration file")
args = parser.parse_args()
# Setup logging.
logging.basicConfig(level=args.loglevel,
format='%(levelname)-8s %(message)s')
# Load the configuration file.
try:
config = Config(args.CONFIG)
except IOError:
logging.exception('Failed to read config file “%s”:', args.CONFIG)
return
# Setup the MUCBot and register plugins. Note that while plugins may
# have interdependencies, the order in which you register them does
# not matter.
xmpp = MUCBot(config)
xmpp.register_plugin('xep_0030') # Service Discovery
xmpp.register_plugin('xep_0045') # Multi-User Chat
xmpp.register_plugin('xep_0071') # XHTML-IM
xmpp.register_plugin('xep_0223') # Persistent Storage of Private Data via PubSub
#xmpp.register_plugin('xep_0198') # Stream Management
xmpp.register_plugin('xep_0199', {'keepalive': True, 'interval': 60}) # XMPP Ping
# Connect to the XMPP server and start processing XMPP stanzas.
xmpp.connect()
xmpp.process()
if __name__ == '__main__':
main()