diff --git a/bot.cfg.example b/bot.cfg.example new file mode 100644 index 0000000..129a61b --- /dev/null +++ b/bot.cfg.example @@ -0,0 +1,24 @@ +[DEFAULT] +# The Jabber identifier of your bot, of the form @. +jid = bot@example.org + +# The password associated with the JID you specified above. +password = bot_password + +# A list of admins who are allowed to do things to your bot, separated with a +# space (" "). +admins = you@example.com + +# The nick you want your bot to use when it will join the rooms. +nick = bot + +# The text and XHTML-IM templates your bot will use to link to new feed +# entries. +text_template = News from {feed}: {title} <{link}> +xhtml_template = News from {feed}: {title} + +# Each room you want to join must be listed between square brackets ("[…]"). +[room@chat.example.net] +# Each room can get one or more feeds associated, in which case your bot will +# talk for every new entry. You must separate the feeds with a space (" "). +feeds = https://example.net/feed.atom diff --git a/log.py b/log.py index ad9ef2b..32c2557 100644 --- a/log.py +++ b/log.py @@ -5,37 +5,160 @@ 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, jid, password, rooms, nick): - slixmpp.ClientXMPP.__init__(self, jid, password) + def __init__(self, config): + slixmpp.ClientXMPP.__init__(self, config.jid, config.password) - self.rooms = rooms - self.nick = nick + 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) - - def start(self, event): + async def start(self, event): for room in self.rooms: - self.plugin['xep_0045'].join_muc(room, - self.nick, - # If a room password is needed, use: - # password=the_room_password, - wait=True) + 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'] @@ -62,7 +185,46 @@ class MUCBot(slixmpp.ClientXMPP): out.write(lines) -if __name__ == '__main__': +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() @@ -74,39 +236,36 @@ if __name__ == '__main__': action="store_const", dest="loglevel", const=logging.DEBUG, default=logging.INFO) - # JID and password options. - parser.add_argument("-j", "--jid", dest="jid", - help="JID to use") - parser.add_argument("-p", "--password", dest="password", - help="password to use") - parser.add_argument("-r", "--rooms", dest="rooms", nargs='+', - help="MUC rooms to join") - parser.add_argument("-n", "--nick", dest="nick", - help="MUC nickname") - + # 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') - if args.jid is None: - args.jid = input("Username: ") - if args.password is None: - args.password = getpass("Password: ") - if args.rooms is None: - args.rooms = input("MUC rooms: ") - if args.nick is None: - args.nick = input("MUC nickname: ") + # 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(args.jid, args.password, args.rooms, args.nick) + xmpp = MUCBot(config) xmpp.register_plugin('xep_0030') # Service Discovery xmpp.register_plugin('xep_0045') # Multi-User Chat - xmpp.register_plugin('xep_0199') # XMPP Ping + 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()