#!/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 entry’s 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()