271 lines
9.7 KiB
Python
271 lines
9.7 KiB
Python
#!/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()
|